json-rest-api 2.0.0 → 2.1.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.
@@ -5,15 +5,23 @@ 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 { composeResourceOptions, relatedResources } from './lib/anyapi/stored-definition.js'
8
9
  import { RestApiResourceError } 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
15
 
16
16
  const DEFAULT_TENANT = 'default'
17
+ // 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']
19
+
20
+ const optionsSignature = options => JSON.stringify([Object.keys(options.schema || {}).sort(), Object.keys(options.searchSchema || {}).sort()])
21
+
22
+ // A validation failure may come from a schema another server already changed.
23
+ // Writes that may have committed are never repeated.
24
+ const isStaleSchemaRejection = error => error?.type === 'rest_api_validation' && !['committed', 'unknown'].includes(error.transactionOutcome)
17
25
 
18
26
  export const RestApiAnyapiKnexPlugin = {
19
27
  name: 'rest-api-anyapi-knex',
@@ -24,14 +32,23 @@ export const RestApiAnyapiKnexPlugin = {
24
32
  const options = pluginOptions || {}
25
33
  const knex = options.knex
26
34
  const tenantId = options.tenantId || DEFAULT_TENANT
35
+ const { ensureSchema = true, ownsKnex = true, onSchemaChanged, schemaCheckInterval, slots } = options
27
36
 
28
37
  if (!knex) {
29
38
  throw new Error('AnyapiKnexPlugin requires a knex instance')
30
39
  }
40
+ if (options.registry !== undefined && (!(options.registry instanceof AnyapiRegistry) || options.registry.knex !== knex)) {
41
+ throw new Error('AnyapiKnexPlugin registry must be an AnyapiRegistry for the same knex instance')
42
+ }
43
+ if (onSchemaChanged !== undefined && typeof onSchemaChanged !== 'function') {
44
+ throw new Error('AnyapiKnexPlugin onSchemaChanged must be a function')
45
+ }
46
+ if (schemaCheckInterval !== undefined && !(Number.isFinite(schemaCheckInterval) && schemaCheckInterval >= 0)) {
47
+ throw new Error('AnyapiKnexPlugin schemaCheckInterval must be a non-negative number of milliseconds')
48
+ }
31
49
 
32
- await ensureAnyApiSchema(knex)
33
-
34
- const registry = new AnyapiRegistry({ knex, log })
50
+ const registry = options.registry || new AnyapiRegistry({ knex, log })
51
+ if (ensureSchema) await registry.ensureSchema({ slots })
35
52
 
36
53
  api.anyapi = api.anyapi || {}
37
54
  api.anyapi.registry = registry
@@ -39,7 +56,7 @@ export const RestApiAnyapiKnexPlugin = {
39
56
  api.knex = {
40
57
  instance: knex,
41
58
  helpers: {},
42
- capabilities: await getDatabaseCapabilities(knex, log),
59
+ capabilities: await registry.getCapabilities(log),
43
60
  }
44
61
 
45
62
  const getScopeStorageAdapter = createStorageAdapterLookup({
@@ -50,13 +67,21 @@ export const RestApiAnyapiKnexPlugin = {
50
67
  api.knex.helpers.getStorageAdapter = getScopeStorageAdapter
51
68
  helpers.getStorageAdapter = getScopeStorageAdapter
52
69
 
53
- const scopeOptionsRegistry = new Map()
70
+ // Owned copies of what this server declared and added, callbacks included.
71
+ const declarations = new Map()
72
+ const runtimeDeclarations = new Map()
73
+ const storageLoaded = new Set()
74
+ const compiledSignatures = new Map()
75
+ const lastChecked = new Map()
76
+ const reloads = new Map()
77
+ const checks = new Map()
78
+ const loading = new Map()
54
79
 
55
80
  /** @type {import('../../lib/transaction-types.js').TransactionFactory} */
56
81
  helpers.newTransaction = context => createKnexTransaction(knex, context)
57
82
 
58
83
  addHook('release', 'anyapi-knex-release', {}, async ({ api }) => {
59
- if (api.knex?.instance) {
84
+ if (ownsKnex && api.knex?.instance) {
60
85
  await api.knex.instance.destroy()
61
86
  }
62
87
  })
@@ -76,11 +101,38 @@ export const RestApiAnyapiKnexPlugin = {
76
101
  api, knex, getDescriptor, getScopeStorageAdapter, linkStore
77
102
  })
78
103
 
79
- const refreshStorageDescriptor = async (scopeName, compiledSchemaInfo) => {
104
+ const publishedVersion = scopeName => api.resources[scopeName]?.vars?.schemaInfo?.descriptor?.schemaVersion ?? 0
105
+
106
+ const composeOptions = (scopeName, descriptor) => composeResourceOptions({
107
+ declaration: declarations.get(scopeName),
108
+ stored: descriptor.definition,
109
+ local: runtimeDeclarations.get(scopeName),
110
+ fromStorage: storageLoaded.has(scopeName)
111
+ })
112
+
113
+ const compileOptions = async (scopeName, scopeOptions) => {
114
+ const compiled = { scopeOptions, vars: { idProperty: api.resources[scopeName].vars.idProperty } }
115
+ await compileSchemas(compiled, { context: { scopeName }, runHooks: (name, context) => api.runHooks(name, context) })
116
+ return compiled.vars.schemaInfo
117
+ }
118
+
119
+ // Publish a descriptor with the schema it implies. Recompile only when the
120
+ // field set changed, so schema hooks do not rerun on every refresh.
121
+ const publish = async (scopeName, descriptor, { compiled } = {}) => {
80
122
  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}'`)
123
+ if (publishedVersion(scopeName) > descriptor.schemaVersion) return
124
+ const { options: scopeOptions, hidden, hiddenFilters } = composeOptions(scopeName, descriptor)
125
+ if (hidden.length || hiddenFilters.length) {
126
+ await log.warn(`AnyAPI resource '${scopeName}' hides stored fields whose callbacks this server does not declare`, {
127
+ tenant: tenantId, scopeName, fields: hidden, filters: hiddenFilters
128
+ })
129
+ }
130
+ const signature = optionsSignature(scopeOptions)
131
+ let schemaInfo = scope.vars.schemaInfo
132
+ if (compiled?.signature === signature) schemaInfo = compiled.schemaInfo
133
+ else if (compiledSignatures.get(scopeName) !== signature) schemaInfo = await compileOptions(scopeName, scopeOptions)
134
+ compiledSignatures.set(scopeName, signature)
135
+
84
136
  descriptor.idProperty = schemaInfo.idProperty
85
137
  scope.vars.schemaInfo = {
86
138
  ...schemaInfo,
@@ -88,10 +140,123 @@ export const RestApiAnyapiKnexPlugin = {
88
140
  canonicalFieldMap: descriptor.canonicalFieldMap,
89
141
  }
90
142
  getScopeStorageAdapter(scopeName)
91
- scopeOptionsRegistry.set(scopeName, {
92
- ...scopeOptionsRegistry.get(scopeName),
93
- canonicalFieldsMap: descriptor.canonicalFieldMap,
143
+ lastChecked.set(scopeName, Date.now())
144
+ }
145
+
146
+ // Reloads of one resource run in order, so an older read never wins.
147
+ const reload = (scopeName) => {
148
+ const run = (reloads.get(scopeName) || Promise.resolve()).catch(() => {}).then(async () => {
149
+ registry.invalidateDescriptor(tenantId, scopeName)
150
+ const descriptor = await registry.getDescriptor(tenantId, scopeName)
151
+ if (!descriptor) {
152
+ throw new RestApiResourceError(`Resource '${scopeName}' is not registered in AnyAPI storage`, {
153
+ subtype: 'not_found', resourceType: 'scope', resourceId: scopeName
154
+ })
155
+ }
156
+ await publish(scopeName, descriptor)
157
+ return publishedVersion(scopeName)
94
158
  })
159
+ reloads.set(scopeName, run)
160
+ run.finally(() => { if (reloads.get(scopeName) === run) reloads.delete(scopeName) }).catch(() => {})
161
+ return run
162
+ }
163
+
164
+ const notifySchemaChanged = async (scopeName, version) => {
165
+ if (!onSchemaChanged) return
166
+ try {
167
+ await onSchemaChanged({ tenant: tenantId, resource: scopeName, version })
168
+ } catch (error) {
169
+ // The change is committed; other servers still catch up through schema checks.
170
+ await log.logError('AnyAPI onSchemaChanged failed', error, { tenant: tenantId, scopeName, version })
171
+ }
172
+ }
173
+
174
+ const ensureFresh = (scopeName, maxAge) => {
175
+ if (maxAge > 0 && Date.now() - (lastChecked.get(scopeName) ?? 0) < maxAge) return false
176
+ if (!checks.has(scopeName)) {
177
+ const check = (async () => {
178
+ lastChecked.set(scopeName, Date.now())
179
+ const version = await registry.getSchemaVersion(tenantId, scopeName)
180
+ if (version === null || version <= publishedVersion(scopeName)) return false
181
+ await reload(scopeName)
182
+ return true
183
+ })()
184
+ checks.set(scopeName, check)
185
+ check.finally(() => checks.delete(scopeName)).catch(() => {})
186
+ }
187
+ return checks.get(scopeName)
188
+ }
189
+
190
+ const watchSchema = (scope) => {
191
+ for (const name of REQUEST_METHODS) {
192
+ const method = scope[name]
193
+ if (typeof method !== 'function') continue
194
+ scope[name] = async function (params = {}, context = {}) {
195
+ // A caller transaction may hold uncommitted metadata; never publish from it.
196
+ if (params?.transaction) return method.call(this, params, context)
197
+ await ensureFresh(scope.name, schemaCheckInterval)
198
+ try {
199
+ return await method.call(this, params, context)
200
+ } catch (error) {
201
+ if (!isStaleSchemaRejection(error)) throw error
202
+ let reloaded = false
203
+ try { reloaded = await ensureFresh(scope.name, 0) } catch { throw error }
204
+ if (!reloaded) throw error
205
+ return method.call(this, params, context)
206
+ }
207
+ }
208
+ }
209
+ }
210
+
211
+ const loadResource = async (name, chain = new Set()) => {
212
+ if (typeof name !== 'string' || !name) throw new TypeError('loadResource requires a resource name')
213
+ if (Object.hasOwn(api.resources, name)) return api.resources[name]
214
+ if (chain.has(name)) return null
215
+ if (!loading.has(name)) {
216
+ const load = (async () => {
217
+ const descriptor = await registry.getDescriptor(tenantId, name)
218
+ if (!descriptor) return null
219
+ if (!descriptor.definition?.options) {
220
+ throw new Error(`AnyAPI resource '${name}' has no stored definition; declare it in code or register it again with this version`)
221
+ }
222
+ const { options: declaration } = composeResourceOptions({
223
+ declaration: descriptor.definition.options,
224
+ stored: { callbacks: descriptor.definition.callbacks },
225
+ fromStorage: true
226
+ })
227
+ const { before, after } = relatedResources(declaration)
228
+ const nextChain = new Set([...chain, name])
229
+ for (const target of before) await loadResource(target, nextChain)
230
+ if (Object.hasOwn(api.resources, name)) return api.resources[name]
231
+ storageLoaded.add(name)
232
+ try {
233
+ await api.addResource(name, declaration)
234
+ } catch (error) {
235
+ // A failed registration must not leave a half-published resource behind.
236
+ storageLoaded.delete(name)
237
+ declarations.delete(name)
238
+ delete api.resources[name]
239
+ throw error
240
+ }
241
+ for (const target of after) await loadResource(target, nextChain)
242
+ return api.resources[name]
243
+ })()
244
+ loading.set(name, load)
245
+ load.finally(() => loading.delete(name)).catch(() => {})
246
+ }
247
+ return loading.get(name)
248
+ }
249
+
250
+ api.anyapi.loadResource = name => loadResource(name)
251
+ api.anyapi.loadResources = async () => {
252
+ for (const { resource } of await registry.listResources(tenantId)) await loadResource(resource)
253
+ return api.resources
254
+ }
255
+ api.anyapi.handleSchemaChange = async ({ tenant, resource, version } = {}) => {
256
+ if ((tenant !== undefined && tenant !== tenantId) || typeof resource !== 'string' || !Object.hasOwn(api.resources, resource)) return false
257
+ if (version !== undefined && publishedVersion(resource) >= version) return false
258
+ await reload(resource)
259
+ return true
95
260
  }
96
261
 
97
262
  const buildSearchMembershipQuery = ({ sourceScopeName, relationshipName, targetScopeName, foreignKey, otherKey, db }) => {
@@ -131,28 +296,40 @@ export const RestApiAnyapiKnexPlugin = {
131
296
  addHook('resource:added', 'anyapi-register-resource', {}, async ({ context }) => {
132
297
  const { scopeName, scopeOptions = {} } = context
133
298
  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)
299
+ const declaration = snapshotResourceConfiguration(scopeOptions)
300
+ declarations.set(scopeName, declaration)
301
+ compiledSignatures.set(scopeName, optionsSignature(declaration))
302
+ let descriptor
303
+ let changed = false
304
+ if (storageLoaded.has(scopeName)) {
305
+ descriptor = await registry.getDescriptor(tenantId, scopeName)
306
+ if (!descriptor) throw new Error(`Descriptor not found for resource '${scopeName}'`)
307
+ } else {
308
+ const { schemaStructure, computed, schemaRelationships, idProperty } = scope.vars.schemaInfo
309
+ ;({ descriptor, changed } = await registry.register({
310
+ tenant: tenantId,
311
+ resource: scopeName,
312
+ schema: { ...schemaStructure, ...computed },
313
+ relationships: schemaRelationships,
314
+ canonicalFieldMap: scopeOptions.canonicalFieldsMap || null,
315
+ idProperty,
316
+ declaration
317
+ }))
318
+ }
319
+ await publish(scopeName, descriptor)
320
+ if (schemaCheckInterval !== undefined) watchSchema(scope)
321
+ if (changed) await notifySchemaChanged(scopeName, descriptor.schemaVersion)
149
322
  })
150
323
 
151
324
  addResourceMethod('createKnexTable', async ({ scopeName }) => {
152
- await ensureAnyApiSchema(knex)
153
- await refreshStorageDescriptor(scopeName)
325
+ if (ensureSchema) await ensureAnyApiSchema(knex, { slots })
326
+ const descriptor = await registry.getDescriptor(tenantId, scopeName)
327
+ if (!descriptor) throw new Error(`Descriptor not found for resource '${scopeName}'`)
328
+ await publish(scopeName, descriptor)
154
329
  })
155
330
 
331
+ addResourceMethod('reloadSchema', async ({ scopeName }) => reload(scopeName))
332
+
156
333
  addResourceMethod('addKnexFields', async ({ scopeName, params }) => {
157
334
  if (!params?.fields || typeof params.fields !== 'object' || Array.isArray(params.fields)) {
158
335
  throw new Error('fields parameter is required for addKnexFields')
@@ -161,32 +338,39 @@ export const RestApiAnyapiKnexPlugin = {
161
338
  assertFieldNameMap(params.searchSchema, `added search schema in '${scopeName}'`)
162
339
  assertFieldNameMap(params.canonicalFieldsMap, `added canonical fields in '${scopeName}'`)
163
340
  const scope = api.resources[scopeName]
164
- const storedBefore = scopeOptionsRegistry.get(scopeName) || {}
165
341
  const unknown = Object.keys(params.canonicalFieldsMap || {}).filter(field => !Object.hasOwn(params.fields, field))
166
342
  if (unknown.length) {
167
343
  throw new Error(`canonicalFieldsMap contains unknown fields for addKnexFields: ${unknown}`)
168
344
  }
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
- }
345
+ const fields = snapshotResourceConfiguration(params.fields)
346
+ const searchSchema = snapshotResourceConfiguration(params.searchSchema || {})
347
+ const current = composeOptions(scopeName, scope.vars.schemaInfo.descriptor).options
348
+ const candidate = snapshotResourceConfiguration({
349
+ ...current,
350
+ schema: { ...current.schema, ...fields },
351
+ searchSchema: { ...current.searchSchema, ...searchSchema }
186
352
  })
353
+ const schemaInfo = await compileOptions(scopeName, candidate)
354
+ await api.transaction(transaction => registry.allocateFields({
355
+ tenant: tenantId,
356
+ resource: scopeName,
357
+ fields: Object.fromEntries(Object.keys(fields).map(fieldName => [fieldName, {
358
+ definition: schemaInfo.schemaStructure[fieldName] || schemaInfo.computed[fieldName],
359
+ declaration: fields[fieldName],
360
+ canonicalField: params.canonicalFieldsMap?.[fieldName]
361
+ }])),
362
+ searchSchema
363
+ }, { transaction }))
187
364
  registry.invalidateDescriptor(tenantId, scopeName)
188
- scopeOptionsRegistry.set(scopeName, updatedStored)
189
- await refreshStorageDescriptor(scopeName, compiled.vars.schemaInfo)
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.
371
+ const descriptor = await registry.getDescriptor(tenantId, scopeName)
372
+ await publish(scopeName, descriptor, { compiled: { signature: optionsSignature(candidate), schemaInfo } })
373
+ await notifySchemaChanged(scopeName, descriptor.schemaVersion)
190
374
  })
191
375
 
192
376
  addResourceMethod('alterKnexFields', async () => {
@@ -0,0 +1,30 @@
1
+ import type { Knex } from 'knex'
2
+
3
+ export type AnyapiSlotPool = 'string' | 'number' | 'boolean' | 'date' | 'json' | 'belongsTo'
4
+ export type AnyapiSlotCounts = Partial<Record<AnyapiSlotPool, number>>
5
+
6
+ export interface AnyapiSchemaChange {
7
+ tenant: string
8
+ resource: string
9
+ version: number
10
+ }
11
+
12
+ export interface AnyapiRegistryOptions {
13
+ knex: Knex
14
+ log?: Partial<Record<'debug' | 'info' | 'warn' | 'error', (...args: unknown[]) => unknown>>
15
+ // Descriptors cached across every tenant API that shares the registry.
16
+ cacheSize?: number
17
+ }
18
+
19
+ /** Canonical metadata shared by tenant APIs that use one Knex instance. */
20
+ export class AnyapiRegistry {
21
+ constructor(options: AnyapiRegistryOptions)
22
+ readonly knex: Knex
23
+ readonly cacheSize: number
24
+ ensureSchema(options?: { slots?: AnyapiSlotCounts }): Promise<void>
25
+ getSchemaVersion(tenant: string, resource: string, options?: { transaction?: Knex.Transaction }): Promise<number | null>
26
+ invalidateDescriptor(tenant: string, resource: string): void
27
+ }
28
+
29
+ /** Create or upgrade the canonical tables; counts only ever add slot columns. */
30
+ export function ensureAnyApiSchema(knex: Knex, options?: { slots?: AnyapiSlotCounts }): Promise<void>
@@ -1,6 +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
5
 
5
6
  export type RestApiPluginOptions = RemovedResourceOptions & {
6
7
  format?: ResourceFormat
@@ -42,6 +43,17 @@ export interface RestApiKnexPluginOptions {
42
43
  }
43
44
  export interface RestApiAnyapiKnexPluginOptions extends RestApiKnexPluginOptions {
44
45
  tenantId?: string
46
+ // Share one registry across tenant APIs on the same Knex instance.
47
+ registry?: AnyapiRegistry
48
+ // false when migrations own the canonical tables.
49
+ ensureSchema?: boolean
50
+ // false leaves a shared Knex instance open when this API is released.
51
+ ownsKnex?: boolean
52
+ slots?: AnyapiSlotCounts
53
+ // Called after a committed schema change, for example to notify other servers.
54
+ onSchemaChanged?: (change: AnyapiSchemaChange) => void | Promise<void>
55
+ // Milliseconds between schema version checks; 0 checks on every request.
56
+ schemaCheckInterval?: number
45
57
  }
46
58
  export interface BulkOperationsPluginOptions {
47
59
  maxBulkOperations?: number