@antelopejs/interface-data-api 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/antelope.test.js +1 -1
  2. package/dist/components.d.ts +64 -15
  3. package/dist/components.js +91 -23
  4. package/dist/components.js.map +1 -1
  5. package/dist/index.d.ts +9 -5
  6. package/dist/index.js +42 -56
  7. package/dist/index.js.map +1 -1
  8. package/dist/metadata.d.ts +14 -0
  9. package/dist/metadata.js +23 -4
  10. package/dist/metadata.js.map +1 -1
  11. package/dist/tests/components/foreign_computed.test.d.ts +1 -0
  12. package/dist/tests/components/foreign_computed.test.js +172 -0
  13. package/dist/tests/components/foreign_computed.test.js.map +1 -0
  14. package/dist/tests/components/mandatory.test.js +3 -5
  15. package/dist/tests/components/mandatory.test.js.map +1 -1
  16. package/dist/tests/components/parsed_value.test.js +2 -2
  17. package/dist/tests/components/parsed_value.test.js.map +1 -1
  18. package/dist/tests/components/partial_edit.test.js +2 -2
  19. package/dist/tests/components/partial_edit.test.js.map +1 -1
  20. package/dist/tests/components/per_action_access.test.js +12 -2
  21. package/dist/tests/components/per_action_access.test.js.map +1 -1
  22. package/dist/tests/components/validator.test.js +3 -5
  23. package/dist/tests/components/validator.test.js.map +1 -1
  24. package/dist/tests/index/inheritance.test.js +1 -1
  25. package/dist/tests/index/inheritance.test.js.map +1 -1
  26. package/dist/tests/index/routes.test.js +45 -3
  27. package/dist/tests/index/routes.test.js.map +1 -1
  28. package/dist/tests/integration.test.js +5 -7
  29. package/dist/tests/integration.test.js.map +1 -1
  30. package/dist/tests/utils.d.ts +6 -1
  31. package/dist/tests/utils.js +3 -1
  32. package/dist/tests/utils.js.map +1 -1
  33. package/docs/1.introduction.md +26 -26
  34. package/docs/10.joined.md +36 -5
  35. package/docs/11.computed.md +6 -6
  36. package/docs/2.data-controllers.md +4 -4
  37. package/docs/3.routes.md +56 -8
  38. package/docs/4.access-rights.md +21 -6
  39. package/docs/5.validators.md +2 -0
  40. package/docs/6.listable.md +2 -2
  41. package/docs/7.foreign-keys.md +9 -10
  42. package/docs/8.filters.md +22 -10
  43. package/docs/9.modifiers.md +2 -4
  44. package/docs/assets/joined-list.svg +23 -0
  45. package/package.json +1 -1
  46. package/skills/data-api-interface/SKILL.md +9 -0
package/docs/10.joined.md CHANGED
@@ -10,19 +10,19 @@ Joined fields are read-only: the value is set by a lookup at query time and is n
10
10
 
11
11
  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`.
12
12
 
13
+ 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.
14
+
13
15
  ```typescript
14
16
  import { Joined } from "@antelopejs/interface-data-api/metadata";
15
17
  import {
16
18
  Field,
17
- Index,
18
19
  RegisterTable,
19
20
  Relation,
20
21
  Table,
21
22
  } from "@antelopejs/interface-database-decorators";
22
23
 
23
- @RegisterTable("users")
24
+ @RegisterTable("users", "myapp")
24
25
  class User extends Table {
25
- @Index({ primary: true })
26
26
  @Field("string")
27
27
  declare _id: string;
28
28
 
@@ -30,9 +30,8 @@ class User extends Table {
30
30
  declare name: string;
31
31
  }
32
32
 
33
- @RegisterTable("orders")
33
+ @RegisterTable("orders", "myapp")
34
34
  class Order extends Table {
35
- @Index({ primary: true })
36
35
  @Field("string")
37
36
  declare _id: string;
38
37
 
@@ -118,6 +117,12 @@ Joined fields are always sorted without a database index (`@Sortable` is forced
118
117
 
119
118
  ## Deferred Resolution on Lists
120
119
 
120
+ 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`.
121
+
122
+ ![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"}
123
+
124
+ ### Advanced: composing `Query.List`
125
+
121
126
  `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.
122
127
 
123
128
  - Paging the stream (`slice`, `nth`) appends the lookups after the page boundary, so they only run on the returned rows.
@@ -127,6 +132,32 @@ Joined fields are always sorted without a database index (`@Sortable` is forced
127
132
 
128
133
  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.
129
134
 
135
+ ### Reading a page: `Query.ListPage`
136
+
137
+ 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.
138
+
139
+ ```typescript
140
+ const [query, total, deferredJoined] = Query.List(
141
+ this,
142
+ meta,
143
+ model.table,
144
+ ctx,
145
+ [params.sortKey, params.sortDirection],
146
+ params.filters,
147
+ model.database,
148
+ { exposeDeferredJoined: true },
149
+ );
150
+ const rows = await Query.ListPage(model.database, meta, query, {
151
+ offset: params.offset ?? 0,
152
+ limit: params.limit ?? 10,
153
+ filters: params.filters,
154
+ sortKey: params.sortKey,
155
+ pluck, // Set of field names; every field when unset
156
+ noForeign: params.noForeign,
157
+ deferredJoined,
158
+ });
159
+ ```
160
+
130
161
  ## Cross-Schema Joins
131
162
 
132
163
  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.
@@ -10,10 +10,12 @@ Computed fields are read-only: the value is produced by the query and is never p
10
10
 
11
11
  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.
12
12
 
13
+ 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.
14
+
13
15
  ```typescript
14
16
  import { Computed } from "@antelopejs/interface-data-api/metadata";
15
17
 
16
- @RegisterTable("groups")
18
+ @RegisterTable("groups", "myapp")
17
19
  class Group extends Table {
18
20
  @Field("string")
19
21
  declare _id: string;
@@ -22,7 +24,7 @@ class Group extends Table {
22
24
  declare name: string;
23
25
  }
24
26
 
25
- @RegisterTable("devices")
27
+ @RegisterTable("devices", "myapp")
26
28
  class Device extends Table {
27
29
  @Field("string")
28
30
  declare _id: string;
@@ -116,15 +118,13 @@ Computed fields are always sorted without a database index (`@Sortable` is force
116
118
 
117
119
  ### Filtering on numeric computed fields
118
120
 
119
- Filter values arrive as strings from the query string. The default filter compares them as-is, which never matches a numeric computed value (`"0"` is not `0` in BSON). Provide a custom filter function that coerces the value:
121
+ 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"`.
120
122
 
121
123
  ```typescript
122
124
  @Computed((row, db) => db.table("devices").getAll(row.key("_id"), "group_id").count(), {
123
125
  default: 0,
124
126
  })
125
- @Filter((_context, proxy, _key, value) =>
126
- (proxy as ValueProxy<number>).eq(parseFloat(value)),
127
- )
127
+ @Filter(numberFilter, false)
128
128
  declare devices_count: number;
129
129
  ```
130
130
 
@@ -8,7 +8,9 @@ A data controller is the central building block of the Data API. It connects a d
8
8
 
9
9
  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.
10
10
 
11
- ```typescript
11
+ 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.
12
+
13
+ ```ts docs-check
12
14
  import { Controller } from "@antelopejs/interface-api";
13
15
  import {
14
16
  DataController,
@@ -24,16 +26,14 @@ import {
24
26
  import {
25
27
  BasicDataModel,
26
28
  Field,
27
- Index,
28
29
  Model,
29
30
  RegisterTable,
30
31
  Table,
31
32
  } from "@antelopejs/interface-database-decorators";
32
33
 
33
34
  // Define the database table
34
- @RegisterTable("users")
35
+ @RegisterTable("users", "myapp")
35
36
  class User extends Table {
36
- @Index({ primary: true })
37
37
  @Field("string")
38
38
  declare _id: string;
39
39
 
package/docs/3.routes.md CHANGED
@@ -138,10 +138,14 @@ Content-Type: application/json
138
138
  }
139
139
  ```
140
140
 
141
- **Response:**
141
+ **Response:** the created record, as [Get](#get) answers it, with its `_id` even when `get` does not read it.
142
142
 
143
143
  ```json
144
- ["user-789"]
144
+ {
145
+ "_id": "user-789",
146
+ "name": "Bob",
147
+ "email": "bob@example.com"
148
+ }
145
149
  ```
146
150
 
147
151
  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`.
@@ -184,10 +188,14 @@ Content-Type: application/json
184
188
  }
185
189
  ```
186
190
 
187
- **Response:**
191
+ **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.
188
192
 
189
- ```http
190
- 200 OK
193
+ ```json
194
+ {
195
+ "_id": "user-123",
196
+ "name": "Robert",
197
+ "email": "robert@example.com"
198
+ }
191
199
  ```
192
200
 
193
201
  Before updating, the `update` event is triggered on any table modifiers attached to the database table.
@@ -264,7 +272,7 @@ class UserAPI extends DataController(User, routes, Controller("/users")) {
264
272
  | `noForeign` | `boolean` | Skip foreign key resolution |
265
273
  | `noPluck` | `boolean` | Return all database fields instead of plucked fields |
266
274
  | `pluckMode` | `string` | Select a named pluck mode (see `@Listable`) |
267
- | `filters` | `Record<string, FilterValue>` | Mandatory filters merged with request filters; route field keys win (see [filters](./8.filters.md#mandatory-route-filters)). |
275
+ | `filters` | `Record<string, FieldFilter>` | Mandatory filters merged with request filters; route field keys win (see [filters](./8.filters.md#mandatory-route-filters)). |
268
276
 
269
277
  **New:**
270
278
 
@@ -279,6 +287,33 @@ class UserAPI extends DataController(User, routes, Controller("/users")) {
279
287
  | `index` | `string` | Use a secondary index instead of the primary key |
280
288
  | `noMandatory` | `string` | Skip mandatory field validation |
281
289
 
290
+ ## Amending the Body of New and Edit
291
+
292
+ 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.
293
+
294
+ ```ts docs-check
295
+ import { DefaultRoutes } from "@antelopejs/interface-data-api";
296
+ import {
297
+ type Parameters,
298
+ Validation,
299
+ } from "@antelopejs/interface-data-api/components";
300
+ import type { RequestContext } from "@antelopejs/interface-api";
301
+
302
+ const stampedNew = {
303
+ ...DefaultRoutes.New,
304
+ func: function (
305
+ this: unknown,
306
+ ctx: RequestContext,
307
+ params: Parameters.NewParameters,
308
+ body: Buffer,
309
+ ) {
310
+ const parsed = Validation.ParseBody(body);
311
+ parsed.source = "import";
312
+ return DefaultRoutes.New.func.call(this, ctx, params, parsed);
313
+ },
314
+ };
315
+ ```
316
+
282
317
  ## Loaded-Model Guards with `WithGetGuard`
283
318
 
284
319
  `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.
@@ -300,9 +335,22 @@ const routes = {
300
335
  };
301
336
  ```
302
337
 
303
- The `GetGuard` callback receives `(ctx, current, params)` and runs with the controller as `this` when declared as a regular function. `ctx` is the request context, `current` is the loaded model including non-readable fields and the normal Get joined/computed/foreign data, and `params` is `Parameters.GetParameters`. Treat `current` as read-only: it is not the response DTO and is not frozen by the API.
338
+ The `GetGuard` callback receives `(ctx, current, params)`. Declare it as a regular function to access the controller through `this`.
339
+
340
+ | Argument | Contents | Use it for |
341
+ | --------- | --------------------------------------------------------------------------------------- | ---------------------------------------------- |
342
+ | `ctx` | Request context | Request-specific policy. |
343
+ | `current` | Loaded model, including non-readable fields and normal Get joined/computed/foreign data | Inspecting stored ownership or discriminators. |
344
+ | `params` | `Parameters.GetParameters` | The parsed Get parameters. |
345
+
346
+ Treat `current` as read-only: it is not the response DTO and the API does not freeze it.
347
+
348
+ Keep these boundaries when writing a guard:
304
349
 
305
- Throw an `HTTPResult` (or use `assert`) to deny the read. A normal return permits it; returning `false` is not a denial mechanism. Missing records return `404` without invoking the guard. The guard does not issue a second read, does not change field-access rules, and applies only to the route using it, not list/edit/delete routes.
350
+ - **Deny by throwing.** Throw an `HTTPResult` or use `assert`. A normal return permits the read, including a return value of `false`.
351
+ - **Missing records bypass the guard.** The route returns `404` without invoking it.
352
+ - **Use the already-loaded model.** The guard does not issue a second read or change field-access rules.
353
+ - **Protect each route deliberately.** The guard applies only to the route using it, not list, edit, or delete routes.
306
354
 
307
355
  ## Custom Routes
308
356
 
@@ -4,6 +4,19 @@
4
4
 
5
5
  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.
6
6
 
7
+ ## Separate field policy from caller permissions
8
+
9
+ `@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.
10
+
11
+ Build the field policy in this order:
12
+
13
+ 1. Set `@Access` for each public field. Undecorated fields remain private.
14
+ 2. Add action overrides for differences between creation and editing.
15
+ 3. Add `@Listable` for the fields selected by list responses; readable alone does not define a list projection.
16
+ 4. Add validators and mandatory-field rules for writable inputs.
17
+
18
+ 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.
19
+
7
20
  ## Access Modes
8
21
 
9
22
  The `AccessMode` enum defines three permission levels:
@@ -12,11 +25,11 @@ The `AccessMode` enum defines three permission levels:
12
25
  import { Access, AccessMode } from "@antelopejs/interface-data-api/metadata";
13
26
  ```
14
27
 
15
- | Mode | Value | Description |
16
- | ---------------------- | ----- | ----------------------------------------------------------------------------- |
17
- | `AccessMode.ReadOnly` | `1` | Field appears in GET and LIST responses but is ignored in POST and PUT bodies |
18
- | `AccessMode.WriteOnly` | `2` | Field is accepted in POST and PUT bodies but never appears in responses |
19
- | `AccessMode.ReadWrite` | `3` | Field is both readable and writable |
28
+ | Mode | Value | Description |
29
+ | ---------------------- | ----- | --------------------------------------------------------------------------------------------------- |
30
+ | `AccessMode.ReadOnly` | `1` | Field is readable; list responses also require selection by the pluck mode. POST and PUT ignore it. |
31
+ | `AccessMode.WriteOnly` | `2` | Field is accepted in POST and PUT bodies but never appears in responses |
32
+ | `AccessMode.ReadWrite` | `3` | Field is both readable and writable |
20
33
 
21
34
  ## Basic Usage
22
35
 
@@ -105,6 +118,8 @@ The `@Access` decorator accepts an optional second argument to override the acce
105
118
 
106
119
  The `overrides` object maps action names (`"get"`, `"list"`, `"new"`, `"edit"`) to an `AccessMode` value. Actions not listed in the overrides use the default mode.
107
120
 
121
+ 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.
122
+
108
123
  ### Example: Writable on Create, Read-Only on Edit
109
124
 
110
125
  ```typescript
@@ -141,7 +156,7 @@ In this configuration:
141
156
 
142
157
  ## Fields Without `@Access`
143
158
 
144
- Fields declared without the `@Access` decorator are not exposed through the API at all. They do not appear in responses and are not accepted in request bodies. To include a field in the API, you must explicitly set its access mode.
159
+ 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.
145
160
 
146
161
  ## Next Steps
147
162
 
@@ -118,6 +118,8 @@ declare appointmentDate: string;
118
118
 
119
119
  ### Async Validation
120
120
 
121
+ 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.
122
+
121
123
  ```typescript
122
124
  // Check uniqueness against the database
123
125
  @Validator(async (value) => {
@@ -224,13 +224,13 @@ class UserAPI extends DataController(
224
224
 
225
225
  ### The `noIndex` Option
226
226
 
227
- By default, `@Sortable()` assumes the field has a database index and uses indexed ordering. Set `noIndex: true` when the field is not indexed, so the framework performs an in-memory sort instead.
227
+ 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.
228
228
 
229
229
  ```typescript
230
230
  @Sortable() // Uses database index for sorting
231
231
  declare _id: string;
232
232
 
233
- @Sortable({ noIndex: true }) // In-memory sort, no index required
233
+ @Sortable({ noIndex: true }) // Non-indexed query ordering
234
234
  declare name: string;
235
235
  ```
236
236
 
@@ -8,19 +8,21 @@ The `@Foreign` decorator establishes relationships between database tables in yo
8
8
 
9
9
  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.
10
10
 
11
+ 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.
12
+
13
+ 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.
14
+
11
15
  ```typescript
12
16
  import { Foreign } from "@antelopejs/interface-data-api/metadata";
13
17
  import {
14
18
  Field,
15
- Index,
16
19
  RegisterTable,
17
20
  Relation,
18
21
  Table,
19
22
  } from "@antelopejs/interface-database-decorators";
20
23
 
21
- @RegisterTable("users")
24
+ @RegisterTable("users", "myapp")
22
25
  class User extends Table {
23
- @Index({ primary: true })
24
26
  @Field("string")
25
27
  declare _id: string;
26
28
 
@@ -31,9 +33,8 @@ class User extends Table {
31
33
  declare email: string;
32
34
  }
33
35
 
34
- @RegisterTable("orders")
36
+ @RegisterTable("orders", "myapp")
35
37
  class Order extends Table {
36
- @Index({ primary: true })
37
38
  @Field("string")
38
39
  declare _id: string;
39
40
 
@@ -61,7 +62,7 @@ class OrderAPI extends DataController(
61
62
 
62
63
  @Listable()
63
64
  @Access(AccessMode.ReadOnly)
64
- @Foreign(User)
65
+ @Foreign(User, undefined, false, ["_id", "name", "email"])
65
66
  declare userId: User;
66
67
 
67
68
  @Listable()
@@ -125,9 +126,8 @@ When a field stores an array of foreign key IDs, set the `multi` parameter to `t
125
126
  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`.
126
127
 
127
128
  ```typescript
128
- @RegisterTable("carts")
129
+ @RegisterTable("carts", "myapp")
129
130
  class Cart extends Table {
130
- @Index({ primary: true })
131
131
  @Field("string")
132
132
  declare _id: string;
133
133
 
@@ -136,9 +136,8 @@ class Cart extends Table {
136
136
  declare items: string[]; // Array of CartItem IDs
137
137
  }
138
138
 
139
- @RegisterTable("cart_items")
139
+ @RegisterTable("cart_items", "myapp")
140
140
  class CartItem extends Table {
141
- @Index({ primary: true })
142
141
  @Field("string")
143
142
  declare _id: string;
144
143
 
package/docs/8.filters.md CHANGED
@@ -81,6 +81,14 @@ Combine multiple filters:
81
81
  GET /users/list?filter_status=active&filter_role=ne:admin
82
82
  ```
83
83
 
84
+ Filter one field several times to keep the rows that pass every filter (AND):
85
+
86
+ ```http
87
+ GET /users/list?filter_age=ge:18&filter_age=lt:65
88
+ ```
89
+
90
+ 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.
91
+
84
92
  ### Numeric and Boolean Filters
85
93
 
86
94
  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.
@@ -148,14 +156,14 @@ type FilterFunction<T, U> = (
148
156
  ) => ValueProxyOrValue<boolean>;
149
157
  ```
150
158
 
151
- | Parameter | Description |
152
- | --------- | -------------------------------------------------------------------------------------------------------------- |
153
- | `context` | The request context, extended with `this` referencing the controller instance |
154
- | `proxy` | A `ValueProxy` representing the field value with modifier transformations applied (e.g., decrypted, localized) |
155
- | `key` | The field name being filtered |
156
- | `value` | The filter value from the query parameter |
157
- | `mode` | The comparison operator from the query parameter |
158
- | `row` | The full database row as a `ValueProxy` |
159
+ | Parameter | Description |
160
+ | --------- | ------------------------------------------------------------------------------------------------------- |
161
+ | `context` | The request context, extended with `this` referencing the controller instance |
162
+ | `proxy` | A query expression produced by the field modifier's `unlockrequest()` method; see the limitations below |
163
+ | `key` | The field name being filtered |
164
+ | `value` | The filter value from the query parameter |
165
+ | `mode` | The comparison operator from the query parameter |
166
+ | `row` | The full database row as a `ValueProxy` |
159
167
 
160
168
  ### Example: Pattern Matching Filter
161
169
 
@@ -168,7 +176,9 @@ function filterByEmailDomain(
168
176
  mode: string,
169
177
  row: ValueProxy<Record<string, any>>,
170
178
  ) {
171
- return proxy.match(`@${value}$`);
179
+ assert(mode === "eq", 400, "Only equality is supported for email domains");
180
+ const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
181
+ return proxy.match(`@${escaped}$`);
172
182
  }
173
183
 
174
184
  @RegisterDataController()
@@ -199,6 +209,8 @@ Query for users with Gmail addresses:
199
209
  GET /users/list?filter_email=gmail.com
200
210
  ```
201
211
 
212
+ 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.
213
+
202
214
  ### The `useIndex` Parameter
203
215
 
204
216
  The `@Filter` decorator accepts an optional second parameter, `useIndex`, that controls whether the filter can use a database index for optimized lookups.
@@ -234,6 +246,6 @@ On a controller with a `status` filter, `?filter_status=inactive&filter_role=adm
234
246
 
235
247
  ## Modifier Integration
236
248
 
237
- The `proxy` parameter in custom filter functions provides the field value after modifier transformations have been applied. For example, if a field uses an encryption modifier, `proxy` contains the decrypted value. This ensures filters work correctly with modifier-protected data.
249
+ 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.
238
250
 
239
251
  For raw database values, use the `row` parameter and access the field directly.
@@ -45,7 +45,6 @@ import {
45
45
  import {
46
46
  BasicDataModel,
47
47
  Field,
48
- Index,
49
48
  Localized,
50
49
  LocalizationModifier,
51
50
  Model,
@@ -53,9 +52,8 @@ import {
53
52
  Table,
54
53
  } from "@antelopejs/interface-database-decorators";
55
54
 
56
- @RegisterTable("content")
55
+ @RegisterTable("content", "myapp")
57
56
  class Content extends Table.with(LocalizationModifier) {
58
- @Index({ primary: true })
59
57
  @Field("string")
60
58
  declare _id: string;
61
59
 
@@ -126,7 +124,7 @@ Modifiers work automatically with foreign key relationships. When the Data API r
126
124
 
127
125
  ### Filters
128
126
 
129
- When filtering modifier-protected fields, custom filter functions receive the unlocked (transformed) value through the `proxy` parameter. This ensures filters operate on the correct data. See the [filters documentation](./8.filters.md) for details.
127
+ 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.
130
128
 
131
129
  ## Learn More
132
130
 
@@ -0,0 +1,23 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 480 510" role="img" aria-labelledby="title desc">
2
+ <title id="title">When the default list route resolves joined fields</title>
3
+ <desc id="desc">Resolve fields needed by filters before filtering and counting. Resolve sort fields before ordering and paging. Resolve remaining selected display fields only for the page. This describes the default list route, not arbitrary Query.List composition.</desc>
4
+ <rect width="480" height="510" rx="16" fill="#f5f7fb"/>
5
+ <g font-family="system-ui, sans-serif" fill="#172b45">
6
+ <text x="24" y="36" font-size="20" font-weight="700">Joined fields in a default list request</text>
7
+ <rect x="24" y="58" width="432" height="100" rx="10" fill="#e1ecfa"/>
8
+ <text x="42" y="87" font-size="18" font-weight="700">1. Needed by a filter</text>
9
+ <text x="42" y="116" font-size="16">Resolve → filter matching rows</text>
10
+ <text x="42" y="143" font-size="16">Count these rows for total.</text>
11
+ <path d="M240 168v24m-6-6 6 6 6-6" fill="none" stroke="#526780" stroke-width="2"/>
12
+ <rect x="24" y="204" width="432" height="100" rx="10" fill="#e1ecfa"/>
13
+ <text x="42" y="233" font-size="18" font-weight="700">2. Needed only for sorting</text>
14
+ <text x="42" y="262" font-size="16">Resolve → sort matching rows</text>
15
+ <text x="42" y="289" font-size="16">Then select the requested page.</text>
16
+ <path d="M240 314v24m-6-6 6 6 6-6" fill="none" stroke="#526780" stroke-width="2"/>
17
+ <rect x="24" y="350" width="432" height="100" rx="10" fill="#d9eee8"/>
18
+ <text x="42" y="379" font-size="18" font-weight="700">3. Needed only for display</text>
19
+ <text x="42" y="408" font-size="16">Resolve selected fields on the page</text>
20
+ <text x="42" y="435" font-size="16">Return results with pagination metadata.</text>
21
+ <text x="24" y="484" font-size="15">A field resolved earlier is not deferred again.</text>
22
+ </g>
23
+ </svg>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/interface-data-api",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "AntelopeJS interface that generates CRUD REST endpoints from database tables, with access rules, validation and pagination",
5
5
  "keywords": [
6
6
  "antelopejs",
@@ -121,6 +121,8 @@ This exposes `GET /tasks/get?id=`, `GET /tasks/list`, `POST /tasks/new`, `PUT /t
121
121
  - Filters come from `filter_<name>=<mode>:<value>` query params (plain value means `eq`); modes are
122
122
  `eq|ne|gt|ge|lt|le` (the default filter compares the raw query-string value — pass a custom filter
123
123
  function to cast for numeric ranges). Only fields declared with `@Filter()` are filterable.
124
+ Repeating a field's filter (`filter_age=ge:18&filter_age=lt:65`) keeps the rows passing them all;
125
+ the route then receives a list of tuples for it (`FieldFilter`, read with `FilterValuesOf`).
124
126
  - `@Joined` and `@Computed` fields are materialized in-database, so `@Sortable` and `@Filter()` work
125
127
  efficiently on them — but those decorators must still be applied explicitly. Such fields are
126
128
  forced read-only and to non-indexed sorting.
@@ -128,6 +130,13 @@ This exposes `GET /tasks/get?id=`, `GET /tasks/list`, `POST /tasks/new`, `PUT /t
128
130
  of `DefaultRoutes.All`; use `DefaultRoutes.WithOptions(route, options, endpoint)` to rename an
129
131
  endpoint or preset parameter options.
130
132
  - `@Access` accepts per-action overrides, e.g. `@Access(AccessMode.ReadOnly, { edit: AccessMode.ReadWrite })`.
133
+ `GetEffectiveMode(field, action)` reads the mode a field has in one action.
134
+ - A custom list route reads its page with `Query.ListPage(db, meta, query, options)` (`offset`,
135
+ `limit`, `filters`, `sortKey`, `pluck`, `noForeign`, `deferredJoined`): joined, then computed, then
136
+ foreign, then pluck.
137
+ - `new` answers the created row as `get` reads it, with its `_id`; `edit` answers the stored row after
138
+ the update. Both accept a body already parsed (`Validation.WrittenBody`), so a wrapping route can
139
+ amend it before the write.
131
140
  - `edit` is a partial update: a field absent from the body is left unchanged, `null` clears it, and
132
141
  setters only run for the fields present. `@Mandatory("edit")` rejects a field cleared by the body
133
142
  (`null`, `""`, `[]`), not an absent one. `new` still writes every writable field and runs every setter.