json-rest-api 2.1.0 → 2.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 (29) hide show
  1. package/docs/API.md +80 -0
  2. package/docs/GUIDE/17-row-policies.md +1 -0
  3. package/docs/GUIDE/21-schema-and-migrations.md +99 -2
  4. package/docs/GUIDE/30-backend-capabilities.md +3 -1
  5. package/docs/GUIDE/33-migrating-to-v2.md +20 -0
  6. package/package.json +1 -1
  7. package/plugins/core/lib/anyapi/anyapi-registry.js +97 -42
  8. package/plugins/core/lib/anyapi/schema-utils.js +168 -53
  9. package/plugins/core/lib/anyapi/stored-definition.js +3 -0
  10. package/plugins/core/lib/dbIntrospection.js +28 -0
  11. package/plugins/core/lib/querying/aggregate-query.js +170 -0
  12. package/plugins/core/lib/querying/knex-query-helpers.js +14 -4
  13. package/plugins/core/lib/querying-writing/compile-schemas.js +17 -0
  14. package/plugins/core/lib/querying-writing/database-value-normalizers.js +16 -0
  15. package/plugins/core/lib/querying-writing/default-data-helpers.js +4 -0
  16. package/plugins/core/lib/querying-writing/field-types.js +43 -0
  17. package/plugins/core/lib/querying-writing/request-contracts.js +1 -1
  18. package/plugins/core/lib/storage/canonical-data-helpers.js +29 -0
  19. package/plugins/core/lib/storage/canonical-storage-mapping.js +11 -0
  20. package/plugins/core/lib/storage/ordinary-data-helpers.js +25 -0
  21. package/plugins/core/lib/storage/storage-types.d.ts +23 -0
  22. package/plugins/core/rest-api-anyapi-knex-plugin.js +140 -31
  23. package/plugins/core/rest-api-plugin-methods/aggregate.js +41 -0
  24. package/plugins/core/rest-api-plugin-methods/common.js +16 -1
  25. package/plugins/core/rest-api-plugin.js +2 -0
  26. package/types/anyapi.d.ts +13 -2
  27. package/types/plugin-options.d.ts +2 -1
  28. package/types/resource-methods.d.ts +19 -0
  29. package/types/resource-schema.d.ts +5 -3
package/docs/API.md CHANGED
@@ -78,6 +78,7 @@ a separate context for each enlisted operation. See [transaction support](#trans
78
78
  - [PUT - Replace Resource](#put---replace-resource)
79
79
  - [PATCH - Update Resource](#patch---update-resource)
80
80
  - [DELETE - Remove Resource](#delete---remove-resource)
81
+ - [AGGREGATE - Grouped Totals](#aggregate---grouped-totals)
81
82
  3. [Relationship Methods](#relationship-methods)
82
83
  - [getRelated - Retrieve Related Resources](#getrelated---retrieve-related-resources)
83
84
  - [getRelationship - Retrieve Relationship Identifiers](#getrelationship---retrieve-relationship-identifiers)
@@ -1136,6 +1137,68 @@ savepoints cannot own library writes.
1136
1137
 
1137
1138
  ---
1138
1139
 
1140
+ ### AGGREGATE - Grouped Totals
1141
+
1142
+ Groups the records a collection query would select and computes measures for
1143
+ each group, on ordinary and AnyAPI storage alike.
1144
+
1145
+ #### Method Signature
1146
+
1147
+ ```javascript
1148
+ await api.resources.sales.aggregate(params, context)
1149
+ ```
1150
+
1151
+ #### Parameters
1152
+
1153
+ | Parameter | Type | Description |
1154
+ |-----------|------|-------------|
1155
+ | `groupBy` | string[] | Fields to group by. Omit for one row over all selected records. |
1156
+ | `measures` | object | Output names mapped to `{ count \| countDistinct \| sum \| avg \| min \| max: field }`. Only `count` accepts `'*'`. |
1157
+ | `queryParams.filters` | object | Search schema filters, validated as for `query`. |
1158
+ | `queryParams.sort` | string[] | Grouped field or measure names, `-` for descending. Grouped fields break ties. |
1159
+ | `queryParams.page` | object | `size` and `number`, for grouped results only. `size` defaults to `queryDefaultLimit` and cannot exceed `queryMaxLimit`. |
1160
+ | `transaction` | Knex transaction | Optional transaction to read in. |
1161
+
1162
+ `sum` and `avg` take numeric fields; `min` and `max` also take strings, dates and
1163
+ times. Grouping and counting accept any scalar stored field, including belongs-to
1164
+ foreign keys. Fields that are `hidden`, virtual, computed, or that have a getter or
1165
+ `storage.serialize` are rejected, because SQL would group or sum values other than
1166
+ the ones clients read. `normallyHidden` fields are allowed.
1167
+
1168
+ #### Return Value
1169
+
1170
+ ```javascript
1171
+ {
1172
+ data: [{ salesperson: 'Ann', total: 150.5, count: 2 }],
1173
+ meta: { page: { size: 20, number: 1, hasMore: false } } // grouped results only
1174
+ }
1175
+ ```
1176
+
1177
+ Both formats return these plain rows. Group values and `min`/`max` use the
1178
+ field's public representation; counts are numbers, and `sum`/`avg` are numbers
1179
+ except on decimal fields (`precision` and `scale`), which keep the driver's value.
1180
+ NULL sorts as the lowest value on every database.
1181
+
1182
+ #### Security
1183
+
1184
+ `aggregate` checks permissions as a `query` (`method: 'query'`, with
1185
+ `originalContext.method === 'aggregate'`). Row policies, autofilters and other
1186
+ `knexQueryFiltering` hooks run with `queryPurpose: 'aggregate'`. The aggregate reads
1187
+ only the IDs the filtered query selects, so joins added by filters or policies
1188
+ never count a record twice.
1189
+
1190
+ #### Examples
1191
+
1192
+ ```javascript
1193
+ const totals = await api.resources.sales.aggregate({
1194
+ groupBy: ['salesperson'],
1195
+ measures: { total: { sum: 'amount' }, count: { count: '*' }, avg: { avg: 'amount' } },
1196
+ queryParams: { filters: { closed: true }, sort: ['-total'], page: { size: 10 } }
1197
+ }, { subject })
1198
+ ```
1199
+
1200
+ ---
1201
+
1139
1202
  ## Relationship Methods
1140
1203
 
1141
1204
  ### getRelated - Retrieve Related Resources
@@ -2039,6 +2102,23 @@ database writes; it is not a persisted UI-state field. Use a setter when a
2039
2102
  virtual input needs to prepare other stored attributes. A `virtualFields` array
2040
2103
  is not the resource declaration contract. See [virtual-field behavior](GUIDE/12-field-transformations.md).
2041
2104
 
2105
+ #### Email and UUID Fields
2106
+
2107
+ `type: 'email'` and `type: 'uuid'` store trimmed strings and reject values that
2108
+ are not an email address or a UUID. Filters with a text operator (`like`,
2109
+ `contains`, `startsWith`, `endsWith`) accept fragments. Registration rejects any
2110
+ type json-rest-schema cannot cast, so a misspelt type fails at `addResource`
2111
+ rather than on the first write.
2112
+
2113
+ #### Retired Fields
2114
+
2115
+ `retired: true` keeps a stored field readable, filterable and sortable while
2116
+ rejecting every value written to it, including `null`, with the `retired_field`
2117
+ rule. `required` and `defaultTo` no longer apply, and PUT leaves its stored value
2118
+ alone. Only stored attributes can be retired, not belongs-to fields. AnyAPI
2119
+ resources can retire fields at run time with
2120
+ [`retireKnexFields`](GUIDE/21-schema-and-migrations.md#retiring-and-migrating-anyapi-fields).
2121
+
2042
2122
  #### Field Transformations
2043
2123
 
2044
2124
  Getters and setters receive a value and an injected context. Computed fields
@@ -286,6 +286,7 @@ Policies are reused for the resource selections that define externally visible d
286
286
  | --- | --- |
287
287
  | `collection` | Top-level collection query, before sorting and pagination. On AnyAPI this filtered builder is also cloned for the matching count. |
288
288
  | `count` | A separate count query, including native offset-pagination counts and standalone count helpers. |
289
+ | `aggregate` | The records a resource `aggregate()` call groups and measures. |
289
290
  | `single` | Minimal lookup used by `get`, and by existing-record preflights for `put`, `patch`, and `delete`. |
290
291
  | `include` | A target resource loaded into JSON:API `included` data. |
291
292
  | `search-join` | A related resource used by a cross-resource or polymorphic search, reference filter, or reference sort, including intermediate search path segments. |
@@ -297,7 +297,8 @@ This optional map may cover a subset of the new stored fields; remaining fields
297
297
  use the existing allocator. An initial resource map must still cover all stored
298
298
  fields. Invalid, occupied or unknown slots and existing field names reject the
299
299
  addition. Computed and virtual fields need no slots and cannot have overrides.
300
- AnyAPI `alterKnexFields` remains explicitly unsupported.
300
+ AnyAPI `alterKnexFields` remains explicitly unsupported; change a field's type with
301
+ [`migrateField`](#retiring-and-migrating-anyapi-fields).
301
302
 
302
303
  One call uses one owned metadata transaction. Allocation failure rolls back the
303
304
  batch and leaves the runtime schema unchanged. Added getters, setters, defaults,
@@ -365,6 +366,7 @@ await api.anyapi.handleSchemaChange(change)
365
366
 
366
367
  `handleSchemaChange({ tenant, resource, version })` reloads the resource only when
367
368
  the version is newer than the published one, and returns whether it did.
369
+ `api.anyapi.getSchemaVersion('items')` reads the stored version with one query.
368
370
  `api.resources.items.reloadSchema()` reloads unconditionally and resolves to the
369
371
  published version. Reloads are read-only: they combine the stored definition
370
372
  with this server's declaration and never re-register it.
@@ -384,6 +386,45 @@ of the tenant. A stored resource whose non-field options needed callbacks, such
384
386
  `normalizeId`, must be declared in code. To load resources on demand over HTTP,
385
387
  call `loadResource` from middleware placed before the API router.
386
388
 
389
+ ### Retiring and migrating AnyAPI fields
390
+
391
+ Slots are never released while records exist, so AnyAPI retires fields instead of
392
+ dropping them:
393
+
394
+ ```js
395
+ await api.resources.contacts.retireKnexFields(['phone'])
396
+ ```
397
+
398
+ A retired field keeps its slot and its values, stays readable, filterable and
399
+ sortable, and rejects writes as a [`retired: true`](../API.md#retired-fields) field
400
+ does. The mark is stored with the resource definition, so a server that registers
401
+ an older declaration still listing the field keeps it retired. Its name stays taken.
402
+
403
+ `migrateField` changes a field's type by copying its values into a new field:
404
+
405
+ ```js
406
+ await api.resources.contacts.migrateField({
407
+ from: 'phone',
408
+ to: { name: 'phoneNumber', type: 'number', nullable: true, search: true },
409
+ transform: (value, { id }) => value === null ? null : Number(value.replace(/\D/g, ''))
410
+ })
411
+ ```
412
+
413
+ In one transaction it allocates a slot for the new field, converts every record's
414
+ value, and retires the old field. Each converted value must pass the new field's
415
+ validation; without `transform`, that validation casts the old value directly. A
416
+ value it rejects names the record and rolls everything back. The new field must be
417
+ a plain stored attribute: add fields with callbacks with `addKnexFields` and
418
+ backfill them. Values are written directly, so setters, hooks and record versions
419
+ do not run. Records are converted one by one, which suits per-tenant tables
420
+ better than very large ones. Other servers keep accepting writes to the old field
421
+ until the migration commits, and those writes do not reach the new field, so run
422
+ it while nothing writes the field.
423
+
424
+ Both methods bump `schema_version` and call `onSchemaChanged` once after commit.
425
+ `AnyapiRegistry.retireFields({ tenant, resource, fields }, { transaction })` is the
426
+ deep form of retirement.
427
+
387
428
  ### AnyAPI tenant instances
388
429
 
389
430
  One API instance per tenant stays cheap when the instances share their start-up work:
@@ -416,7 +457,8 @@ Slot limits belong to the database: every server reads the slot columns that
416
457
  date, 5 JSON and 5 belongsTo slots. `ensureAnyApiSchema(knex, { slots })`, or the
417
458
  plugin's `slots` option when it ensures the schema, adds missing columns. Lower
418
459
  counts never drop columns. A server whose pool is exhausted rereads the layout
419
- once, so a migration that adds slots takes effect without a restart.
460
+ once, so a migration that adds slots takes effect without a restart. Indexed
461
+ slots have their own counts; see [AnyAPI indexed fields](#anyapi-indexed-fields).
420
462
 
421
463
  On PostgreSQL, adding a nullable column without a default does not rewrite the
422
464
  table. MySQL can add columns instantly from 8.0.29; earlier versions may copy the
@@ -457,6 +499,61 @@ lookups return null for absent metadata; operations requiring that relationship
457
499
  descriptor reject a missing descriptor. Schema errors and database failures
458
500
  are distinct from a valid relationship with no matching records.
459
501
 
502
+ ### AnyAPI indexed fields
503
+
504
+ All AnyAPI resources share `any_records`, so plain slots have no indexes: a filter
505
+ or sort on one reads every record of that tenant's resource. Indexed slots
506
+ (`indexed_string_N`, `indexed_number_N`, `indexed_boolean_N`, `indexed_date_N`)
507
+ each carry an index on `(tenant_id, resource, slot)`. A field declared
508
+ `indexed: true` takes the first free indexed slot of its type:
509
+
510
+ ```js
511
+ await api.addResource('contacts', {
512
+ schema: {
513
+ email: { type: 'email', indexed: true, search: true },
514
+ score: { type: 'integer', indexed: true },
515
+ notes: { type: 'string' }
516
+ }
517
+ })
518
+ ```
519
+
520
+ New tables get 5 string, 3 number and 2 date indexed slots, and no boolean ones.
521
+ `ensureAnyApiSchema(knex, { indexedSlots: { string: 8, boolean: 2 } })`, or the
522
+ plugin's `indexedSlots` option, adds more; JSON slots cannot be indexed. When a
523
+ resource has no free indexed slot of a type, registration and `addKnexFields`
524
+ fail with an error naming `indexedSlots`. The registry rereads the layout once,
525
+ so a migration that adds slots takes effect without a restart. Every index adds
526
+ work to each write to `any_records`, whatever the resource, so reserve indexed
527
+ slots for fields that large resources filter or sort on.
528
+
529
+ Indexed string slots are `VARCHAR(255)`, so MySQL can index them whole. A field
530
+ whose `max` is larger cannot take one, and longer values fail validation with a
531
+ `storage_range` violation on every database. Lengths count characters, not bytes.
532
+
533
+ Slots never move on their own. A field keeps its slot when a declaration adds or
534
+ drops `indexed`, and a server whose declaration marks a field indexed that is
535
+ stored in a plain slot logs a warning. To index such a field, copy it into a new
536
+ indexed field:
537
+
538
+ ```js
539
+ await api.resources.contacts.migrateField({
540
+ from: 'phone',
541
+ to: { name: 'phoneKey', type: 'string', nullable: true, indexed: true, search: true }
542
+ })
543
+ ```
544
+
545
+ Belongs-to slots are always indexed by parent ID, and `any_links` by each side's
546
+ record ID, so child lookups, includes and many-to-many reads stay inside one
547
+ tenant's rows.
548
+
549
+ `ensureAnyApiSchema` adds missing slot columns and indexes to existing tables too.
550
+ MySQL builds indexes in place and PostgreSQL concurrently, so writes continue; on
551
+ PostgreSQL one server builds at a time and the others start without waiting. A
552
+ PostgreSQL pool with a single connection builds without `CONCURRENTLY`, which
553
+ blocks writes. Either way the build reads the whole table, so for a large
554
+ `any_records` run `ensureAnyApiSchema` from a migration before deploying. An
555
+ interrupted concurrent build leaves an invalid index, which the next run rebuilds.
556
+
460
557
  ## Live Table Snapshots
461
558
 
462
559
  Use `introspectKnexTableSnapshot()` to inspect the physical table shape:
@@ -67,7 +67,9 @@ AnyAPI field additions persist slot metadata and their definitions;
67
67
  and keeps the mapping of populated fields a declaration omits; changing a populated
68
68
  field's storage type needs an explicit data migration. Servers sharing a database
69
69
  see each other's additions after a reload, notification or schema check, and slot
70
- limits follow the columns the database has. Old canonical temporal storage needs
70
+ limits follow the columns the database has. Plain slots have no indexes; fields
71
+ declared `indexed: true` take [indexed slots](21-schema-and-migrations.md#anyapi-indexed-fields),
72
+ and belongs-to slots are always indexed. Old canonical temporal storage needs
71
73
  the documented data migration, not just a package update. See
72
74
  [table helpers and migration limits](21-schema-and-migrations.md#anyapi-across-several-servers).
73
75
 
@@ -1038,6 +1038,15 @@ Use `HookHandler<Event, AppContext>` for known hooks and
1038
1038
  `RuntimeHook<PluginContext>` for arbitrary plugin events; see the
1039
1039
  [hook guide](13-hooks-and-lifecycle.md).
1040
1040
 
1041
+ ## Field type names
1042
+
1043
+ `addResource` now rejects a field whose type json-rest-schema cannot cast, naming
1044
+ the field and type. Such fields used to register and then fail on their first
1045
+ write with `No casting function for type`. AnyAPI no longer lists `text`, `float`,
1046
+ `decimal`, `datetime` or `json`, which no storage could write; use `string`,
1047
+ `number`, `dateTime` and `object` or `array`. `email` and `uuid` are now real
1048
+ string types with format checks.
1049
+
1041
1050
  ## Object and array attributes
1042
1051
 
1043
1052
  Declare structured fields using their public JSON type:
@@ -1476,6 +1485,17 @@ are, and omitted populated fields keep their data without being published.
1476
1485
  Fields added before this release that the declaration does not repeat are kept
1477
1486
  and published from their stored definitions.
1478
1487
 
1488
+ `ensureAnyApiSchema` also adds indexed slot columns (`indexed_string_N` and so on),
1489
+ indexes on `(tenant_id, resource, slot)` for them and for every belongs-to slot,
1490
+ and `(tenant_id, left_id)` and `(tenant_id, right_id)` indexes on `any_links`. On
1491
+ an existing table the plugin builds them at start-up without blocking writes, but
1492
+ the build reads the whole table: for a large one, run `ensureAnyApiSchema` in a
1493
+ migration before deploying. On AnyAPI, `indexed: true` now places fields. A new
1494
+ field declaring it takes an indexed slot, and registration fails when none is
1495
+ free, when the field is an `object` or `array`, or when a string field's `max`
1496
+ exceeds 255. Existing fields stay in their slots; see
1497
+ [AnyAPI indexed fields](21-schema-and-migrations.md#anyapi-indexed-fields).
1498
+
1479
1499
  Explicit maps take effect during registration, before `createKnexTable()`.
1480
1500
  Repeating that method no longer re-registers the initial schema and discards
1481
1501
  additions. Keep the expanded declaration and complete map in app source for
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "json-rest-api",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "main": "index.js",
5
5
  "types": "index.d.ts",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  import { createKnexTransaction, readCommittedTransaction } from '../../../../lib/knex-transaction.js'
2
2
  import { applyInsertReturning, getDatabaseCapabilities } from '../querying-writing/database-capabilities.js'
3
- import { DEFAULT_CANONICAL_CONFIG, TYPE_TO_POOL, ensureAnyApiSchema, readSlotPools } from './schema-utils.js'
3
+ import { DEFAULT_CANONICAL_CONFIG, INDEXED_STRING_LENGTH, TYPE_TO_POOL, ensureAnyApiSchema, readSlotPools } from './schema-utils.js'
4
4
  import { toStoredJson } from './stored-definition.js'
5
5
  import { beginWriteTransaction, commitTransaction, endWriteOperation, getOperationDiagnosticContext, rollbackAfterError, wrapUnexpectedError, wrapWriteError } from '../../../../lib/error-context.js'
6
6
  import { createEnhancedLogger } from '../../../../lib/enhanced-logger.js'
@@ -46,17 +46,13 @@ const BELONGS_TO_ALIAS = (fieldName, fieldDef) => {
46
46
  return fieldName
47
47
  }
48
48
 
49
- const SLOT_PATTERNS = {
50
- string: /^string_(\d+)$/,
51
- number: /^number_(\d+)$/,
52
- boolean: /^boolean_(\d+)$/,
53
- date: /^date_(\d+)$/,
54
- json: /^json_(\d+)$/,
55
- }
49
+ const SIMPLE_SLOT_PATTERN = /^(indexed_)?(string|number|boolean|date|json)_(\d+)$/
56
50
 
57
51
  const BELONGS_TO_ID_PATTERN = /^rel_(\d+)_id$/
58
52
 
59
- const slotsExhausted = pool => Object.assign(new Error(`No available ${pool} slots remaining`), { slotsExhausted: true })
53
+ const slotsExhausted = (pool, indexed) => Object.assign(new Error(indexed
54
+ ? `No available indexed ${pool} slots remaining; add them with ensureAnyApiSchema(knex, { indexedSlots: { ${pool}: count } })`
55
+ : `No available ${pool} slots remaining`), { slotsExhausted: true })
60
56
 
61
57
  const slotPoolFor = (fieldDef = {}) => {
62
58
  if (fieldDef.belongsTo) return 'belongsTo'
@@ -65,17 +61,18 @@ const slotPoolFor = (fieldDef = {}) => {
65
61
 
66
62
  const belongsToColumns = index => [`rel_${index}_id`, `rel_${index}_type`]
67
63
 
64
+ const isIndexedSlot = column => column.startsWith('indexed_')
65
+
66
+ const slotColumnsOf = (pools, pool, indexed) => (indexed ? pools.indexed?.[pool] : pools[pool]) || []
67
+
68
68
  const parseSimpleSlotColumn = (column, pools) => {
69
- for (const [type, pattern] of Object.entries(SLOT_PATTERNS)) {
70
- const match = pattern.exec(column)
71
- if (match) {
72
- if (!pools[type].includes(column)) {
73
- throw new Error(`Canonical slot '${column}' is out of range`)
74
- }
75
- return { slotType: type, slotIndex: Number(match[1]) }
76
- }
69
+ const match = SIMPLE_SLOT_PATTERN.exec(column)
70
+ if (!match) throw new Error(`Invalid canonical slot column '${column}'`)
71
+ const [, indexed, slotType, slotIndex] = match
72
+ if (!slotColumnsOf(pools, slotType, Boolean(indexed)).includes(column)) {
73
+ throw new Error(`Canonical slot '${column}' is out of range`)
77
74
  }
78
- throw new Error(`Invalid canonical slot column '${column}'`)
75
+ return { slotType, slotIndex: Number(slotIndex) }
79
76
  }
80
77
 
81
78
  const parseBelongsToSlots = ({ idSlot, typeSlot }, pools) => {
@@ -148,9 +145,9 @@ export class AnyapiRegistry {
148
145
  return this.#shared.get(key)
149
146
  }
150
147
 
151
- ensureSchema ({ slots } = {}) {
152
- return this.#once(`schema:${JSON.stringify(slots ?? null)}`, async () => {
153
- await ensureAnyApiSchema(this.knex, { slots })
148
+ ensureSchema ({ slots, indexedSlots } = {}) {
149
+ return this.#once(`schema:${JSON.stringify([slots ?? null, indexedSlots ?? null])}`, async () => {
150
+ await ensureAnyApiSchema(this.knex, { slots, indexedSlots })
154
151
  this.#shared.delete('slots')
155
152
  })
156
153
  }
@@ -243,22 +240,16 @@ export class AnyapiRegistry {
243
240
  })
244
241
  }
245
242
 
246
- async #allocate (options, method, readParams) {
247
- const context = { method, [readCommittedTransaction]: true }
248
- let trx; let managed = false
249
- try {
250
- const { tenant, resource, fields, searchSchema } = readParams()
251
- Object.assign(context, { tenant, resource })
243
+ #allocate (options, method, readParams) {
244
+ return this.#changeResource(options, method, () => {
245
+ const params = readParams()
246
+ const { resource, fields, searchSchema } = params
252
247
  for (const [fieldName, { definition }] of Object.entries(fields)) {
253
248
  validateSchemaFieldNames({ [fieldName]: definition }, resource)
254
249
  }
255
250
  assertFieldNameMap(searchSchema, `added search schema in '${resource}'`)
256
-
257
- managed = !options.transaction
258
- trx = await beginWriteTransaction(context, options.transaction, ownerContext => createKnexTransaction(this.knex, ownerContext))
259
- const resourceRow = await this.#lockResourceRow(trx, tenant, resource)
260
- const descriptor = resourceRow && await this.#loadDescriptor(tenant, resource, trx)
261
- if (!descriptor) throw new Error(`Resource '${resource}' not registered for tenant '${tenant}'`)
251
+ return params
252
+ }, async ({ trx, resourceRow, descriptor }, { fields, searchSchema }) => {
262
253
  const plan = await this.#withSlotPools(trx, pools => this.#planAllocation(descriptor, fields, pools))
263
254
 
264
255
  for (const row of plan.fieldRows) {
@@ -280,11 +271,53 @@ export class AnyapiRegistry {
280
271
  runtime.callbacks.push(...callbacks.map(path => [kind, name, ...path]))
281
272
  }
282
273
  }
283
- await this.#bumpVersion(trx, resourceRow, {
274
+ return {
284
275
  schema_json: JSON.stringify({ ...descriptor.schema, ...plan.definitions }),
285
- definition_json: JSON.stringify({ ...stored, runtime }),
286
- updated_at: this.knex.fn.now()
287
- })
276
+ definition_json: JSON.stringify({ ...stored, runtime })
277
+ }
278
+ })
279
+ }
280
+
281
+ /**
282
+ * Mark stored fields retired: they keep their slots and values, and the
283
+ * published schema stops accepting writes to them. Registration keeps the mark.
284
+ */
285
+ async retireFields (params, options = {}) {
286
+ return this.#changeResource(options, 'retireFields', () => {
287
+ const { tenant, resource, fields } = params || {}
288
+ if (!tenant || !resource || !Array.isArray(fields) || !fields.length || fields.some(field => typeof field !== 'string')) {
289
+ throw new Error('retireFields requires tenant, resource, and an array of field names')
290
+ }
291
+ return { tenant, resource, fields }
292
+ }, ({ descriptor }, { fields }) => {
293
+ for (const fieldName of fields) {
294
+ const field = descriptor.fields[fieldName]
295
+ if (!field) throw new Error(`Field '${descriptor.resource}.${fieldName}' has no stored slot to retire`)
296
+ if (field.slotType === 'belongsTo') throw new Error(`Field '${descriptor.resource}.${fieldName}' is a relationship and cannot be retired`)
297
+ }
298
+ const stored = descriptor.definition || { options: null, callbacks: [] }
299
+ const retired = [...new Set([...(stored.retired || []), ...fields])]
300
+ return { definition_json: JSON.stringify({ ...stored, retired }) }
301
+ })
302
+ }
303
+
304
+ // Run one schema change under the resource's row lock and bump its version once.
305
+ // `change` returns the any_resource_configs values to write.
306
+ async #changeResource (options, method, readParams, change) {
307
+ const context = { method, [readCommittedTransaction]: true }
308
+ let trx; let managed = false
309
+ try {
310
+ const params = readParams()
311
+ const { tenant, resource } = params
312
+ Object.assign(context, { tenant, resource })
313
+
314
+ managed = !options.transaction
315
+ trx = await beginWriteTransaction(context, options.transaction, ownerContext => createKnexTransaction(this.knex, ownerContext))
316
+ const resourceRow = await this.#lockResourceRow(trx, tenant, resource)
317
+ const descriptor = resourceRow && await this.#loadDescriptor(tenant, resource, trx)
318
+ if (!descriptor) throw new Error(`Resource '${resource}' not registered for tenant '${tenant}'`)
319
+ const values = await change({ trx, resourceRow, descriptor }, params)
320
+ await this.#bumpVersion(trx, resourceRow, { ...values, updated_at: this.knex.fn.now() })
288
321
  const updated = await this.#loadDescriptor(tenant, resource, trx)
289
322
  if (managed) {
290
323
  await commitTransaction(trx, context)
@@ -551,7 +584,13 @@ export class AnyapiRegistry {
551
584
  const row = {
552
585
  schema_json: JSON.stringify(storageSchema),
553
586
  relationships_json: JSON.stringify(relationships || {}),
554
- definition_json: JSON.stringify({ options: declaration.json, callbacks: declaration.callbacks, runtime }),
587
+ definition_json: JSON.stringify({
588
+ options: declaration.json,
589
+ callbacks: declaration.callbacks,
590
+ runtime,
591
+ // Retirement outlives declarations that still list the field.
592
+ ...(stored?.retired?.length ? { retired: stored.retired } : {})
593
+ }),
555
594
  }
556
595
  const unchanged = previous &&
557
596
  Object.entries(row).every(([column, value]) => previous.resourceRow[column] === value) &&
@@ -715,9 +754,10 @@ export class AnyapiRegistry {
715
754
  const previous = previousDescriptor?.fields?.[entry.fieldName]
716
755
  if (entry.override || !previous || previous.slotType !== slotPoolFor(entry.fieldDef)) continue
717
756
  const columns = previous.slotType === 'belongsTo' ? belongsToColumns(previous.slotIndex) : [previous.slot]
757
+ // A field keeps its slot even when its `indexed` declaration changed.
718
758
  const available = previous.slotType === 'belongsTo'
719
759
  ? pools.belongsTo.includes(previous.slotIndex)
720
- : pools[previous.slotType]?.includes(previous.slot)
760
+ : slotColumnsOf(pools, previous.slotType, isIndexedSlot(previous.slot)).includes(previous.slot)
721
761
  if (!available || columns.some(column => used.has(column))) continue
722
762
  columns.forEach(column => used.add(column))
723
763
  entry.override = previous.slotType === 'belongsTo' ? { idSlot: columns[0], typeSlot: columns[1] } : { slotColumn: previous.slot }
@@ -827,6 +867,8 @@ export class AnyapiRegistry {
827
867
  slot: row.slot_column,
828
868
  slotType: row.slot_type,
829
869
  slotIndex: row.slot_index,
870
+ // belongsTo slots are always indexed by parent id.
871
+ indexed: row.slot_type === 'belongsTo' || isIndexedSlot(row.slot_column),
830
872
  nullable: row.nullable === 1 || row.nullable === true,
831
873
  required: row.required === 1 || row.required === true,
832
874
  target: row.target_resource,
@@ -956,16 +998,29 @@ export class AnyapiRegistry {
956
998
  `Canonical slot '${slotColumn}' does not match expected type '${pool}' for field '${fieldDef.fieldName}'`
957
999
  )
958
1000
  }
1001
+ this.#assertFitsSlot(fieldDef, pool, slotColumn)
959
1002
  used.add(slotColumn)
960
1003
  return { slotType: pool, slotIndex, slotColumn }
961
1004
  }
962
1005
 
963
- const slotColumn = pools[pool].find(column => !used.has(column))
1006
+ const indexed = fieldDef.indexed === true
1007
+ if (indexed && !pools.indexed?.[pool]) {
1008
+ throw new Error(`Field '${fieldDef.fieldName}' is declared indexed, but AnyAPI ${pool} slots cannot be indexed`)
1009
+ }
1010
+ const columns = slotColumnsOf(pools, pool, indexed)
1011
+ const slotColumn = columns.find(column => !used.has(column))
964
1012
  if (!slotColumn) {
965
- throw slotsExhausted(pool)
1013
+ throw slotsExhausted(pool, indexed)
966
1014
  }
1015
+ this.#assertFitsSlot(fieldDef, pool, slotColumn)
967
1016
  used.add(slotColumn)
968
- return { slotType: pool, slotIndex: pools[pool].indexOf(slotColumn) + 1, slotColumn }
1017
+ return { slotType: pool, slotIndex: columns.indexOf(slotColumn) + 1, slotColumn }
1018
+ }
1019
+
1020
+ #assertFitsSlot (fieldDef, pool, slotColumn) {
1021
+ if (pool === 'string' && isIndexedSlot(slotColumn) && Number(fieldDef.max) > INDEXED_STRING_LENGTH) {
1022
+ throw new Error(`Field '${fieldDef.fieldName}' allows ${fieldDef.max} characters, but indexed string slots hold at most ${INDEXED_STRING_LENGTH}`)
1023
+ }
969
1024
  }
970
1025
 
971
1026
  #assignBelongsTo (fieldDef, used, canonicalOverride, pools) {