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
@@ -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
  }
@@ -1,6 +1,8 @@
1
1
  // @ts-check
2
2
  import { serializeFieldValueForStorage } from './storage-mapping.js'
3
3
  import { getPolymorphicLinkage } from '../querying-writing/relationship-contracts.js'
4
+ import { INDEXED_STRING_LENGTH } from '../anyapi/schema-utils.js'
5
+ import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
4
6
 
5
7
  /** @import { CanonicalDescriptor, StorageRow, StorageSchemaInfo, StorageWriteOptions } from './storage-types.js' */
6
8
 
@@ -34,6 +36,15 @@ export const translateCanonicalAttributesForStorage = (attributes = {}, descript
34
36
  textStorage: slot.slotType === 'string'
35
37
  })
36
38
  row[slot.slot] = definition?.type === 'id' && serialized != null ? String(serialized) : serialized
39
+ const stored = row[slot.slot]
40
+ // Databases count code points; one surrogate pair is one character.
41
+ if (slot.indexed && typeof stored === 'string' && stored.length > INDEXED_STRING_LENGTH && [...stored].length > INDEXED_STRING_LENGTH) {
42
+ const field = `data.attributes.${fieldName}`
43
+ throw new RestApiValidationError(`Field '${fieldName}' holds at most ${INDEXED_STRING_LENGTH} characters in its indexed slot.`, {
44
+ fields: [field],
45
+ violations: [{ field, rule: 'storage_range', message: `Indexed string slots hold at most ${INDEXED_STRING_LENGTH} characters.` }]
46
+ })
47
+ }
37
48
  }
38
49
 
39
50
  if (slot.slotType === 'belongsTo') {
@@ -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,
@@ -79,6 +79,8 @@ export interface StorageInfo {
79
79
  export interface CanonicalFieldSlot {
80
80
  slot: string
81
81
  slotType: string
82
+ // The slot has a (tenant_id, resource, slot) index; belongsTo slots always do.
83
+ indexed?: boolean
82
84
  alias?: string
83
85
  }
84
86
 
@@ -307,6 +309,27 @@ export interface DataReadHelpers<GetResult> {
307
309
  queryParams: NonNullable<DataReadContext['queryParams']>
308
310
  }
309
311
  }) => Promise<DataDocument<DataResource[]>>
312
+ // The plan comes from compileAggregateRequest; filters are in context.queryParams.
313
+ dataAggregate: (request: DataReadRequest & {
314
+ runHooks: NonNullable<DataReadRequest['runHooks']>
315
+ context: DataReadContext & {
316
+ queryParams: NonNullable<DataReadContext['queryParams']>
317
+ }
318
+ plan: AggregatePlan
319
+ }) => Promise<AggregateResult>
320
+ }
321
+
322
+ // Compiled by compileAggregateRequest: outputs carry SQL aliases.
323
+ export interface AggregatePlan {
324
+ scopeName: string
325
+ groups: Array<{ name: string; field: string; definition: StorageFieldDefinition; alias: string }>
326
+ measures: Array<{ name: string; fn: 'count' | 'countDistinct' | 'sum' | 'avg' | 'min' | 'max'; field: string; definition: StorageFieldDefinition | null; alias: string }>
327
+ sort: Array<{ alias: string; direction: 'asc' | 'desc' }>
328
+ page: { size: number; number: number } | null
329
+ }
330
+ export interface AggregateResult {
331
+ data: Array<Record<string, unknown>>
332
+ meta?: { page: { size: number; number: number; hasMore: boolean } }
310
333
  }
311
334
 
312
335
  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.
@@ -32,7 +39,7 @@ export const RestApiAnyapiKnexPlugin = {
32
39
  const options = pluginOptions || {}
33
40
  const knex = options.knex
34
41
  const tenantId = options.tenantId || DEFAULT_TENANT
35
- const { ensureSchema = true, ownsKnex = true, onSchemaChanged, schemaCheckInterval, slots } = options
42
+ const { ensureSchema = true, ownsKnex = true, onSchemaChanged, schemaCheckInterval, slots, indexedSlots } = options
36
43
 
37
44
  if (!knex) {
38
45
  throw new Error('AnyapiKnexPlugin requires a knex instance')
@@ -48,7 +55,7 @@ export const RestApiAnyapiKnexPlugin = {
48
55
  }
49
56
 
50
57
  const registry = options.registry || new AnyapiRegistry({ knex, log })
51
- if (ensureSchema) await registry.ensureSchema({ slots })
58
+ if (ensureSchema) await registry.ensureSchema({ slots, indexedSlots })
52
59
 
53
60
  api.anyapi = api.anyapi || {}
54
61
  api.anyapi.registry = registry
@@ -127,6 +134,15 @@ export const RestApiAnyapiKnexPlugin = {
127
134
  tenant: tenantId, scopeName, fields: hidden, filters: hiddenFilters
128
135
  })
129
136
  }
137
+ // Fields never move on their own; migrateField copies one into an indexed slot.
138
+ const unindexed = Object.entries(scopeOptions.schema || {})
139
+ .filter(([name, definition]) => definition?.indexed === true && descriptor.fields[name]?.indexed === false)
140
+ .map(([name]) => name)
141
+ if (unindexed.length) {
142
+ await log.warn(`AnyAPI resource '${scopeName}' declares indexed fields stored in unindexed slots`, {
143
+ tenant: tenantId, scopeName, fields: unindexed
144
+ })
145
+ }
130
146
  const signature = optionsSignature(scopeOptions)
131
147
  let schemaInfo = scope.vars.schemaInfo
132
148
  if (compiled?.signature === signature) schemaInfo = compiled.schemaInfo
@@ -252,6 +268,7 @@ export const RestApiAnyapiKnexPlugin = {
252
268
  for (const { resource } of await registry.listResources(tenantId)) await loadResource(resource)
253
269
  return api.resources
254
270
  }
271
+ api.anyapi.getSchemaVersion = resource => registry.getSchemaVersion(tenantId, resource)
255
272
  api.anyapi.handleSchemaChange = async ({ tenant, resource, version } = {}) => {
256
273
  if ((tenant !== undefined && tenant !== tenantId) || typeof resource !== 'string' || !Object.hasOwn(api.resources, resource)) return false
257
274
  if (version !== undefined && publishedVersion(resource) >= version) return false
@@ -322,7 +339,7 @@ export const RestApiAnyapiKnexPlugin = {
322
339
  })
323
340
 
324
341
  addResourceMethod('createKnexTable', async ({ scopeName }) => {
325
- if (ensureSchema) await ensureAnyApiSchema(knex, { slots })
342
+ if (ensureSchema) await ensureAnyApiSchema(knex, { slots, indexedSlots })
326
343
  const descriptor = await registry.getDescriptor(tenantId, scopeName)
327
344
  if (!descriptor) throw new Error(`Descriptor not found for resource '${scopeName}'`)
328
345
  await publish(scopeName, descriptor)
@@ -330,49 +347,141 @@ export const RestApiAnyapiKnexPlugin = {
330
347
 
331
348
  addResourceMethod('reloadSchema', async ({ scopeName }) => reload(scopeName))
332
349
 
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
350
+ // Compile the resource with the additions, so allocation stores enriched definitions.
351
+ const prepareAdditions = async (scopeName, { fields, searchSchema = {}, canonicalFieldsMap }) => {
352
+ const current = composeOptions(scopeName, api.resources[scopeName].vars.schemaInfo.descriptor).options
348
353
  const candidate = snapshotResourceConfiguration({
349
354
  ...current,
350
355
  schema: { ...current.schema, ...fields },
351
356
  searchSchema: { ...current.searchSchema, ...searchSchema }
352
357
  })
353
358
  const schemaInfo = await compileOptions(scopeName, candidate)
354
- await api.transaction(transaction => registry.allocateFields({
359
+ const allocation = {
355
360
  tenant: tenantId,
356
361
  resource: scopeName,
357
362
  fields: Object.fromEntries(Object.keys(fields).map(fieldName => [fieldName, {
358
363
  definition: schemaInfo.schemaStructure[fieldName] || schemaInfo.computed[fieldName],
359
364
  declaration: fields[fieldName],
360
- canonicalField: params.canonicalFieldsMap?.[fieldName]
365
+ canonicalField: canonicalFieldsMap?.[fieldName]
361
366
  }])),
362
367
  searchSchema
363
- }, { transaction }))
368
+ }
369
+ return { candidate, schemaInfo, allocation }
370
+ }
371
+
372
+ const publishChange = async (scopeName, { fields = {}, searchSchema = {}, compiled } = {}) => {
364
373
  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.
374
+ if (Object.keys(fields).length || Object.keys(searchSchema).length) {
375
+ const local = runtimeDeclarations.get(scopeName) || {}
376
+ runtimeDeclarations.set(scopeName, {
377
+ schema: { ...local.schema, ...fields },
378
+ searchSchema: { ...local.searchSchema, ...searchSchema }
379
+ })
380
+ }
381
+ // The fresh descriptor also carries changes other servers made meanwhile.
371
382
  const descriptor = await registry.getDescriptor(tenantId, scopeName)
372
- await publish(scopeName, descriptor, { compiled: { signature: optionsSignature(candidate), schemaInfo } })
383
+ await publish(scopeName, descriptor, { compiled })
373
384
  await notifySchemaChanged(scopeName, descriptor.schemaVersion)
385
+ }
386
+
387
+ addResourceMethod('addKnexFields', async ({ scopeName, params }) => {
388
+ if (!params?.fields || typeof params.fields !== 'object' || Array.isArray(params.fields)) {
389
+ throw new Error('fields parameter is required for addKnexFields')
390
+ }
391
+ assertFieldNameMap(params.fields, `added fields in '${scopeName}'`)
392
+ assertFieldNameMap(params.searchSchema, `added search schema in '${scopeName}'`)
393
+ assertFieldNameMap(params.canonicalFieldsMap, `added canonical fields in '${scopeName}'`)
394
+ const unknown = Object.keys(params.canonicalFieldsMap || {}).filter(field => !Object.hasOwn(params.fields, field))
395
+ if (unknown.length) {
396
+ throw new Error(`canonicalFieldsMap contains unknown fields for addKnexFields: ${unknown}`)
397
+ }
398
+ const fields = snapshotResourceConfiguration(params.fields)
399
+ const searchSchema = snapshotResourceConfiguration(params.searchSchema || {})
400
+ const { candidate, schemaInfo, allocation } = await prepareAdditions(scopeName, { fields, searchSchema, canonicalFieldsMap: params.canonicalFieldsMap })
401
+ await api.transaction(transaction => registry.allocateFields(allocation, { transaction }))
402
+ await publishChange(scopeName, { fields, searchSchema, compiled: { signature: optionsSignature(candidate), schemaInfo } })
403
+ })
404
+
405
+ const retirable = (scopeName, fieldName) => {
406
+ const definition = api.resources[scopeName].vars.schemaInfo.schemaStructure[fieldName]
407
+ if (!definition || fieldName === 'id' || definition.virtual === true || definition.belongsTo) {
408
+ throw new Error(`'${scopeName}.${fieldName}' is not a stored attribute`)
409
+ }
410
+ return definition
411
+ }
412
+
413
+ addResourceMethod('retireKnexFields', async ({ scopeName, params }) => {
414
+ const fields = Array.isArray(params) ? params : params?.fields
415
+ if (!Array.isArray(fields) || !fields.length) throw new Error('retireKnexFields requires an array of field names')
416
+ fields.forEach(fieldName => retirable(scopeName, fieldName))
417
+ await registry.retireFields({ tenant: tenantId, resource: scopeName, fields })
418
+ await publishChange(scopeName)
374
419
  })
375
420
 
421
+ // Change a field's type: copy its values into a new field, then retire it.
422
+ addResourceMethod('migrateField', async ({ scopeName, params }) => {
423
+ const { from, to, transform } = params || {}
424
+ const { name, ...declaration } = to || {}
425
+ if (typeof from !== 'string' || typeof name !== 'string' || !name) {
426
+ throw new Error('migrateField requires from and to: { name, ...definition }')
427
+ }
428
+ if (transform !== undefined && typeof transform !== 'function') throw new Error('migrateField transform must be a function')
429
+ const fromDefinition = retirable(scopeName, from)
430
+ if (fromDefinition.retired) throw new Error(`Field '${scopeName}.${from}' is already retired`)
431
+ if (declaration.computed || declaration.virtual || declaration.belongsTo || declaration.getter || declaration.setter || declaration.storage) {
432
+ throw new Error(`migrateField target '${name}' must be a plain stored attribute; add other fields with addKnexFields`)
433
+ }
434
+ assertFieldNameMap({ [name]: declaration }, `migrated field in '${scopeName}'`)
435
+ const fields = snapshotResourceConfiguration({ [name]: declaration })
436
+ const { schemaInfo, allocation } = await prepareAdditions(scopeName, { fields })
437
+
438
+ await api.transaction(async (transaction) => {
439
+ const descriptor = await registry.allocateFields(allocation, { transaction })
440
+ await copyFieldValues({ transaction, descriptor, schemaInfo, from, fromDefinition, to: name, transform })
441
+ await registry.retireFields({ tenant: tenantId, resource: scopeName, fields: [from] }, { transaction })
442
+ })
443
+ await publishChange(scopeName, { fields })
444
+ })
445
+
446
+ const copyFieldValues = async ({ transaction, descriptor, schemaInfo, from, fromDefinition, to, transform }) => {
447
+ const { canonical } = descriptor
448
+ const source = descriptor.fields[from].slot
449
+ const target = descriptor.fields[to].slot
450
+ const idColumn = canonical.logicalIdColumn
451
+ for (let lastId = null; ;) {
452
+ const query = transaction(canonical.tableName)
453
+ .where(canonical.tenantColumn, tenantId)
454
+ .where(canonical.resourceColumn, descriptor.resource)
455
+ .orderBy('id')
456
+ .limit(MIGRATION_BATCH_SIZE)
457
+ .select('id', idColumn, source)
458
+ if (lastId !== null) query.where('id', '>', lastId)
459
+ const rows = await applyDatabaseReadOptions(query)
460
+ if (!rows.length) return
461
+ for (const row of rows) {
462
+ lastId = row.id
463
+ const value = normalizeStoredFieldValue(row[source], fromDefinition, { fieldName: from, resourceType: descriptor.resource })
464
+ const next = transform ? await transform(value, { id: String(row[idColumn]) }) : value
465
+ const rejected = reason => {
466
+ const message = `Record '${row[idColumn]}' cannot migrate '${from}' to '${to}': ${reason}`
467
+ return new RestApiValidationError(message, { fields: [to], violations: [{ field: to, rule: 'field_migration', message }] })
468
+ }
469
+ const { validatedObject, errors } = await schemaInfo.schemaInstance.patch({ [to]: next ?? null })
470
+ if (Object.keys(errors).length) throw rejected(Object.values(errors)[0].message)
471
+ let stored
472
+ try {
473
+ stored = translateCanonicalAttributesForStorage({ [to]: validatedObject[to] ?? null }, descriptor, {
474
+ schemaInfo, databaseClient: knex.client.config.client
475
+ })
476
+ } catch (error) {
477
+ if (error?.type === 'rest_api_validation') throw rejected(error.message)
478
+ throw error
479
+ }
480
+ await transaction(canonical.tableName).where('id', row.id).update({ [target]: stored[target] ?? null })
481
+ }
482
+ }
483
+ }
484
+
376
485
  addResourceMethod('alterKnexFields', async () => {
377
486
  throw new Error('alterKnexFields is not supported by AnyAPI Knex plugin yet')
378
487
  })
@@ -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
@@ -2,6 +2,15 @@ import type { Knex } from 'knex'
2
2
 
3
3
  export type AnyapiSlotPool = 'string' | 'number' | 'boolean' | 'date' | 'json' | 'belongsTo'
4
4
  export type AnyapiSlotCounts = Partial<Record<AnyapiSlotPool, number>>
5
+ // Pools whose slots can carry a (tenant_id, resource, slot) index.
6
+ export type AnyapiIndexedSlotPool = 'string' | 'number' | 'boolean' | 'date'
7
+ export type AnyapiIndexedSlotCounts = Partial<Record<AnyapiIndexedSlotPool, number>>
8
+
9
+ export interface AnyapiSchemaOptions {
10
+ slots?: AnyapiSlotCounts
11
+ // Fields declared `indexed: true` take these slots.
12
+ indexedSlots?: AnyapiIndexedSlotCounts
13
+ }
5
14
 
6
15
  export interface AnyapiSchemaChange {
7
16
  tenant: string
@@ -21,10 +30,12 @@ export class AnyapiRegistry {
21
30
  constructor(options: AnyapiRegistryOptions)
22
31
  readonly knex: Knex
23
32
  readonly cacheSize: number
24
- ensureSchema(options?: { slots?: AnyapiSlotCounts }): Promise<void>
33
+ ensureSchema(options?: AnyapiSchemaOptions): Promise<void>
25
34
  getSchemaVersion(tenant: string, resource: string, options?: { transaction?: Knex.Transaction }): Promise<number | null>
35
+ // Retired fields keep their slots and values and reject new writes.
36
+ retireFields(params: { tenant: string; resource: string; fields: string[] }, options?: { transaction?: Knex.Transaction }): Promise<Record<string, unknown>>
26
37
  invalidateDescriptor(tenant: string, resource: string): void
27
38
  }
28
39
 
29
40
  /** Create or upgrade the canonical tables; counts only ever add slot columns. */
30
- export function ensureAnyApiSchema(knex: Knex, options?: { slots?: AnyapiSlotCounts }): Promise<void>
41
+ export function ensureAnyApiSchema(knex: Knex, options?: AnyapiSchemaOptions): Promise<void>
@@ -1,7 +1,7 @@
1
1
  import type { DirectResourceId, ResourceFormat, WriteReturning } from './representations.js'
2
2
  import type { RemovedResourceOptions } from './resource-methods.js'
3
3
  import type { Knex } from 'knex'
4
- import type { AnyapiRegistry, AnyapiSchemaChange, AnyapiSlotCounts } from './anyapi.js'
4
+ import type { AnyapiIndexedSlotCounts, AnyapiRegistry, AnyapiSchemaChange, AnyapiSlotCounts } from './anyapi.js'
5
5
 
6
6
  export type RestApiPluginOptions = RemovedResourceOptions & {
7
7
  format?: ResourceFormat
@@ -50,6 +50,7 @@ export interface RestApiAnyapiKnexPluginOptions extends RestApiKnexPluginOptions
50
50
  // false leaves a shared Knex instance open when this API is released.
51
51
  ownsKnex?: boolean
52
52
  slots?: AnyapiSlotCounts
53
+ indexedSlots?: AnyapiIndexedSlotCounts
53
54
  // Called after a committed schema change, for example to notify other servers.
54
55
  onSchemaChanged?: (change: AnyapiSchemaChange) => void | Promise<void>
55
56
  // Milliseconds between schema version checks; 0 checks on every request.
@@ -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