json-rest-api 2.1.0 → 2.2.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.
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:
@@ -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:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "json-rest-api",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "main": "index.js",
5
5
  "types": "index.d.ts",
6
6
  "type": "module",
@@ -243,22 +243,16 @@ export class AnyapiRegistry {
243
243
  })
244
244
  }
245
245
 
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 })
246
+ #allocate (options, method, readParams) {
247
+ return this.#changeResource(options, method, () => {
248
+ const params = readParams()
249
+ const { resource, fields, searchSchema } = params
252
250
  for (const [fieldName, { definition }] of Object.entries(fields)) {
253
251
  validateSchemaFieldNames({ [fieldName]: definition }, resource)
254
252
  }
255
253
  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}'`)
254
+ return params
255
+ }, async ({ trx, resourceRow, descriptor }, { fields, searchSchema }) => {
262
256
  const plan = await this.#withSlotPools(trx, pools => this.#planAllocation(descriptor, fields, pools))
263
257
 
264
258
  for (const row of plan.fieldRows) {
@@ -280,11 +274,53 @@ export class AnyapiRegistry {
280
274
  runtime.callbacks.push(...callbacks.map(path => [kind, name, ...path]))
281
275
  }
282
276
  }
283
- await this.#bumpVersion(trx, resourceRow, {
277
+ return {
284
278
  schema_json: JSON.stringify({ ...descriptor.schema, ...plan.definitions }),
285
- definition_json: JSON.stringify({ ...stored, runtime }),
286
- updated_at: this.knex.fn.now()
287
- })
279
+ definition_json: JSON.stringify({ ...stored, runtime })
280
+ }
281
+ })
282
+ }
283
+
284
+ /**
285
+ * Mark stored fields retired: they keep their slots and values, and the
286
+ * published schema stops accepting writes to them. Registration keeps the mark.
287
+ */
288
+ async retireFields (params, options = {}) {
289
+ return this.#changeResource(options, 'retireFields', () => {
290
+ const { tenant, resource, fields } = params || {}
291
+ if (!tenant || !resource || !Array.isArray(fields) || !fields.length || fields.some(field => typeof field !== 'string')) {
292
+ throw new Error('retireFields requires tenant, resource, and an array of field names')
293
+ }
294
+ return { tenant, resource, fields }
295
+ }, ({ descriptor }, { fields }) => {
296
+ for (const fieldName of fields) {
297
+ const field = descriptor.fields[fieldName]
298
+ if (!field) throw new Error(`Field '${descriptor.resource}.${fieldName}' has no stored slot to retire`)
299
+ if (field.slotType === 'belongsTo') throw new Error(`Field '${descriptor.resource}.${fieldName}' is a relationship and cannot be retired`)
300
+ }
301
+ const stored = descriptor.definition || { options: null, callbacks: [] }
302
+ const retired = [...new Set([...(stored.retired || []), ...fields])]
303
+ return { definition_json: JSON.stringify({ ...stored, retired }) }
304
+ })
305
+ }
306
+
307
+ // Run one schema change under the resource's row lock and bump its version once.
308
+ // `change` returns the any_resource_configs values to write.
309
+ async #changeResource (options, method, readParams, change) {
310
+ const context = { method, [readCommittedTransaction]: true }
311
+ let trx; let managed = false
312
+ try {
313
+ const params = readParams()
314
+ const { tenant, resource } = params
315
+ Object.assign(context, { tenant, resource })
316
+
317
+ managed = !options.transaction
318
+ trx = await beginWriteTransaction(context, options.transaction, ownerContext => createKnexTransaction(this.knex, ownerContext))
319
+ const resourceRow = await this.#lockResourceRow(trx, tenant, resource)
320
+ const descriptor = resourceRow && await this.#loadDescriptor(tenant, resource, trx)
321
+ if (!descriptor) throw new Error(`Resource '${resource}' not registered for tenant '${tenant}'`)
322
+ const values = await change({ trx, resourceRow, descriptor }, params)
323
+ await this.#bumpVersion(trx, resourceRow, { ...values, updated_at: this.knex.fn.now() })
288
324
  const updated = await this.#loadDescriptor(tenant, resource, trx)
289
325
  if (managed) {
290
326
  await commitTransaction(trx, context)
@@ -551,7 +587,13 @@ export class AnyapiRegistry {
551
587
  const row = {
552
588
  schema_json: JSON.stringify(storageSchema),
553
589
  relationships_json: JSON.stringify(relationships || {}),
554
- definition_json: JSON.stringify({ options: declaration.json, callbacks: declaration.callbacks, runtime }),
590
+ definition_json: JSON.stringify({
591
+ options: declaration.json,
592
+ callbacks: declaration.callbacks,
593
+ runtime,
594
+ // Retirement outlives declarations that still list the field.
595
+ ...(stored?.retired?.length ? { retired: stored.retired } : {})
596
+ }),
555
597
  }
556
598
  const unchanged = previous &&
557
599
  Object.entries(row).every(([column, value]) => previous.resourceRow[column] === value) &&
@@ -270,22 +270,17 @@ export const readSlotPools = async (db) => {
270
270
 
271
271
  export const TYPE_TO_POOL = new Map([
272
272
  ['string', 'string'],
273
- ['text', 'string'],
274
273
  ['uuid', 'string'],
275
274
  ['email', 'string'],
276
275
  ['file', 'string'],
277
276
  ['number', 'number'],
278
277
  ['integer', 'number'],
279
- ['float', 'number'],
280
- ['decimal', 'number'],
281
278
  ['boolean', 'boolean'],
282
279
  ['date', 'string'],
283
- ['datetime', 'date'],
284
280
  ['dateTime', 'date'],
285
281
  ['time', 'string'],
286
282
  ['epochMilliseconds', 'number'],
287
283
  ['epochSeconds', 'number'],
288
- ['json', 'json'],
289
284
  ['object', 'json'],
290
285
  ['array', 'json'],
291
286
  ])
@@ -72,6 +72,9 @@ export const composeResourceOptions = ({ declaration = {}, stored = null, local
72
72
  merge('searchSchema', searchSchema, declaredSearch, local.searchSchema, hiddenFilters)
73
73
 
74
74
  for (const name of hidden) delete schema[name]
75
+ for (const name of stored?.retired || []) {
76
+ if (Object.hasOwn(schema, name)) schema[name] = { ...schema[name], retired: true }
77
+ }
75
78
  for (const [name, definition] of Object.entries(searchSchema)) {
76
79
  if (hiddenFilters.has(name) || targetsOf(definition).some(target => hidden.has(target))) {
77
80
  hiddenFilters.add(name)
@@ -0,0 +1,170 @@
1
+ import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
2
+ import { applyDatabaseReadOptions, normalizeStoredFieldValue } from '../querying-writing/database-value-normalizers.js'
3
+
4
+ const NUMERIC_TYPES = new Set(['number', 'integer', 'epochMilliseconds', 'epochSeconds'])
5
+ const ORDERED_TYPES = new Set([...NUMERIC_TYPES, 'string', 'email', 'uuid', 'date', 'dateTime', 'time'])
6
+ const SCALAR_TYPES = new Set([...ORDERED_TYPES, 'boolean', 'id'])
7
+ // Measure functions and the field types each accepts.
8
+ const MEASURES = {
9
+ count: SCALAR_TYPES,
10
+ countDistinct: SCALAR_TYPES,
11
+ sum: NUMERIC_TYPES,
12
+ avg: NUMERIC_TYPES,
13
+ min: ORDERED_TYPES,
14
+ max: ORDERED_TYPES,
15
+ }
16
+ const PARAMS = new Set(['groupBy', 'measures', 'queryParams', 'transaction'])
17
+ const QUERY_PARAMS = new Set(['filters', 'sort', 'page'])
18
+ const OUTPUT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
19
+ const AGGREGATE_ALIAS = '__jra_aggregate'
20
+
21
+ const invalid = (field, message) => {
22
+ throw new RestApiValidationError(message, { fields: [field], violations: [{ field, rule: 'aggregate', message }] })
23
+ }
24
+
25
+ const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
26
+
27
+ const isDecimal = definition => definition.type === 'number' && definition.precision !== undefined && definition.scale !== undefined
28
+
29
+ const positiveInteger = (value, field) => {
30
+ const number = typeof value === 'string' && value.trim() !== '' ? Number(value) : value
31
+ if (!Number.isSafeInteger(number) || number < 1) invalid(field, `${field} must be a positive integer`)
32
+ return number
33
+ }
34
+
35
+ /**
36
+ * Validate an aggregate request against a resource's compiled schema.
37
+ * Only stored scalar fields whose stored values are their public values qualify:
38
+ * getters and storage serializers would make SQL group or sum other values than clients see.
39
+ * @param {{ params: Record<string, any>, schemaInfo: Record<string, any>, scopeName: string, defaultLimit: number, maxLimit: number }} request
40
+ */
41
+ export function compileAggregateRequest ({ params, schemaInfo, scopeName, defaultLimit, maxLimit }) {
42
+ if (!isPlainObject(params)) invalid('params', 'aggregate requires a parameter object')
43
+ for (const key of Object.keys(params)) {
44
+ if (!PARAMS.has(key)) invalid(key, `Unknown aggregate parameter '${key}'`)
45
+ }
46
+ const { groupBy = [], measures, queryParams = {} } = params
47
+ if (!isPlainObject(queryParams)) invalid('queryParams', 'queryParams must be an object')
48
+ for (const key of Object.keys(queryParams)) {
49
+ if (!QUERY_PARAMS.has(key)) invalid(`queryParams.${key}`, `aggregate does not support queryParams.${key}`)
50
+ }
51
+
52
+ const fields = schemaInfo.schemaStructure || {}
53
+ const fieldDefinition = (name, parameter) => {
54
+ const definition = typeof name === 'string' && Object.hasOwn(fields, name) ? fields[name] : null
55
+ if (!definition || name === 'id' || name === schemaInfo.idProperty || definition.virtual === true || definition.hidden === true) {
56
+ invalid(parameter, `'${name}' is not an attribute of '${scopeName}' that can be aggregated`)
57
+ }
58
+ if (typeof definition.getter === 'function' || definition.storage?.serialize) {
59
+ invalid(parameter, `Field '${name}' transforms its stored values, so it cannot be aggregated`)
60
+ }
61
+ return definition
62
+ }
63
+
64
+ if (!Array.isArray(groupBy)) invalid('groupBy', 'groupBy must be an array of field names')
65
+ const groups = groupBy.map((field, index) => {
66
+ const definition = fieldDefinition(field, `groupBy.${index}`)
67
+ if (!SCALAR_TYPES.has(definition.type)) invalid(`groupBy.${index}`, `Field '${field}' has type '${definition.type}' and cannot be grouped`)
68
+ return { name: field, field, definition, alias: `g${index}` }
69
+ })
70
+ if (new Set(groupBy).size !== groupBy.length) invalid('groupBy', 'groupBy lists a field twice')
71
+
72
+ if (!isPlainObject(measures) || !Object.keys(measures).length) invalid('measures', 'measures must name at least one measure')
73
+ const names = new Set(groupBy)
74
+ const measureList = Object.entries(measures).map(([name, spec], index) => {
75
+ const parameter = `measures.${name}`
76
+ if (!OUTPUT_NAME.test(name)) invalid(parameter, `Measure name '${name}' must be a letter or underscore followed by letters, digits or underscores`)
77
+ if (names.has(name)) invalid(parameter, `Measure '${name}' has the name of a grouped field`)
78
+ names.add(name)
79
+ const entries = isPlainObject(spec) ? Object.entries(spec) : []
80
+ if (entries.length !== 1 || !Object.hasOwn(MEASURES, entries[0][0])) {
81
+ invalid(parameter, `Measure '${name}' must be one of { ${Object.keys(MEASURES).join(' | ')}: field }`)
82
+ }
83
+ const [fn, field] = entries[0]
84
+ if (field === '*') {
85
+ if (fn !== 'count') invalid(parameter, "Only count accepts '*'")
86
+ return { name, fn, field, definition: null, alias: `m${index}` }
87
+ }
88
+ const definition = fieldDefinition(field, parameter)
89
+ if (!MEASURES[fn].has(definition.type)) invalid(parameter, `${fn} cannot be applied to field '${field}' of type '${definition.type}'`)
90
+ return { name, fn, field, definition, alias: `m${index}` }
91
+ })
92
+
93
+ const outputs = new Map([...groups, ...measureList].map(output => [output.name, output.alias]))
94
+ const sortParam = queryParams.sort ?? []
95
+ if (!Array.isArray(sortParam)) invalid('queryParams.sort', 'queryParams.sort must be an array')
96
+ const sort = sortParam.map((entry, index) => {
97
+ const name = typeof entry === 'string' ? entry.replace(/^-/, '') : null
98
+ if (!outputs.has(name)) invalid(`queryParams.sort.${index}`, `Sort '${entry}' must name a grouped field or a measure`)
99
+ return { alias: outputs.get(name), direction: entry.startsWith('-') ? 'desc' : 'asc' }
100
+ })
101
+ // Grouped fields break ties, so pages never overlap.
102
+ for (const { alias } of groups) {
103
+ if (!sort.some(entry => entry.alias === alias)) sort.push({ alias, direction: 'asc' })
104
+ }
105
+
106
+ let page = null
107
+ if (groups.length) {
108
+ const pageParam = queryParams.page ?? {}
109
+ if (!isPlainObject(pageParam)) invalid('queryParams.page', 'queryParams.page must be an object')
110
+ for (const key of Object.keys(pageParam)) {
111
+ if (!['size', 'number'].includes(key)) invalid(`queryParams.page.${key}`, `aggregate pages support only size and number, not ${key}`)
112
+ }
113
+ const size = pageParam.size === undefined ? defaultLimit : positiveInteger(pageParam.size, 'queryParams.page.size')
114
+ if (size > maxLimit) invalid('queryParams.page.size', `queryParams.page.size cannot exceed ${maxLimit}`)
115
+ const number = pageParam.number === undefined ? 1 : positiveInteger(pageParam.number, 'queryParams.page.number')
116
+ page = { size, number }
117
+ } else if (queryParams.page !== undefined) {
118
+ invalid('queryParams.page', 'An aggregate without groupBy returns one row and takes no page')
119
+ }
120
+
121
+ return { groups, measures: measureList, sort, page, scopeName }
122
+ }
123
+
124
+ const measureValue = (value, measure, resourceType) => {
125
+ if (measure.fn === 'count' || measure.fn === 'countDistinct') return Number(value ?? 0)
126
+ if (value == null) return null
127
+ if (measure.fn === 'min' || measure.fn === 'max') return normalizeStoredFieldValue(value, measure.definition, { fieldName: measure.field, resourceType })
128
+ // Drivers return numeric aggregates as strings; decimals keep theirs to avoid rounding.
129
+ return isDecimal(measure.definition) ? value : Number(value)
130
+ }
131
+
132
+ /**
133
+ * Run a compiled aggregate over the rows a filtered query selects.
134
+ * The filtered query only supplies identifiers, so joins added by filters or
135
+ * policies cannot repeat a row inside a group.
136
+ * @param {{ source: import('knex').Knex.QueryBuilder, sourceTable: string, storageAdapter: Record<string, any>, db: import('knex').Knex | import('knex').Knex.Transaction, plan: ReturnType<typeof compileAggregateRequest> }} request
137
+ */
138
+ export async function runAggregateQuery ({ source, sourceTable, storageAdapter, db, plan }) {
139
+ const idColumn = storageAdapter.getIdColumn()
140
+ const ids = source.clearSelect().clearOrder().select(`${sourceTable}.${idColumn}`)
141
+ const query = storageAdapter.buildBaseQuery({ transaction: db, tableAlias: AGGREGATE_ALIAS })
142
+ .whereIn(`${AGGREGATE_ALIAS}.${idColumn}`, ids)
143
+ const column = field => `${AGGREGATE_ALIAS}.${storageAdapter.translateColumn(field)}`
144
+
145
+ for (const group of plan.groups) {
146
+ query.select({ [group.alias]: column(group.field) }).groupBy(column(group.field))
147
+ }
148
+ for (const measure of plan.measures) {
149
+ query[measure.fn]({ [measure.alias]: measure.field === '*' ? '*' : column(measure.field) })
150
+ }
151
+ // NULL sorts as the lowest value, as MySQL and SQLite order it by default.
152
+ const pg = ['pg', 'postgresql'].includes(db.client.config.client)
153
+ for (const { alias, direction } of plan.sort) {
154
+ if (pg) query.orderBy(alias, direction, direction === 'asc' ? 'first' : 'last')
155
+ else query.orderBy(alias, direction)
156
+ }
157
+ if (plan.page) query.limit(plan.page.size + 1).offset((plan.page.number - 1) * plan.page.size)
158
+
159
+ const rows = await applyDatabaseReadOptions(query)
160
+ const hasMore = Boolean(plan.page && rows.length > plan.page.size)
161
+ if (hasMore) rows.pop()
162
+
163
+ const data = rows.map(row => {
164
+ const result = {}
165
+ for (const group of plan.groups) result[group.name] = normalizeStoredFieldValue(row[group.alias], group.definition, { fieldName: group.field, resourceType: plan.scopeName })
166
+ for (const measure of plan.measures) result[measure.name] = measureValue(row[measure.alias], measure, plan.scopeName)
167
+ return result
168
+ })
169
+ return plan.page ? { data, meta: { page: { ...plan.page, hasMore } } } : { data }
170
+ }
@@ -5,6 +5,7 @@ import { compileQueryFields } from './query-field-helpers.js'
5
5
  import { buildEffectiveSortList } from '../querying/query-field-sort-helpers.js'
6
6
  import { assertScalarQueryField, assertFieldNameMap } from './field-utils.js'
7
7
  import { validateRelationshipFieldNames, validateSchemaFieldNames } from './scope-validations.js'
8
+ import { assertKnownFieldTypes, installFieldTypes } from './field-types.js'
8
9
 
9
10
  /**
10
11
  * Compile one resource and publish its completed metadata in vars.schemaInfo.
@@ -74,7 +75,22 @@ export async function compileSchemas (scope, deps) {
74
75
  assertFieldNameMap(schemaContext.queryFields, `query fields in '${scopeName}'`)
75
76
 
76
77
  // Create schema object
78
+ // A retired field keeps its stored values readable and accepts no new ones.
79
+ for (const [fieldName, fieldDef] of Object.entries(schemaContext.fields)) {
80
+ if (fieldDef.retired === undefined) continue
81
+ if (typeof fieldDef.retired !== 'boolean') throw new Error(`Field '${fieldName}' in resource '${scopeName}' has a non-boolean retired option`)
82
+ if (fieldDef.retired && (fieldDef.belongsTo || fieldDef.virtual === true)) {
83
+ throw new Error(`Field '${fieldName}' in resource '${scopeName}' cannot be retired: only stored attributes can`)
84
+ }
85
+ if (fieldDef.retired) {
86
+ delete fieldDef.required
87
+ delete fieldDef.defaultTo
88
+ }
89
+ }
90
+
91
+ installFieldTypes()
77
92
  const schemaObject = createSchema(schemaContext.fields)
93
+ assertKnownFieldTypes(schemaObject, `resource '${scopeName}'`)
78
94
 
79
95
  // Generate searchSchema by merging explicit searchSchema with fields marked search:true.
80
96
  // This allows two ways to define searchable fields: either mark fields with search:true
@@ -98,6 +114,7 @@ export async function compileSchemas (scope, deps) {
98
114
  assertFieldNameMap(searchSchemaContext.fields, `search schema in '${scopeName}'`)
99
115
  ensureSearchFieldsAreIndexed(searchSchemaContext.fields)
100
116
  const searchSchemaObject = createSchema(searchSchemaContext.fields)
117
+ assertKnownFieldTypes(searchSchemaObject, `the search schema of '${scopeName}'`)
101
118
 
102
119
  // Derived fields must be rebuilt from the current candidate before publication.
103
120
  const idProperty = scopeOptions.idProperty || scope.vars.idProperty || 'id'
@@ -416,6 +416,22 @@ export function normalizeAttributes (attributes, schemaStructure, {
416
416
  return normalized
417
417
  }
418
418
 
419
+ /**
420
+ * Convert one value read from a field's storage column to its public value,
421
+ * without the field's getter.
422
+ * @param {unknown} value
423
+ * @param {Record<string, any>} definition
424
+ * @param {{ fieldName: string, resourceType?: string }} options
425
+ */
426
+ export function normalizeStoredFieldValue (value, definition, { fieldName, resourceType }) {
427
+ if (value == null) return null
428
+ if (definition.type === 'id') return String(value)
429
+ const decimal = definition.precision !== undefined && definition.scale !== undefined
430
+ // Drivers return some numeric columns as strings; decimals keep theirs to avoid rounding.
431
+ if (['number', 'integer'].includes(definition.type) && typeof value === 'string' && !decimal) return Number(value)
432
+ return normalizeAttributes({ [fieldName]: value }, { [fieldName]: definition }, { resourceType })[fieldName]
433
+ }
434
+
419
435
  /**
420
436
  * Normalize a JSON:API document or one plain resource using compiled output fields.
421
437
  * JSON:API primary/included attributes are replaced on the supplied document;
@@ -24,6 +24,10 @@ export const defaultDataHelpers = {
24
24
  throw new Error('No storage implementation for query. Install a storage plugin.')
25
25
  },
26
26
 
27
+ dataAggregate: async function (request) {
28
+ throw new Error('No storage implementation for aggregate. Install a storage plugin.')
29
+ },
30
+
27
31
  dataPost: async function (request) {
28
32
  throw new Error('No storage implementation for post. Install a storage plugin.')
29
33
  },
@@ -0,0 +1,43 @@
1
+ import { addType } from 'json-rest-schema'
2
+
3
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
4
+ const UUID_PATTERN = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/
5
+ const TEXT_SEARCH_OPERATORS = new Set(['like', 'contains', 'startswith', 'endswith'])
6
+
7
+ // Partial-match filters search fragments of a value, not whole values.
8
+ const isTextSearch = definition => TEXT_SEARCH_OPERATORS.has(String(definition?.filterOperator).toLowerCase())
9
+
10
+ const formattedStringType = (pattern, format) => {
11
+ const type = (context) => {
12
+ if (typeof context.value !== 'string') context.throwTypeError()
13
+ const value = context.definition.noTrim ? context.value : context.value.trim()
14
+ if (!isTextSearch(context.definition) && !pattern.test(value)) context.throwTypeError()
15
+ return value
16
+ }
17
+ type.toJsonSchema = ({ definition }) => isTextSearch(definition) ? { type: 'string' } : { type: 'string', format, pattern: pattern.source }
18
+ return type
19
+ }
20
+
21
+ let installed = false
22
+
23
+ /** Register the string types json-rest-schema lacks; both storages keep them in string columns. */
24
+ export function installFieldTypes () {
25
+ if (installed) return
26
+ installed = true
27
+ addType('email', formattedStringType(EMAIL_PATTERN, 'email'))
28
+ addType('uuid', formattedStringType(UUID_PATTERN, 'uuid'))
29
+ }
30
+
31
+ /**
32
+ * Reject field types no casting function exists for, at registration rather than on the first write.
33
+ * @param {{ structure: Record<string, any>, types: Record<string, unknown> }} schema
34
+ * @param {string} label
35
+ */
36
+ export function assertKnownFieldTypes (schema, label) {
37
+ for (const [fieldName, definition] of Object.entries(schema.structure)) {
38
+ const type = definition?.type
39
+ if (typeof type === 'string' && !Object.hasOwn(schema.types, type)) {
40
+ throw new Error(`Field '${fieldName}' in ${label} has unknown type '${type}'`)
41
+ }
42
+ }
43
+ }
@@ -354,7 +354,7 @@ function buildWritableAttributesStructure (schemaInfo = {}) {
354
354
  if (!fieldDef) continue
355
355
  if (fieldName === idProperty) continue
356
356
  if (excludedFieldNames.has(fieldName)) continue
357
- if (fieldDef.computed === true) continue
357
+ if (fieldDef.computed === true || fieldDef.retired === true) continue
358
358
  if (fieldDef.type === undefined) continue
359
359
  attributesStructure[fieldName] = { ...fieldDef }
360
360
  }
@@ -5,6 +5,7 @@ import { applySortDescriptorOrder, buildEffectiveSortList, parseSortEntry, resol
5
5
  import { normalizeId, resolveFieldInfo } from '../anyapi/utils/descriptor-helpers.js'
6
6
  import { decorateResourceLinks, getAllowedQueryFieldNames } from '../anyapi/canonical-relationship-reader.js'
7
7
  import { applyQueryConstraint } from '../querying/query-constraint.js'
8
+ import { runAggregateQuery } from '../querying/aggregate-query.js'
8
9
  import { calculatePaginationMeta, generatePaginationLinks, generateCursorPaginationLinks, buildCursorMeta, applyPaginationToQuery } from '../querying/knex-pagination-helpers.js'
9
10
  import { getUrlPrefix, buildJsonApiLink } from '../querying/url-helpers.js'
10
11
  import { applyFieldSelectionToQuery, buildFieldSelection } from '../querying-writing/knex-field-helpers.js'
@@ -670,6 +671,33 @@ export function createCanonicalDataHelpers ({
670
671
  return response
671
672
  }
672
673
 
674
+ /** @type {CanonicalDataReadHelpers['dataAggregate']} */
675
+ const dataAggregate = async ({ scopeName, context, runHooks, plan }) => {
676
+ const storageAdapter = getScopeStorageAdapter(scopeName)
677
+ if (!storageAdapter) throw new Error(`Storage adapter for resource '${scopeName}' not found`)
678
+ context.storageAdapter = storageAdapter
679
+ const descriptor = getDescriptor(scopeName)
680
+ const db = context.db || context.transaction || api.knex.instance
681
+ const tableAlias = context.schemaInfo.tableName || descriptor.resource
682
+
683
+ const filteredState = await withQueryFilteringContext(context, {
684
+ query: storageAdapter.buildBaseQuery({ transaction: db, tableAlias }),
685
+ filters: context.queryParams.filters,
686
+ schemaInfo: context.schemaInfo,
687
+ scopeName,
688
+ tableName: tableAlias,
689
+ db,
690
+ queryPurpose: 'aggregate',
691
+ isAnyApi: true,
692
+ adapter: storageAdapter,
693
+ storageAdapter,
694
+ }, async () => {
695
+ await applyBuiltInAnyApiQueryFilters(context)
696
+ await runHooks('knexQueryFiltering')
697
+ })
698
+ return runAggregateQuery({ source: filteredState.query, sourceTable: tableAlias, storageAdapter, db, plan })
699
+ }
700
+
673
701
  return {
674
702
  dataExists,
675
703
  dataPost,
@@ -678,6 +706,7 @@ export function createCanonicalDataHelpers ({
678
706
  dataDelete,
679
707
  dataGet,
680
708
  dataQuery,
709
+ dataAggregate,
681
710
  dataGetMinimal
682
711
  }
683
712
  }
@@ -9,6 +9,7 @@ import { toJsonApiRecordWithBelongsTo } from '../querying-writing/knex-json-api-
9
9
  import { processIncludes } from '../querying/knex-process-includes.js'
10
10
  import { loadRelationshipIdentifiers } from '../querying/relationship-identifiers.js'
11
11
  import { applyQueryConstraint } from '../querying/query-constraint.js'
12
+ import { runAggregateQuery } from '../querying/aggregate-query.js'
12
13
  import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
13
14
  import { getOperationDiagnosticContext } from '../../../../lib/error-context.js'
14
15
  import { applyInsertReturning } from '../querying-writing/database-capabilities.js'
@@ -505,6 +506,29 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
505
506
  return buildJsonApiResponse(scope, records, included, false, scopeName, context)
506
507
  }
507
508
 
509
+ /** @type {OrdinaryDataReadHelpers['dataAggregate']} */
510
+ const dataAggregate = async ({ scopeName, context, runHooks, plan }) => {
511
+ const storageAdapter = getScopeStorageAdapter(scopeName)
512
+ context.storageAdapter = storageAdapter
513
+ const tableName = storageAdapter.getTableName()
514
+ const db = context.db || api.knex.instance
515
+
516
+ const filteredState = await withQueryFilteringContext(context, {
517
+ query: db(tableName),
518
+ filters: context.queryParams.filters,
519
+ schemaInfo: context.schemaInfo,
520
+ scopeName,
521
+ tableName,
522
+ db,
523
+ queryPurpose: 'aggregate',
524
+ adapter: storageAdapter,
525
+ storageAdapter,
526
+ }, async () => {
527
+ await runHooks('knexQueryFiltering')
528
+ })
529
+ return runAggregateQuery({ source: filteredState.query, sourceTable: tableName, storageAdapter, db, plan })
530
+ }
531
+
508
532
  /** @type {OrdinaryDataWriteHelpers['dataPost']} */
509
533
  const dataPost = async ({ scopeName, context }) => {
510
534
  const storageAdapter = getScopeStorageAdapter(scopeName)
@@ -701,6 +725,7 @@ export function createOrdinaryDataHelpers ({ api, scopes, knex, log, getScopeSto
701
725
  dataExists,
702
726
  dataGet,
703
727
  dataQuery,
728
+ dataAggregate,
704
729
  dataPost,
705
730
  dataPut,
706
731
  dataPatch,
@@ -307,6 +307,27 @@ export interface DataReadHelpers<GetResult> {
307
307
  queryParams: NonNullable<DataReadContext['queryParams']>
308
308
  }
309
309
  }) => Promise<DataDocument<DataResource[]>>
310
+ // The plan comes from compileAggregateRequest; filters are in context.queryParams.
311
+ dataAggregate: (request: DataReadRequest & {
312
+ runHooks: NonNullable<DataReadRequest['runHooks']>
313
+ context: DataReadContext & {
314
+ queryParams: NonNullable<DataReadContext['queryParams']>
315
+ }
316
+ plan: AggregatePlan
317
+ }) => Promise<AggregateResult>
318
+ }
319
+
320
+ // Compiled by compileAggregateRequest: outputs carry SQL aliases.
321
+ export interface AggregatePlan {
322
+ scopeName: string
323
+ groups: Array<{ name: string; field: string; definition: StorageFieldDefinition; alias: string }>
324
+ measures: Array<{ name: string; fn: 'count' | 'countDistinct' | 'sum' | 'avg' | 'min' | 'max'; field: string; definition: StorageFieldDefinition | null; alias: string }>
325
+ sort: Array<{ alias: string; direction: 'asc' | 'desc' }>
326
+ page: { size: number; number: number } | null
327
+ }
328
+ export interface AggregateResult {
329
+ data: Array<Record<string, unknown>>
330
+ meta?: { page: { size: number; number: number; hasMore: boolean } }
310
331
  }
311
332
 
312
333
  export type OrdinaryDataReadHelpers = DataReadHelpers<DataDocument<DataResource>>
@@ -6,18 +6,25 @@ import { createEnhancedLogger } from '../../lib/enhanced-logger.js'
6
6
  import { ensureAnyApiSchema } from './lib/anyapi/schema-utils.js'
7
7
  import { AnyapiRegistry } from './lib/anyapi/anyapi-registry.js'
8
8
  import { composeResourceOptions, relatedResources } from './lib/anyapi/stored-definition.js'
9
- import { RestApiResourceError } from '../../lib/rest-api-errors.js'
9
+ import { RestApiResourceError, RestApiValidationError } from '../../lib/rest-api-errors.js'
10
10
  import { compileSchemas } from './lib/querying-writing/compile-schemas.js'
11
11
  import { snapshotResourceConfiguration } from './lib/querying-writing/schema-helpers.js'
12
12
  import { assertFieldNameMap } from './lib/querying-writing/field-utils.js'
13
13
  import { polymorphicFiltersHook, crossTableFiltersHook, basicFiltersHook } from './lib/querying/knex-query-helpers.js'
14
14
  import { createStorageAdapterLookup } from './lib/storage/storage-adapter.js'
15
+ import { translateCanonicalAttributesForStorage } from './lib/storage/canonical-storage-mapping.js'
16
+ import { applyDatabaseReadOptions, normalizeStoredFieldValue } from './lib/querying-writing/database-value-normalizers.js'
15
17
 
16
18
  const DEFAULT_TENANT = 'default'
19
+ const MIGRATION_BATCH_SIZE = 500
17
20
  // Methods that read a published schema; schema checks wrap these.
18
- const REQUEST_METHODS = ['query', 'get', 'post', 'put', 'patch', 'delete', 'getRelated', 'getRelationship', 'postRelationship', 'patchRelationship', 'deleteRelationship']
21
+ const REQUEST_METHODS = ['query', 'aggregate', 'get', 'post', 'put', 'patch', 'delete', 'getRelated', 'getRelationship', 'postRelationship', 'patchRelationship', 'deleteRelationship']
19
22
 
20
- const optionsSignature = options => JSON.stringify([Object.keys(options.schema || {}).sort(), Object.keys(options.searchSchema || {}).sort()])
23
+ const optionsSignature = options => JSON.stringify([
24
+ Object.keys(options.schema || {}).sort(),
25
+ Object.keys(options.searchSchema || {}).sort(),
26
+ Object.entries(options.schema || {}).filter(([, definition]) => definition?.retired === true).map(([name]) => name).sort()
27
+ ])
21
28
 
22
29
  // A validation failure may come from a schema another server already changed.
23
30
  // Writes that may have committed are never repeated.
@@ -252,6 +259,7 @@ export const RestApiAnyapiKnexPlugin = {
252
259
  for (const { resource } of await registry.listResources(tenantId)) await loadResource(resource)
253
260
  return api.resources
254
261
  }
262
+ api.anyapi.getSchemaVersion = resource => registry.getSchemaVersion(tenantId, resource)
255
263
  api.anyapi.handleSchemaChange = async ({ tenant, resource, version } = {}) => {
256
264
  if ((tenant !== undefined && tenant !== tenantId) || typeof resource !== 'string' || !Object.hasOwn(api.resources, resource)) return false
257
265
  if (version !== undefined && publishedVersion(resource) >= version) return false
@@ -330,49 +338,134 @@ export const RestApiAnyapiKnexPlugin = {
330
338
 
331
339
  addResourceMethod('reloadSchema', async ({ scopeName }) => reload(scopeName))
332
340
 
333
- addResourceMethod('addKnexFields', async ({ scopeName, params }) => {
334
- if (!params?.fields || typeof params.fields !== 'object' || Array.isArray(params.fields)) {
335
- throw new Error('fields parameter is required for addKnexFields')
336
- }
337
- assertFieldNameMap(params.fields, `added fields in '${scopeName}'`)
338
- assertFieldNameMap(params.searchSchema, `added search schema in '${scopeName}'`)
339
- assertFieldNameMap(params.canonicalFieldsMap, `added canonical fields in '${scopeName}'`)
340
- const scope = api.resources[scopeName]
341
- const unknown = Object.keys(params.canonicalFieldsMap || {}).filter(field => !Object.hasOwn(params.fields, field))
342
- if (unknown.length) {
343
- throw new Error(`canonicalFieldsMap contains unknown fields for addKnexFields: ${unknown}`)
344
- }
345
- const fields = snapshotResourceConfiguration(params.fields)
346
- const searchSchema = snapshotResourceConfiguration(params.searchSchema || {})
347
- const current = composeOptions(scopeName, scope.vars.schemaInfo.descriptor).options
341
+ // Compile the resource with the additions, so allocation stores enriched definitions.
342
+ const prepareAdditions = async (scopeName, { fields, searchSchema = {}, canonicalFieldsMap }) => {
343
+ const current = composeOptions(scopeName, api.resources[scopeName].vars.schemaInfo.descriptor).options
348
344
  const candidate = snapshotResourceConfiguration({
349
345
  ...current,
350
346
  schema: { ...current.schema, ...fields },
351
347
  searchSchema: { ...current.searchSchema, ...searchSchema }
352
348
  })
353
349
  const schemaInfo = await compileOptions(scopeName, candidate)
354
- await api.transaction(transaction => registry.allocateFields({
350
+ const allocation = {
355
351
  tenant: tenantId,
356
352
  resource: scopeName,
357
353
  fields: Object.fromEntries(Object.keys(fields).map(fieldName => [fieldName, {
358
354
  definition: schemaInfo.schemaStructure[fieldName] || schemaInfo.computed[fieldName],
359
355
  declaration: fields[fieldName],
360
- canonicalField: params.canonicalFieldsMap?.[fieldName]
356
+ canonicalField: canonicalFieldsMap?.[fieldName]
361
357
  }])),
362
358
  searchSchema
363
- }, { transaction }))
359
+ }
360
+ return { candidate, schemaInfo, allocation }
361
+ }
362
+
363
+ const publishChange = async (scopeName, { fields = {}, searchSchema = {}, compiled } = {}) => {
364
364
  registry.invalidateDescriptor(tenantId, scopeName)
365
- const local = runtimeDeclarations.get(scopeName) || {}
366
- runtimeDeclarations.set(scopeName, {
367
- schema: { ...local.schema, ...fields },
368
- searchSchema: { ...local.searchSchema, ...searchSchema }
369
- })
370
- // The fresh descriptor also carries fields other servers added meanwhile.
365
+ if (Object.keys(fields).length || Object.keys(searchSchema).length) {
366
+ const local = runtimeDeclarations.get(scopeName) || {}
367
+ runtimeDeclarations.set(scopeName, {
368
+ schema: { ...local.schema, ...fields },
369
+ searchSchema: { ...local.searchSchema, ...searchSchema }
370
+ })
371
+ }
372
+ // The fresh descriptor also carries changes other servers made meanwhile.
371
373
  const descriptor = await registry.getDescriptor(tenantId, scopeName)
372
- await publish(scopeName, descriptor, { compiled: { signature: optionsSignature(candidate), schemaInfo } })
374
+ await publish(scopeName, descriptor, { compiled })
373
375
  await notifySchemaChanged(scopeName, descriptor.schemaVersion)
376
+ }
377
+
378
+ addResourceMethod('addKnexFields', async ({ scopeName, params }) => {
379
+ if (!params?.fields || typeof params.fields !== 'object' || Array.isArray(params.fields)) {
380
+ throw new Error('fields parameter is required for addKnexFields')
381
+ }
382
+ assertFieldNameMap(params.fields, `added fields in '${scopeName}'`)
383
+ assertFieldNameMap(params.searchSchema, `added search schema in '${scopeName}'`)
384
+ assertFieldNameMap(params.canonicalFieldsMap, `added canonical fields in '${scopeName}'`)
385
+ const unknown = Object.keys(params.canonicalFieldsMap || {}).filter(field => !Object.hasOwn(params.fields, field))
386
+ if (unknown.length) {
387
+ throw new Error(`canonicalFieldsMap contains unknown fields for addKnexFields: ${unknown}`)
388
+ }
389
+ const fields = snapshotResourceConfiguration(params.fields)
390
+ const searchSchema = snapshotResourceConfiguration(params.searchSchema || {})
391
+ const { candidate, schemaInfo, allocation } = await prepareAdditions(scopeName, { fields, searchSchema, canonicalFieldsMap: params.canonicalFieldsMap })
392
+ await api.transaction(transaction => registry.allocateFields(allocation, { transaction }))
393
+ await publishChange(scopeName, { fields, searchSchema, compiled: { signature: optionsSignature(candidate), schemaInfo } })
374
394
  })
375
395
 
396
+ const retirable = (scopeName, fieldName) => {
397
+ const definition = api.resources[scopeName].vars.schemaInfo.schemaStructure[fieldName]
398
+ if (!definition || fieldName === 'id' || definition.virtual === true || definition.belongsTo) {
399
+ throw new Error(`'${scopeName}.${fieldName}' is not a stored attribute`)
400
+ }
401
+ return definition
402
+ }
403
+
404
+ addResourceMethod('retireKnexFields', async ({ scopeName, params }) => {
405
+ const fields = Array.isArray(params) ? params : params?.fields
406
+ if (!Array.isArray(fields) || !fields.length) throw new Error('retireKnexFields requires an array of field names')
407
+ fields.forEach(fieldName => retirable(scopeName, fieldName))
408
+ await registry.retireFields({ tenant: tenantId, resource: scopeName, fields })
409
+ await publishChange(scopeName)
410
+ })
411
+
412
+ // Change a field's type: copy its values into a new field, then retire it.
413
+ addResourceMethod('migrateField', async ({ scopeName, params }) => {
414
+ const { from, to, transform } = params || {}
415
+ const { name, ...declaration } = to || {}
416
+ if (typeof from !== 'string' || typeof name !== 'string' || !name) {
417
+ throw new Error('migrateField requires from and to: { name, ...definition }')
418
+ }
419
+ if (transform !== undefined && typeof transform !== 'function') throw new Error('migrateField transform must be a function')
420
+ const fromDefinition = retirable(scopeName, from)
421
+ if (fromDefinition.retired) throw new Error(`Field '${scopeName}.${from}' is already retired`)
422
+ if (declaration.computed || declaration.virtual || declaration.belongsTo || declaration.getter || declaration.setter || declaration.storage) {
423
+ throw new Error(`migrateField target '${name}' must be a plain stored attribute; add other fields with addKnexFields`)
424
+ }
425
+ assertFieldNameMap({ [name]: declaration }, `migrated field in '${scopeName}'`)
426
+ const fields = snapshotResourceConfiguration({ [name]: declaration })
427
+ const { schemaInfo, allocation } = await prepareAdditions(scopeName, { fields })
428
+
429
+ await api.transaction(async (transaction) => {
430
+ const descriptor = await registry.allocateFields(allocation, { transaction })
431
+ await copyFieldValues({ transaction, descriptor, schemaInfo, from, fromDefinition, to: name, transform })
432
+ await registry.retireFields({ tenant: tenantId, resource: scopeName, fields: [from] }, { transaction })
433
+ })
434
+ await publishChange(scopeName, { fields })
435
+ })
436
+
437
+ const copyFieldValues = async ({ transaction, descriptor, schemaInfo, from, fromDefinition, to, transform }) => {
438
+ const { canonical } = descriptor
439
+ const source = descriptor.fields[from].slot
440
+ const target = descriptor.fields[to].slot
441
+ const idColumn = canonical.logicalIdColumn
442
+ for (let lastId = null; ;) {
443
+ const query = transaction(canonical.tableName)
444
+ .where(canonical.tenantColumn, tenantId)
445
+ .where(canonical.resourceColumn, descriptor.resource)
446
+ .orderBy('id')
447
+ .limit(MIGRATION_BATCH_SIZE)
448
+ .select('id', idColumn, source)
449
+ if (lastId !== null) query.where('id', '>', lastId)
450
+ const rows = await applyDatabaseReadOptions(query)
451
+ if (!rows.length) return
452
+ for (const row of rows) {
453
+ lastId = row.id
454
+ const value = normalizeStoredFieldValue(row[source], fromDefinition, { fieldName: from, resourceType: descriptor.resource })
455
+ const next = transform ? await transform(value, { id: String(row[idColumn]) }) : value
456
+ const { validatedObject, errors } = await schemaInfo.schemaInstance.patch({ [to]: next ?? null })
457
+ if (Object.keys(errors).length) {
458
+ const message = `Record '${row[idColumn]}' cannot migrate '${from}' to '${to}': ${Object.values(errors)[0].message}`
459
+ throw new RestApiValidationError(message, { fields: [to], violations: [{ field: to, rule: 'field_migration', message }] })
460
+ }
461
+ const stored = translateCanonicalAttributesForStorage({ [to]: validatedObject[to] ?? null }, descriptor, {
462
+ schemaInfo, databaseClient: knex.client.config.client
463
+ })
464
+ await transaction(canonical.tableName).where('id', row.id).update({ [target]: stored[target] ?? null })
465
+ }
466
+ }
467
+ }
468
+
376
469
  addResourceMethod('alterKnexFields', async () => {
377
470
  throw new Error('alterKnexFields is not supported by AnyAPI Knex plugin yet')
378
471
  })
@@ -0,0 +1,41 @@
1
+ import { getRequestContracts, validateRequestContractOrThrow } from '../lib/querying-writing/request-contracts.js'
2
+ import { getEffectiveSortableFields } from '../lib/querying/query-field-sort-helpers.js'
3
+ import { compileAggregateRequest } from '../lib/querying/aggregate-query.js'
4
+
5
+ /**
6
+ * Group a filtered collection and compute measures for each group.
7
+ * Filters are validated like query filters; permissions are checked as a
8
+ * query, and row policies and autofilters see queryPurpose 'aggregate'.
9
+ * Returns plain rows in either format; grouped results are paged.
10
+ */
11
+ export default async function aggregateMethod ({ params, context, vars, helpers, scope, runHooks, scopeName, api }) {
12
+ context.method = 'aggregate'
13
+ context.scopeName = scopeName
14
+ context.schemaInfo = scope.vars.schemaInfo
15
+
16
+ const plan = compileAggregateRequest({
17
+ params,
18
+ schemaInfo: context.schemaInfo,
19
+ scopeName,
20
+ defaultLimit: vars.queryDefaultLimit,
21
+ maxLimit: vars.queryMaxLimit
22
+ })
23
+ const requestContracts = getRequestContracts({
24
+ scopeName,
25
+ schemaInfo: context.schemaInfo,
26
+ includeDepthLimit: vars.includeDepthLimit,
27
+ sortableFields: getEffectiveSortableFields(vars)
28
+ })
29
+ const validated = validateRequestContractOrThrow(
30
+ requestContracts.query,
31
+ { queryParams: { filters: params.queryParams?.filters ?? {} } },
32
+ 'Aggregate filters are invalid'
33
+ )
34
+ context.queryParams = { filters: validated.queryParams?.filters || {} }
35
+
36
+ context.transaction = params.transaction
37
+ context.db = context.transaction || api.knex.instance
38
+
39
+ await scope.checkPermissions({ method: 'query', originalContext: context })
40
+ return helpers.dataAggregate({ scopeName, context, runHooks, plan })
41
+ }
@@ -213,7 +213,7 @@ export const validateCompleteReplacePayload = ({
213
213
  if (!fieldDef) continue
214
214
  if (fieldName === idProperty) continue
215
215
  if (Object.hasOwn(belongsToUpdates, fieldName)) continue
216
- if (fieldDef.computed === true || fieldDef.virtual === true) continue
216
+ if (fieldDef.computed === true || fieldDef.virtual === true || fieldDef.retired === true) continue
217
217
  if (fieldDef.type === undefined) continue
218
218
 
219
219
  if (fieldDef.belongsTo && fieldDef.as) {
@@ -296,8 +296,16 @@ export const validateResourceAttributesBeforeWrite = async ({
296
296
  /** @type {StorageRow} */
297
297
  const attributesForValidation = { ...attributesToValidate }
298
298
  const foreignKeyViolations = []
299
+ const retiredViolations = []
299
300
  for (const [field, value] of Object.entries(attributesToValidate)) {
300
301
  const definition = schemaStructure[field]
302
+ if (definition?.retired === true && value !== undefined) {
303
+ retiredViolations.push({
304
+ field: `data.attributes.${field}`,
305
+ rule: 'retired_field',
306
+ message: `Field '${field}' is retired and accepts no new values`
307
+ })
308
+ }
301
309
  if (!definition?.belongsTo || !definition.as || Object.hasOwn(belongsToUpdates, field)) continue
302
310
  delete attributesForValidation[field]
303
311
  if (value === undefined) continue
@@ -307,6 +315,13 @@ export const validateResourceAttributesBeforeWrite = async ({
307
315
  message: `Foreign key field '${field}' should not be in attributes. Use 'data.relationships.${definition.as}' instead.`
308
316
  })
309
317
  }
318
+ const [firstRetired] = retiredViolations
319
+ if (firstRetired) {
320
+ throw new RestApiValidationError(firstRetired.message, {
321
+ fields: retiredViolations.map(violation => violation.field),
322
+ violations: retiredViolations
323
+ })
324
+ }
310
325
  if (foreignKeyViolations.length) {
311
326
  throw new RestApiValidationError('Foreign key fields cannot be set directly in attributes', {
312
327
  fields: foreignKeyViolations.map(violation => violation.field),
@@ -10,6 +10,7 @@ import registerRelationshipRoutes from './rest-api-plugin-hooks/register-relatio
10
10
 
11
11
  // Import method functions
12
12
  import queryMethod from './rest-api-plugin-methods/query.js'
13
+ import aggregateMethod from './rest-api-plugin-methods/aggregate.js'
13
14
  import getMethod from './rest-api-plugin-methods/get.js'
14
15
  import postMethod from './rest-api-plugin-methods/post.js'
15
16
  import putMethod from './rest-api-plugin-methods/put.js'
@@ -90,6 +91,7 @@ export const RestApiPlugin = {
90
91
 
91
92
  // Main REST methods
92
93
  addResourceMethod('query', withAvailableContext(queryMethod))
94
+ addResourceMethod('aggregate', withAvailableContext(aggregateMethod))
93
95
  addResourceMethod('get', withAvailableContext(getMethod))
94
96
  addResourceMethod('post', withWriteOutcome(postMethod))
95
97
  addResourceMethod('put', withWriteOutcome(putMethod))
package/types/anyapi.d.ts CHANGED
@@ -23,6 +23,8 @@ export class AnyapiRegistry {
23
23
  readonly cacheSize: number
24
24
  ensureSchema(options?: { slots?: AnyapiSlotCounts }): Promise<void>
25
25
  getSchemaVersion(tenant: string, resource: string, options?: { transaction?: Knex.Transaction }): Promise<number | null>
26
+ // Retired fields keep their slots and values and reject new writes.
27
+ retireFields(params: { tenant: string; resource: string; fields: string[] }, options?: { transaction?: Knex.Transaction }): Promise<Record<string, unknown>>
26
28
  invalidateDescriptor(tenant: string, resource: string): void
27
29
  }
28
30
 
@@ -23,6 +23,24 @@ export interface QueryParams extends SelectionParams {
23
23
  sort?: string[]
24
24
  page?: PageParams
25
25
  }
26
+ export type AggregateFunction = 'count' | 'countDistinct' | 'sum' | 'avg' | 'min' | 'max'
27
+ // One function per measure; only count accepts '*'.
28
+ export type AggregateMeasure = { count: string } | { [Function in Exclude<AggregateFunction, 'count'>]: { [Name in Function]: string } }[Exclude<AggregateFunction, 'count'>]
29
+ export interface AggregateParams extends TransactionParam {
30
+ groupBy?: string[]
31
+ measures: Record<string, AggregateMeasure>
32
+ queryParams?: {
33
+ filters?: Record<string, unknown>
34
+ // Grouped field or measure names, '-' for descending.
35
+ sort?: string[]
36
+ // Grouped results only.
37
+ page?: { size?: number; number?: number }
38
+ }
39
+ }
40
+ export interface AggregateResult {
41
+ data: Array<Record<string, unknown>>
42
+ meta?: { page: { size: number; number: number; hasMore: boolean } }
43
+ }
26
44
  export interface TransactionParam {
27
45
  transaction?: Knex.Transaction
28
46
  }
@@ -78,6 +96,7 @@ export interface ResourceCoreMethods<
78
96
  query<Format extends ResourceFormat = DefaultFormat>(
79
97
  params?: RemovedResourceOptions & TransactionParam & { format?: Format; queryParams?: QueryParams }, context?: Context
80
98
  ): Promise<CollectionResult<Format, Fields, Type>>
99
+ aggregate(params: AggregateParams, context?: Context): Promise<AggregateResult>
81
100
  post<Format extends ResourceFormat = DefaultFormat, Returning extends WriteReturning = DefaultReturning>(
82
101
  params: WriteOptions<Format, Returning> & ResourceWriteInput<Input, Type> & {
83
102
  expectedVersion?: never
@@ -1,7 +1,7 @@
1
1
  import type { StorageFieldDefinition, StorageQuery } from '../plugins/core/lib/storage/storage-types.js'
2
2
  import type { FileStorage } from './file-storage.js'
3
3
 
4
- export type ResourceFieldType = 'string' | 'number' | 'integer' | 'boolean' | 'id' |
4
+ export type ResourceFieldType = 'string' | 'email' | 'uuid' | 'number' | 'integer' | 'boolean' | 'id' |
5
5
  'date' | 'time' | 'dateTime' | 'epochSeconds' | 'epochMilliseconds' |
6
6
  'array' | 'object' | 'file' | 'blob' | 'serialize' | 'none'
7
7
 
@@ -46,6 +46,8 @@ export interface ResourceFieldOptions {
46
46
  normallyHidden?: boolean
47
47
  virtual?: boolean
48
48
  computed?: boolean
49
+ // Readable and filterable, but rejects new values.
50
+ retired?: boolean
49
51
  include?: ResourceRelationshipOptions['include']
50
52
  search?: boolean | ResourceSearchOptions | Record<string, ResourceSearchOptions>
51
53
  dependencies?: readonly string[]
@@ -113,7 +115,7 @@ type ObjectValue<Field, Mode extends Direction> =
113
115
  : Field extends { values: infer Values } ? Record<string, NestedValue<Values, Mode>> : Record<string, unknown>
114
116
 
115
117
  type BaseValue<Field, Mode extends Direction> =
116
- Field extends { type: 'string' | 'file' | 'date' | 'time' | 'dateTime' } ? string
118
+ Field extends { type: 'string' | 'email' | 'uuid' | 'file' | 'date' | 'time' | 'dateTime' } ? string
117
119
  : Field extends { type: 'number'; precision: number; scale: number } ? Mode extends 'output' ? string | number : number
118
120
  : Field extends { type: 'number' | 'integer' | 'epochSeconds' | 'epochMilliseconds' } ? number
119
121
  : Field extends { type: 'boolean' } ? boolean
@@ -147,7 +149,7 @@ type StoredNull<Field> = Field extends { required: true } | { nullable: false }
147
149
  // Attribute helpers exclude the conventional ID and relationship backing fields.
148
150
  // Hooks/defaults may supply required values, so caller input stays optional.
149
151
  export type InferInput<Schema extends object> = {
150
- -readonly [Key in keyof Schema as Schema[Key] extends { computed: true } ? never : AttributeKey<Schema, Key>]?: FieldValue<Schema[Key], 'input'>
152
+ -readonly [Key in keyof Schema as Schema[Key] extends { computed: true } | { retired: true } ? never : AttributeKey<Schema, Key>]?: FieldValue<Schema[Key], 'input'>
151
153
  }
152
154
 
153
155
  // Resource result types add optionality for sparse fields and permissions.