json-rest-api 2.0.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.
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 +154 -14
  4. package/docs/GUIDE/30-backend-capabilities.md +8 -5
  5. package/docs/GUIDE/33-migrating-to-v2.md +27 -7
  6. package/index.d.ts +1 -0
  7. package/index.js +2 -0
  8. package/lib/knex-transaction.js +10 -4
  9. package/package.json +1 -1
  10. package/plugins/core/lib/anyapi/anyapi-registry.js +569 -408
  11. package/plugins/core/lib/anyapi/schema-utils.js +101 -51
  12. package/plugins/core/lib/anyapi/stored-definition.js +112 -0
  13. package/plugins/core/lib/querying/aggregate-query.js +170 -0
  14. package/plugins/core/lib/querying-writing/compile-schemas.js +17 -0
  15. package/plugins/core/lib/querying-writing/database-value-normalizers.js +16 -0
  16. package/plugins/core/lib/querying-writing/default-data-helpers.js +4 -0
  17. package/plugins/core/lib/querying-writing/field-types.js +43 -0
  18. package/plugins/core/lib/querying-writing/request-contracts.js +1 -1
  19. package/plugins/core/lib/storage/canonical-data-helpers.js +29 -0
  20. package/plugins/core/lib/storage/ordinary-data-helpers.js +25 -0
  21. package/plugins/core/lib/storage/storage-types.d.ts +21 -0
  22. package/plugins/core/rest-api-anyapi-knex-plugin.js +331 -54
  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 +32 -0
  27. package/types/plugin-options.d.ts +12 -0
  28. package/types/resource-methods.d.ts +19 -0
  29. package/types/resource-schema.d.ts +5 -3
@@ -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>>
@@ -5,15 +5,30 @@ import { createKnexTransaction } from '../../lib/knex-transaction.js'
5
5
  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
- import { RestApiResourceError } from '../../lib/rest-api-errors.js'
8
+ import { composeResourceOptions, relatedResources } from './lib/anyapi/stored-definition.js'
9
+ import { RestApiResourceError, RestApiValidationError } from '../../lib/rest-api-errors.js'
9
10
  import { compileSchemas } from './lib/querying-writing/compile-schemas.js'
10
11
  import { snapshotResourceConfiguration } from './lib/querying-writing/schema-helpers.js'
11
12
  import { assertFieldNameMap } from './lib/querying-writing/field-utils.js'
12
13
  import { polymorphicFiltersHook, crossTableFiltersHook, basicFiltersHook } from './lib/querying/knex-query-helpers.js'
13
14
  import { createStorageAdapterLookup } from './lib/storage/storage-adapter.js'
14
- import { getDatabaseCapabilities } from './lib/querying-writing/database-capabilities.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
20
+ // Methods that read a published schema; schema checks wrap these.
21
+ const REQUEST_METHODS = ['query', 'aggregate', 'get', 'post', 'put', 'patch', 'delete', 'getRelated', 'getRelationship', 'postRelationship', 'patchRelationship', 'deleteRelationship']
22
+
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
+ ])
28
+
29
+ // A validation failure may come from a schema another server already changed.
30
+ // Writes that may have committed are never repeated.
31
+ const isStaleSchemaRejection = error => error?.type === 'rest_api_validation' && !['committed', 'unknown'].includes(error.transactionOutcome)
17
32
 
18
33
  export const RestApiAnyapiKnexPlugin = {
19
34
  name: 'rest-api-anyapi-knex',
@@ -24,14 +39,23 @@ export const RestApiAnyapiKnexPlugin = {
24
39
  const options = pluginOptions || {}
25
40
  const knex = options.knex
26
41
  const tenantId = options.tenantId || DEFAULT_TENANT
42
+ const { ensureSchema = true, ownsKnex = true, onSchemaChanged, schemaCheckInterval, slots } = options
27
43
 
28
44
  if (!knex) {
29
45
  throw new Error('AnyapiKnexPlugin requires a knex instance')
30
46
  }
47
+ if (options.registry !== undefined && (!(options.registry instanceof AnyapiRegistry) || options.registry.knex !== knex)) {
48
+ throw new Error('AnyapiKnexPlugin registry must be an AnyapiRegistry for the same knex instance')
49
+ }
50
+ if (onSchemaChanged !== undefined && typeof onSchemaChanged !== 'function') {
51
+ throw new Error('AnyapiKnexPlugin onSchemaChanged must be a function')
52
+ }
53
+ if (schemaCheckInterval !== undefined && !(Number.isFinite(schemaCheckInterval) && schemaCheckInterval >= 0)) {
54
+ throw new Error('AnyapiKnexPlugin schemaCheckInterval must be a non-negative number of milliseconds')
55
+ }
31
56
 
32
- await ensureAnyApiSchema(knex)
33
-
34
- const registry = new AnyapiRegistry({ knex, log })
57
+ const registry = options.registry || new AnyapiRegistry({ knex, log })
58
+ if (ensureSchema) await registry.ensureSchema({ slots })
35
59
 
36
60
  api.anyapi = api.anyapi || {}
37
61
  api.anyapi.registry = registry
@@ -39,7 +63,7 @@ export const RestApiAnyapiKnexPlugin = {
39
63
  api.knex = {
40
64
  instance: knex,
41
65
  helpers: {},
42
- capabilities: await getDatabaseCapabilities(knex, log),
66
+ capabilities: await registry.getCapabilities(log),
43
67
  }
44
68
 
45
69
  const getScopeStorageAdapter = createStorageAdapterLookup({
@@ -50,13 +74,21 @@ export const RestApiAnyapiKnexPlugin = {
50
74
  api.knex.helpers.getStorageAdapter = getScopeStorageAdapter
51
75
  helpers.getStorageAdapter = getScopeStorageAdapter
52
76
 
53
- const scopeOptionsRegistry = new Map()
77
+ // Owned copies of what this server declared and added, callbacks included.
78
+ const declarations = new Map()
79
+ const runtimeDeclarations = new Map()
80
+ const storageLoaded = new Set()
81
+ const compiledSignatures = new Map()
82
+ const lastChecked = new Map()
83
+ const reloads = new Map()
84
+ const checks = new Map()
85
+ const loading = new Map()
54
86
 
55
87
  /** @type {import('../../lib/transaction-types.js').TransactionFactory} */
56
88
  helpers.newTransaction = context => createKnexTransaction(knex, context)
57
89
 
58
90
  addHook('release', 'anyapi-knex-release', {}, async ({ api }) => {
59
- if (api.knex?.instance) {
91
+ if (ownsKnex && api.knex?.instance) {
60
92
  await api.knex.instance.destroy()
61
93
  }
62
94
  })
@@ -76,11 +108,38 @@ export const RestApiAnyapiKnexPlugin = {
76
108
  api, knex, getDescriptor, getScopeStorageAdapter, linkStore
77
109
  })
78
110
 
79
- const refreshStorageDescriptor = async (scopeName, compiledSchemaInfo) => {
111
+ const publishedVersion = scopeName => api.resources[scopeName]?.vars?.schemaInfo?.descriptor?.schemaVersion ?? 0
112
+
113
+ const composeOptions = (scopeName, descriptor) => composeResourceOptions({
114
+ declaration: declarations.get(scopeName),
115
+ stored: descriptor.definition,
116
+ local: runtimeDeclarations.get(scopeName),
117
+ fromStorage: storageLoaded.has(scopeName)
118
+ })
119
+
120
+ const compileOptions = async (scopeName, scopeOptions) => {
121
+ const compiled = { scopeOptions, vars: { idProperty: api.resources[scopeName].vars.idProperty } }
122
+ await compileSchemas(compiled, { context: { scopeName }, runHooks: (name, context) => api.runHooks(name, context) })
123
+ return compiled.vars.schemaInfo
124
+ }
125
+
126
+ // Publish a descriptor with the schema it implies. Recompile only when the
127
+ // field set changed, so schema hooks do not rerun on every refresh.
128
+ const publish = async (scopeName, descriptor, { compiled } = {}) => {
80
129
  const scope = api.resources[scopeName]
81
- const schemaInfo = compiledSchemaInfo || scope.vars.schemaInfo
82
- const descriptor = await registry.getDescriptor(tenantId, scopeName)
83
- if (!descriptor) throw new Error(`Descriptor not found for resource '${scopeName}'`)
130
+ if (publishedVersion(scopeName) > descriptor.schemaVersion) return
131
+ const { options: scopeOptions, hidden, hiddenFilters } = composeOptions(scopeName, descriptor)
132
+ if (hidden.length || hiddenFilters.length) {
133
+ await log.warn(`AnyAPI resource '${scopeName}' hides stored fields whose callbacks this server does not declare`, {
134
+ tenant: tenantId, scopeName, fields: hidden, filters: hiddenFilters
135
+ })
136
+ }
137
+ const signature = optionsSignature(scopeOptions)
138
+ let schemaInfo = scope.vars.schemaInfo
139
+ if (compiled?.signature === signature) schemaInfo = compiled.schemaInfo
140
+ else if (compiledSignatures.get(scopeName) !== signature) schemaInfo = await compileOptions(scopeName, scopeOptions)
141
+ compiledSignatures.set(scopeName, signature)
142
+
84
143
  descriptor.idProperty = schemaInfo.idProperty
85
144
  scope.vars.schemaInfo = {
86
145
  ...schemaInfo,
@@ -88,10 +147,124 @@ export const RestApiAnyapiKnexPlugin = {
88
147
  canonicalFieldMap: descriptor.canonicalFieldMap,
89
148
  }
90
149
  getScopeStorageAdapter(scopeName)
91
- scopeOptionsRegistry.set(scopeName, {
92
- ...scopeOptionsRegistry.get(scopeName),
93
- canonicalFieldsMap: descriptor.canonicalFieldMap,
150
+ lastChecked.set(scopeName, Date.now())
151
+ }
152
+
153
+ // Reloads of one resource run in order, so an older read never wins.
154
+ const reload = (scopeName) => {
155
+ const run = (reloads.get(scopeName) || Promise.resolve()).catch(() => {}).then(async () => {
156
+ registry.invalidateDescriptor(tenantId, scopeName)
157
+ const descriptor = await registry.getDescriptor(tenantId, scopeName)
158
+ if (!descriptor) {
159
+ throw new RestApiResourceError(`Resource '${scopeName}' is not registered in AnyAPI storage`, {
160
+ subtype: 'not_found', resourceType: 'scope', resourceId: scopeName
161
+ })
162
+ }
163
+ await publish(scopeName, descriptor)
164
+ return publishedVersion(scopeName)
94
165
  })
166
+ reloads.set(scopeName, run)
167
+ run.finally(() => { if (reloads.get(scopeName) === run) reloads.delete(scopeName) }).catch(() => {})
168
+ return run
169
+ }
170
+
171
+ const notifySchemaChanged = async (scopeName, version) => {
172
+ if (!onSchemaChanged) return
173
+ try {
174
+ await onSchemaChanged({ tenant: tenantId, resource: scopeName, version })
175
+ } catch (error) {
176
+ // The change is committed; other servers still catch up through schema checks.
177
+ await log.logError('AnyAPI onSchemaChanged failed', error, { tenant: tenantId, scopeName, version })
178
+ }
179
+ }
180
+
181
+ const ensureFresh = (scopeName, maxAge) => {
182
+ if (maxAge > 0 && Date.now() - (lastChecked.get(scopeName) ?? 0) < maxAge) return false
183
+ if (!checks.has(scopeName)) {
184
+ const check = (async () => {
185
+ lastChecked.set(scopeName, Date.now())
186
+ const version = await registry.getSchemaVersion(tenantId, scopeName)
187
+ if (version === null || version <= publishedVersion(scopeName)) return false
188
+ await reload(scopeName)
189
+ return true
190
+ })()
191
+ checks.set(scopeName, check)
192
+ check.finally(() => checks.delete(scopeName)).catch(() => {})
193
+ }
194
+ return checks.get(scopeName)
195
+ }
196
+
197
+ const watchSchema = (scope) => {
198
+ for (const name of REQUEST_METHODS) {
199
+ const method = scope[name]
200
+ if (typeof method !== 'function') continue
201
+ scope[name] = async function (params = {}, context = {}) {
202
+ // A caller transaction may hold uncommitted metadata; never publish from it.
203
+ if (params?.transaction) return method.call(this, params, context)
204
+ await ensureFresh(scope.name, schemaCheckInterval)
205
+ try {
206
+ return await method.call(this, params, context)
207
+ } catch (error) {
208
+ if (!isStaleSchemaRejection(error)) throw error
209
+ let reloaded = false
210
+ try { reloaded = await ensureFresh(scope.name, 0) } catch { throw error }
211
+ if (!reloaded) throw error
212
+ return method.call(this, params, context)
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ const loadResource = async (name, chain = new Set()) => {
219
+ if (typeof name !== 'string' || !name) throw new TypeError('loadResource requires a resource name')
220
+ if (Object.hasOwn(api.resources, name)) return api.resources[name]
221
+ if (chain.has(name)) return null
222
+ if (!loading.has(name)) {
223
+ const load = (async () => {
224
+ const descriptor = await registry.getDescriptor(tenantId, name)
225
+ if (!descriptor) return null
226
+ if (!descriptor.definition?.options) {
227
+ throw new Error(`AnyAPI resource '${name}' has no stored definition; declare it in code or register it again with this version`)
228
+ }
229
+ const { options: declaration } = composeResourceOptions({
230
+ declaration: descriptor.definition.options,
231
+ stored: { callbacks: descriptor.definition.callbacks },
232
+ fromStorage: true
233
+ })
234
+ const { before, after } = relatedResources(declaration)
235
+ const nextChain = new Set([...chain, name])
236
+ for (const target of before) await loadResource(target, nextChain)
237
+ if (Object.hasOwn(api.resources, name)) return api.resources[name]
238
+ storageLoaded.add(name)
239
+ try {
240
+ await api.addResource(name, declaration)
241
+ } catch (error) {
242
+ // A failed registration must not leave a half-published resource behind.
243
+ storageLoaded.delete(name)
244
+ declarations.delete(name)
245
+ delete api.resources[name]
246
+ throw error
247
+ }
248
+ for (const target of after) await loadResource(target, nextChain)
249
+ return api.resources[name]
250
+ })()
251
+ loading.set(name, load)
252
+ load.finally(() => loading.delete(name)).catch(() => {})
253
+ }
254
+ return loading.get(name)
255
+ }
256
+
257
+ api.anyapi.loadResource = name => loadResource(name)
258
+ api.anyapi.loadResources = async () => {
259
+ for (const { resource } of await registry.listResources(tenantId)) await loadResource(resource)
260
+ return api.resources
261
+ }
262
+ api.anyapi.getSchemaVersion = resource => registry.getSchemaVersion(tenantId, resource)
263
+ api.anyapi.handleSchemaChange = async ({ tenant, resource, version } = {}) => {
264
+ if ((tenant !== undefined && tenant !== tenantId) || typeof resource !== 'string' || !Object.hasOwn(api.resources, resource)) return false
265
+ if (version !== undefined && publishedVersion(resource) >= version) return false
266
+ await reload(resource)
267
+ return true
95
268
  }
96
269
 
97
270
  const buildSearchMembershipQuery = ({ sourceScopeName, relationshipName, targetScopeName, foreignKey, otherKey, db }) => {
@@ -131,28 +304,77 @@ export const RestApiAnyapiKnexPlugin = {
131
304
  addHook('resource:added', 'anyapi-register-resource', {}, async ({ context }) => {
132
305
  const { scopeName, scopeOptions = {} } = context
133
306
  const scope = api.resources[scopeName]
134
- const { schemaStructure, computed, schemaRelationships, idProperty } = scope.vars.schemaInfo
135
- const descriptor = await registry.registerResource({
136
- tenant: tenantId,
137
- resource: scopeName,
138
- schema: { ...schemaStructure, ...computed },
139
- relationships: schemaRelationships,
140
- canonicalFieldMap: scopeOptions.canonicalFieldsMap || null,
141
- idProperty
142
- })
143
- scopeOptionsRegistry.set(scopeName, snapshotResourceConfiguration({
144
- ...scopeOptions,
145
- idProperty,
146
- canonicalFieldsMap: descriptor.canonicalFieldMap
147
- }))
148
- await refreshStorageDescriptor(scopeName)
307
+ const declaration = snapshotResourceConfiguration(scopeOptions)
308
+ declarations.set(scopeName, declaration)
309
+ compiledSignatures.set(scopeName, optionsSignature(declaration))
310
+ let descriptor
311
+ let changed = false
312
+ if (storageLoaded.has(scopeName)) {
313
+ descriptor = await registry.getDescriptor(tenantId, scopeName)
314
+ if (!descriptor) throw new Error(`Descriptor not found for resource '${scopeName}'`)
315
+ } else {
316
+ const { schemaStructure, computed, schemaRelationships, idProperty } = scope.vars.schemaInfo
317
+ ;({ descriptor, changed } = await registry.register({
318
+ tenant: tenantId,
319
+ resource: scopeName,
320
+ schema: { ...schemaStructure, ...computed },
321
+ relationships: schemaRelationships,
322
+ canonicalFieldMap: scopeOptions.canonicalFieldsMap || null,
323
+ idProperty,
324
+ declaration
325
+ }))
326
+ }
327
+ await publish(scopeName, descriptor)
328
+ if (schemaCheckInterval !== undefined) watchSchema(scope)
329
+ if (changed) await notifySchemaChanged(scopeName, descriptor.schemaVersion)
149
330
  })
150
331
 
151
332
  addResourceMethod('createKnexTable', async ({ scopeName }) => {
152
- await ensureAnyApiSchema(knex)
153
- await refreshStorageDescriptor(scopeName)
333
+ if (ensureSchema) await ensureAnyApiSchema(knex, { slots })
334
+ const descriptor = await registry.getDescriptor(tenantId, scopeName)
335
+ if (!descriptor) throw new Error(`Descriptor not found for resource '${scopeName}'`)
336
+ await publish(scopeName, descriptor)
154
337
  })
155
338
 
339
+ addResourceMethod('reloadSchema', async ({ scopeName }) => reload(scopeName))
340
+
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
344
+ const candidate = snapshotResourceConfiguration({
345
+ ...current,
346
+ schema: { ...current.schema, ...fields },
347
+ searchSchema: { ...current.searchSchema, ...searchSchema }
348
+ })
349
+ const schemaInfo = await compileOptions(scopeName, candidate)
350
+ const allocation = {
351
+ tenant: tenantId,
352
+ resource: scopeName,
353
+ fields: Object.fromEntries(Object.keys(fields).map(fieldName => [fieldName, {
354
+ definition: schemaInfo.schemaStructure[fieldName] || schemaInfo.computed[fieldName],
355
+ declaration: fields[fieldName],
356
+ canonicalField: canonicalFieldsMap?.[fieldName]
357
+ }])),
358
+ searchSchema
359
+ }
360
+ return { candidate, schemaInfo, allocation }
361
+ }
362
+
363
+ const publishChange = async (scopeName, { fields = {}, searchSchema = {}, compiled } = {}) => {
364
+ registry.invalidateDescriptor(tenantId, scopeName)
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.
373
+ const descriptor = await registry.getDescriptor(tenantId, scopeName)
374
+ await publish(scopeName, descriptor, { compiled })
375
+ await notifySchemaChanged(scopeName, descriptor.schemaVersion)
376
+ }
377
+
156
378
  addResourceMethod('addKnexFields', async ({ scopeName, params }) => {
157
379
  if (!params?.fields || typeof params.fields !== 'object' || Array.isArray(params.fields)) {
158
380
  throw new Error('fields parameter is required for addKnexFields')
@@ -160,35 +382,90 @@ export const RestApiAnyapiKnexPlugin = {
160
382
  assertFieldNameMap(params.fields, `added fields in '${scopeName}'`)
161
383
  assertFieldNameMap(params.searchSchema, `added search schema in '${scopeName}'`)
162
384
  assertFieldNameMap(params.canonicalFieldsMap, `added canonical fields in '${scopeName}'`)
163
- const scope = api.resources[scopeName]
164
- const storedBefore = scopeOptionsRegistry.get(scopeName) || {}
165
385
  const unknown = Object.keys(params.canonicalFieldsMap || {}).filter(field => !Object.hasOwn(params.fields, field))
166
386
  if (unknown.length) {
167
387
  throw new Error(`canonicalFieldsMap contains unknown fields for addKnexFields: ${unknown}`)
168
388
  }
169
- const updatedStored = snapshotResourceConfiguration({
170
- ...storedBefore,
171
- schema: { ...storedBefore.schema, ...params.fields },
172
- searchSchema: { ...storedBefore.searchSchema, ...params.searchSchema }
173
- })
174
- const compiled = { scopeOptions: updatedStored, vars: { idProperty: scope.vars.idProperty } }
175
- await compileSchemas(compiled, { context: { scopeName }, runHooks: (name, context) => api.runHooks(name, context) })
176
- await api.transaction(async transaction => {
177
- for (const fieldName of Object.keys(params.fields)) {
178
- await registry.allocateField({
179
- tenant: tenantId,
180
- resource: scopeName,
181
- fieldName,
182
- definition: compiled.vars.schemaInfo.schemaStructure[fieldName] || compiled.vars.schemaInfo.computed[fieldName],
183
- canonicalField: params.canonicalFieldsMap?.[fieldName]
184
- }, { transaction })
185
- }
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 } })
394
+ })
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 })
186
433
  })
187
- registry.invalidateDescriptor(tenantId, scopeName)
188
- scopeOptionsRegistry.set(scopeName, updatedStored)
189
- await refreshStorageDescriptor(scopeName, compiled.vars.schemaInfo)
434
+ await publishChange(scopeName, { fields })
190
435
  })
191
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
+
192
469
  addResourceMethod('alterKnexFields', async () => {
193
470
  throw new Error('alterKnexFields is not supported by AnyAPI Knex plugin yet')
194
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
+ }