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.
- package/docs/GUIDE/21-schema-and-migrations.md +112 -13
- package/docs/GUIDE/30-backend-capabilities.md +8 -5
- package/docs/GUIDE/33-migrating-to-v2.md +18 -7
- package/index.d.ts +1 -0
- package/index.js +2 -0
- package/lib/knex-transaction.js +10 -4
- package/package.json +1 -1
- package/plugins/core/lib/anyapi/anyapi-registry.js +528 -409
- package/plugins/core/lib/anyapi/schema-utils.js +101 -46
- package/plugins/core/lib/anyapi/stored-definition.js +109 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +235 -51
- package/types/anyapi.d.ts +30 -0
- package/types/plugin-options.d.ts +12 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
82
|
-
const
|
|
83
|
-
if (
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|