Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .agents/skills/notora-v2/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
name: notora-v2
description: Use when working with notora v2 models, repositories, services, query DSL, pagination, schemas, soft delete, actor-aware writes, or migration from legacy notora patterns. This skill provides bundled notora v2 references so agents do not guess APIs from memory.
---

# Notora v2

## Purpose

Use this skill to implement or review code that depends on `notora.v2`. Prefer the bundled references over memory. New code should use `notora.v2`; do not introduce new `notora.v1` usage unless maintaining existing legacy code.

## Reference Routing

Read only the references needed for the current task:

- Overview and topic map: `references/index.md`
- SQLAlchemy base models and mixins: `references/models.md`
- Repository classes, mixins, `RepoConfig`, `QueryParams`: `references/repositories.md`
- Service classes, `ServiceConfig`, raw vs serialized methods, actor-aware writes: `references/services.md`
- HTTP query filtering/sorting DSL, allowlisted fields, FastAPI dependency helpers: `references/query-dsl.md`
- Pagination response shape and no-limit behavior: `references/pagination.md`
- M2M sync helpers and modes: `references/m2m.md`
- Common patterns and endpoint recipes: `references/recipes.md`
- Term disambiguation for query/filter/order APIs: `references/glossary.md`

If exact signatures or implementation behavior matter and source code is available in the current environment, inspect the installed or local `notora.v2` source after reading the relevant bundled reference.

## Working Rules

- Use v2 imports such as `notora.v2.repositories`, `notora.v2.services`, `notora.v2.models.base`, and `notora.v2.schemas.base`.
- Prefer v2 repository/service base classes before writing custom SQL or custom execution helpers.
- Keep SQL construction in repositories/services, not app handlers.
- In service methods, prefer the service execution helpers documented in `references/services.md` over direct `session.execute(...)`.
- Use allowlists for query filters and sorting; do not expose arbitrary model fields to HTTP query parameters.
- Be explicit about `limit=None` vs omitted limit: `None` means no limit, omitted limit uses repository defaults.
- For writes with user attribution, pass `actor_id` only when the model supports the configured updated-by field.

## Integration With Host Projects

When this skill is copied into another repository, keep `references/` with it. Do not replace the bundled references with machine-local absolute paths. If upstream notora docs change, update this skill by copying the refreshed references from the notora repository.
4 changes: 4 additions & 0 deletions .agents/skills/notora-v2/agents/openai.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
interface:
display_name: "Notora v2"
short_description: "Use notora v2 APIs correctly."
default_prompt: "Use $notora-v2 to implement notora v2 models, repositories, services, query DSL, or pagination correctly."
117 changes: 117 additions & 0 deletions .agents/skills/notora-v2/references/glossary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# Query Glossary (v2)

Terminology for filtering, ordering, and pagination. Use this to disambiguate the layered types.

## Filter terms

| Term | Kind | Purpose |
|---|---|---|
| `FilterClause` | Type alias | A SQLAlchemy `ColumnElement[bool]` — a raw WHERE clause. |
| `FilterFactory[M]` | Type alias | `Callable[[type[M]], FilterClause]` — lazy clause tied to a model. |
| `FilterSpec[M]` | Type alias | `FilterClause \| FilterFactory[M]` — what repo methods accept in `filters=`. |
| `apply_filter_operator(col, op, value)` | Function | Maps an operator name (`eq`, `gte`, …) to a SQLAlchemy clause. |
| `FilterField[M]` | Dataclass | DSL allowlist entry: resolver + allowed operators + value type. Used with `build_query_params`. |
| `FilterToken` | Dataclass | Parsed `field:op:value` from a DSL query string. |
| `PydanticFilterField[M]` | Dataclass | Pydantic-schema allowlist entry: resolver + predicate + fixed operator. Used with `PydanticFiltersSchema`. |
| `Filter` | Dataclass | Annotated-metadata allowlist entry: resolver + predicate + fixed operator. Used inside `Annotated[T, Filter(...)]` on `PydanticFiltersSchema` fields. |

## Order terms

| Term | Kind | Purpose |
|---|---|---|
| `OrderClause` | Type alias | A SQLAlchemy `ColumnElement[Any] \| UnaryExpression[Any]`. |
| `OrderFactory[M]` | Type alias | `Callable[[type[M]], OrderClause]`. |
| `OrderSpec[M]` | Type alias | `OrderClause \| OrderFactory[M]` — what repo methods accept in `ordering=`. |
| `SortField[M]` | Dataclass | DSL allowlist entry for sorting. |
| `SortToken` | Dataclass | Parsed `±field` from DSL. |
| `PydanticSortField[M]` | Dataclass | Pydantic-schema allowlist entry for sorting. |

## Pagination / parameters

| Term | Kind | Purpose |
|---|---|---|
| `QueryParams[M]` | Dataclass | Bag of filters/ordering/limit/offset for `list_*_params`. Carries `apply_default_filters` for per-call bypass. |
| `PaginationParams[M]` | Dataclass | Same bag plus a concrete `limit` default for `paginate_params`. |
| `QueryInput` | Pydantic | FastAPI DSL query schema (`filter=...`, `sort=...`, `limit`, `offset`). |
| `PydanticFiltersSchema[M]` | Pydantic base | OpenAPI-native query-param filters → `list[FilterSpec[M]]`. |
| `PydanticOrderBySchema[M]` | Pydantic base | OpenAPI-native `order_by` + `direction` → `list[OrderSpec[M]]`. |
| `make_query_params_dependency` | Factory | Build a FastAPI `Depends` for the DSL path. |
| `make_list_params_dependency` | Factory | Build a FastAPI `Depends` for the pydantic path. Composes filters + ordering + pagination into one `PaginationParams`. Supports `max_limit=200` default cap and optional `default_filter_bypass_param` for exposing the bypass flag under a domain-appropriate query name (e.g. `show_deleted`). |
| `paginate_from_queries` | Service method | Paginate ORM-entity rows from ready-made `data_query` + `count_query` (no add_columns). |
| `paginate_rows_from_queries` | Service method | Paginate tuple rows from a `Select` with `add_columns`/`outerjoin`. Caller supplies `data_query`, `count_query`, and a `row_to_schema` callable. |

## Default filters and the bypass flag

Repositories can carry a `default_filters` sequence that gets merged with per-call filters. `SoftDeleteRepository` adds `deleted_at IS NULL`; other repos can register arbitrary clauses via `RepoConfig.default_filters`.

Every method that accepts filters also accepts `apply_default_filters: bool = True`. Pass `False` to skip the defaults for a single call. Used by admin endpoints that need to see soft-deleted rows.

The flag is also a field on `QueryParams` and `PaginationParams`, so it travels through `list_params` / `paginate_params` automatically.

When `paginate(apply_default_filters=False)` is called, BOTH the data query AND the count query skip the defaults. `meta.total` stays consistent with `len(page.data)`.

`build_query_params` (the DSL path's entrypoint) does NOT thread `apply_default_filters` — DSL strings describe per-call filters, not repo-layer defaults. If you need the bypass on a DSL endpoint, construct `QueryParams(..., apply_default_filters=False)` manually after `build_query_params` returns.

## Two paths from HTTP to SQL

### DSL path (gateway APIs, public filtering)

Query: `?filter=age:gte:18&sort=-created_at`

Flow: `QueryInput` → `build_query_params(...)` → `QueryParams[M]` → `service.list_params(session, params)`.

Best when: clients are trusted intermediaries; you want a single flexible endpoint; OpenAPI form generation is not needed.

### Pydantic path (admin UIs, OpenAPI-first)

Query: `?age_gte=18&order_by=created_at&direction=desc`

Flow: a `PydanticFiltersSchema` subclass + a `PydanticOrderBySchema` subclass → `make_list_params_dependency(...)` → FastAPI `Depends` → `PaginationParams[M]` → `service.paginate_params(session, params)`.

Best when: the front-end is an admin panel with auto-generated forms; fields are typed (UUID / Enum / Literal); HTTP 422 on invalid input is preferred over a runtime error.

Both paths produce the same `FilterSpec` / `OrderSpec` types, so service/repo layers are agnostic.

## Footguns (pydantic path)

**Subclass field overrides:** `PydanticOrderBySchema` declares `order_by: str | None = None` on the base class. Every subclass MUST override `order_by` with a `Literal[...]` of its `sort_fields` keys. Otherwise pydantic accepts any string from the URL and the `ValueError('Unsupported sort field …')` fires at runtime, producing an HTTP 500 instead of the cleaner 422 that a `Literal` would have produced at validation time.

Example:

```python
class ThingOrdering(PydanticOrderBySchema[Thing]):
order_by: Literal['created_at', 'updated_at'] | None = None
direction: Literal['asc', 'desc'] = 'desc'

sort_fields: ClassVar[dict[str, PydanticSortField[Any]]] = {
'created_at': PydanticSortField(resolver=Thing.created_at),
'updated_at': PydanticSortField(resolver=Thing.updated_at),
}
```

**Mutable ClassVar dict:** `filter_fields` / `sort_fields` are mutable dicts. If you mutate a subclass's dict AFTER class definition (e.g. `ThingFilters.filter_fields['new'] = ...`), the mutation is scoped to that subclass — but if you've **inherited** without overriding the ClassVar, mutation will leak into the parent's dict. Always assign a fresh dict literal in each subclass body; never rely on inheritance of the mutable default.

Safe:

```python
class ThingFilters(PydanticFiltersSchema[Thing]):
filter_fields: ClassVar[dict[str, PydanticFilterField[Any]]] = {...} # new dict per subclass
```

Unsafe (don't do this):

```python
class ThingFilters(PydanticFiltersSchema[Thing]):
... # inherits the empty default from the base
# Later, somewhere in the codebase:
# ThingFilters.filter_fields['name'] = PydanticFilterField(...)
# This mutates the BASE class's dict and leaks to all other subclasses.
```

**On the Annotated path this footgun does not apply.** `Annotated[T, Filter(...)]` declarations live on each pydantic field, not in a shared mutable ClassVar — there is nothing to leak across subclasses. Use the Annotated form when adding new schemas to sidestep this entirely.

**Pydantic field without `filter_fields` entry = silent skip:** `build_filter_specs` iterates `model_dump(exclude_unset=True, exclude_none=True)` and skips any field name that's not in `filter_fields`. Pydantic accepts the HTTP value (so no HTTP 422), but the filter has NO effect on the SQL query. This is by design — it lets a schema carry non-filter fields (mode toggles, pagination hints, etc.) alongside the declared filters — but it means forgetting to register a field produces a "ghost filter" that silently does nothing.

Mitigation: when authoring a filter schema, add every pydantic field to `filter_fields`. If you want a non-filter field in the same schema, name it distinctly (e.g. prefix with `_`) and document it.

**On the Annotated path the failure mode shifts but does not disappear.** A pydantic field without `Filter(...)` metadata is now an explicit non-filter declaration (e.g. `show_archived: bool = False` for control flags) — `build_filter_specs` skips it by design. The "registered the filter, forgot to wire SQL" mistake is gone because there is one source of truth, not two; but the "added a field without thinking about whether it should be a filter" possibility remains. When in doubt, declare `Annotated[..., Filter(...)]` explicitly.
88 changes: 88 additions & 0 deletions .agents/skills/notora-v2/references/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# notora v2 documentation

This section describes the v2 toolkit in detail with practical examples for
backend developers (FastAPI-friendly) and library users who want to customize
behavior without adding new dependencies.

## Quickstart

```python
from notora.v2.repositories import Repository, RepoConfig
from notora.v2.services import RepositoryService, ServiceConfig

repo = Repository(
User,
config=RepoConfig(default_limit=50),
)
service = RepositoryService(
repo,
config=ServiceConfig(detail_schema=UserSchema, list_schema=UserListSchema),
)
```

## Detailed quickstart

```python
from uuid import UUID

from pydantic import BaseModel
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.ext.asyncio import AsyncSession

from notora.v2.models.base import AuditedBaseModel
from notora.v2.repositories import Repository
from notora.v2.schemas.base import BaseResponseSchema, BaseRequestSchema
from notora.v2.services import RepositoryService, ServiceConfig


class User(AuditedBaseModel):
# SQLAlchemy columns
email: Mapped[str] = mapped_column(unique=True)
name: Mapped[str]


class UserResponseSchema(BaseResponseSchema):
# Pydantic response schema
id: UUID
email: str
name: str


class UserCreateSchema(BaseRequestSchema):
email: str
name: str


repo = Repository(User)
service = RepositoryService(
repo,
config=ServiceConfig(detail_schema=UserResponseSchema),
)


async def create_user(session: AsyncSession, payload: UserCreateSchema, actor_id: UUID) -> UserResponseSchema:
# actor_id populates updated_by when the model supports it
return await service.create(session, payload, actor_id=actor_id)


async def list_users(session: AsyncSession) -> list[UserResponseSchema]:
# schema=None uses the service default schema
return await service.list(session, limit=50)
```

## Topics

- Models and mixins: `models.md`
- Repositories and configs: `repositories.md`
- Services and `actor_id`: `services.md`
- Query DSL and FastAPI: `query-dsl.md`
- Query glossary: `glossary.md`
- Pagination: `pagination.md`
- M2M sync helpers: `m2m.md`
- Recipes and patterns: `recipes.md`

## Design notes

- v2 is built around mixins and explicit configuration.
- Query filtering and sorting are done via allowlists for safety.
- No extra dependencies are required beyond SQLAlchemy and Pydantic.
100 changes: 100 additions & 0 deletions .agents/skills/notora-v2/references/m2m.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Many-to-many helpers (v2)

The `ManyToManySyncMixin` helps synchronize association tables during
create/update/upsert flows.

## Sync modes

`M2MSyncMode` enum:

- `REPLACE` (default)
- `ADD`
- `REMOVE`

```python
from notora.v2.services import M2MSyncMode

class UserService(RepositoryService[UUID, User, UserSchema]):
m2m_sync_mode = M2MSyncMode.ADD
```

## Defining relations

```python
from notora.v2.services.mixins.m2m import ManyToManyRelation

class UserService(RepositoryService[UUID, User, UserSchema]):
many_to_many_relations = (
ManyToManyRelation[
UserRole
](
payload_field="role_ids",
association_model=UserRole,
left_key=UserRole.user_id,
right_key=UserRole.role_id,
),
)
```

When the payload contains `role_ids`, the service will sync that association
set according to the configured mode.

## Detailed examples

### Custom row_factory for extra columns

```python
from datetime import datetime
from notora.v2.services.mixins.m2m import ManyToManyRelation

class UserService(RepositoryService[UUID, User, UserSchema]):
many_to_many_relations = (
ManyToManyRelation[
UserRole
](
payload_field="role_ids",
association_model=UserRole,
left_key=UserRole.user_id,
right_key=UserRole.role_id,
# Include extra fields on the association row.
row_factory=lambda user_id, role_id: {
"user_id": user_id,
"role_id": role_id,
"created_at": datetime.utcnow(),
},
),
)
```

### Manual sync for custom flows

```python
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession

class UserService(RepositoryService[UUID, User, UserSchema]):
async def add_roles(
self,
session: AsyncSession,
user_id: UUID,
role_ids: list[UUID],
) -> None:
# Only add missing relations without removing existing ones.
await self.sync_m2m_relations(
session,
user_id,
{"role_ids": role_ids},
mode=M2MSyncMode.ADD,
)
```

### Remove specific relations

```python
await service.sync_m2m_relations(
session,
user_id,
{"role_ids": [role_to_remove]},
mode=M2MSyncMode.REMOVE,
)
```
Loading
Loading