From 3d973ea211634d58c0da1e5fe145bef2197fb7a8 Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Tue, 3 Nov 2026 01:36:15 +0100 Subject: [PATCH 1/3] docs: pass the data-api docs to the docs standard --- README.md | 26 ++-- docs/1.introduction.md | 73 +++++------ docs/10.joined.md | 81 +++++++------ docs/11.computed.md | 140 +++++++++++++-------- docs/2.data-controllers.md | 88 ++++++++------ docs/3.routes.md | 243 +++++++++++++++++-------------------- docs/4.access-rights.md | 82 +++++++------ docs/5.validators.md | 81 +++++++------ docs/6.listable.md | 82 +++++++------ docs/7.foreign-keys.md | 85 +++++++------ docs/8.filters.md | 99 ++++++++------- docs/9.modifiers.md | 52 ++++---- 12 files changed, 608 insertions(+), 524 deletions(-) diff --git a/README.md b/README.md index 1926c2f..579c6ec 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,24 @@ -# @antelopejs/interface-data-api +# `@antelopejs/interface-data-api`
-npm -License -Discord -Docs +npm +License +Discord +Docs
-CRUD REST endpoints generated from a database table: declare a data controller and its fields, and get `get`, `list`, `new`, `edit` and `delete` routes with access rules, validation and pagination. +The AntelopeJS interface that generates CRUD REST endpoints from a database table. Declare a data controller and its fields, and it serves the `get`, `list`, `new`, `edit` and `delete` routes with access rules, validation and pagination. ## What it offers - `DataController` and `@RegisterDataController()` turn a table class into a controller with the default routes you pick from `DefaultRoutes`. -- `@Access`, `@Mandatory` and `@Validator` decide which fields each route reads and writes, and check them. +- `@Access` decides which fields each route reads and writes. `@Mandatory` and `@Validator` check the written values. - `@Listable`, `@Sortable` and `@Filter` shape the paginated `list` route. - `@Foreign`, `@Joined` and `@Computed` resolve references, flatten remote fields and add values computed in the database. ## Implementations -The package runs in your module and has no implementing module of its own. The project needs modules that implement the interfaces it builds on: +The package runs in your module and has no implementing module of its own. The project needs modules that implement the interfaces it builds on. | Interface | Module | | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | @@ -27,13 +27,13 @@ The package runs in your module and has no implementing module of its own. The p ## Install -Add the package and the interfaces your controllers import to your module: +Add the package and the interfaces your controllers import to your module. ```bash pnpm add @antelopejs/interface-data-api @antelopejs/interface-api @antelopejs/interface-database-decorators ``` -Then add the implementations to the project: +Then add the implementations to the project. ```bash ajs project modules add @antelopejs/api @antelopejs/mongodb @@ -101,7 +101,7 @@ export async function construct() { } ``` -The controller serves `GET /tasks/get?id=`, `GET /tasks/list`, `POST /tasks/new`, `PUT /tasks/edit?id=` and `DELETE /tasks/delete?id=`. Fields without `@Access` stay out of every response. +The controller serves `GET /tasks/get?id=`, `GET /tasks/list`, `POST /tasks/new`, `PUT /tasks/edit?id=` and `DELETE /tasks/delete?id=`. A field without `@Access` stays out of every response and every write, unless `@Joined` or `@Computed` makes it read-only. ## Documentation @@ -119,7 +119,7 @@ The controller serves `GET /tasks/get?id=`, `GET /tasks/list`, `POST /tasks/new` ## Compatibility -The package declares these `@antelopejs/*` peer dependencies: +The package declares these `@antelopejs/*` peer dependencies. @@ -135,7 +135,7 @@ The package declares these `@antelopejs/*` peer dependencies: ## Contributing -Read [CONTRIBUTING.md](./CONTRIBUTING.md) before you open a pull request. The local checks are: +Read [CONTRIBUTING.md](./CONTRIBUTING.md) before you open a pull request. Run the local checks with these commands. ```bash pnpm install diff --git a/docs/1.introduction.md b/docs/1.introduction.md index 7c5eaa9..de6aa71 100644 --- a/docs/1.introduction.md +++ b/docs/1.introduction.md @@ -1,21 +1,27 @@ -# Expose database records through an API +--- +title: Expose database records through an API +description: Build a first data controller that serves CRUD routes for a users table, then check which fields it reads and writes. +mode: tutorial +--- ## Overview -Use `@antelopejs/interface-data-api` to expose a table through named CRUD routes and an explicit field policy. A data controller connects the table model to HTTP handlers; its metadata decides which fields clients may read, write, list, and filter. +In this tutorial, we expose a `users` table through five CRUD routes with `@antelopejs/interface-data-api`. A data controller connects the table model to HTTP handlers. Its metadata decides which fields clients can read, write, list and filter. ## Build one resource first -1. **Prepare the database.** Define a table and register its schema with database-decorators. Your AntelopeJS module needs a database provider and an API provider. -2. **Choose the routes.** Start with the [route set](./3.routes.md) your clients need, rather than exposing every write operation by default. -3. **Set the field policy.** Add [access rights](./4.access-rights.md), [validators](./5.validators.md), and [list fields](./6.listable.md). Field permissions are not user authentication or row-level authorization. -4. **Check one read and one rejected write.** Confirm the response excludes private fields and that invalid input cannot reach storage. Then add [relationships](./7.foreign-keys.md) and [filters](./8.filters.md). +We build one resource in four steps. -## Quick Start +1. **Prepare the database.** Define a table and register its schema with database-decorators. The AntelopeJS project needs a database module and an API module. +2. **Choose the routes.** Start with the [route set](./3.routes.md) your clients need, instead of exposing every write route by default. +3. **Set the field policy.** Add [access rights](./4.access-rights.md), [validators](./5.validators.md) and [list fields](./6.listable.md). Field permissions do not authenticate users and do not authorize access to rows. +4. **Check one read and one rejected write.** Confirm that the response leaves out private fields and that invalid input never reaches storage. Then add [relationships](./7.foreign-keys.md) and [filters](./8.filters.md). -The following example declares all five CRUD routes for a `users` table. It assumes a configured provider and an existing `my-database` instance. Import the table declaration before calling `await RegisterSchema("myapp")` in the declaring module's initialization; await that setup before serving requests. +## Quick start -This is a field-policy example, not a production account system: write-only prevents a password from appearing in responses, but does not hash it or authenticate requests. Add an appropriate [database modifier](./9.modifiers.md) and your authentication policy before accepting real credentials. +The example declares all five CRUD routes for a `users` table. It assumes a configured database module and an existing `my-database` instance. Import the table declaration, then `await RegisterSchema("myapp")` when the declaring module initializes. Wait for that setup to finish before the module serves requests. + +This example sets a field policy. It is not a production account system. `AccessMode.WriteOnly` keeps the password out of responses, but it does not hash the password or authenticate requests. Add a [database modifier](./9.modifiers.md) and your authentication policy before you accept real credentials. ```ts docs-check import { Controller } from "@antelopejs/interface-api"; @@ -40,7 +46,6 @@ import { Table, } from "@antelopejs/interface-database-decorators"; -// Define the database table @RegisterTable("users", "myapp") class User extends Table { @Field("string") @@ -57,10 +62,8 @@ class User extends Table { declare password: string; } -// Create the database model class UserModel extends BasicDataModel(User, "users") {} -// Create the data controller @RegisterDataController() class UserAPI extends DataController( User, @@ -91,38 +94,38 @@ class UserAPI extends DataController( } ``` -This generates the following REST endpoints: +The controller serves these REST endpoints. -| Method | Endpoint | Description | -| ------ | ----------------------- | -------------------------- | -| GET | `/users/get?id=` | Retrieve a single user | -| GET | `/users/list?limit=10` | List users with pagination | -| POST | `/users/new` | Create a new user | -| PUT | `/users/edit?id=` | Update an existing user | -| DELETE | `/users/delete?id=` | Delete a user | +| Method | Endpoint | Description | +| ------ | ----------------------- | -------------------- | +| GET | `/users/get?id=` | Read one user | +| GET | `/users/list?limit=10` | List users, one page | +| POST | `/users/new` | Create a user | +| PUT | `/users/edit?id=` | Update a user | +| DELETE | `/users/delete?id=` | Delete a user | ### Check the field policy -Use disposable data and your configured API base URL. Create a user with `name`, `email`, and a dummy `password`; the response is an array of inserted IDs. Use the first ID for the following checks: +Use disposable data and the base URL of your API module. First, create a user with `POST /users/new` and a body that holds `name`, `email` and a dummy `password`. The response is the created user, with its `_id` and without `password`. Use that `_id` in the next checks. -1. `GET /users/get?id=` returns the readable fields, never `password`. -2. `GET /users/list?limit=10` includes `_id`, `name`, and `email` in `results`. -3. `PUT /users/edit?id=` with `{ "name": "Updated" }` preserves the omitted email. With `{ "email": "" }`, it returns `400` because email is mandatory on edit. +1. `GET /users/get?id=` returns the readable fields. You see `_id`, `email` and `name`, and never `password`. +2. `GET /users/list?limit=10` returns `_id`, `name` and `email` for each entry of `results`. +3. `PUT /users/edit?id=` with `{ "name": "Updated" }` keeps the email you did not send. With `{ "email": "" }`, the route answers `400`, because `email` is mandatory on `edit`. -`@Mandatory` does not check email syntax or password strength. Add [validators](./5.validators.md) before accepting untrusted input. +`@Mandatory` does not check the email syntax or the password strength. Add [validators](./5.validators.md) before you accept untrusted input. ## Core guides -- [Data Controllers](./2.data-controllers.md) - Create and configure data controllers -- [Routes](./3.routes.md) - Default routes, custom routes, and route options -- [Access Rights](./4.access-rights.md) - Control field read/write permissions -- [Validators](./5.validators.md) - Field-level validation and mandatory fields -- [Listable Fields](./6.listable.md) - Pagination, pluck modes, and sorting +- [Data controllers](./2.data-controllers.md). Create and configure data controllers. +- [Routes](./3.routes.md). Default routes, custom routes and route options. +- [Access rights](./4.access-rights.md). Which fields each action reads and writes. +- [Validators](./5.validators.md). Field validation and mandatory fields. +- [Listable fields](./6.listable.md). Pagination, pluck modes and sorting. ## Relationships and advanced queries -- [Foreign Keys](./7.foreign-keys.md) - Establish relationships between tables -- [Filters](./8.filters.md) - Add filtering capabilities to list endpoints -- [Modifiers](./9.modifiers.md) - Automatic data transformation with database-decorators -- [Joined Fields](./10.joined.md) - Flatten a remote field onto rows for native sort/filter -- [Computed Fields](./11.computed.md) - In-database computed values, sortable and pageable natively +- [Foreign keys](./7.foreign-keys.md). Relations between tables. +- [Filters](./8.filters.md). Filters on list endpoints. +- [Modifiers](./9.modifiers.md). Data transformation with database-decorators modifiers. +- [Joined fields](./10.joined.md). A remote field flattened onto each row, sortable and filterable in the database. +- [Computed fields](./11.computed.md). Values computed in the database, sortable and pageable there. diff --git a/docs/10.joined.md b/docs/10.joined.md index c60da78..76a8246 100644 --- a/docs/10.joined.md +++ b/docs/10.joined.md @@ -1,16 +1,20 @@ -# Joined Fields +--- +title: Joined fields +description: Flatten one field of another table onto each row with @Joined, so list requests sort and filter on it in the database. +mode: reference +--- ## Overview -The `@Joined` decorator imports a single scalar field from another table and flattens it onto each row at query time. Unlike `@Foreign`, which embeds the full referenced record as a nested object in the response, `@Joined` copies one remote field to the top level of the row before sorting and filtering are applied. This makes the joined field natively sortable, filterable, and searchable in the database. +The `@Joined` decorator copies one scalar field from another table onto each row at query time. `@Foreign` nests the whole referenced record under the field. `@Joined` puts one remote field at the top level of the row, before the list query sorts and filters. The database can therefore sort, filter, and search on the joined field. -Joined fields are read-only: the value is set by a lookup at query time and is never persisted on the controller's own table. Declaring a field as joined automatically sets its access mode to read-only and forces non-indexed sorting. +Joined fields are read-only. A lookup sets the value at query time, and the controller's own table never stores it. Declaring a field as joined sets its access mode to read-only and makes its sort non-indexed. -## Basic Usage +## Basic usage -Apply `@Joined` to a field that should be populated from a remote table. The local row must carry a foreign key (`localKey`) that matches the remote table's `remoteIndex`. +Apply `@Joined` to a field that takes its value from a remote table. The local row must hold a key (`localKey`) that matches the remote table's `remoteIndex`. -Use [foreign keys](./7.foreign-keys.md) when the response needs a nested record instead. The snippets below assume the controller imports and model from the [quick start](./1.introduction.md#quick-start), with `myapp` registered before requests are served. +Use [foreign keys](./7.foreign-keys.md) when the response needs a nested record instead. The snippets below assume the controller imports and the model from the [quick start](./1.introduction.md#quick-start), with `myapp` registered before the module serves requests. ```typescript fragment import { Joined } from "@antelopejs/interface-data-api/metadata"; @@ -84,7 +88,7 @@ A GET request to `/orders/get?id=order-123` returns the joined value at the top } ``` -## Decorator Parameters +## Decorator parameters ```typescript fragment @Joined({ table, localKey, remoteField, remoteIndex?, schema? }) @@ -92,15 +96,17 @@ A GET request to `/orders/get?id=order-123` returns the joined value at the top | Option | Type | Required | Default | Description | | ------------- | -------------------------- | -------- | ----------------- | -------------------------------------------------------------------------------------------------- | -| `table` | `Class` or `string` | Yes | - | The remote table class or registered table name string | -| `localKey` | `string` | Yes | - | The field on the local row holding the value matched against the remote table | -| `remoteField` | `string` | Yes | - | The remote field whose value is flattened onto the row | +| `table` | `Class
` or `string` | Yes | None | The remote table class or registered table name string | +| `localKey` | `string` | Yes | None | The field on the local row holding the value matched against the remote table | +| `remoteField` | `string` | Yes | None | The remote field whose value is flattened onto the row | | `remoteIndex` | `string` | No | `"_id"` | The index on the remote table to match `localKey` against | | `schema` | `string` | No | Controller schema | Name of the schema the remote table is registered in, when it differs from the controller's schema | -## Sorting and Filtering +When a list mode includes a joined field, the mode also selects the field's `localKey`. -Because the join is applied to the underlying query before sorting and filtering, joined fields behave like native columns. Combine `@Joined` with `@Sortable` or `@Filter` to order or filter by the imported value. +## Sorting and filtering + +The list query applies the join before it sorts and filters, so a joined field behaves like a stored column. Combine `@Joined` with `@Sortable` or `@Filter` to sort or filter on the joined value. ```typescript fragment @Sortable() @@ -113,28 +119,33 @@ Because the join is applied to the underlying query before sorting and filtering declare userName: string; ``` -Joined fields are always sorted without a database index (`@Sortable` is forced to non-indexed for them), since the value comes from a runtime lookup rather than a stored column. +A joined field always sorts without a database index, because its value comes from a lookup at query time and not from a stored column. `@Sortable` on a joined field is always non-indexed. -## Deferred Resolution on Lists +## Deferred resolution on lists -The default list route handles this scheduling for you. Continue with [cross-schema joins](#cross-schema-joins) unless you are building a custom route with `Query.List`. +The default list route schedules the lookups itself. Skip to [cross-schema joins](#cross-schema-joins) unless you are building a custom route with `Query.List`. -![Joined fields used by filters are resolved before counting; sort fields before paging; display-only fields after paging in the default list route.](./assets/joined-list.svg){width="480"} +![Joined fields used by filters are resolved before counting; sort fields before paging; display-only fields after paging in the default list route.](./assets/joined-list.svg "illustration"){width="480"} ### Advanced: composing `Query.List` -`Query.List` only materializes the joined groups that the request's sort or filters reference before the count. The remaining display-only groups are deferred: the returned stream resolves them itself as soon as an operation can observe them, so destructuring `[query, total]` alone always yields rows with every `@Joined` field resolved. +`Query.List` resolves the joined fields that the request's filters read before it counts the rows. It resolves the joined fields that the sort reads after the count and before the sort. It defers the remaining display-only joined fields. The returned stream resolves them as soon as an operation can read rows, so destructuring `[query, total]` alone always gives rows with every `@Joined` field resolved. -- Paging the stream (`slice`, `nth`) appends the lookups after the page boundary, so they only run on the returned rows. -- A bare `count()` — and any field aggregate (`sum`, `min`, `distinct(field)`, ...) on a non-joined field — skips them entirely (left-join lookups never change the row count nor other fields). -- `changes()` attaches to the raw stream, since change feeds cannot carry the join lookups. -- Any other operation (`filter`, `orderBy`, `map`, ...) materializes them first, since it may read the joined values. +- Paging the stream (`slice`, `nth`) adds the lookups after the page boundary, so they run only on the returned rows. +- A bare `count()` skips the lookups, and so does a field aggregate such as `sum`, `min`, or `distinct(field)` on a field that is not joined. A left-join lookup never changes the row count or the other fields. +- `changes()` attaches to the raw stream, because change feeds cannot carry the join lookups. +- Any other operation, such as `filter`, `orderBy`, or `map`, resolves the lookups first, because it may read the joined values. -The page-only benefit therefore holds as long as paging is the first thing the caller does. An operation applied to the whole stream before paging — a filter, a sort, or the `lookup` that `Query.Foreign` stages for a `@Foreign` field — materializes the joined groups over the whole matched set instead. The lookups still stay out of the count pipeline, but a caller that wants them strictly on the page must pass `{ exposeDeferredJoined: true }` to `Query.List` and resolve them itself after the slice: the raw stream is then returned unchanged and the third tuple element names the joined fields left to resolve with `Query.Joined`. This is what the default list route does, which also lets it limit the page lookups to the plucked fields. +The lookups therefore run on the page only when paging is the first operation the caller applies. An operation on the whole stream before paging resolves the joined fields over the whole matched set. Such operations include a filter, a sort, and the `lookup` that `Query.Foreign` adds for a `@Foreign` field. The lookups still stay out of the count. A caller that wants them on the page only must pass `{ exposeDeferredJoined: true }` to `Query.List` and resolve them after the slice. `Query.List` then returns the raw stream unchanged, and the third tuple element names the joined fields to resolve with `Query.Joined`. The default list route works this way, which also lets it limit the page lookups to the plucked fields. ### Reading a page: `Query.ListPage` -A custom list route over `Query.List` reads its page with `Query.ListPage` rather than chaining the steps itself. It slices the stream, then resolves on the page only, in this order: the deferred joined groups, the `@Computed` fields the filters and the sort did not already merge, the `@Foreign` relations (last, since a computed expression may read a relation's key), and the pluck. +A custom list route over `Query.List` reads its page with `Query.ListPage` instead of chaining the steps itself. `Query.ListPage` slices the stream, then resolves these on the page only, in this order: + +1. The deferred joined fields. +2. The `@Computed` fields that the filters and the sort did not already merge. +3. The `@Foreign` relations. They come last because a computed expression may read a relation's key. +4. The pluck. ```typescript fragment const [query, total, deferredJoined] = Query.List( @@ -158,9 +169,9 @@ const rows = await Query.ListPage(model.database, meta, query, { }); ``` -## Cross-Schema Joins +## Cross-schema joins -By default, the remote table is resolved in the same schema as the controller's table. Use the `schema` option to join against a table registered in a different schema; the join is resolved through the remote table's class, against the default instance of the schema `RegisterSchema` bound it to. The remote class must be registered with `@RegisterTable` before the field is declared; a string table name with no registered class in that schema throws at declaration. +By default, the Data API looks for the remote table in the schema of the controller's table. The `schema` option joins a table registered in a different schema. The join goes through the remote table's class, against the default instance of the schema that `RegisterSchema` bound it to. Register the remote class with `@RegisterTable` before you declare the field. A string table name with no registered class in that schema throws at declaration. ```typescript fragment @Joined({ @@ -172,17 +183,17 @@ By default, the remote table is resolved in the same schema as the controller's declare userName: string; ``` -When a table class is passed, its schema is inferred from its `@RegisterTable` metadata. If both the class metadata and the explicit `schema` option specify a schema and they disagree, an error is thrown. +When you pass a table class, the Data API reads its schema from its `@RegisterTable` metadata. If the class metadata and the `schema` option name different schemas, the declaration throws. -## Joined vs. Foreign +## `@Joined` compared with `@Foreign` -| Aspect | `@Joined` | `@Foreign` | -| ------------------- | -------------------------------------------- | --------------------------------------------------------------------- | -| Result shape | A single scalar value flattened onto the row | The full referenced record (or plucked subset) nested under the field | -| Access | Read-only (enforced) | Configurable via `@Access` | -| Sort / filter | Native (join applied before sort/filter) | Not natively sortable/filterable on remote fields | -| Multiple references | One remote field per decorator | Single or array (`multi`) references | +| Aspect | `@Joined` | `@Foreign` | +| ------------------- | -------------------------------------------- | ------------------------------------------------------------------------- | +| Result shape | A single scalar value flattened onto the row | The full referenced record, or its plucked fields, nested under the field | +| Access | Read-only (enforced) | Set with `@Access` | +| Sort and filter | In the database, join applied before both | No sort or filter on remote fields | +| Multiple references | One remote field per decorator | Single or array (`multi`) references | -## Next Steps +## Next steps -See the [computed fields documentation](./11.computed.md) to generalize this merge mechanism to arbitrary in-database expressions, or the [modifiers documentation](./9.modifiers.md) to learn how automatic data transformation integrates with the Data API. +To merge any in-database expression onto rows, see [Computed fields](./11.computed.md). To see how modifiers transform data in the Data API, see [Modifiers](./9.modifiers.md). diff --git a/docs/11.computed.md b/docs/11.computed.md index 5b11819..8fa1938 100644 --- a/docs/11.computed.md +++ b/docs/11.computed.md @@ -1,19 +1,45 @@ -# Computed Fields +--- +title: Computed fields +description: Compute a field in the database with @Computed, from the row or from a per-row subquery, and sort, filter, and page on it. +mode: reference +--- ## Overview -The `@Computed` decorator declares a field whose value is computed in the database from an expression, then merged onto each row at query time. The expression can be a row-local computation (derived from other fields of the same row) or a per-row subquery, such as an aggregate over another table. Because the merge happens before sorting and filtering, computed fields are natively sortable, filterable, and pageable in DB — no fetch-all or in-memory sorting required. - -Computed fields are read-only: the value is produced by the query and is never persisted on the controller's table. Declaring a field as computed automatically sets its access mode to read-only and forces non-indexed sorting, regardless of decorator order. - -## Basic Usage - -The expression receives the row proxy and the schema instance of the controller's table. It must return a value built from the query builder (`ValueProxy` operations or a subquery), not plain JavaScript computed at request time. - -The snippets extend the [quick-start controller pattern](./1.introduction.md#quick-start); define `GroupModel` with `BasicDataModel(Group)` and register the `myapp` schema before serving requests. Check [driver support](#driver-support) before using aggregate subqueries. - -```typescript fragment -import { Computed } from "@antelopejs/interface-data-api/metadata"; +The `@Computed` decorator declares a field whose value the database computes from an expression and merges onto each row at query time. The expression can compute from other fields of the same row, or run a per-row subquery such as an aggregate over another table. The merge happens before the list query sorts and filters, so the database sorts, filters, and pages computed fields. The API never fetches every row or sorts in memory. + +Computed fields are read-only. The query produces the value, and the controller's table never stores it. Declaring a field as computed sets its access mode to read-only and makes its sort non-indexed, whatever the decorator order. + +## Basic usage + +The expression receives the row proxy and the schema instance of the controller's table. It must return a value built with the query builder (`ValueProxy` operations or a subquery), not plain JavaScript computed at request time. + +The example follows the [quick-start controller pattern](./1.introduction.md#quick-start). Register the `myapp` schema before the module serves requests. Check [driver support](#driver-support) before you use aggregate subqueries. + +```typescript +import { Controller } from "@antelopejs/interface-api"; +import { + DataController, + DefaultRoutes, + RegisterDataController, +} from "@antelopejs/interface-data-api"; +import { + Access, + AccessMode, + Computed, + Listable, + ModelReference, + Sortable, +} from "@antelopejs/interface-data-api/metadata"; +import { + BasicDataModel, + Field, + Index, + Model, + RegisterTable, + Relation, + Table, +} from "@antelopejs/interface-database-decorators"; @RegisterTable("groups", "myapp") class Group extends Table { @@ -35,6 +61,8 @@ class Device extends Table { declare group_id: string; } +class GroupModel extends BasicDataModel(Group, "groups") {} + @RegisterDataController() class GroupAPI extends DataController( Group, @@ -56,7 +84,11 @@ class GroupAPI extends DataController( // Aggregate: number of devices referencing this group, computed in DB @Listable() @Computed( - (row, db) => db.table("devices").getAll(row.key("_id"), "group_id").count(), + (row, db) => + db + .table("devices") + .filter((device) => device.key("group_id").eq(row.key("_id"))) + .count(), { default: 0 }, ) @Sortable() @@ -64,11 +96,13 @@ class GroupAPI extends DataController( // Row-local expression @Listable() - @Computed((row) => (row.key("name") as ValueProxy).concat("!")) + @Computed((row) => row.key("name").concat("!")) declare display_name: string; } ``` +The row proxy types every field as a `FieldValue`. The subquery compares each device's `group_id` with the row's `_id` through `eq`, which accepts that type. + A GET request to `/groups/get?id=group-123` returns the computed values at the top level: ```json @@ -80,69 +114,79 @@ A GET request to `/groups/get?id=group-123` returns the computed values at the t } ``` -## Decorator Parameters +## Decorator parameters ```typescript fragment @Computed(expr, options?) ``` -| Parameter | Type | Required | Description | -| ----------------- | ------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------- | -| `expr` | `(row, db) => expression` | Yes | Expression receiving the row proxy and the schema instance; returns a `ValueProxy` expression or a subquery (`Datum`) | -| `options.default` | `unknown` | No | Fallback merged over the computed value when the expression yields `null` | +| Parameter | Type | Required | Description | +| ----------------- | ------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `expr` | `(row, db) => expression` | Yes | Expression receiving the row proxy and the schema instance. Returns a `ValueProxy` expression, a subquery (`Query`), or a constant | +| `options.default` | `FieldValue` | No | Fallback merged over the computed value when the expression yields `null` | ### The `default` option -Aggregate subqueries yield `null` instead of their natural zero value when the underlying set is empty (a `count()` over a group with no devices returns `null`, not `0`). Calling `.default(0)` inside the subquery expression does not help: the aggregation produces no document at all, so there is nothing to apply the default to. The `options.default` value solves this by being applied at the row level, after the merge: +An aggregate subquery over an empty set yields `null` instead of its zero value. A `count()` over a group with no devices returns `null`, not `0`. Calling `.default(0)` inside the subquery expression does not help, because the aggregation produces no document to apply the default to. The `options.default` value works because the Data API applies it to the row, after the merge: ```typescript fragment -@Computed((row, db) => db.table("devices").getAll(row.key("_id"), "group_id").count(), { - default: 0, -}) +@Computed( + (row, db) => + db + .table("devices") + .filter((device) => device.key("group_id").eq(row.key("_id"))) + .count(), + { default: 0 }, +) declare devices_count: number; ``` -Without it, rows with an empty aggregate carry `null`, which breaks equality filters (`filter_devices_count=eq:0`) and makes sort ordering depend on BSON type ordering. +Without it, a row with an empty aggregate holds `null`. An equality filter such as `filter_devices_count=eq:0` then misses the row, and the sort order depends on BSON type ordering. -## Sorting, Filtering, and Pagination +## Sorting, filtering, and pagination -Computed fields combine with `@Sortable` and `@Filter` like native columns. The framework injects the computation lazily, only when and where the value is needed: +Computed fields combine with `@Sortable` and `@Filter` like stored columns. The list query merges each computed field only where a step needs its value: -- A computed field referenced by an active filter is merged before filters run. -- A computed field used as the sort key is merged after the total count is captured and before the non-indexed `orderBy`, so the aggregate is not paid twice. -- A computed field requested only for display (in the pluck, neither sorted nor filtered) is merged after pagination (`slice`), so it is computed only for the returned page, not the whole collection. +- A computed field that an active filter reads is merged before the filters run. +- A computed field used as the sort key is merged after the total count and before the non-indexed `orderBy`, so the count does not compute the aggregate. +- A computed field requested only for display, in the pluck and neither sorted nor filtered, is merged after pagination (`slice`). The database computes it for the returned page only. -Sorting and pagination both stay in the database: sorting by a computed aggregate with `limit`/`offset` produces correct pages and a constant `total`. +Sorting and pagination both stay in the database. Sorting by a computed aggregate with `limit` and `offset` gives correct pages and the same `total` on every page. -Computed fields are always sorted without a database index (`@Sortable` is forced to non-indexed), since the value does not exist as a stored column. +A computed field always sorts without a database index, because its value is not a stored column. `@Sortable` on a computed field is always non-indexed. ### Filtering on numeric computed fields -Filter values arrive as strings from the query string. Use the validated `numberFilter` from [Numeric and Boolean Filters](./8.filters.md#numeric-and-boolean-filters), imported into the controller file. It rejects invalid numbers and honors the requested comparison operator; `parseFloat` alone accepts partial input such as `"3devices"`. +Filter values arrive as strings from the query string. Import the validated `numberFilter` from [Numeric and boolean filters](./8.filters.md#numeric-and-boolean-filters) into the controller file. It rejects invalid numbers and applies the requested comparison operator. `parseFloat` alone accepts partial input such as `"3devices"`. ```typescript fragment -@Computed((row, db) => db.table("devices").getAll(row.key("_id"), "group_id").count(), { - default: 0, -}) +@Computed( + (row, db) => + db + .table("devices") + .filter((device) => device.key("group_id").eq(row.key("_id"))) + .count(), + { default: 0 }, +) @Filter(numberFilter, false) declare devices_count: number; ``` -## Computed vs. Joined +## `@Computed` compared with `@Joined` -| Aspect | `@Computed` | `@Joined` | -| ------------- | --------------------------------------------------------------- | ------------------------------------------------------------ | -| Source | Arbitrary expression: row-local computation or per-row subquery | One scalar field from another table matched by a foreign key | -| Typical use | Aggregates (`count`, `sum`), derived values | Flattening a referenced record's field | -| Access | Read-only (enforced) | Read-only (enforced) | -| Sort / filter | Native, lazily injected only where needed | Native, always applied before sort/filter | +| Aspect | `@Computed` | `@Joined` | +| --------------- | ---------------------------------------------------------------- | ------------------------------------------------------------ | +| Source | Any expression: a computation over the row or a per-row subquery | One scalar field from another table matched by a foreign key | +| Typical use | Aggregates (`count`, `sum`), derived values | Flattening a field of a referenced record | +| Access | Read-only (enforced) | Read-only (enforced) | +| Sort and filter | In the database, merged only where a step needs the value | In the database, join applied before sort and filter | -`@Joined` remains the simpler choice for the 1:1 flattening case; `@Computed` generalizes the same merge mechanism to arbitrary expressions. +For one field copied from one referenced record, `@Joined` is simpler. `@Computed` applies the same merge to any expression. -## Driver Support +## Driver support -The aggregate form relies on the database driver translating a per-row subquery inside a merge into a correlated lookup (`$lookup` with `let`/`pipeline` on MongoDB). `@antelopejs/mongodb` supports this in the releases that translate such a subquery into that `$lookup`; its changelog lists the release that added it. Row-local expressions only use standard expression compilation and work on any driver. +The aggregate form needs a database driver that turns a per-row subquery inside a merge into a correlated lookup. On MongoDB, the lookup is a `$lookup` with `let` and `pipeline`. The `@antelopejs/mongodb` changelog names the release that added this translation. Row-local expressions use standard expression compilation and work on any driver. -## Next Steps +## Next steps -See the [joined fields documentation](./10.joined.md) for the dedicated 1:1 flattening decorator. +For the decorator that copies one field from a referenced record, see [Joined fields](./10.joined.md). diff --git a/docs/2.data-controllers.md b/docs/2.data-controllers.md index a6e27e1..43a3c82 100644 --- a/docs/2.data-controllers.md +++ b/docs/2.data-controllers.md @@ -1,14 +1,18 @@ -# Data Controllers +--- +title: Data controllers +description: The DataController function, the RegisterDataController decorator, and the parts every data controller declares. +mode: reference +--- ## Overview -A data controller is the central building block of the Data API. It connects a database table to a set of API endpoints, exposing the table fields you declare on the class. The `DataController` function creates a base class that combines your table definition, route definitions, and controller path into a single cohesive unit. +A data controller connects a database table to a set of API endpoints and exposes the table fields you declare on the class. The `DataController` function returns a base class built from three parts: the table class, the route definitions and the controller base that holds the endpoint path. -## Create a Data Controller +## Create a data controller -Use the `DataController` function to define a new controller. It accepts three arguments: the table class, a route definition object, and a controller base with the endpoint path. +`DataController` takes three arguments: the table class, a route definition object, and a controller base with the endpoint path. -This example uses the `myapp` schema and the `my-database` instance. Import the table declaration and await `RegisterSchema("myapp")` in the declaring module before serving requests; provision the named instance through your database setup. +The example uses the `myapp` schema and the `my-database` instance. Import the table declaration and await `RegisterSchema("myapp")` in the declaring module before it serves requests. Your database setup provisions the named instance. ```ts docs-check import { Controller } from "@antelopejs/interface-api"; @@ -31,7 +35,6 @@ import { Table, } from "@antelopejs/interface-database-decorators"; -// Define the database table @RegisterTable("users", "myapp") class User extends Table { @Field("string") @@ -44,10 +47,8 @@ class User extends Table { declare age: number; } -// Create the database model class UserModel extends BasicDataModel(User, "users") {} -// Create the data controller @RegisterDataController() class UserAPI extends DataController( User, @@ -71,50 +72,57 @@ class UserAPI extends DataController( } ``` -## `DataController` Signature +## `DataController` signature ```typescript fragment -DataController( - tableClass: typeof Table, // Database table class - routes: DataControllerDef, // Route definitions (e.g., DefaultRoutes.All) - base: typeof Controller, // Controller base with path -) +function DataController< + C extends Class, + P extends DataControllerDef = DataControllerDef, + Base extends ControllerClass = ControllerClass, +>( + tableClass: C, + def: P, + base: Base, +): Class & { table: InstanceType }> & Base; ``` -| Parameter | Description | -| ------------ | --------------------------------------------------------------------------------------------------------------------------------- | -| `tableClass` | The database table class decorated with `@RegisterTable`. The controller validates that this table exists in the database schema. | -| `routes` | An object mapping endpoint names to route callbacks. Use `DefaultRoutes.All` for standard CRUD or pass a custom subset. | -| `base` | The controller base, typically created with `Controller("/path")`, which sets the URL prefix for all endpoints. | +| Parameter | Description | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `tableClass` | The table class decorated with `@RegisterTable`. `DataController` throws `Non-existent Database Schema` when the table's schema is not registered, and `Unregistered Database Table` when the schema does not hold the table. | +| `def` | An object that maps endpoint names to route callbacks. Use `DefaultRoutes.All` for the five CRUD routes, or pass a subset or custom routes. | +| `base` | The controller base, usually `Controller("/path")` from `@antelopejs/interface-api`. Its location is the URL prefix of every endpoint. | -## Required Components +The returned class extends `base`. Its prototype holds one method per key of `def`, and its instances declare a `table` field typed by the table class. -Every data controller requires the following elements: +## Required components -1. **Table Class** - A database table decorated with `@RegisterTable`, defining the schema. -2. **Route Definitions** - Which operations to expose. Use `DefaultRoutes.All` or a custom selection. -3. **Controller Base** - The URL path prefix, created with `Controller("/path")`. -4. **Model Reference** - A property decorated with `@ModelReference()` that holds a database model instance. This model provides the actual database operations (insert, get, update, delete). A controller derived from another data controller (for example `Controller("/path", ParentController)`) inherits its model reference unless it declares its own. +Every data controller declares these four parts. -## The `@RegisterDataController` Decorator +1. **Table class.** A database table decorated with `@RegisterTable`, which defines the schema. +2. **Route definitions.** The operations to expose. Use `DefaultRoutes.All` or a custom selection. +3. **Controller base.** The URL path prefix, created with `Controller("/path")`. +4. **Model reference.** A property decorated with `@ModelReference()` that holds a database model instance. The model runs the database operations (insert, get, update and delete). A controller derived from another data controller (for example `Controller("/path", ParentController)`) inherits its model reference unless it declares its own. -Apply `@RegisterDataController()` to the class to finalize the controller setup. This decorator processes all field decorators, registers the defined routes, and connects parameter providers to each endpoint. +## The `@RegisterDataController` decorator -## Field Declarations +Apply `@RegisterDataController()` to the class to finish the controller setup. For each route of the definition, the decorator applies the route's parameter decorators, registers a prefix route that records the route entry on the request context, and registers the route method as the handler at `/`. -Fields declared on the data controller class determine which database columns are exposed through the API. Each field can be annotated with decorators to control its behavior: +## Field declarations -- `@Access` - Set read/write permissions. -- `@Listable` - Include the field in list responses. -- `@Mandatory` - Require the field on specific operations. -- `@Validator` - Attach a validation function. -- `@Foreign` - Establish a foreign key relationship. -- `@Joined` - Flatten a scalar field from another table onto the row. -- `@Filter` - Enable filtering on the field. -- `@Sortable` - Allow sorting by the field. +The fields you declare on the data controller class decide which table fields the API exposes. Each field takes decorators that set its behaviour: -Fields without any decorator are ignored by the Data API and do not appear in API responses or accept input. +- `@Access` sets read and write permissions. +- `@Listable` includes the field in list responses. +- `@Mandatory` requires the field on specific operations. +- `@Validator` attaches a validation function. +- `@Foreign` declares a foreign key relationship. +- `@Joined` flattens a scalar field from another table onto the row. +- `@Computed` adds a value the database computes for each row. +- `@Filter` enables filtering on the field. +- `@Sortable` allows sorting by the field. -## Next Steps +The Data API ignores a field without an access mode. Such a field never appears in responses and never accepts input. `@Access` gives a field its mode, and `@Joined` and `@Computed` make their field read-only. -See the [routes documentation](./3.routes.md) to learn about available routes and how to customize them. +## Next steps + +The [routes page](./3.routes.md) lists the available routes and how to customize them. diff --git a/docs/3.routes.md b/docs/3.routes.md index 62607aa..c8d388c 100644 --- a/docs/3.routes.md +++ b/docs/3.routes.md @@ -1,18 +1,22 @@ -# Routes +--- +title: Routes +description: The default CRUD routes, their parameters and responses, the WithOptions and WithGetGuard helpers, custom routes, and route errors. +mode: reference +--- ## Overview -Routes define which operations are available on a data controller. The module provides predefined route configurations that automatically generate endpoints with the correct HTTP methods, parameter extraction, and response handling. +Routes define which operations a data controller serves. The `DefaultRoutes` namespace holds ready-made routes. Each one sets its HTTP method, the parameters it reads from the request and the response it sends. -## Default Routes +## Default routes -The `DefaultRoutes` namespace provides five built-in routes covering standard CRUD operations. +The `DefaultRoutes` namespace holds five routes for the CRUD operations. ```typescript import { DefaultRoutes } from "@antelopejs/interface-data-api"; ``` -| Route | Object | HTTP Method | Endpoint Pattern | +| Route | Object | HTTP method | Endpoint pattern | | ------ | ---------------------- | ----------- | -------------------------- | | Get | `DefaultRoutes.Get` | GET | `/resource/get?id=` | | List | `DefaultRoutes.List` | GET | `/resource/list` | @@ -20,11 +24,11 @@ import { DefaultRoutes } from "@antelopejs/interface-data-api"; | Edit | `DefaultRoutes.Edit` | PUT | `/resource/edit?id=` | | Delete | `DefaultRoutes.Delete` | DELETE | `/resource/delete?id=` | -Use `DefaultRoutes.All` to include all five routes at once. +`DefaultRoutes.All` holds all five routes under the keys `get`, `list`, `new`, `edit` and `delete`. -## Route Configuration +## Route configuration -Pass a route definition object to `DataController` to control which endpoints are created. +The route definition object you pass to `DataController` decides which endpoints exist. Each key is an endpoint name. ```typescript fragment // All CRUD routes @@ -48,31 +52,27 @@ class UserAPI extends DataController( }, Controller("/users"), ) { - // edit and delete endpoints are not created + // no edit or delete endpoint } ``` -## Route Details +## Route details ### Get -Retrieves a single record by its identifier. +Reads one record by its identifier. The endpoint is `GET /resource/get`. -**Endpoint:** `GET /resource/get` +| Query parameter | Required | Description | +| --------------- | -------- | ----------------- | +| `id` | Yes | Record identifier | -**Query Parameters:** - -| Parameter | Required | Description | -| --------- | -------- | ----------------- | -| `id` | Yes | Record identifier | - -**Example:** +A request without `id` answers `400` with `Missing id.`. A missing record answers `404 Not Found`. ```http GET /users/get?id=user-123 ``` -**Response:** +The response holds the readable fields of the record. ```json { @@ -84,26 +84,21 @@ GET /users/get?id=user-123 ### List -Retrieves multiple records with pagination, sorting, and filtering support. - -**Endpoint:** `GET /resource/list` - -**Query Parameters:** +Reads one page of records, with sorting and filtering. The endpoint is `GET /resource/list`. -| Parameter | Required | Default | Description | -| --------------- | -------- | ------- | --------------------------------------------- | -| `limit` | No | `10` | Maximum number of records to return | -| `offset` | No | `0` | Number of records to skip | -| `sortKey` | No | - | Field to sort by (must be marked `@Sortable`) | -| `sortDirection` | No | `asc` | Sort direction: `asc` or `desc` | - -**Example:** +| Query parameter | Required | Default | Description | +| ---------------- | -------- | -------------------- | ------------------------------------------------------------------------------------------------ | +| `limit` | No | `maxPage`, else `10` | Maximum number of records to return. The route caps it at `maxPage`, or at `100` without one. | +| `offset` | No | `0` | Number of records to skip | +| `sortKey` | No | none | Field to sort by. The field needs `@Sortable`, or the route answers `400 Field is not sortable.` | +| `sortDirection` | No | `asc` | `asc` or `desc`. Any other value sorts ascending. | +| `filter_` | No | none | A filter on a field declared with `@Filter`. See [filters](./8.filters.md). | ```http GET /users/list?limit=10&offset=0&sortKey=name&sortDirection=asc ``` -**Response:** +The response holds the page, the total count of matching records, and the `offset` and `limit` the route used. ```json { @@ -119,13 +114,7 @@ GET /users/list?limit=10&offset=0&sortKey=name&sortDirection=asc ### New -Creates a new record. The request body must be a JSON object with the fields to set. - -**Endpoint:** `POST /resource/new` - -**Body:** JSON object with field values. - -**Example:** +Creates a record. The endpoint is `POST /resource/new`, and the body is a JSON object with the field values. ```http POST /users/new @@ -138,7 +127,7 @@ Content-Type: application/json } ``` -**Response:** the created record, as [Get](#get) answers it, with its `_id` even when `get` does not read it. +The response is the created record, as [Get](#get) answers it, with its `_id` even when `get` does not read `_id`. ```json { @@ -148,23 +137,19 @@ Content-Type: application/json } ``` -Every writable field is written: a field absent from the body is written as `undefined`, and every writable setter of the controller runs (with `undefined` for absent fields). A body that is not valid JSON or not a JSON object is rejected with `400 Bad Request`. +The row starts from the defaults the controller class declares. Every writable field is then written. A field absent from the body is written as `undefined`, and every writable setter of the controller runs, with `undefined` for an absent field. The route answers `400 Bad Request` for a body that is not valid JSON or not a JSON object. -Before inserting, the `insert` event is triggered on any table modifiers attached to the database table. +Before the insert, the route triggers the `insert` event on the modifiers of the table. ### Edit -Updates an existing record by its identifier. +Updates a record by its identifier. The endpoint is `PUT /resource/edit`. -**Endpoint:** `PUT /resource/edit` +| Query parameter | Required | Description | +| --------------- | -------- | ----------------- | +| `id` | Yes | Record identifier | -**Query Parameters:** - -| Parameter | Required | Description | -| --------- | -------- | ----------------- | -| `id` | Yes | Record identifier | - -**Body:** JSON object with the fields to update. Only the fields present in the body are written: +The body is a JSON object with the fields to update. The route writes only the fields present in the body. | In the body | Effect on the field | | ----------- | ------------------- | @@ -172,11 +157,9 @@ Updates an existing record by its identifier. | `null` | cleared | | a value | set | -Writable setters only run for the fields present in the body. Modifier fields (localized, encrypted…) absent from the body keep their stored value in every language. +Writable setters run only for the fields present in the body. A modifier field (localized or encrypted, for example) absent from the body keeps its stored value in every language. -A missing record answers `404 Not Found`. A body that is not valid JSON or not a JSON object is rejected with `400 Bad Request`. - -**Example:** +A request without `id` answers `400` with `Missing id.`. A missing record answers `404 Not Found`. A body that is not valid JSON or not a JSON object answers `400 Bad Request`. ```http PUT /users/edit?id=user-123 @@ -188,7 +171,7 @@ Content-Type: application/json } ``` -**Response:** the stored record after the update, as [Get](#get) answers it (joined, computed and foreign fields included), so the caller needs no second read. +The response is the stored record after the update, as [Get](#get) answers it, with joined, computed and foreign fields. The caller needs no second read. ```json { @@ -198,45 +181,41 @@ Content-Type: application/json } ``` -Before updating, the `update` event is triggered on any table modifiers attached to the database table. +Before the update, the route triggers the `update` event on the modifiers of the table. ### Delete -Deletes one or more records by their identifiers. - -**Endpoint:** `DELETE /resource/delete` +Deletes one or more records by their identifiers. The endpoint is `DELETE /resource/delete`. -**Query Parameters:** +| Query parameter | Required | Description | +| --------------- | -------- | ----------------------------------------------------------------------------------- | +| `id` | Yes | Record identifier. Repeat the `id` parameter to delete several records in one call. | -| Parameter | Required | Description | -| --------- | -------- | -------------------------------------------------------------------------------------- | -| `id` | Yes | Record identifier(s). Pass multiple `id` parameters to delete several records at once. | - -**Example:** +A request without `id` answers `400` with `Missing id.`. ```http DELETE /users/delete?id=user-123 ``` -**Response:** +The route answers `200 OK`. The body is the number of records the database deleted. An identifier that matches no record is not an error. ```http 200 OK ``` -## Route Options with `WithOptions` +## Route options with `WithOptions` -The `DefaultRoutes.WithOptions` function customizes route behavior by overriding query parameters or route settings. +`DefaultRoutes.WithOptions` customizes a route. It sets route options, which take precedence over the query parameters of the same name, and it can rename the endpoint. ```typescript fragment DefaultRoutes.WithOptions(callback, options?, endpoint?) ``` -| Parameter | Description | -| ---------- | ------------------------------------------------------------------------------------ | -| `callback` | The route callback to customize (e.g., `DefaultRoutes.Edit`). | -| `options` | An object with option overrides. These values take precedence over query parameters. | -| `endpoint` | An optional custom endpoint path. Defaults to the route key name. | +| Parameter | Description | +| ---------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `callback` | The route to customize, such as `DefaultRoutes.Edit`, or a route `WithOptions` already returned. In that case the options merge. | +| `options` | An object of option values. A query parameter with the same name is ignored. Route `filters` combine with the request's filters. | +| `endpoint` | A custom endpoint path. It defaults to the route key in the definition object. | ### Example @@ -250,48 +229,52 @@ const routes = { @RegisterDataController() class UserAPI extends DataController(User, routes, Controller("/users")) { - // /users/edit enforces mandatory fields - // /users/quickEdit skips mandatory field validation + // /users/edit checks mandatory fields + // /users/quickEdit skips the mandatory field check } ``` -### Available Options per Route +### Available options per route + +The string options `noForeign` and `noMandatory` take effect for any non-empty string, `"false"` included. -**Get:** +The Get route takes these options. -| Option | Type | Description | -| ----------- | -------- | ------------------------------------------------ | -| `index` | `string` | Use a secondary index instead of the primary key | -| `noForeign` | `string` | Skip foreign key resolution | +| Option | Type | Description | +| ----------- | -------- | -------------------------------------------------------------------- | +| `index` | `string` | Read the record through a secondary index instead of the primary key | +| `noForeign` | `string` | Skip foreign key resolution | -**List:** +The List route takes these options. | Option | Type | Description | | ----------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `maxPage` | `number` | Maximum allowed value for `limit` (caps the page size) | +| `maxPage` | `number` | Maximum value of `limit`, and the page size when the request sends no `limit`. Without it, the cap is `100`. | | `noForeign` | `boolean` | Skip foreign key resolution | -| `noPluck` | `boolean` | Return all database fields instead of plucked fields | -| `pluckMode` | `string` | Select a named pluck mode (see `@Listable`) | -| `filters` | `Record` | Mandatory filters merged with request filters; route field keys win (see [filters](./8.filters.md#mandatory-route-filters)). | +| `noPluck` | `boolean` | Return all database fields instead of the plucked fields | +| `pluckMode` | `string` | Select a named pluck mode (see `@Listable`). A mode with no field answers `400`. | +| `filters` | `Record` | Mandatory filters merged with request filters. Route field keys win (see [filters](./8.filters.md#mandatory-route-filters)). | + +The New route takes this option. -**New:** +| Option | Type | Description | +| ------------- | -------- | ------------------------------ | +| `noMandatory` | `string` | Skip the mandatory field check | -| Option | Type | Description | -| ------------- | -------- | ------------------------------- | -| `noMandatory` | `string` | Skip mandatory field validation | +The Edit route takes these options. -**Edit:** +| Option | Type | Description | +| ------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `index` | `string` | Find the record and read the response through a secondary index. The update itself targets the record whose primary key equals `id`. | +| `noMandatory` | `string` | Skip the mandatory field check | -| Option | Type | Description | -| ------------- | -------- | ------------------------------------------------ | -| `index` | `string` | Use a secondary index instead of the primary key | -| `noMandatory` | `string` | Skip mandatory field validation | +## Amending the body of `new` and `edit` -## Amending the Body of New and Edit +The `new` and `edit` routes take the raw request body (`Buffer`) or a body already parsed (`Validation.WrittenBody`). A route that wraps them can parse the body, amend it (stamp a field or drop one) and pass the object on. The route validates and writes the object as it receives it, with no second serialization. -The `new` and `edit` routes take the raw request body (`Buffer`) or a body already parsed (`Validation.WrittenBody`). A route wrapping them can parse the body, amend it (stamp a field, drop one) and hand the object on: the route validates and writes it as handed, without a second serialization. +A parsed body is `FieldValues`, which holds fields by name. Each field holds a `FieldValue`: a string, a number, a boolean, `null`, a `Date`, or an array or plain object of those. `Validation.ParseBody` answers `400` for anything else, whether it comes from a `Buffer` or from a body already parsed. That includes a function, a class instance, a `bigint`, a cycle and an `undefined` array element. -A parsed body is `FieldValues`: fields by name, each holding a `FieldValue` (a string, number, boolean, `null`, `Date`, or an array or plain object of those). `Validation.ParseBody` answers 400 for anything else, whether it comes from a `Buffer` or from a body already parsed: a function, a class instance, a `bigint`, a cycle or an `undefined` array element. The default routes run with the controller as `this`, so a wrapping function declares `this: DataControllerInstance` and calls `route.func.call(this, ...)`. +The default routes run with the controller as `this`. A wrapping function declares `this: DataControllerInstance` and calls `route.func.call(this, ...)`. ```ts docs-check import { @@ -319,9 +302,9 @@ const stampedNew = { }; ``` -## Loaded-Model Guards with `WithGetGuard` +## Loaded-model guards with `WithGetGuard` -`DefaultRoutes.WithGetGuard(guard)` returns a Get route that awaits a guard after loading and unlocking the model, but before selecting readable fields and applying response getters. Use it to check a stored discriminator or ownership field that must not appear in the response. +`DefaultRoutes.WithGetGuard(guard)` returns a Get route that awaits a guard after it loads and unlocks the model. The guard runs before the route selects the readable fields and applies the response getters. A guard can check a stored discriminator or ownership field that never appears in the response. ```ts docs-check import { DefaultRoutes } from "@antelopejs/interface-data-api"; @@ -329,7 +312,7 @@ import { assert } from "@antelopejs/interface-api-util"; const invoiceGet = DefaultRoutes.WithGetGuard( async function (ctx, current, params) { - // documentType can be stored without being exposed through @Access. + // The table stores documentType, and no @Access exposes it. assert(current.documentType === "invoice", 404, "Not Found"); }, ); @@ -340,26 +323,26 @@ const routes = { }; ``` -The `GetGuard` callback receives `(ctx, current, params)`. Declare it as a regular function to access the controller through `this`, typed `DataControllerInstance`. `current` is `FieldValues`. +The `GetGuard` callback receives `(ctx, current, params)`. Declare it as a regular function to reach the controller through `this`, typed `DataControllerInstance`. That type is `object`, with no string index, so narrow `this` (with `instanceof` your controller class, for example) before you read a controller field. `current` is `RowFields`, whose values are `unknown`. -| Argument | Contents | Use it for | -| --------- | --------------------------------------------------------------------------------------- | ---------------------------------------------- | -| `ctx` | Request context | Request-specific policy. | -| `current` | Loaded model, including non-readable fields and normal Get joined/computed/foreign data | Inspecting stored ownership or discriminators. | -| `params` | `Parameters.GetParameters` | The parsed Get parameters. | +| Argument | Contents | Use it for | +| --------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------- | +| `ctx` | Request context | Policy that depends on the request | +| `current` | Loaded model, with the fields no `@Access` reads and the joined, computed and foreign data of Get | Checking stored ownership or discriminators | +| `params` | `Parameters.GetParameters` | The parsed Get parameters | -Treat `current` as read-only: it is not the response DTO and the API does not freeze it. +Treat `current` as read-only. It is not the response object, and the API does not freeze it. -Keep these boundaries when writing a guard: +A guard follows these rules. -- **Deny by throwing.** Throw an `HTTPResult` or use `assert`. A normal return permits the read, including a return value of `false`. -- **Missing records bypass the guard.** The route returns `404` without invoking it. -- **Use the already-loaded model.** The guard does not issue a second read or change field-access rules. -- **Protect each route deliberately.** The guard applies only to the route using it, not list, edit, or delete routes. +- **Deny by throwing.** Throw an `HTTPResult` or call `assert`. A normal return allows the read, even a return value of `false`. +- **A missing record skips the guard.** The route answers `404` without calling it. +- **The guard reads the loaded model.** It issues no second read and does not change field access rules. +- **Each route needs its own guard.** The guard applies only to the route that uses it, not to the list, edit or delete routes. -## Custom Routes +## Custom routes -For operations beyond CRUD, define a custom route callback with `func`, `args`, and `method` properties. +For operations beyond CRUD, define a route callback with `func`, `args` and `method` properties. ```typescript fragment import { Context } from "@antelopejs/interface-api"; @@ -392,9 +375,9 @@ class UserAPI extends DataController( } ``` -The `args` array contains parameter decorators that extract values from the request context. Each decorator corresponds to a parameter in the `func` callback, in order. +The `args` array holds parameter decorators that read values from the request context. Entry `i` of `args` supplies parameter `i` of `func`. -`DataControllerCallback` states that pairing: `A` is the tuple of what `func` receives, and `args` holds one decorator per element of `A`. The default routes declare theirs (`DefaultRoutes.Get` takes `[RequestContext, Parameters.GetParameters]`, `DefaultRoutes.New` and `Edit` add the `Validation.WrittenBody`), so a route wrapping one can be generic over the tuple and call `route.func.call(this, ...)` with typed arguments. Without `A`, the parameters are not known and not checked: that is the type a `DataControllerDef` holds. +`DataControllerCallback` states that pairing. `A` is the tuple of what `func` receives, and `args` holds one decorator per element of `A`. The default routes declare their tuple. `DefaultRoutes.Get` takes `[RequestContext, Parameters.GetParameters]`, and `DefaultRoutes.New` and `DefaultRoutes.Edit` add the `Validation.WrittenBody`. A route that wraps one of them can be generic over the tuple and call `route.func.call(this, ...)` with typed arguments. Without `A`, the parameters are unknown and unchecked. A `DataControllerDef` holds routes of that type. ```ts docs-check import type { RequestContext } from "@antelopejs/interface-api"; @@ -428,18 +411,18 @@ function withLoggedId( const loggedGet = withLoggedId(DefaultRoutes.Get); ``` -## Error Handling +## Error handling -Default routes return standard HTTP error responses: +The default routes answer with these HTTP errors. -| Status | Condition | -| --------------------------- | ---------------------------------------------------------------- | -| `400 Bad Request` | Validation fails, missing required fields, or invalid parameters | -| `404 Not Found` | Record does not exist | -| `500 Internal Server Error` | Database operation fails | +| Status | Condition | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `400 Bad Request` | A field fails its validator, a mandatory field is missing, the body is not a JSON object, or a parameter is missing or invalid | +| `404 Not Found` | The record does not exist | +| `500 Internal Server Error` | The database operation fails, or the controller holds no model | -Error messages are returned as plain text in the response body. +A failed mandatory or validator check answers a JSON body with a `code` and the `fields` concerned (see [validators](./5.validators.md#error-handling)). Every other error the routes raise answers its message as text, such as `Missing id.` or `Not Found`. -## Next Steps +## Next steps -See the [access rights documentation](./4.access-rights.md) to learn how to control field read/write permissions. +The [access rights page](./4.access-rights.md) covers which fields each action reads and writes. diff --git a/docs/4.access-rights.md b/docs/4.access-rights.md index 1fbf778..5a3f3db 100644 --- a/docs/4.access-rights.md +++ b/docs/4.access-rights.md @@ -1,41 +1,45 @@ -# Access Rights +--- +title: Access rights +description: The @Access decorator, the three AccessMode values and per-action overrides that decide which fields each route reads and writes. +mode: reference +--- ## Overview -The `@Access` decorator controls how fields are read and written through the API. Each field can be set to read-only, write-only, or read-write, providing fine-grained control over data exposure. Access modes can also be overridden on a per-action basis, allowing different permissions for `get`, `list`, `new`, and `edit` operations. +The `@Access` decorator sets how the API reads and writes a field. A field is read-only, write-only or read-write. An override can give one action (`get`, `list`, `new` or `edit`) a different mode. ## Separate field policy from caller permissions -`@Access` answers “may this action read or write this field?”, not “may this user access this record?”. Authenticate the caller and apply record-level policy separately. A write-only password is hidden from responses, but this decorator does not hash it. +`@Access` answers "may this action read or write this field?". It does not answer "may this user access this record?". Authenticate the caller and apply record-level policy separately. A write-only password stays out of responses, but `@Access` does not hash it. -Build the field policy in this order: +Build the field policy in this order. -1. Set `@Access` for each public field. Undecorated fields remain private. -2. Add action overrides for differences between creation and editing. -3. Add `@Listable` for the fields selected by list responses; readable alone does not define a list projection. +1. Set `@Access` on each public field. A field without it stays private. +2. Add action overrides where creation and editing differ. +3. Add `@Listable` to the fields list responses select. Read access alone does not put a field in a list response. 4. Add validators and mandatory-field rules for writable inputs. -For example, a display name can be readable and writable, an ID readable only, and a password writable only. Test each direction: attempting to write a read-only field is ignored, while omitting a mandatory writable field is a validation concern. +For example, a display name is readable and writable, an ID is read-only, and a password is write-only. Test each direction. The routes ignore a value sent for a read-only field. A missing mandatory field is a validation error, covered on the [validators page](./5.validators.md). -## Access Modes +## Access modes -The `AccessMode` enum defines three permission levels: +The `AccessMode` enum defines three modes. ```typescript import { Access, AccessMode } from "@antelopejs/interface-data-api/metadata"; ``` -| Mode | Value | Description | -| ---------------------- | ----- | --------------------------------------------------------------------------------------------------- | -| `AccessMode.ReadOnly` | `1` | Field is readable; list responses also require selection by the pluck mode. POST and PUT ignore it. | -| `AccessMode.WriteOnly` | `2` | Field is accepted in POST and PUT bodies but never appears in responses | -| `AccessMode.ReadWrite` | `3` | Field is both readable and writable | +| Mode | Value | Description | +| ---------------------- | ----- | ------------------------------------------------------------------------------------------------------ | +| `AccessMode.ReadOnly` | `1` | The field is readable. A list response also needs the pluck mode to select it. POST and PUT ignore it. | +| `AccessMode.WriteOnly` | `2` | POST and PUT bodies accept the field, and no response holds it | +| `AccessMode.ReadWrite` | `3` | The field is readable and writable | -## Basic Usage +## Basic usage -### Read-Only Fields +### Read-only fields -Read-only fields appear in API responses but cannot be modified through create or update requests. Values sent for these fields in request bodies are silently ignored. +A read-only field appears in responses, and create and update requests cannot change it. The routes ignore a value sent for it in a request body and report nothing. ```typescript fragment @RegisterDataController() @@ -59,11 +63,11 @@ class UserAPI extends DataController( } ``` -A PUT request to `/users/edit?id=user-123` with `{ "createdAt": "2025-01-01", "name": "Alice" }` updates only `name`. The `createdAt` field is ignored. +A PUT request to `/users/edit?id=user-123` with `{ "createdAt": "2025-01-01", "name": "Alice" }` updates only `name`. The route ignores `createdAt`. -### Write-Only Fields +### Write-only fields -Write-only fields accept input during create and update operations but are never included in API responses. This is useful for sensitive data like passwords. +A write-only field accepts input on create and update, and no response holds it. Passwords and other secrets use this mode. ```typescript fragment @RegisterDataController() @@ -87,7 +91,7 @@ class UserAPI extends DataController( } ``` -A GET request to `/users/get?id=user-123` returns `name` and `email` but never includes `password`: +A GET request to `/users/get?id=user-123` returns `name` and `email`, and never `password`. ```json { @@ -96,9 +100,9 @@ A GET request to `/users/get?id=user-123` returns `name` and `email` but never i } ``` -### Read-Write Fields +### Read-write fields -Read-write fields are fully accessible: they appear in responses and accept input in request bodies. +A read-write field appears in responses and accepts input in request bodies. ```typescript fragment @Access(AccessMode.ReadWrite) @@ -108,19 +112,19 @@ declare email: string; declare name: string; ``` -## Per-Action Overrides +## Per-action overrides -The `@Access` decorator accepts an optional second argument to override the access mode for specific actions. This allows a field to behave differently depending on the operation being performed. +`@Access` takes an optional second argument that overrides the mode for named actions. ```typescript fragment -@Access(defaultMode: AccessMode, overrides?: Record) +@Access(mode: AccessMode, overrides?: Record) ``` -The `overrides` object maps action names (`"get"`, `"list"`, `"new"`, `"edit"`) to an `AccessMode` value. Actions not listed in the overrides use the default mode. +The `overrides` object maps action names (`"get"`, `"list"`, `"new"` and `"edit"`) to an `AccessMode`. An action that `overrides` does not name uses `mode`. -To read the access a field has in one action, outside the routes (to render a form read-only on edit, for example), call `GetEffectiveMode(field, action)` from `@antelopejs/interface-data-api/metadata` with the field's metadata (`meta.fields[name]`). It is the mode the routes apply. +To read the mode a field has in one action outside the routes (to render a form read-only on edit, for example), call `GetEffectiveMode(field, action)` from `@antelopejs/interface-data-api/metadata` with the field's metadata (`meta.fields[name]`). It returns the mode the routes apply. -### Example: Writable on Create, Read-Only on Edit +### Example: writable on create, read-only on edit ```typescript fragment @RegisterDataController() @@ -139,25 +143,25 @@ class UserAPI extends DataController( @Access(AccessMode.ReadWrite) declare name: string; - // role can be set when creating, but cannot be changed during edit + // Writable on new, read-only on edit @Access(AccessMode.ReadWrite, { edit: AccessMode.ReadOnly }) declare role: string; - // age is read-only by default, but writable during creation + // Read-only, except on new @Access(AccessMode.ReadOnly, { new: AccessMode.ReadWrite }) declare age: number; } ``` -In this configuration: +With this configuration: -- `role` is readable and writable in all operations except `edit`, where it becomes read-only. -- `age` is read-only by default but can be set during `new` (create) operations. +- `role` is readable and writable in every action except `edit`, where it is read-only. +- `age` is read-only, except in `new`, where a create request can set it. -## Fields Without `@Access` +## Fields without `@Access` -Ordinary fields need `@Access` to appear in responses or accept input; `@Listable` alone does not grant access. The derived-field decorators [`@Joined`](./10.joined.md) and [`@Computed`](./11.computed.md) are exceptions: they set read-only access themselves. +An ordinary field needs `@Access` to appear in responses or accept input. `@Listable` alone grants no access. The derived-field decorators [`@Joined`](./10.joined.md) and [`@Computed`](./11.computed.md) are the exceptions. They set read-only access themselves. -## Next Steps +## Next steps -See the [validators documentation](./5.validators.md) to learn how to enforce validation rules on field values. +The [validators page](./5.validators.md) covers validation rules on field values. diff --git a/docs/5.validators.md b/docs/5.validators.md index 0198839..c7c9d71 100644 --- a/docs/5.validators.md +++ b/docs/5.validators.md @@ -1,14 +1,20 @@ -# Validators +--- +title: Validators +description: The @Validator, @Mandatory and @Optional decorators, the checks they run on create and update, and the 400 bodies they answer. +mode: reference +--- ## Overview -Validators provide field-level data integrity checks that run automatically during create and update operations. The `@Validator` decorator attaches a custom validation function to a field, while `@Mandatory` ensures that specific fields are present in request bodies for given operations. The `@Optional` decorator explicitly marks a field as not mandatory. +Validators check field values on the `new` and `edit` routes. `@Validator` attaches a validation function to a field. `@Mandatory` requires a field in the request body for the named operations. `@Optional` marks a field as mandatory for no operation. -## The `@Validator` Decorator +The routes parse the body, then run the mandatory check, then the validators. A failed mandatory check answers `400` before any validator runs. -### Basic Usage +## The `@Validator` decorator -The `@Validator` decorator accepts a function that receives the field value and returns `true` if valid or `false` if invalid. It can instead return a parse result (see [Parsed Values](#parsed-values)) so that the parsed value is written. The function can also return a `Promise` of either for asynchronous validation. +### Basic usage + +`@Validator` takes a function that receives the field value and returns `true` for a valid value or `false` for an invalid one. It can return a parse result instead (see [parsed values](#parsed-values)), and the route then writes the parsed value. For asynchronous validation, the function returns a `Promise` of either. ```typescript fragment import { Validator } from "@antelopejs/interface-data-api/metadata"; @@ -33,7 +39,7 @@ class UserAPI extends DataController( } ``` -### Validation Function Signature +### Validation function signature ```typescript type FieldValue = @@ -53,17 +59,18 @@ type FieldValidationResult = | { success: false }; ``` -The function: +The function behaves as follows. -- Receives the raw field value from the request body: JSON, or a `Date` or `undefined` when a route wrapping `new` or `edit` amended the parsed body. `FieldValue | undefined` has to be narrowed before use. -- Returns `true` when the value is valid: the raw value is written. -- Returns `{ success: true, data }` when the value is valid: `data` is written instead of the raw value. -- Returns `false` or `{ success: false }` when the value is invalid, which triggers a `400 Bad Request` response. -- Should handle unexpected types gracefully (e.g., check `typeof` before accessing properties). +- It runs only for a field present in the request body. +- It receives the raw field value from the request body. The value is JSON, or a `Date` or `undefined` when a route that wraps `new` or `edit` amended the parsed body. Narrow `FieldValue | undefined` before you use it. +- It returns `true` for a valid value, and the route writes the raw value. +- It returns `{ success: true, data }` for a valid value, and the route writes `data` instead of the raw value. +- It returns `false` or `{ success: false }` for an invalid value, and the route answers `400 Bad Request`. +- It receives any JSON type, so check the type with `typeof` before you read a property. -### Parsed Values +### Parsed values -A validator returning a parse result writes the parsed value on both `new` and `edit`. The result has the shape of zod's `safeParse`, so a zod schema can be used directly: +When a validator returns a parse result, the route writes the parsed value on both `new` and `edit`. The result has the shape of the result of zod's `safeParse`, so a zod schema works as a validator. ```typescript fragment const startsAtSchema = z.coerce.date(); @@ -73,9 +80,9 @@ const startsAtSchema = z.coerce.date(); declare startsAt: Date; // "2026-01-15" is stored as a Date ``` -## Common Validation Patterns +## Common validation patterns -### String Validation +### String validation ```typescript fragment // Minimum length @@ -93,7 +100,7 @@ const VALID_ROLES = ["admin", "editor", "viewer"]; declare role: string; ``` -### Number Validation +### Number validation ```typescript fragment // Range @@ -109,7 +116,7 @@ declare quantity: number; declare price: number; ``` -### Date Validation +### Date validation ```typescript fragment // Valid date string @@ -125,9 +132,9 @@ declare birthDate: string; declare appointmentDate: string; ``` -### Async Validation +### Async validation -An asynchronous lookup can give early feedback, but a check followed by an insert is not a uniqueness guarantee: concurrent requests can both pass. Enforce uniqueness in the database where supported, and handle write conflicts. `lookupByEmail` below is an application-supplied function. +An asynchronous lookup gives early feedback. A check followed by an insert does not guarantee uniqueness, because two concurrent requests can both pass the check. Enforce uniqueness in the database where the database supports it, and handle write conflicts. `lookupByEmail` below is a function your application supplies. ```typescript fragment // Check uniqueness against the database @@ -139,12 +146,12 @@ An asynchronous lookup can give early feedback, but a check followed by an inser declare email: string; ``` -## The `@Mandatory` Decorator +## The `@Mandatory` decorator -The `@Mandatory` decorator specifies which operations require a field in the request body. Unlike `@Validator`, which checks field content, `@Mandatory` only verifies that the field is provided: +`@Mandatory` names the operations that require a field in the request body. `@Validator` checks the content of a field. `@Mandatory` checks only that the body provides it. -- On `"edit"`, the body only carries the fields to update: a mandatory field absent from the body keeps its stored value and is accepted, while a mandatory field cleared by the body (`null`, `""` or `[]`) is rejected. -- On any other operation (such as `"new"`), a mandatory field must be present in the body. +- On `"edit"`, the body carries only the fields to update. The route accepts a mandatory field absent from the body, which keeps its stored value. It rejects a mandatory field that the body clears with `null`, `""` or `[]`. +- On any other operation, such as `"new"`, the body must hold every mandatory field. ```typescript import { Mandatory } from "@antelopejs/interface-data-api/metadata"; @@ -152,7 +159,7 @@ import { Mandatory } from "@antelopejs/interface-data-api/metadata"; ### Usage -Pass one or more operation names as arguments. Common operation names are `"new"` (create) and `"edit"` (update). +Pass one or more operation names as arguments. The default routes check `"new"` (create) and `"edit"` (update). ```typescript fragment @RegisterDataController() @@ -189,9 +196,9 @@ In this example: - `status` cannot be cleared when editing an existing record. - `notes` is always optional. -### Skip Mandatory Validation +### Skip mandatory validation -Use `DefaultRoutes.WithOptions` with the `noMandatory` option to create an endpoint that skips mandatory field checks. +`DefaultRoutes.WithOptions` with the `noMandatory` option creates an endpoint that skips the mandatory field check. Any non-empty string turns the option on. The validators still run. ```typescript fragment const routes = { @@ -211,9 +218,9 @@ class UserAPI extends DataController(User, routes, Controller("/users")) { Requests to `/users/edit` cannot clear `status`, while requests to `/users/patchEdit` can. -## The `@Optional` Decorator +## The `@Optional` decorator -The `@Optional` decorator explicitly marks a field as not mandatory for any operation. Use it on fields that have no other decorators but still need to be recognized by the Data API metadata system. +`@Optional` marks a field as mandatory for no operation, and records the field in the Data API metadata. Use it only on a field with no other decorator, since it clears what `@Mandatory` set. ```typescript fragment import { Optional } from "@antelopejs/interface-data-api/metadata"; @@ -222,15 +229,17 @@ import { Optional } from "@antelopejs/interface-data-api/metadata"; declare notes: string; ``` -## Error Handling +## Error handling -When validation or mandatory checks fail, the API returns a `400 Bad Request` response whose JSON body names what went wrong and the fields concerned, for the client to word it and show it under each field: +When a mandatory check or a validator fails, the route answers `400 Bad Request`. The JSON body holds a `code` for what went wrong and the `fields` concerned, so the client can word the error and show it under each field. -- **Mandatory failure:** `{ "code": "missing_mandatory_fields", "fields": ["email", "password"] }` -- **Validator failure:** `{ "code": "invalid_field_types", "fields": ["age", "email"] }` +| Failure | Body | +| --------- | ------------------------------------------------------------------------- | +| Mandatory | `{ "code": "missing_mandatory_fields", "fields": ["email", "password"] }` | +| Validator | `{ "code": "invalid_field_types", "fields": ["age", "email"] }` | -The codes are exported as `Validation.MISSING_MANDATORY_FIELDS` and `Validation.INVALID_FIELD_TYPES` (`@antelopejs/interface-data-api/components`), the body's type as `Validation.FieldsErrorBody`. All fields are checked: `fields` lists every one that failed. +`@antelopejs/interface-data-api/components` exports the codes as `Validation.MISSING_MANDATORY_FIELDS` and `Validation.INVALID_FIELD_TYPES`, and the body type as `Validation.FieldsErrorBody`. Each check covers every field, and `fields` lists every field that failed it. -## Next Steps +## Next steps -See the [listable fields documentation](./6.listable.md) to learn how to control field visibility in list operations and configure pagination. +The [listable fields page](./6.listable.md) covers which fields list responses hold and how pagination works. diff --git a/docs/6.listable.md b/docs/6.listable.md index 94beb99..aa0335e 100644 --- a/docs/6.listable.md +++ b/docs/6.listable.md @@ -1,14 +1,18 @@ -# Listable Fields +--- +title: Listable fields +description: Choose the fields a list route returns with @Listable, serve several field sets with pluck modes, page the results, and sort them with @Sortable. +mode: reference +--- ## Overview -The `@Listable` decorator controls which fields are included in list endpoint responses. By default, list operations only return fields explicitly marked as listable, keeping payloads lightweight. The decorator supports multiple pluck modes for different levels of detail, and list responses include built-in pagination metadata. +The `@Listable` decorator selects the fields that list responses include. A list route returns only the fields marked as listable, so list payloads stay small. Pluck modes let one controller serve several field sets, and every list response carries pagination metadata. -The `@Sortable` decorator marks fields that can be used as a sorting key in list queries. +The `@Sortable` decorator marks the fields that a list request can sort on. -## Basic Usage +## Basic usage -Fields decorated with `@Listable()` are included in list responses. Fields without this decorator are excluded from list results but still appear in single-record `get` responses (provided they have read access). +A list response includes the fields decorated with `@Listable()`. A field without the decorator is left out of list results, but a single-record `get` response still includes it when the field has read access. ```typescript fragment import { @@ -43,22 +47,22 @@ class UserAPI extends DataController( A request to `GET /users/list` returns `_id` and `email` for each record. A request to `GET /users/get?id=user-123` returns all three fields. -## Pluck Modes +## Pluck modes -Pluck modes allow a single controller to serve multiple list views with different field selections. Each `@Listable` call can target a named mode. +A pluck mode is a named field set. With pluck modes, one controller serves several list views, each with its own fields. Each `@Listable` call adds the field to one mode. -### Default Mode +### Default mode -Calling `@Listable()` without a mode name registers the field under the default `"list"` mode. +`@Listable()` without a mode name adds the field to the default `"list"` mode. ```typescript fragment @Listable() declare name: string; ``` -### Custom Modes +### Custom modes -Pass `true` and a mode name to register a field under a specific pluck mode. +`@Listable(true, "detailed")` adds the field to the `"detailed"` mode. ```typescript fragment @Listable() // Included in default "list" mode @@ -72,9 +76,9 @@ declare internalNotes: string; declare email: string; ``` -### Exclude from a Mode +### Exclude from a mode -Pass `false` to explicitly exclude a field from a mode. +`@Listable(false, "minimal")` leaves the field out of the `"minimal"` mode. ```typescript fragment @Listable() @@ -82,9 +86,9 @@ Pass `false` to explicitly exclude a field from a mode. declare description: string; ``` -### Access Pluck Modes via Routes +### Access pluck modes via routes -Define separate routes for each pluck mode using `DefaultRoutes.WithOptions` with the `pluckMode` option. +Each pluck mode needs its own route. `DefaultRoutes.WithOptions` sets the route's `pluckMode` option. ```typescript fragment const routes = { @@ -114,11 +118,11 @@ class UserAPI extends DataController(User, routes, Controller("/users")) { - `GET /users/list` returns only `name`. - `GET /users/detailed` returns both `name` and `internalNotes`. -Modes are independent field sets: `"detailed"` does not inherit `"list"`. The example includes `name` in both modes explicitly. A registered list route whose `pluckMode` has never been declared returns `400 Bad Request` unless `noPluck` is enabled; an unregistered URL instead returns `404`. +Modes are independent field sets. The `"detailed"` mode does not inherit the fields of `"list"`, so the example adds `name` to both modes. A registered list route whose `pluckMode` no field declares returns `400 Bad Request`, unless the route sets `noPluck`. A URL that matches no route returns `404`. -### Listable with Getters +### Listable with getters -When a field uses a getter (computed property), the `@Listable` decorator must specify which database fields the getter depends on. This ensures those fields are fetched from the database. +A getter (computed property) reads fields that the database must return. Its `@Listable` decorator lists those database fields, so the list query fetches them. ```typescript fragment @Listable(["firstName", "lastName"]) @@ -130,7 +134,7 @@ get fullName() { ## Pagination -List responses include pagination metadata. The response structure is: +Every list response carries pagination metadata. The response has this shape: ```typescript fragment { @@ -141,12 +145,12 @@ List responses include pagination metadata. The response structure is: } ``` -### Query Parameters +### Query parameters -| Parameter | Default | Description | -| --------- | ------- | ----------------------------------- | -| `limit` | `10` | Maximum number of records to return | -| `offset` | `0` | Number of records to skip | +| Parameter | Default | Description | +| --------- | ------------------------------------------------ | ----------------------------------- | +| `limit` | The route's `maxPage`, or `10` when it sets none | Maximum number of records to return | +| `offset` | `0` | Number of records to skip | ### Example @@ -166,9 +170,9 @@ GET /users/list?limit=2&offset=4 } ``` -### Limit Maximum Page Size +### Limit maximum page size -Use the `maxPage` route option to cap the `limit` parameter. If a request specifies a `limit` larger than `maxPage`, the value is clamped to `maxPage`. +The `maxPage` route option caps the `limit` parameter. A request whose `limit` is larger than `maxPage` gets `maxPage` records at most. A request without `limit` also gets `maxPage` records. A route without `maxPage` caps `limit` at `100`. ```typescript fragment const routes = { @@ -183,7 +187,7 @@ class UserAPI extends DataController(User, routes, Controller("/users")) { ## Sorting with `@Sortable` -The `@Sortable` decorator marks a field as eligible for use as a sorting key in list operations. +The `@Sortable` decorator lets a list request sort on the field. ```typescript fragment import { Sortable } from "@antelopejs/interface-data-api/metadata"; @@ -215,16 +219,16 @@ class UserAPI extends DataController( } ``` -### Sort Query Parameters +### Sort query parameters -| Parameter | Description | -| --------------- | ------------------------------------------------------------------ | -| `sortKey` | The field name to sort by. Must be decorated with `@Sortable`. | -| `sortDirection` | `"asc"` (ascending) or `"desc"` (descending). Defaults to `"asc"`. | +| Parameter | Description | +| --------------- | --------------------------------------------------------------------------------------- | +| `sortKey` | The field name to sort by. The field must have `@Sortable`. | +| `sortDirection` | `"asc"` (ascending) or `"desc"` (descending). A missing or other value sorts ascending. | -### The `noIndex` Option +### The `noIndex` option -By default, `@Sortable()` marks the field for indexed ordering. Set `noIndex: true` when it is not indexed. Both paths build an AQL `orderBy` operation; this option changes where sorting is staged, not whether rows are sorted in application memory. The provider determines how the database executes that ordering. +By default, `@Sortable()` marks the field for indexed ordering. Set `noIndex: true` when the field has no index. Both settings build a database `orderBy` operation, and neither sorts rows in application memory. The option changes where the list query places the sort. The database provider decides how it runs the ordering. ```typescript fragment @Sortable() // Uses database index for sorting @@ -234,9 +238,9 @@ declare _id: string; declare name: string; ``` -### Localized Fields +### Localized fields -A `@Localized` field is sorted on its value in the request's locale, the one the controller's `@ModifierKey(LocalizationModifier)` field holds. A row with no value in that locale, or an empty one, sorts on its `fallbackLocale` value. This ordering is computed per row, so it never uses an index, whatever `noIndex` says. +A `@Localized` field sorts on its value in the request's locale, which the controller's `@ModifierKey(LocalizationModifier)` field holds. A row with no value in that locale, or an empty one, sorts on its `fallbackLocale` value. The list query computes this value for each row, so the sort never uses an index, whatever `noIndex` says. ### Example @@ -257,8 +261,8 @@ GET /users/list?sortKey=age&sortDirection=desc&limit=3 } ``` -If `sortKey` refers to a field that is not marked with `@Sortable`, the API returns a `400 Bad Request` error. +If `sortKey` names a field without `@Sortable`, the API returns `400 Bad Request` with the message `Field is not sortable.` -## Next Steps +## Next steps -See the [foreign keys documentation](./7.foreign-keys.md) to learn how to establish relationships between tables. +To link records across tables, see [Foreign keys](./7.foreign-keys.md). diff --git a/docs/7.foreign-keys.md b/docs/7.foreign-keys.md index f7af598..7634fa5 100644 --- a/docs/7.foreign-keys.md +++ b/docs/7.foreign-keys.md @@ -1,16 +1,20 @@ -# Foreign Keys +--- +title: Foreign keys +description: Resolve a foreign key field into the referenced record with @Foreign, for single and array references, across schemas, and per route. +mode: reference +--- ## Overview -The `@Foreign` decorator establishes relationships between database tables in your data controller. When a field holds a foreign key (an ID referencing another table), the Data API automatically resolves the reference and includes the related record in API responses. This works for both single references and arrays of references. +The `@Foreign` decorator links a field of your data controller to another table. When a field holds a foreign key (an ID of a record in another table), the Data API looks up the referenced record and puts it in the response. The field can hold one key or an array of keys. -## Basic Usage +## Basic usage -Apply `@Foreign` to a field that stores a foreign key value. The decorator replaces the raw ID in the response with the full referenced record. +Apply `@Foreign` to a field that stores a foreign key value. The response then holds the referenced record in place of the raw ID. -Choose the response fields explicitly with `pluck`. A foreign lookup reads the target table, not its API controller: do not rely on the target controller's `@Access` policy to hide passwords or internal fields. The example below selects only the user's ID, name, and email. +Choose the response fields with `pluck`. A foreign lookup reads the target table, not its API controller, so the target controller's `@Access` policy does not hide passwords or internal fields. The example below selects only the user's ID, name, and email. -The snippets use the `myapp` schema. Register it in the declaring module before serving requests, as in the [quick start](./1.introduction.md#quick-start). Controller snippets assume the corresponding model and shared imports are already declared. +The snippets use the `myapp` schema. Register it in the declaring module before serving requests, as in the [quick start](./1.introduction.md#quick-start). The controller snippets assume that the matching model and the shared imports exist. ```typescript fragment import { Foreign } from "@antelopejs/interface-data-api/metadata"; @@ -71,7 +75,7 @@ class OrderAPI extends DataController( } ``` -A GET request to `/orders/get?id=order-123` returns the full user object instead of a raw ID: +A GET request to `/orders/get?id=order-123` returns the user object in place of the raw ID: ```json { @@ -85,45 +89,46 @@ A GET request to `/orders/get?id=order-123` returns the full user object instead } ``` -## Decorator Parameters +## Decorator parameters ```typescript fragment -@Foreign(table, index?, multi?, pluck?, schema?) +@Foreign(table, index?, multi?, pluck?, schema?, targetMeta?) ``` -| Parameter | Type | Required | Default | Description | -| --------- | -------------------------- | -------- | ----------------- | -------------------------------------------------------------------------------------------------- | -| `table` | `Class
` or `string` | Yes | - | The target table class or table name string | -| `index` | `string` | No | `"_id"` | The index on the target table to match against | -| `multi` | `boolean` | No | `false` | Set to `true` when the field holds an array of foreign keys | -| `pluck` | `string[]` | No | All fields | List of fields to include from the referenced record | -| `schema` | `string` | No | Controller schema | Name of the schema the target table is registered in, when it differs from the controller's schema | +| Parameter | Type | Required | Default | Description | +| ------------ | -------------------------- | -------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `table` | `Class
` or `string` | Yes | None | The target table class or table name string | +| `index` | `string` | No | `"_id"` | The index on the target table to match against | +| `multi` | `boolean` | No | `false` | Set to `true` when the field holds an array of foreign keys | +| `pluck` | `string[]` | No | All fields | List of fields to include from the referenced record | +| `schema` | `string` | No | Controller schema | Name of the schema the target table is registered in, when it differs from the controller's schema | +| `targetMeta` | `DataAPIMeta` | No | None | Metadata of the target's data controller. Its `@Joined` fields that `pluck` names, or all of them when `pluck` is unset, are resolved on a relation that is not `multi` | -## Table Reference Methods +## Table reference methods -### Class Reference +### Class reference -Pass the table class directly. This provides IDE autocompletion, refactoring support, and compile-time validation. +Pass the table class. Your editor can then complete and rename the reference, and the compiler checks it. ```typescript fragment @Foreign(User) declare userId: User; ``` -### String Reference +### String reference -Pass the registered table name as a string. Use this when importing the class would create circular dependencies. +Pass the registered table name as a string. Use a string when importing the class would create a circular import. ```typescript fragment @Foreign("users") declare userId: User; ``` -## Multi-Record References +## Multi-record references -When a field stores an array of foreign key IDs, set the `multi` parameter to `true`. The API resolves each ID and returns an array of full records. +When a field stores an array of foreign key IDs, set the `multi` parameter to `true`. The API looks up each ID and returns an array of records. -The table-level `@Relation({ many: true })` is declarative metadata recording that the field holds multiple target keys; API-side resolution is controlled solely by the `multi` parameter of `@Foreign`. +The table-level `@Relation({ many: true })` is metadata only. It records that the field holds several target keys. Only the `multi` parameter of `@Foreign` controls how the API resolves the field. ```typescript fragment @RegisterTable("carts", "myapp") @@ -181,9 +186,9 @@ Response: } ``` -## Selective Field Retrieval with Pluck +## Selective field retrieval with pluck -The `pluck` parameter limits which fields are included from the referenced record. This reduces response payload size when you only need specific fields. +The `pluck` parameter limits the fields taken from the referenced record. Use it to keep the response small when you need only some fields. ```typescript fragment @RegisterDataController() @@ -239,19 +244,19 @@ Response with `pluck: ["name", "email"]`: } ``` -## Custom Index +## Custom index -Use the `index` parameter to match against a secondary index instead of the primary key. +The `index` parameter matches the field against a secondary index of the target table, in place of the primary key. ```typescript fragment -// Match userId against the "email" index on the User table +// Match userEmail against the "email" index on the User table @Foreign(User, "email") declare userEmail: User; ``` -## Cross-Schema References +## Cross-schema references -By default, the target table is resolved in the same schema as the controller's table. Use the `schema` parameter to reference a table registered in a different schema. At runtime the foreign lookup is resolved through the target's table class, against the default instance of the schema `RegisterSchema` bound it to, so it follows the database of the module that declared the table. The target's class must be registered with `@RegisterTable` before the field is declared; a string table name with no registered class in that schema throws at declaration. +By default, the Data API looks for the target table in the schema of the controller's table. The `schema` parameter names a different schema. At runtime, the foreign lookup goes through the target's table class, against the default instance of the schema that `RegisterSchema` bound it to. The lookup therefore uses the database of the module that declared the table. Register the target's class with `@RegisterTable` before you declare the field. A string table name with no registered class in that schema throws at declaration. ```typescript fragment // Resolve userId against the User table registered in the "auth" schema @@ -260,11 +265,11 @@ By default, the target table is resolved in the same schema as the controller's declare userId: User; ``` -When a table class is passed (instead of a string), its schema is inferred from its `@RegisterTable` metadata, so the `schema` parameter is only needed to override that or when passing a string table name. If both the class metadata and the explicit `schema` option specify a schema and they disagree, an error is thrown. +When you pass a table class, the Data API reads its schema from its `@RegisterTable` metadata. The `schema` parameter is then needed only to override that schema, or with a string table name. If the class metadata and the `schema` parameter name different schemas, the declaration throws. -## Disable Foreign Key Resolution +## Disable foreign key resolution -Use the `noForeign` route option to skip foreign key resolution for specific endpoints. This returns raw IDs instead of resolved objects. +The `noForeign` route option skips foreign key resolution on one route. The route then returns raw IDs in place of records. On a `get` route the option is a string, and on a `list` route it is a boolean. ```typescript import { DefaultRoutes } from "@antelopejs/interface-data-api"; @@ -275,12 +280,12 @@ const routes = { }; ``` -## Error Handling +## Error handling -- **Missing References** - If a referenced record does not exist, the field value is `null`. -- **Invalid IDs** - Invalid foreign key values result in `null` for the resolved field. -- **Arrays with Missing Items** - In multi-record references, missing items appear as `null` in the array. +- If a referenced record does not exist, the field value is `null`. +- An invalid foreign key value resolves to `null`. +- In a multi-record reference, each missing item is `null` in the array. -## Next Steps +## Next steps -See the [filters documentation](./8.filters.md) to learn how to add filtering capabilities to list endpoints. +To filter list results, see [Filters](./8.filters.md). diff --git a/docs/8.filters.md b/docs/8.filters.md index 0946278..c8fa086 100644 --- a/docs/8.filters.md +++ b/docs/8.filters.md @@ -1,14 +1,18 @@ -# Filters +--- +title: Filters +description: Let list requests filter rows with @Filter, through the default comparisons, custom filter functions, and mandatory route filters. +mode: reference +--- ## Overview -The `@Filter` decorator adds filtering capabilities to list endpoints. Filters allow clients to narrow down results using query parameters. The module provides a default filter with standard comparison operators and supports custom filter functions for advanced logic. +The `@Filter` decorator lets a list request narrow its results through query parameters. The default filter compares the field with six comparison operators. A custom filter function can hold any other logic. -## Basic Usage +## Basic usage -### Default Filter +### Default filter -Apply `@Filter()` without arguments to enable standard comparison filtering on a string field. Query filter values remain strings: TypeScript annotations, `@Field`, and write validators do not parse them into numbers or booleans. +`@Filter()` without arguments enables the default comparison filter on a string field. Filter values stay strings. TypeScript annotations, `@Field`, and write validators do not parse them into numbers or booleans. ```typescript fragment import { Filter } from "@antelopejs/interface-data-api/metadata"; @@ -39,18 +43,18 @@ class UserAPI extends DataController( } ``` -### Query Parameter Format +### Query parameter format -Filters are applied through query parameters using the following format: +A filter is a query parameter in one of these forms: ``` filter_= filter_=: ``` -When no operator is specified, `eq` (equal) is used by default. +Without an operator, the filter uses `eq` (equal). The text before the first colon is the operator, so a value that contains a colon needs the `eq:` prefix. The default filter answers `400` to an operator that is not in the table below. -### Comparison Operators +### Comparison operators | Operator | Description | | -------- | ------------------------ | @@ -75,7 +79,7 @@ Retrieve users whose role is not admin: GET /users/list?filter_role=ne:admin ``` -Combine multiple filters: +Combine filters on several fields: ```http GET /users/list?filter_status=active&filter_role=ne:admin @@ -87,19 +91,21 @@ Filter one field several times to keep the rows that pass every filter (AND): GET /users/list?filter_age=ge:18&filter_age=lt:65 ``` -The route receives that field's filters as a list of `FilterValue` tuples (`FieldFilter`); a field filtered once keeps a single tuple. Read either shape with `FilterValuesOf(filter)` from `@antelopejs/interface-data-api/metadata`. An indexed equality filter only uses its index when it is the field's only filter. +The route receives the filters of that field as a list of `FilterValue` tuples (`FieldFilter`). A field filtered once keeps a single tuple. `FilterValuesOf(filter)` from `@antelopejs/interface-data-api/metadata` reads either shape as a list. An indexed equality filter uses its index only when it is the only filter on the field. -### Numeric and Boolean Filters +### Numeric and boolean filters -Parse and validate values explicitly in a custom filter. These filters support the six numeric comparisons and boolean equality/inequality; unsupported input throws an HTTP 400 response. +Parse and validate numbers and booleans in a custom filter. The filters below support the six numeric comparisons and boolean `eq` and `ne`. Any other input fails with an HTTP 400 response. ```ts docs-check import { assert } from "@antelopejs/interface-api-util"; import type { FilterFunction } from "@antelopejs/interface-data-api/metadata"; -const COMPARISONS = ["eq", "ne", "gt", "ge", "lt", "le"] as const; +type Comparison = "eq" | "ne" | "gt" | "ge" | "lt" | "le"; -function isComparison(mode: string): mode is (typeof COMPARISONS)[number] { +const COMPARISONS: readonly Comparison[] = ["eq", "ne", "gt", "ge", "lt", "le"]; + +function isComparison(mode: string): mode is Comparison { return COMPARISONS.some((comparison) => comparison === mode); } @@ -129,7 +135,7 @@ export const booleanFilter: FilterFunction> = ( }; ``` -Apply the functions to the corresponding fields in your controller: +Apply the functions to the matching fields of your controller: ```typescript fragment @Filter(numberFilter, false) @@ -139,35 +145,38 @@ declare age: number; declare isActive: boolean; ``` -With these custom filters, `?filter_age=gt:30&filter_isActive=true` compares against `30` and `true`, not `"30"` and `"true"`. Keep `useIndex` false for callbacks that parse values: indexed equality uses the raw string directly and bypasses the filter callback. +With these custom filters, `?filter_age=gt:30&filter_isActive=true` compares against `30` and `true`, not `"30"` and `"true"`. Keep `useIndex` false for a filter that parses its value. An indexed equality lookup uses the raw string and skips the filter function. -## Custom Filters +## Custom filters -For filtering logic beyond simple comparisons, pass a custom filter function to `@Filter`. +For logic beyond the comparison operators, pass a custom filter function to `@Filter`. -### Filter Function Signature +### Filter function signature ```typescript fragment -type FilterFunction = ( +type FilterFunction< + T extends Record, + U extends Record = FieldValues, +> = ( context: RequestContext & { this: T }, proxy: ValueProxy, key: string, - ...rest: [value: string, mode: string, row: ValueProxy] + ...rest: [value: FilterValue[0], mode: FilterValue[1], row: ValueProxy] ) => ValueProxyOrValue; ``` -| Parameter | Description | -| --------- | ------------------------------------------------------------------------------------------------------- | -| `context` | The request context, extended with `this` referencing the controller instance | -| `proxy` | A query expression produced by the field modifier's `unlockrequest()` method; see the limitations below | -| `key` | The field name being filtered | -| `value` | The filter value from the query parameter | -| `mode` | The text before the first colon of the query parameter, unchecked: the filter defines its own modes | -| `row` | The full database row as a `ValueProxy` | +| Parameter | Description | +| --------- | -------------------------------------------------------------------------------------------------------- | +| `context` | The request context, with `this` set to the controller instance | +| `proxy` | A query expression that the field modifier's `unlockrequest()` method builds. See the limitations below. | +| `key` | The name of the filtered field | +| `value` | The filter value from the query parameter, a string | +| `mode` | The text before the first colon of the query parameter, unchecked. The filter defines its own modes. | +| `row` | The full database row as a `ValueProxy` | -`value`, `mode` and `row` are one rest tuple, so that the function type stays within five parameters. A function that declares them as separate parameters is assignable to it, and `Parameters>` lists all six. +`value`, `mode`, and `row` form one rest tuple, so the function type stays within five parameters. A function that declares them as separate parameters is assignable to the type, and `Parameters>` lists all six. -### Example: Pattern Matching Filter +### Example: pattern matching filter ```typescript fragment function filterByEmailDomain( @@ -211,11 +220,11 @@ Query for users with Gmail addresses: GET /users/list?filter_email=gmail.com ``` -Import `assert` from `@antelopejs/interface-api-util`. Escaping the domain makes the dot literal rather than a regular-expression wildcard. This example deliberately rejects operators other than `eq` instead of silently treating every request as equality. +Import `assert` from `@antelopejs/interface-api-util`. The function escapes the domain, so the dot matches a literal dot and not any character. The function rejects operators other than `eq`, so a request with another operator fails instead of running as an equality filter. -### The `useIndex` Parameter +### The `useIndex` parameter -The `@Filter` decorator accepts an optional second parameter, `useIndex`, that controls whether the filter can use a database index for optimized lookups. +The second parameter of `@Filter`, `useIndex`, allows the filter to use a database index. ```typescript fragment @Filter(undefined, true) // Default filter with index optimization @@ -225,16 +234,16 @@ declare status: string; declare name: string; ``` -When `useIndex` is not specified: +When `useIndex` is not set: -- Default filters (no custom function) automatically use the index if the field has one. -- Custom filters default to no index usage. +- The default filter (no custom function) uses the index when the field has a single-field index. +- A custom filter uses no index. -Index-optimized filters with an `eq` operator perform an indexed lookup instead of a full table scan, which significantly improves performance on large datasets. +An indexed filter with the `eq` operator reads the matching rows through the index instead of scanning the whole table. -## Mandatory Route Filters +## Mandatory route filters -Use `DefaultRoutes.WithOptions` to constrain a list route. The `filters` map contains `[stringValue, comparison]` tuples for fields declared with `@Filter`. Request filters for other fields are retained, but a route filter wins when both specify the same field. +`DefaultRoutes.WithOptions` can constrain a list route. Its `filters` map holds `[stringValue, comparison]` tuples for fields declared with `@Filter`. The route keeps request filters on other fields. When the route and the request filter the same field, the route filter wins. ```ts docs-check import { DefaultRoutes } from "@antelopejs/interface-data-api"; @@ -244,10 +253,10 @@ const activeUsers = DefaultRoutes.WithOptions(DefaultRoutes.List, { }); ``` -On a controller with a `status` filter, `?filter_status=inactive&filter_role=admin` still restricts `status` to `"active"` while retaining the requested `role`. Route filters are list-query constraints, not authorization for separate get/edit/delete routes. +On a controller with a `status` filter, `?filter_status=inactive&filter_role=admin` still restricts `status` to `"active"` and keeps the requested `role`. Route filters constrain list queries only. They do not authorize or restrict the `get`, `edit`, or `delete` routes. -## Modifier Integration +## Modifier integration -The `proxy` parameter is a database expression, not an unlocked model instance. The Data API calls the modifier's `unlockrequest()` method to build that expression. Localization can select a locale at query time, but the built-in `EncryptionModifier.unlockrequest()` returns the stored expression unchanged: it does not decrypt ciphertext in the database. A custom modifier must explicitly support query-time access before a filter can operate on its logical value. +The `proxy` parameter is a database expression, not an unlocked model instance. The Data API calls the modifier's `unlockrequest()` method to build that expression. Localization can select a locale at query time. The built-in `EncryptionModifier.unlockrequest()` returns the stored expression unchanged, so it does not decrypt ciphertext in the database. A custom modifier must support query-time access before a filter can compare its plain value. -For raw database values, use the `row` parameter and access the field directly. +For raw database values, read the field from the `row` parameter. diff --git a/docs/9.modifiers.md b/docs/9.modifiers.md index 0a43175..e51d044 100644 --- a/docs/9.modifiers.md +++ b/docs/9.modifiers.md @@ -1,14 +1,18 @@ -# Modifiers +--- +title: Modifiers +description: Use database modifiers such as @Hashed, @Encrypted, and @Localized with a data controller, and pass a per-request modifier key with @ModifierKey. +mode: reference +--- ## Overview -Modifiers from `@antelopejs/interface-database-decorators` work automatically with the Data API. Modifiers such as `@Hashed` or `@Encrypted` transform data transparently during read and write operations without any additional configuration in the data controller. +Modifiers from `@antelopejs/interface-database-decorators` work with the Data API without extra configuration in the data controller. Modifiers such as `@Hashed` or `@Encrypted` transform data on every read and write. -Some modifiers require a runtime parameter (for example, a locale key for localization). The `@ModifierKey` decorator bridges this gap by mapping a controller field to a modifier's runtime key. +Some modifiers need a value at runtime, such as the locale key of the localization modifier. The `@ModifierKey` decorator maps a controller field to the runtime key of one modifier. -## The `@ModifierKey` Decorator +## The `@ModifierKey` decorator -Use `@ModifierKey` when a modifier needs a runtime value that varies per request. The decorator associates a controller field with a specific modifier class. The field's value at request time is passed to the modifier during lock and unlock operations. +Use `@ModifierKey` when a modifier needs a value that changes per request. The decorator links a controller field to a modifier class. At request time, the Data API passes the value of the field to the modifier when it locks and unlocks data. ```typescript import { ModifierKey } from "@antelopejs/interface-data-api/metadata"; @@ -20,13 +24,13 @@ import { ModifierKey } from "@antelopejs/interface-data-api/metadata"; @ModifierKey(modifierClass: typeof ContainerModifier) ``` -| Parameter | Description | -| --------------- | ----------------------------------------------------------------------------- | -| `modifierClass` | The modifier class that requires a runtime key (e.g., `LocalizationModifier`) | +| Parameter | Description | +| --------------- | ------------------------------------------------------------------------------- | +| `modifierClass` | The modifier class that needs a runtime key, for example `LocalizationModifier` | -### Example: Localized Content +### Example: localized content -The `LocalizationModifier` requires a locale identifier to determine which translation to store or retrieve. Use `@ModifierKey` to supply this value from an HTTP header. +The `LocalizationModifier` needs a locale identifier to choose which translation to store or read. In this example, `@ModifierKey` takes that value from an HTTP header. ```typescript import { Controller, Parameter } from "@antelopejs/interface-api"; @@ -92,14 +96,14 @@ class ContentAPI extends DataController( } ``` -### How It Works +### How it works 1. The HTTP request includes the header `x-content-language: fr`. 2. The `language` property receives the value `"fr"`. -3. During write operations, the modifier uses `"fr"` as the key to store the localized value. -4. During read operations, the modifier uses `"fr"` to retrieve the correct localized value. +3. On a write, the modifier stores the localized value under the key `"fr"`. +4. On a read, the modifier reads the localized value stored under `"fr"`. -**Example request:** +This request writes French values: ```http POST /content/new @@ -112,24 +116,24 @@ Content-Type: application/json } ``` -The data is stored under the `"fr"` locale key. A subsequent request with `x-content-language: en` accesses different localized values for the same record. +The record stores the data under the `"fr"` locale key. A later request with `x-content-language: en` reads and writes other localized values of the same record. -> **Note:** Most modifiers (such as `@Hashed` and `@Encrypted`) do not require `@ModifierKey`. Only modifiers that need a runtime parameter per request require this decorator. +Most modifiers, such as `@Hashed` and `@Encrypted`, do not need `@ModifierKey`. Only a modifier that needs a runtime value per request needs this decorator. -## Integration with Other Features +## Integration with other features -### Foreign Keys +### Foreign keys -Modifiers work automatically with foreign key relationships. When the Data API resolves foreign records, modifier-protected fields on those records are properly unlocked during retrieval. See the [foreign keys documentation](./7.foreign-keys.md) for details. +Modifiers work with foreign key relationships. When the Data API resolves foreign records, it unlocks the modifier fields of those records. See [Foreign keys](./7.foreign-keys.md) for details. ### Filters -Custom filters receive a query expression produced by the modifier's `unlockrequest()` method, not a decrypted JavaScript value. Localization supports query-time access; the built-in encryption modifier leaves its stored representation unchanged in query expressions. Do not use it to filter by plaintext. See the [filters documentation](./8.filters.md#modifier-integration) for details. +A custom filter receives a query expression that the modifier's `unlockrequest()` method builds, not a decrypted JavaScript value. Localization supports access at query time. The built-in encryption modifier leaves its stored value unchanged in query expressions, so do not filter on the plain text of an encrypted field. See [Modifier integration](./8.filters.md#modifier-integration) in the filters page for details. -## Learn More +## Learn more -For the full list of available modifiers and instructions on creating custom ones, see the [@antelopejs/interface-database-decorators documentation](https://github.com/AntelopeJS/interface-database-decorators). +For the full list of modifiers and how to write your own, see the [`@antelopejs/interface-database-decorators` repository](https://github.com/AntelopeJS/interface-database-decorators). -## Next Steps +## Next steps -See the [joined fields documentation](./10.joined.md) to learn how to flatten a remote field onto rows for native sorting and filtering. +To flatten a field of another table onto each row for sorting and filtering in the database, see [Joined fields](./10.joined.md). From 081740a47dce3579d5794aac8fc610cccc36c39b Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Tue, 3 Nov 2026 01:36:16 +0100 Subject: [PATCH 2/3] docs: turn on the docs standard --- package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index e6f15db..0725569 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,9 @@ "test": "src/antelope.test.ts", "skills": [ "./skills" - ] + ], + "docs": { + "standard": true + } } } From 4ce9f79ae2bf9616166013576be8ec82fbcb78dd Mon Sep 17 00:00:00 2001 From: Antony Rizzitelli Date: Tue, 3 Nov 2026 01:50:47 +0100 Subject: [PATCH 3/3] docs: state the computed and joined access rule and the count index --- README.md | 4 ++-- docs/10.joined.md | 14 +++++++------- docs/11.computed.md | 6 +++--- docs/3.routes.md | 4 ++-- docs/7.foreign-keys.md | 2 +- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 579c6ec..ebab73b 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,10 @@ The package runs in your module and has no implementing module of its own. The p ## Install -Add the package and the interfaces your controllers import to your module. +Add the package and its peer interfaces to your module. ```bash -pnpm add @antelopejs/interface-data-api @antelopejs/interface-api @antelopejs/interface-database-decorators +pnpm add @antelopejs/interface-data-api @antelopejs/interface-api @antelopejs/interface-api-util @antelopejs/interface-core @antelopejs/interface-database @antelopejs/interface-database-decorators ``` Then add the implementations to the project. diff --git a/docs/10.joined.md b/docs/10.joined.md index 76a8246..23b0f8d 100644 --- a/docs/10.joined.md +++ b/docs/10.joined.md @@ -8,7 +8,7 @@ mode: reference The `@Joined` decorator copies one scalar field from another table onto each row at query time. `@Foreign` nests the whole referenced record under the field. `@Joined` puts one remote field at the top level of the row, before the list query sorts and filters. The database can therefore sort, filter, and search on the joined field. -Joined fields are read-only. A lookup sets the value at query time, and the controller's own table never stores it. Declaring a field as joined sets its access mode to read-only and makes its sort non-indexed. +Joined fields are meant to be read-only. A lookup sets the value at query time, and the controller's own table never stores it. `@Joined` sets the field's access mode to read-only when it runs. An `@Access` written above it runs later and replaces that mode, so do not combine the two. `@Joined` also makes the field's sort non-indexed, whatever the decorator order. ## Basic usage @@ -187,12 +187,12 @@ When you pass a table class, the Data API reads its schema from its `@RegisterTa ## `@Joined` compared with `@Foreign` -| Aspect | `@Joined` | `@Foreign` | -| ------------------- | -------------------------------------------- | ------------------------------------------------------------------------- | -| Result shape | A single scalar value flattened onto the row | The full referenced record, or its plucked fields, nested under the field | -| Access | Read-only (enforced) | Set with `@Access` | -| Sort and filter | In the database, join applied before both | No sort or filter on remote fields | -| Multiple references | One remote field per decorator | Single or array (`multi`) references | +| Aspect | `@Joined` | `@Foreign` | +| ------------------- | ----------------------------------------------- | ------------------------------------------------------------------------- | +| Result shape | A single scalar value flattened onto the row | The full referenced record, or its plucked fields, nested under the field | +| Access | Read-only, unless a later `@Access` replaces it | Set with `@Access` | +| Sort and filter | In the database, join applied before both | No sort or filter on remote fields | +| Multiple references | One remote field per decorator | Single or array (`multi`) references | ## Next steps diff --git a/docs/11.computed.md b/docs/11.computed.md index 8fa1938..a7badca 100644 --- a/docs/11.computed.md +++ b/docs/11.computed.md @@ -8,7 +8,7 @@ mode: reference The `@Computed` decorator declares a field whose value the database computes from an expression and merges onto each row at query time. The expression can compute from other fields of the same row, or run a per-row subquery such as an aggregate over another table. The merge happens before the list query sorts and filters, so the database sorts, filters, and pages computed fields. The API never fetches every row or sorts in memory. -Computed fields are read-only. The query produces the value, and the controller's table never stores it. Declaring a field as computed sets its access mode to read-only and makes its sort non-indexed, whatever the decorator order. +Computed fields are meant to be read-only. The query produces the value, and the controller's table never stores it. `@Computed` sets the field's access mode to read-only when it runs. An `@Access` written above it runs later and replaces that mode, so do not combine the two. `@Computed` also makes the field's sort non-indexed, whatever the decorator order. ## Basic usage @@ -101,7 +101,7 @@ class GroupAPI extends DataController( } ``` -The row proxy types every field as a `FieldValue`. The subquery compares each device's `group_id` with the row's `_id` through `eq`, which accepts that type. +The row proxy types every field as a `FieldValue`. The subquery compares each device's `group_id` with the row's `_id` through `eq`, which accepts that type. This filter checks every device for each row and does not use the `group_id` index. `getAll(row.key("_id"), "group_id")` would use the index, but `getAll` does not accept a row key typed as a `FieldValue`, so it compiles only with a cast. A GET request to `/groups/get?id=group-123` returns the computed values at the top level: @@ -178,7 +178,7 @@ declare devices_count: number; | --------------- | ---------------------------------------------------------------- | ------------------------------------------------------------ | | Source | Any expression: a computation over the row or a per-row subquery | One scalar field from another table matched by a foreign key | | Typical use | Aggregates (`count`, `sum`), derived values | Flattening a field of a referenced record | -| Access | Read-only (enforced) | Read-only (enforced) | +| Access | Read-only, unless a later `@Access` replaces it | Read-only, unless a later `@Access` replaces it | | Sort and filter | In the database, merged only where a step needs the value | In the database, join applied before sort and filter | For one field copied from one referenced record, `@Joined` is simpler. `@Computed` applies the same merge to any expression. diff --git a/docs/3.routes.md b/docs/3.routes.md index c8d388c..3709d91 100644 --- a/docs/3.routes.md +++ b/docs/3.routes.md @@ -251,8 +251,8 @@ The List route takes these options. | ----------- | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `maxPage` | `number` | Maximum value of `limit`, and the page size when the request sends no `limit`. Without it, the cap is `100`. | | `noForeign` | `boolean` | Skip foreign key resolution | -| `noPluck` | `boolean` | Return all database fields instead of the plucked fields | -| `pluckMode` | `string` | Select a named pluck mode (see `@Listable`). A mode with no field answers `400`. | +| `noPluck` | `boolean` | Skip the database pluck. The response still holds only the fields readable in `list` | +| `pluckMode` | `string` | Select a named pluck mode (see `@Listable`). A mode that no field declares answers `400`. | | `filters` | `Record` | Mandatory filters merged with request filters. Route field keys win (see [filters](./8.filters.md#mandatory-route-filters)). | The New route takes this option. diff --git a/docs/7.foreign-keys.md b/docs/7.foreign-keys.md index 7634fa5..0f5c217 100644 --- a/docs/7.foreign-keys.md +++ b/docs/7.foreign-keys.md @@ -265,7 +265,7 @@ By default, the Data API looks for the target table in the schema of the control declare userId: User; ``` -When you pass a table class, the Data API reads its schema from its `@RegisterTable` metadata. The `schema` parameter is then needed only to override that schema, or with a string table name. If the class metadata and the `schema` parameter name different schemas, the declaration throws. +When you pass a table class, the Data API reads its schema from its `@RegisterTable` metadata. The `schema` parameter is then needed only with a string table name, or when the class metadata names no schema. If the class metadata and the `schema` parameter name different schemas, the declaration throws. ## Disable foreign key resolution