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.
- package/docs/API.md +80 -0
- package/docs/GUIDE/17-row-policies.md +1 -0
- package/docs/GUIDE/21-schema-and-migrations.md +154 -14
- package/docs/GUIDE/30-backend-capabilities.md +8 -5
- package/docs/GUIDE/33-migrating-to-v2.md +27 -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 +569 -408
- package/plugins/core/lib/anyapi/schema-utils.js +101 -51
- package/plugins/core/lib/anyapi/stored-definition.js +112 -0
- package/plugins/core/lib/querying/aggregate-query.js +170 -0
- package/plugins/core/lib/querying-writing/compile-schemas.js +17 -0
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +16 -0
- package/plugins/core/lib/querying-writing/default-data-helpers.js +4 -0
- package/plugins/core/lib/querying-writing/field-types.js +43 -0
- package/plugins/core/lib/querying-writing/request-contracts.js +1 -1
- package/plugins/core/lib/storage/canonical-data-helpers.js +29 -0
- package/plugins/core/lib/storage/ordinary-data-helpers.js +25 -0
- package/plugins/core/lib/storage/storage-types.d.ts +21 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +331 -54
- package/plugins/core/rest-api-plugin-methods/aggregate.js +41 -0
- package/plugins/core/rest-api-plugin-methods/common.js +16 -1
- package/plugins/core/rest-api-plugin.js +2 -0
- package/types/anyapi.d.ts +32 -0
- package/types/plugin-options.d.ts +12 -0
- package/types/resource-methods.d.ts +19 -0
- package/types/resource-schema.d.ts +5 -3
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { createKnexTransaction } from '../../../../lib/knex-transaction.js'
|
|
2
|
-
import { applyInsertReturning } from '../querying-writing/database-capabilities.js'
|
|
3
|
-
import { DEFAULT_CANONICAL_CONFIG,
|
|
1
|
+
import { createKnexTransaction, readCommittedTransaction } from '../../../../lib/knex-transaction.js'
|
|
2
|
+
import { applyInsertReturning, getDatabaseCapabilities } from '../querying-writing/database-capabilities.js'
|
|
3
|
+
import { DEFAULT_CANONICAL_CONFIG, TYPE_TO_POOL, ensureAnyApiSchema, readSlotPools } from './schema-utils.js'
|
|
4
|
+
import { toStoredJson } from './stored-definition.js'
|
|
4
5
|
import { beginWriteTransaction, commitTransaction, endWriteOperation, getOperationDiagnosticContext, rollbackAfterError, wrapUnexpectedError, wrapWriteError } from '../../../../lib/error-context.js'
|
|
5
6
|
import { createEnhancedLogger } from '../../../../lib/enhanced-logger.js'
|
|
7
|
+
import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
|
|
6
8
|
import { assertFieldName, assertFieldNameMap } from '../querying-writing/field-utils.js'
|
|
7
9
|
import { validateRelationshipFieldNames, validateSchemaFieldNames } from '../querying-writing/scope-validations.js'
|
|
8
10
|
|
|
@@ -14,7 +16,12 @@ const clone = (value) => {
|
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
const SUPPORTED_TYPES = new Set([...TYPE_TO_POOL.keys(), 'id'])
|
|
17
|
-
const
|
|
19
|
+
const DEFAULT_CACHE_SIZE = 100
|
|
20
|
+
const EMPTY_RUNTIME = Object.freeze({ schema: {}, searchSchema: {}, callbacks: [] })
|
|
21
|
+
|
|
22
|
+
// Errors from two servers inserting the same new resource at once.
|
|
23
|
+
const WRITE_CONFLICT_CODES = new Set(['23505', '40001', '40P01', 'ER_DUP_ENTRY', 'ER_LOCK_DEADLOCK', 'SQLITE_BUSY', 'SQLITE_CONSTRAINT_UNIQUE'])
|
|
24
|
+
const creationRaces = new WeakSet()
|
|
18
25
|
|
|
19
26
|
const resolveIdProperty = (schema = {}, explicitIdProperty = null) => {
|
|
20
27
|
if (explicitIdProperty) return explicitIdProperty
|
|
@@ -49,11 +56,20 @@ const SLOT_PATTERNS = {
|
|
|
49
56
|
|
|
50
57
|
const BELONGS_TO_ID_PATTERN = /^rel_(\d+)_id$/
|
|
51
58
|
|
|
52
|
-
const
|
|
59
|
+
const slotsExhausted = pool => Object.assign(new Error(`No available ${pool} slots remaining`), { slotsExhausted: true })
|
|
60
|
+
|
|
61
|
+
const slotPoolFor = (fieldDef = {}) => {
|
|
62
|
+
if (fieldDef.belongsTo) return 'belongsTo'
|
|
63
|
+
return fieldDef.type === 'id' ? 'string' : TYPE_TO_POOL.get(fieldDef.type)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const belongsToColumns = index => [`rel_${index}_id`, `rel_${index}_type`]
|
|
67
|
+
|
|
68
|
+
const parseSimpleSlotColumn = (column, pools) => {
|
|
53
69
|
for (const [type, pattern] of Object.entries(SLOT_PATTERNS)) {
|
|
54
70
|
const match = pattern.exec(column)
|
|
55
71
|
if (match) {
|
|
56
|
-
if (!
|
|
72
|
+
if (!pools[type].includes(column)) {
|
|
57
73
|
throw new Error(`Canonical slot '${column}' is out of range`)
|
|
58
74
|
}
|
|
59
75
|
return { slotType: type, slotIndex: Number(match[1]) }
|
|
@@ -62,7 +78,7 @@ const parseSimpleSlotColumn = (column) => {
|
|
|
62
78
|
throw new Error(`Invalid canonical slot column '${column}'`)
|
|
63
79
|
}
|
|
64
80
|
|
|
65
|
-
const parseBelongsToSlots = ({ idSlot, typeSlot }) => {
|
|
81
|
+
const parseBelongsToSlots = ({ idSlot, typeSlot }, pools) => {
|
|
66
82
|
if (!idSlot || typeof idSlot !== 'string') {
|
|
67
83
|
throw new Error('BelongsTo canonical mapping requires idSlot')
|
|
68
84
|
}
|
|
@@ -71,7 +87,7 @@ const parseBelongsToSlots = ({ idSlot, typeSlot }) => {
|
|
|
71
87
|
throw new Error(`Invalid belongsTo id slot '${idSlot}'`)
|
|
72
88
|
}
|
|
73
89
|
const index = Number(idMatch[1])
|
|
74
|
-
if (
|
|
90
|
+
if (!pools.belongsTo.includes(index) || idSlot !== `rel_${index}_id`) {
|
|
75
91
|
throw new Error(`BelongsTo slot '${idSlot}' is out of range`)
|
|
76
92
|
}
|
|
77
93
|
const expectedTypeSlot = `rel_${index}_type`
|
|
@@ -82,16 +98,82 @@ const parseBelongsToSlots = ({ idSlot, typeSlot }) => {
|
|
|
82
98
|
return { slotType: 'belongsTo', slotIndex: index, idColumn: idSlot, typeColumn: actualTypeSlot }
|
|
83
99
|
}
|
|
84
100
|
|
|
101
|
+
const reserveSlots = (used, columns) => {
|
|
102
|
+
if (columns.some(column => used.has(column))) {
|
|
103
|
+
throw new Error(columns.length > 1 ? `Canonical slots '${columns[0]}'/'${columns[1]}' already in use` : `Canonical slot '${columns[0]}' already in use`)
|
|
104
|
+
}
|
|
105
|
+
columns.forEach(column => used.add(column))
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const FIELD_ROW_COLUMNS = ['field_name', 'slot_type', 'slot_index', 'slot_column', 'nullable', 'required', 'target_resource', 'alias', 'meta_json']
|
|
109
|
+
const RELATIONSHIP_ROW_COLUMNS = ['relationship_name', 'relationship_type', 'target_resource', 'slot_index', 'id_column', 'type_column', 'relationship_key', 'through', 'foreign_key', 'other_key', 'alias', 'meta_json']
|
|
110
|
+
|
|
111
|
+
// Drivers return booleans and integers in different shapes; compare meaning only.
|
|
112
|
+
const rowsSignature = (rows, columns) => JSON.stringify(rows.map(row => columns.map(column => {
|
|
113
|
+
const value = row[column]
|
|
114
|
+
if (column === 'nullable' || column === 'required') return Boolean(value)
|
|
115
|
+
if (column === 'slot_index') return value == null ? null : Number(value)
|
|
116
|
+
return value ?? null
|
|
117
|
+
})).sort((left, right) => String(left[0]).localeCompare(String(right[0]))))
|
|
118
|
+
|
|
119
|
+
const isWriteConflict = error => WRITE_CONFLICT_CODES.has(error?.code) || [1062, 1213].includes(error?.errno)
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Canonical resource metadata. One registry can serve every tenant API that
|
|
123
|
+
* shares a Knex instance: start-up checks, capabilities and the slot layout are
|
|
124
|
+
* read once, and descriptors are cached per tenant and resource.
|
|
125
|
+
*/
|
|
85
126
|
export class AnyapiRegistry {
|
|
86
|
-
|
|
127
|
+
#shared = new Map()
|
|
128
|
+
|
|
129
|
+
constructor ({ knex, log, cacheSize = DEFAULT_CACHE_SIZE } = {}) {
|
|
87
130
|
if (!knex) {
|
|
88
131
|
throw new Error('AnyapiRegistry requires a knex instance')
|
|
89
132
|
}
|
|
133
|
+
if (!Number.isInteger(cacheSize) || cacheSize < 1) {
|
|
134
|
+
throw new Error('AnyapiRegistry cacheSize must be a positive integer')
|
|
135
|
+
}
|
|
90
136
|
this.knex = knex
|
|
91
137
|
this.log = log || console
|
|
138
|
+
this.cacheSize = cacheSize
|
|
92
139
|
this.cache = new Map()
|
|
93
140
|
}
|
|
94
141
|
|
|
142
|
+
#once (key, run) {
|
|
143
|
+
if (!this.#shared.has(key)) {
|
|
144
|
+
const result = run()
|
|
145
|
+
this.#shared.set(key, result)
|
|
146
|
+
result.catch(() => { if (this.#shared.get(key) === result) this.#shared.delete(key) })
|
|
147
|
+
}
|
|
148
|
+
return this.#shared.get(key)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
ensureSchema ({ slots } = {}) {
|
|
152
|
+
return this.#once(`schema:${JSON.stringify(slots ?? null)}`, async () => {
|
|
153
|
+
await ensureAnyApiSchema(this.knex, { slots })
|
|
154
|
+
this.#shared.delete('slots')
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
getCapabilities (log = this.log) {
|
|
159
|
+
return this.#once('capabilities', () => getDatabaseCapabilities(this.knex, log))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
getSlotPools ({ db = this.knex, refresh = false } = {}) {
|
|
163
|
+
if (refresh) this.#shared.delete('slots')
|
|
164
|
+
return this.#once('slots', () => readSlotPools(db))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async #withSlotPools (db, plan) {
|
|
168
|
+
try {
|
|
169
|
+
return plan(await this.getSlotPools({ db }))
|
|
170
|
+
} catch (error) {
|
|
171
|
+
// A migration may have added slot columns since the layout was read.
|
|
172
|
+
if (!error?.slotsExhausted) throw error
|
|
173
|
+
return plan(await this.getSlotPools({ db, refresh: true }))
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
95
177
|
#key (tenant, resource) {
|
|
96
178
|
return JSON.stringify([tenant, resource])
|
|
97
179
|
}
|
|
@@ -100,16 +182,33 @@ export class AnyapiRegistry {
|
|
|
100
182
|
const key = this.#key(descriptor.tenant, descriptor.resource)
|
|
101
183
|
this.cache.delete(key)
|
|
102
184
|
this.cache.set(key, descriptor)
|
|
103
|
-
if (this.cache.size >
|
|
185
|
+
if (this.cache.size > this.cacheSize) this.cache.delete(this.cache.keys().next().value)
|
|
104
186
|
}
|
|
105
187
|
|
|
106
188
|
async registerResource (definition, options = {}) {
|
|
107
|
-
|
|
189
|
+
return (await this.register(definition, options)).descriptor
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Register a declaration and report whether stored metadata changed.
|
|
194
|
+
* @returns {Promise<{ descriptor: Record<string, any>, changed: boolean }>}
|
|
195
|
+
*/
|
|
196
|
+
async register (definition, options = {}) {
|
|
197
|
+
try {
|
|
198
|
+
return await this.#registerOnce(definition, options)
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (options.transaction || !creationRaces.has(error?.cause)) throw error
|
|
201
|
+
return this.#registerOnce(definition, options)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async #registerOnce (definition, options) {
|
|
206
|
+
const context = { method: 'registerResource', tenant: definition?.tenant, resource: definition?.resource, [readCommittedTransaction]: true }
|
|
108
207
|
try {
|
|
109
208
|
this.#validateDefinition(definition)
|
|
110
|
-
const descriptor = await this.#register(definition, options.transaction, context)
|
|
209
|
+
const { descriptor, changed } = await this.#register(definition, options.transaction, context)
|
|
111
210
|
if (!options.transaction) this.#rememberDescriptor(descriptor)
|
|
112
|
-
return clone(descriptor)
|
|
211
|
+
return { descriptor: clone(descriptor), changed }
|
|
113
212
|
} catch (error) {
|
|
114
213
|
throw wrapWriteError(error, context, context.transaction || options.transaction)
|
|
115
214
|
} finally {
|
|
@@ -118,45 +217,150 @@ export class AnyapiRegistry {
|
|
|
118
217
|
}
|
|
119
218
|
|
|
120
219
|
async allocateField (params, options = {}) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
try {
|
|
124
|
-
const { tenant, resource, fieldName, definition, canonicalField } = params || {}
|
|
125
|
-
Object.assign(context, { tenant, resource })
|
|
220
|
+
return this.#allocate(options, 'allocateField', () => {
|
|
221
|
+
const { tenant, resource, fieldName, definition, declaration, canonicalField } = params || {}
|
|
126
222
|
if (!tenant || !resource || !fieldName || !definition) {
|
|
127
223
|
throw new Error('allocateField requires tenant, resource, fieldName, and definition')
|
|
128
224
|
}
|
|
129
|
-
|
|
225
|
+
return { tenant, resource, fields: { [fieldName]: { definition, declaration, canonicalField } } }
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Allocate several fields under one lock and one schema version.
|
|
231
|
+
* `declaration` is the uncompiled definition stored for other servers.
|
|
232
|
+
*/
|
|
233
|
+
async allocateFields (params, options = {}) {
|
|
234
|
+
return this.#allocate(options, 'allocateFields', () => {
|
|
235
|
+
const { tenant, resource, fields, searchSchema } = params || {}
|
|
236
|
+
if (!tenant || !resource || !fields || typeof fields !== 'object' || !Object.keys(fields).length) {
|
|
237
|
+
throw new Error('allocateFields requires tenant, resource, and fields')
|
|
238
|
+
}
|
|
239
|
+
for (const field of Object.values(fields)) {
|
|
240
|
+
if (!field?.definition) throw new Error('allocateFields requires a definition for each field')
|
|
241
|
+
}
|
|
242
|
+
return { tenant, resource, fields, searchSchema }
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
#allocate (options, method, readParams) {
|
|
247
|
+
return this.#changeResource(options, method, () => {
|
|
248
|
+
const params = readParams()
|
|
249
|
+
const { resource, fields, searchSchema } = params
|
|
250
|
+
for (const [fieldName, { definition }] of Object.entries(fields)) {
|
|
251
|
+
validateSchemaFieldNames({ [fieldName]: definition }, resource)
|
|
252
|
+
}
|
|
253
|
+
assertFieldNameMap(searchSchema, `added search schema in '${resource}'`)
|
|
254
|
+
return params
|
|
255
|
+
}, async ({ trx, resourceRow, descriptor }, { fields, searchSchema }) => {
|
|
256
|
+
const plan = await this.#withSlotPools(trx, pools => this.#planAllocation(descriptor, fields, pools))
|
|
257
|
+
|
|
258
|
+
for (const row of plan.fieldRows) {
|
|
259
|
+
await trx('any_field_configs').insert({ resource_config_id: resourceRow.id, ...row })
|
|
260
|
+
}
|
|
261
|
+
for (const row of plan.relationshipRows) {
|
|
262
|
+
await trx('any_relationship_configs').insert({ resource_config_id: resourceRow.id, ...row })
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const stored = descriptor.definition || { options: null, callbacks: [] }
|
|
266
|
+
const runtime = clone(stored.runtime || EMPTY_RUNTIME)
|
|
267
|
+
for (const [kind, entries] of [
|
|
268
|
+
['schema', Object.entries(fields).map(([name, field]) => [name, field.declaration ?? field.definition])],
|
|
269
|
+
['searchSchema', Object.entries(searchSchema || {})]
|
|
270
|
+
]) {
|
|
271
|
+
for (const [name, value] of entries) {
|
|
272
|
+
const { json, callbacks } = toStoredJson(value)
|
|
273
|
+
runtime[kind][name] = json
|
|
274
|
+
runtime.callbacks.push(...callbacks.map(path => [kind, name, ...path]))
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
schema_json: JSON.stringify({ ...descriptor.schema, ...plan.definitions }),
|
|
279
|
+
definition_json: JSON.stringify({ ...stored, runtime })
|
|
280
|
+
}
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Mark stored fields retired: they keep their slots and values, and the
|
|
286
|
+
* published schema stops accepting writes to them. Registration keeps the mark.
|
|
287
|
+
*/
|
|
288
|
+
async retireFields (params, options = {}) {
|
|
289
|
+
return this.#changeResource(options, 'retireFields', () => {
|
|
290
|
+
const { tenant, resource, fields } = params || {}
|
|
291
|
+
if (!tenant || !resource || !Array.isArray(fields) || !fields.length || fields.some(field => typeof field !== 'string')) {
|
|
292
|
+
throw new Error('retireFields requires tenant, resource, and an array of field names')
|
|
293
|
+
}
|
|
294
|
+
return { tenant, resource, fields }
|
|
295
|
+
}, ({ descriptor }, { fields }) => {
|
|
296
|
+
for (const fieldName of fields) {
|
|
297
|
+
const field = descriptor.fields[fieldName]
|
|
298
|
+
if (!field) throw new Error(`Field '${descriptor.resource}.${fieldName}' has no stored slot to retire`)
|
|
299
|
+
if (field.slotType === 'belongsTo') throw new Error(`Field '${descriptor.resource}.${fieldName}' is a relationship and cannot be retired`)
|
|
300
|
+
}
|
|
301
|
+
const stored = descriptor.definition || { options: null, callbacks: [] }
|
|
302
|
+
const retired = [...new Set([...(stored.retired || []), ...fields])]
|
|
303
|
+
return { definition_json: JSON.stringify({ ...stored, retired }) }
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Run one schema change under the resource's row lock and bump its version once.
|
|
308
|
+
// `change` returns the any_resource_configs values to write.
|
|
309
|
+
async #changeResource (options, method, readParams, change) {
|
|
310
|
+
const context = { method, [readCommittedTransaction]: true }
|
|
311
|
+
let trx; let managed = false
|
|
312
|
+
try {
|
|
313
|
+
const params = readParams()
|
|
314
|
+
const { tenant, resource } = params
|
|
315
|
+
Object.assign(context, { tenant, resource })
|
|
130
316
|
|
|
131
317
|
managed = !options.transaction
|
|
132
318
|
trx = await beginWriteTransaction(context, options.transaction, ownerContext => createKnexTransaction(this.knex, ownerContext))
|
|
133
|
-
const
|
|
319
|
+
const resourceRow = await this.#lockResourceRow(trx, tenant, resource)
|
|
320
|
+
const descriptor = resourceRow && await this.#loadDescriptor(tenant, resource, trx)
|
|
134
321
|
if (!descriptor) throw new Error(`Resource '${resource}' not registered for tenant '${tenant}'`)
|
|
135
|
-
|
|
322
|
+
const values = await change({ trx, resourceRow, descriptor }, params)
|
|
323
|
+
await this.#bumpVersion(trx, resourceRow, { ...values, updated_at: this.knex.fn.now() })
|
|
324
|
+
const updated = await this.#loadDescriptor(tenant, resource, trx)
|
|
325
|
+
if (managed) {
|
|
326
|
+
await commitTransaction(trx, context)
|
|
327
|
+
this.#rememberDescriptor(updated)
|
|
328
|
+
}
|
|
329
|
+
return clone(updated)
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (managed) {
|
|
332
|
+
await this.#handleWriteFailure(error, trx || context.transaction, context)
|
|
333
|
+
}
|
|
334
|
+
throw wrapWriteError(error, context, context.transaction || options.transaction)
|
|
335
|
+
} finally {
|
|
336
|
+
endWriteOperation(context)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
#planAllocation (descriptor, fields, pools) {
|
|
341
|
+
const { resource } = descriptor
|
|
342
|
+
const used = new Set(Object.values(descriptor.fields).map(info => info.slot))
|
|
343
|
+
for (const info of Object.values(descriptor.belongsTo)) {
|
|
344
|
+
used.add(info.idColumn)
|
|
345
|
+
used.add(info.typeColumn)
|
|
346
|
+
}
|
|
347
|
+
const fieldRows = []
|
|
348
|
+
const relationshipRows = []
|
|
349
|
+
const definitions = {}
|
|
350
|
+
|
|
351
|
+
for (const [fieldName, { definition, canonicalField }] of Object.entries(fields)) {
|
|
352
|
+
if (fieldName === descriptor.idProperty || Object.hasOwn(descriptor.schema, fieldName) || Object.hasOwn(definitions, fieldName)) {
|
|
136
353
|
throw new Error(`Field '${resource}.${fieldName}' already exists`)
|
|
137
354
|
}
|
|
138
355
|
if (canonicalField && (definition.belongsToPolymorphic || definition.computed || definition.virtual)) {
|
|
139
356
|
throw new Error(`canonicalFieldsMap should not include non-stored field '${fieldName}'`)
|
|
140
357
|
}
|
|
141
|
-
|
|
142
|
-
for (const info of Object.values(descriptor.belongsTo)) {
|
|
143
|
-
usedSlots.add(info.idColumn)
|
|
144
|
-
usedSlots.add(info.typeColumn)
|
|
145
|
-
}
|
|
146
|
-
const resourceRow = await trx('any_resource_configs')
|
|
147
|
-
.where({ tenant_id: tenant, resource })
|
|
148
|
-
.first()
|
|
149
|
-
|
|
150
|
-
if (!resourceRow) {
|
|
151
|
-
throw new Error(`Resource '${resource}' not found in metadata`)
|
|
152
|
-
}
|
|
358
|
+
definitions[fieldName] = definition
|
|
153
359
|
|
|
154
360
|
if (definition?.belongsToPolymorphic) {
|
|
155
361
|
const alias = definition.as || fieldName
|
|
156
362
|
const { typeField, idField, types = [] } = definition.belongsToPolymorphic
|
|
157
|
-
|
|
158
|
-
await trx('any_relationship_configs').insert({
|
|
159
|
-
resource_config_id: resourceRow.id,
|
|
363
|
+
relationshipRows.push({
|
|
160
364
|
relationship_name: alias,
|
|
161
365
|
relationship_type: 'belongsToPolymorphic',
|
|
162
366
|
target_resource: null,
|
|
@@ -170,90 +374,103 @@ export class AnyapiRegistry {
|
|
|
170
374
|
alias,
|
|
171
375
|
meta_json: JSON.stringify({ types }),
|
|
172
376
|
})
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
throw new Error(`Canonical slots '${parsed.idColumn}'/'${parsed.typeColumn}' already in use`)
|
|
186
|
-
}
|
|
187
|
-
usedSlots.add(parsed.idColumn)
|
|
188
|
-
usedSlots.add(parsed.typeColumn)
|
|
189
|
-
override = normalized
|
|
190
|
-
} else {
|
|
191
|
-
const slotColumn = typeof canonicalField === 'string'
|
|
192
|
-
? canonicalField
|
|
193
|
-
: canonicalField?.slot || canonicalField?.slotColumn
|
|
194
|
-
if (!slotColumn) {
|
|
195
|
-
throw new Error(`canonicalFieldsMap for field '${fieldName}' must provide a slot column`)
|
|
196
|
-
}
|
|
197
|
-
if (usedSlots.has(slotColumn)) {
|
|
198
|
-
throw new Error(`Canonical slot '${slotColumn}' already in use`)
|
|
199
|
-
}
|
|
200
|
-
usedSlots.add(slotColumn)
|
|
201
|
-
override = { slotColumn }
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
379
|
+
if (definition.computed || definition.virtual) continue
|
|
380
|
+
|
|
381
|
+
let override = null
|
|
382
|
+
if (canonicalField) {
|
|
383
|
+
if (definition.belongsTo) {
|
|
384
|
+
const normalized = typeof canonicalField === 'string'
|
|
385
|
+
? { idSlot: canonicalField }
|
|
386
|
+
: canonicalField
|
|
387
|
+
if (!normalized || !normalized.idSlot) {
|
|
388
|
+
throw new Error(`canonicalFieldsMap for belongsTo field '${fieldName}' requires idSlot`)
|
|
202
389
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
slot_index: fieldSlot.slotIndex,
|
|
213
|
-
slot_column: fieldSlot.slotColumn,
|
|
214
|
-
nullable: definition.nullable === true,
|
|
215
|
-
required: definition.required === true,
|
|
216
|
-
target_resource: definition.belongsTo || null,
|
|
217
|
-
alias: definition.as || null,
|
|
218
|
-
meta_json: meta,
|
|
219
|
-
})
|
|
220
|
-
|
|
221
|
-
if (fieldSlot.relationshipRow) {
|
|
222
|
-
const relationshipRow = {
|
|
223
|
-
resource_config_id: resourceRow.id,
|
|
224
|
-
relationship_name: fieldSlot.relationshipRow.name,
|
|
225
|
-
relationship_type: fieldSlot.relationshipRow.type,
|
|
226
|
-
target_resource: fieldSlot.relationshipRow.target,
|
|
227
|
-
slot_index: fieldSlot.relationshipRow.slotIndex,
|
|
228
|
-
id_column: fieldSlot.relationshipRow.idColumn,
|
|
229
|
-
type_column: fieldSlot.relationshipRow.typeColumn,
|
|
230
|
-
relationship_key: fieldSlot.relationshipRow.relationshipKey,
|
|
231
|
-
through: fieldSlot.relationshipRow.through,
|
|
232
|
-
foreign_key: fieldSlot.relationshipRow.foreignKey,
|
|
233
|
-
other_key: fieldSlot.relationshipRow.otherKey,
|
|
234
|
-
alias: fieldSlot.relationshipRow.alias,
|
|
235
|
-
meta_json: fieldSlot.relationshipRow.meta ? JSON.stringify(fieldSlot.relationshipRow.meta) : null,
|
|
390
|
+
const parsed = parseBelongsToSlots(normalized, pools)
|
|
391
|
+
reserveSlots(used, [parsed.idColumn, parsed.typeColumn])
|
|
392
|
+
override = normalized
|
|
393
|
+
} else {
|
|
394
|
+
const slotColumn = typeof canonicalField === 'string'
|
|
395
|
+
? canonicalField
|
|
396
|
+
: canonicalField?.slot || canonicalField?.slotColumn
|
|
397
|
+
if (!slotColumn) {
|
|
398
|
+
throw new Error(`canonicalFieldsMap for field '${fieldName}' must provide a slot column`)
|
|
236
399
|
}
|
|
237
|
-
|
|
400
|
+
reserveSlots(used, [slotColumn])
|
|
401
|
+
override = { slotColumn }
|
|
238
402
|
}
|
|
239
403
|
}
|
|
240
|
-
|
|
241
|
-
|
|
404
|
+
const fieldSlot = this.#assignFieldSlot({ ...definition, fieldName }, used, override, pools)
|
|
405
|
+
if (!fieldSlot) throw new Error(`Field '${resource}.${fieldName}' requires a supported type`)
|
|
406
|
+
fieldRows.push(this.#fieldRow(fieldName, definition, fieldSlot))
|
|
407
|
+
if (fieldSlot.relationshipRow) relationshipRows.push(this.#relationshipRow(fieldSlot.relationshipRow))
|
|
408
|
+
}
|
|
409
|
+
return { fieldRows, relationshipRows, definitions }
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
#fieldRow (fieldName, fieldDef, allocation) {
|
|
413
|
+
return {
|
|
414
|
+
field_name: fieldName,
|
|
415
|
+
slot_type: allocation.slotType,
|
|
416
|
+
slot_index: allocation.slotIndex,
|
|
417
|
+
slot_column: allocation.slotColumn,
|
|
418
|
+
nullable: fieldDef.nullable === true,
|
|
419
|
+
required: fieldDef.required === true,
|
|
420
|
+
target_resource: fieldDef.belongsTo || null,
|
|
421
|
+
alias: fieldDef.as || null,
|
|
422
|
+
meta_json: fieldDef.meta ? JSON.stringify(fieldDef.meta) : null,
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
#relationshipRow (relationship) {
|
|
427
|
+
return {
|
|
428
|
+
relationship_name: relationship.name,
|
|
429
|
+
relationship_type: relationship.type,
|
|
430
|
+
target_resource: relationship.target,
|
|
431
|
+
slot_index: relationship.slotIndex,
|
|
432
|
+
id_column: relationship.idColumn,
|
|
433
|
+
type_column: relationship.typeColumn,
|
|
434
|
+
relationship_key: relationship.relationshipKey,
|
|
435
|
+
through: relationship.through,
|
|
436
|
+
foreign_key: relationship.foreignKey,
|
|
437
|
+
other_key: relationship.otherKey,
|
|
438
|
+
alias: relationship.alias,
|
|
439
|
+
meta_json: relationship.meta ? JSON.stringify(relationship.meta) : null,
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Concurrent schema changes to one resource queue on its metadata row.
|
|
444
|
+
#lockResourceRow (trx, tenant, resource) {
|
|
445
|
+
return trx('any_resource_configs').where({ tenant_id: tenant, resource }).forUpdate().first()
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async #bumpVersion (trx, resourceRow, values) {
|
|
449
|
+
const version = Number(resourceRow.schema_version ?? 1)
|
|
450
|
+
const updated = await trx('any_resource_configs')
|
|
451
|
+
.where({ id: resourceRow.id, schema_version: version })
|
|
452
|
+
.update({ ...values, schema_version: version + 1 })
|
|
453
|
+
if (Number(updated) !== 1) {
|
|
454
|
+
throw new RestApiResourceError(`AnyAPI resource '${resourceRow.resource}' changed concurrently; retry the schema change`, {
|
|
455
|
+
subtype: 'conflict', resourceType: 'anyapi_resource', resourceId: resourceRow.resource
|
|
242
456
|
})
|
|
243
|
-
const updated = await this.#loadDescriptor(tenant, resource, trx)
|
|
244
|
-
if (managed) {
|
|
245
|
-
await commitTransaction(trx, context)
|
|
246
|
-
this.#rememberDescriptor(updated)
|
|
247
|
-
}
|
|
248
|
-
return clone(updated)
|
|
249
|
-
} catch (error) {
|
|
250
|
-
if (managed) {
|
|
251
|
-
await this.#handleWriteFailure(error, trx || context.transaction, context)
|
|
252
|
-
}
|
|
253
|
-
throw wrapWriteError(error, context, context.transaction || options.transaction)
|
|
254
|
-
} finally {
|
|
255
|
-
endWriteOperation(context)
|
|
256
457
|
}
|
|
458
|
+
return version + 1
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async #hasRecords (trx, descriptor) {
|
|
462
|
+
const { canonical } = descriptor
|
|
463
|
+
return Boolean(await trx(canonical.tableName)
|
|
464
|
+
.where(canonical.tenantColumn, descriptor.tenant)
|
|
465
|
+
.where(canonical.resourceColumn, descriptor.resource)
|
|
466
|
+
.first('id'))
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async getSchemaVersion (tenant, resource, { transaction } = {}) {
|
|
470
|
+
const row = await (transaction || this.knex)('any_resource_configs')
|
|
471
|
+
.where({ tenant_id: tenant, resource })
|
|
472
|
+
.first('schema_version')
|
|
473
|
+
return row ? Number(row.schema_version) : null
|
|
257
474
|
}
|
|
258
475
|
|
|
259
476
|
async getDescriptor (tenant, resource, options = {}) {
|
|
@@ -342,261 +559,246 @@ export class AnyapiRegistry {
|
|
|
342
559
|
try {
|
|
343
560
|
trx = await beginWriteTransaction(context, externalTrx, ownerContext => createKnexTransaction(this.knex, ownerContext))
|
|
344
561
|
const now = this.knex.fn.now()
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
const
|
|
351
|
-
let
|
|
562
|
+
const resourceRow = await this.#lockResourceRow(trx, tenant, resource)
|
|
563
|
+
// Validate persisted mappings before registration can replace migration evidence.
|
|
564
|
+
const previous = resourceRow ? await this.#loadState(tenant, resource, trx, { idProperty }) : null
|
|
565
|
+
const previousDescriptor = previous?.descriptor
|
|
566
|
+
const stored = previousDescriptor?.definition || null
|
|
567
|
+
const runtime = clone(stored?.runtime || EMPTY_RUNTIME)
|
|
568
|
+
let populated
|
|
569
|
+
const isPopulated = () => (populated ??= this.#hasRecords(trx, previousDescriptor))
|
|
570
|
+
|
|
571
|
+
// Registration never drops a mapping other servers may still use. Runtime
|
|
572
|
+
// additions stay published; a populated field the declaration omits keeps
|
|
573
|
+
// its slot but is no longer published. Metadata written before
|
|
574
|
+
// definitions were stored cannot tell the two apart, so it keeps both.
|
|
575
|
+
const retained = {}
|
|
576
|
+
for (const [fieldName, fieldDef] of Object.entries(previousDescriptor?.schema || {})) {
|
|
577
|
+
if (Object.hasOwn(schema, fieldName)) continue
|
|
578
|
+
if (!stored?.options && !Object.hasOwn(runtime.schema, fieldName)) runtime.schema[fieldName] = fieldDef
|
|
579
|
+
if (Object.hasOwn(runtime.schema, fieldName) || await isPopulated()) retained[fieldName] = fieldDef
|
|
580
|
+
}
|
|
581
|
+
const storageSchema = { ...schema, ...retained }
|
|
582
|
+
|
|
583
|
+
const plan = await this.#withSlotPools(trx, pools => this.#planRegistration({
|
|
584
|
+
tenant, resource, schema, storageSchema, relationships, canonicalFieldMap, idProperty, previousDescriptor, pools
|
|
585
|
+
}))
|
|
586
|
+
const declaration = toStoredJson(definition.declaration ?? { schema, relationships })
|
|
587
|
+
const row = {
|
|
588
|
+
schema_json: JSON.stringify(storageSchema),
|
|
589
|
+
relationships_json: JSON.stringify(relationships || {}),
|
|
590
|
+
definition_json: JSON.stringify({
|
|
591
|
+
options: declaration.json,
|
|
592
|
+
callbacks: declaration.callbacks,
|
|
593
|
+
runtime,
|
|
594
|
+
// Retirement outlives declarations that still list the field.
|
|
595
|
+
...(stored?.retired?.length ? { retired: stored.retired } : {})
|
|
596
|
+
}),
|
|
597
|
+
}
|
|
598
|
+
const unchanged = previous &&
|
|
599
|
+
Object.entries(row).every(([column, value]) => previous.resourceRow[column] === value) &&
|
|
600
|
+
rowsSignature(previous.fieldRows, FIELD_ROW_COLUMNS) === rowsSignature(plan.fieldRows, FIELD_ROW_COLUMNS) &&
|
|
601
|
+
rowsSignature(previous.relationshipRows, RELATIONSHIP_ROW_COLUMNS) === rowsSignature(plan.relationshipRows, RELATIONSHIP_ROW_COLUMNS)
|
|
602
|
+
if (unchanged) {
|
|
603
|
+
if (managed) await commitTransaction(trx, context)
|
|
604
|
+
return { descriptor: previousDescriptor, changed: false }
|
|
605
|
+
}
|
|
352
606
|
|
|
607
|
+
let resourceConfigId = resourceRow?.id
|
|
353
608
|
if (resourceRow) {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
await trx
|
|
357
|
-
.where({ id: resourceRow.id })
|
|
358
|
-
.update({
|
|
359
|
-
schema_json: schemaJson,
|
|
360
|
-
relationships_json: relationshipsJson,
|
|
361
|
-
updated_at: now,
|
|
362
|
-
})
|
|
609
|
+
await trx('any_field_configs').where({ resource_config_id: resourceRow.id }).delete()
|
|
610
|
+
await trx('any_relationship_configs').where({ resource_config_id: resourceRow.id }).delete()
|
|
611
|
+
await this.#bumpVersion(trx, resourceRow, { ...row, updated_at: now })
|
|
363
612
|
} else {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
schema_json: schemaJson,
|
|
378
|
-
relationships_json: relationshipsJson,
|
|
613
|
+
try {
|
|
614
|
+
const [inserted] = await applyInsertReturning(trx('any_resource_configs').insert({
|
|
615
|
+
tenant_id: tenant,
|
|
616
|
+
resource,
|
|
617
|
+
...row,
|
|
618
|
+
schema_version: 1,
|
|
619
|
+
created_at: now,
|
|
620
|
+
updated_at: now,
|
|
621
|
+
}), 'id')
|
|
622
|
+
resourceConfigId = inserted && typeof inserted === 'object' ? inserted.id : inserted
|
|
623
|
+
} catch (error) {
|
|
624
|
+
if (isWriteConflict(error)) creationRaces.add(error)
|
|
625
|
+
throw error
|
|
379
626
|
}
|
|
380
627
|
}
|
|
381
628
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
if (canonicalFieldMap) {
|
|
389
|
-
canonicalOverrides = new Map(Object.entries(canonicalFieldMap))
|
|
629
|
+
const stamp = { resource_config_id: resourceConfigId, created_at: now, updated_at: now }
|
|
630
|
+
if (plan.fieldRows.length > 0) {
|
|
631
|
+
await trx('any_field_configs').insert(plan.fieldRows.map(fieldRow => ({ ...fieldRow, ...stamp })))
|
|
632
|
+
}
|
|
633
|
+
if (plan.relationshipRows.length > 0) {
|
|
634
|
+
await trx('any_relationship_configs').insert(plan.relationshipRows.map(relationshipRow => ({ ...relationshipRow, ...stamp })))
|
|
390
635
|
}
|
|
391
|
-
const fieldInserts = []
|
|
392
|
-
const relationshipInserts = []
|
|
393
636
|
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
637
|
+
const descriptor = await this.#loadDescriptor(tenant, resource, trx, { idProperty })
|
|
638
|
+
const changedField = Object.keys(previousDescriptor?.fields || {}).find(fieldName => {
|
|
639
|
+
const previousField = previousDescriptor.fields[fieldName]
|
|
640
|
+
const current = descriptor.fields[fieldName]
|
|
641
|
+
if (!current || previousField.slot !== current.slot || previousField.slotType !== current.slotType) return true
|
|
642
|
+
return previousField.slotType === 'belongsTo' &&
|
|
643
|
+
previousDescriptor.belongsTo[previousField.alias]?.typeColumn !== descriptor.belongsTo[current.alias]?.typeColumn
|
|
644
|
+
})
|
|
645
|
+
if (changedField && await isPopulated()) {
|
|
646
|
+
throw new Error(`Canonical storage migration required for '${resource}.${changedField}': registration cannot remove or remap stored fields while records exist. Preserve canonicalFieldsMap or migrate the stored data before changing the schema.`)
|
|
647
|
+
}
|
|
405
648
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
}
|
|
410
|
-
const alias = fieldDef.as || fieldName
|
|
411
|
-
const { typeField, idField, types = [] } = fieldDef.belongsToPolymorphic
|
|
412
|
-
|
|
413
|
-
relationshipInserts.push({
|
|
414
|
-
resource_config_id: resourceRow.id,
|
|
415
|
-
relationship_name: alias,
|
|
416
|
-
relationship_type: 'belongsToPolymorphic',
|
|
417
|
-
target_resource: null,
|
|
418
|
-
slot_index: null,
|
|
419
|
-
id_column: idField,
|
|
420
|
-
type_column: typeField,
|
|
421
|
-
relationship_key: null,
|
|
422
|
-
through: null,
|
|
423
|
-
foreign_key: null,
|
|
424
|
-
other_key: null,
|
|
425
|
-
alias,
|
|
426
|
-
meta_json: JSON.stringify({ types }),
|
|
427
|
-
created_at: this.knex.fn.now(),
|
|
428
|
-
updated_at: this.knex.fn.now(),
|
|
429
|
-
})
|
|
430
|
-
continue
|
|
431
|
-
}
|
|
649
|
+
if (managed) {
|
|
650
|
+
await commitTransaction(trx, context)
|
|
651
|
+
}
|
|
432
652
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
653
|
+
return { descriptor, changed: true }
|
|
654
|
+
} catch (error) {
|
|
655
|
+
if (managed) {
|
|
656
|
+
await this.#handleWriteFailure(error, trx || context.transaction, context)
|
|
657
|
+
}
|
|
658
|
+
throw error
|
|
659
|
+
}
|
|
660
|
+
}
|
|
439
661
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
662
|
+
#planRegistration ({ tenant, resource, schema, storageSchema, relationships, canonicalFieldMap, idProperty, previousDescriptor, pools }) {
|
|
663
|
+
const used = new Set()
|
|
664
|
+
const canonicalOverrides = canonicalFieldMap ? new Map(Object.entries(canonicalFieldMap)) : null
|
|
665
|
+
const entries = []
|
|
666
|
+
const fieldRows = []
|
|
667
|
+
const relationshipRows = []
|
|
668
|
+
|
|
669
|
+
// ID attributes previously had no slot; append them without moving existing slots.
|
|
670
|
+
const fields = Object.entries(storageSchema).sort(([, left], [, right]) =>
|
|
671
|
+
Number(left?.type === 'id' && !left.belongsTo) - Number(right?.type === 'id' && !right.belongsTo)
|
|
672
|
+
)
|
|
673
|
+
for (const [fieldName, fieldDef] of fields) {
|
|
674
|
+
if (fieldName === 'id' || (fieldName === idProperty && fieldDef?.type === 'id')) {
|
|
675
|
+
canonicalOverrides?.delete(fieldName)
|
|
676
|
+
continue
|
|
677
|
+
}
|
|
446
678
|
|
|
447
|
-
|
|
448
|
-
if (canonicalOverrides) {
|
|
449
|
-
|
|
450
|
-
throw new Error(`canonicalFieldsMap missing entry for field '${fieldName}'`)
|
|
451
|
-
}
|
|
452
|
-
const rawOverride = canonicalOverrides.get(fieldName)
|
|
453
|
-
canonicalOverrides.delete(fieldName)
|
|
454
|
-
|
|
455
|
-
if (fieldDef.belongsTo) {
|
|
456
|
-
const normalized = typeof rawOverride === 'string'
|
|
457
|
-
? { idSlot: rawOverride }
|
|
458
|
-
: rawOverride
|
|
459
|
-
if (!normalized || !normalized.idSlot) {
|
|
460
|
-
throw new Error(`canonicalFieldsMap for belongsTo field '${fieldName}' requires idSlot`)
|
|
461
|
-
}
|
|
462
|
-
const parsed = parseBelongsToSlots(normalized)
|
|
463
|
-
if (usedSlotColumns.has(parsed.idColumn) || usedSlotColumns.has(parsed.typeColumn)) {
|
|
464
|
-
throw new Error(`Canonical slots '${parsed.idColumn}'/'${parsed.typeColumn}' already in use`)
|
|
465
|
-
}
|
|
466
|
-
usedSlotColumns.add(parsed.idColumn)
|
|
467
|
-
usedSlotColumns.add(parsed.typeColumn)
|
|
468
|
-
override = normalized
|
|
469
|
-
} else {
|
|
470
|
-
const slotColumn = typeof rawOverride === 'string'
|
|
471
|
-
? rawOverride
|
|
472
|
-
: rawOverride?.slot || rawOverride?.slotColumn
|
|
473
|
-
if (!slotColumn) {
|
|
474
|
-
throw new Error(`canonicalFieldsMap for field '${fieldName}' must provide a slot column`)
|
|
475
|
-
}
|
|
476
|
-
const parsed = parseSimpleSlotColumn(slotColumn)
|
|
477
|
-
if (usedSlotColumns.has(slotColumn)) {
|
|
478
|
-
throw new Error(`Canonical slot '${slotColumn}' already in use`)
|
|
479
|
-
}
|
|
480
|
-
usedSlotColumns.add(slotColumn)
|
|
481
|
-
override = { slotColumn, slotType: parsed.slotType, slotIndex: parsed.slotIndex }
|
|
482
|
-
}
|
|
679
|
+
if (fieldDef?.belongsToPolymorphic) {
|
|
680
|
+
if (canonicalOverrides?.has(fieldName)) {
|
|
681
|
+
throw new Error(`canonicalFieldsMap should not include polymorphic field '${fieldName}'`)
|
|
483
682
|
}
|
|
484
|
-
|
|
485
|
-
const
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
meta_json: meta,
|
|
500
|
-
created_at: this.knex.fn.now(),
|
|
501
|
-
updated_at: this.knex.fn.now(),
|
|
683
|
+
const alias = fieldDef.as || fieldName
|
|
684
|
+
const { typeField, idField, types = [] } = fieldDef.belongsToPolymorphic
|
|
685
|
+
relationshipRows.push({
|
|
686
|
+
relationship_name: alias,
|
|
687
|
+
relationship_type: 'belongsToPolymorphic',
|
|
688
|
+
target_resource: null,
|
|
689
|
+
slot_index: null,
|
|
690
|
+
id_column: idField,
|
|
691
|
+
type_column: typeField,
|
|
692
|
+
relationship_key: null,
|
|
693
|
+
through: null,
|
|
694
|
+
foreign_key: null,
|
|
695
|
+
other_key: null,
|
|
696
|
+
alias,
|
|
697
|
+
meta_json: JSON.stringify({ types }),
|
|
502
698
|
})
|
|
699
|
+
continue
|
|
700
|
+
}
|
|
503
701
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
relationship_name: allocation.relationshipRow.name,
|
|
508
|
-
relationship_type: allocation.relationshipRow.type,
|
|
509
|
-
target_resource: allocation.relationshipRow.target,
|
|
510
|
-
slot_index: allocation.relationshipRow.slotIndex,
|
|
511
|
-
id_column: allocation.relationshipRow.idColumn,
|
|
512
|
-
type_column: allocation.relationshipRow.typeColumn,
|
|
513
|
-
relationship_key: allocation.relationshipRow.relationshipKey,
|
|
514
|
-
through: allocation.relationshipRow.through,
|
|
515
|
-
foreign_key: allocation.relationshipRow.foreignKey,
|
|
516
|
-
other_key: allocation.relationshipRow.otherKey,
|
|
517
|
-
alias: allocation.relationshipRow.alias,
|
|
518
|
-
meta_json: allocation.relationshipRow.meta ? JSON.stringify(allocation.relationshipRow.meta) : null,
|
|
519
|
-
created_at: this.knex.fn.now(),
|
|
520
|
-
updated_at: this.knex.fn.now(),
|
|
521
|
-
}
|
|
522
|
-
relationshipInserts.push(relationshipRow)
|
|
702
|
+
if (fieldDef?.computed) {
|
|
703
|
+
if (canonicalOverrides?.has(fieldName)) {
|
|
704
|
+
throw new Error(`canonicalFieldsMap should not include computed field '${fieldName}'`)
|
|
523
705
|
}
|
|
706
|
+
continue
|
|
707
|
+
}
|
|
524
708
|
|
|
525
|
-
|
|
526
|
-
if (
|
|
527
|
-
|
|
709
|
+
if (fieldDef?.virtual === true) {
|
|
710
|
+
if (canonicalOverrides?.has(fieldName)) {
|
|
711
|
+
throw new Error(`canonicalFieldsMap should not include virtual field '${fieldName}'`)
|
|
528
712
|
}
|
|
713
|
+
continue
|
|
529
714
|
}
|
|
530
715
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
716
|
+
let override = null
|
|
717
|
+
// Retained fields are not part of the declaration, so its map does not cover them.
|
|
718
|
+
if (canonicalOverrides && Object.hasOwn(schema, fieldName)) {
|
|
719
|
+
if (!canonicalOverrides.has(fieldName)) {
|
|
720
|
+
throw new Error(`canonicalFieldsMap missing entry for field '${fieldName}'`)
|
|
721
|
+
}
|
|
722
|
+
const rawOverride = canonicalOverrides.get(fieldName)
|
|
723
|
+
canonicalOverrides.delete(fieldName)
|
|
724
|
+
|
|
725
|
+
if (fieldDef.belongsTo) {
|
|
726
|
+
const normalized = typeof rawOverride === 'string'
|
|
727
|
+
? { idSlot: rawOverride }
|
|
728
|
+
: rawOverride
|
|
729
|
+
if (!normalized || !normalized.idSlot) {
|
|
730
|
+
throw new Error(`canonicalFieldsMap for belongsTo field '${fieldName}' requires idSlot`)
|
|
731
|
+
}
|
|
732
|
+
const parsed = parseBelongsToSlots(normalized, pools)
|
|
733
|
+
reserveSlots(used, [parsed.idColumn, parsed.typeColumn])
|
|
734
|
+
override = normalized
|
|
735
|
+
} else {
|
|
736
|
+
const slotColumn = typeof rawOverride === 'string'
|
|
737
|
+
? rawOverride
|
|
738
|
+
: rawOverride?.slot || rawOverride?.slotColumn
|
|
739
|
+
if (!slotColumn) {
|
|
740
|
+
throw new Error(`canonicalFieldsMap for field '${fieldName}' must provide a slot column`)
|
|
741
|
+
}
|
|
742
|
+
parseSimpleSlotColumn(slotColumn, pools)
|
|
743
|
+
reserveSlots(used, [slotColumn])
|
|
744
|
+
override = { slotColumn }
|
|
552
745
|
}
|
|
553
746
|
}
|
|
747
|
+
entries.push({ fieldName, fieldDef, override })
|
|
748
|
+
}
|
|
554
749
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
await trx('any_relationship_configs').insert(relationshipInserts)
|
|
560
|
-
}
|
|
750
|
+
if (canonicalOverrides && canonicalOverrides.size > 0) {
|
|
751
|
+
const unknownFields = [...canonicalOverrides.keys()].join(', ')
|
|
752
|
+
throw new Error(`canonicalFieldsMap contains unknown fields: ${unknownFields}`)
|
|
753
|
+
}
|
|
561
754
|
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
if (
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
.where(canonical.resourceColumn, resource)
|
|
575
|
-
.first()
|
|
576
|
-
if (storedRecord) {
|
|
577
|
-
throw new Error(`Canonical storage migration required for '${resource}.${changedField}': registration cannot remove or remap stored fields while records exist. Preserve canonicalFieldsMap or migrate the stored data before changing the schema.`)
|
|
578
|
-
}
|
|
579
|
-
}
|
|
755
|
+
// Stored fields keep their slots, so declaration order never moves data.
|
|
756
|
+
for (const entry of entries) {
|
|
757
|
+
const previous = previousDescriptor?.fields?.[entry.fieldName]
|
|
758
|
+
if (entry.override || !previous || previous.slotType !== slotPoolFor(entry.fieldDef)) continue
|
|
759
|
+
const columns = previous.slotType === 'belongsTo' ? belongsToColumns(previous.slotIndex) : [previous.slot]
|
|
760
|
+
const available = previous.slotType === 'belongsTo'
|
|
761
|
+
? pools.belongsTo.includes(previous.slotIndex)
|
|
762
|
+
: pools[previous.slotType]?.includes(previous.slot)
|
|
763
|
+
if (!available || columns.some(column => used.has(column))) continue
|
|
764
|
+
columns.forEach(column => used.add(column))
|
|
765
|
+
entry.override = previous.slotType === 'belongsTo' ? { idSlot: columns[0], typeSlot: columns[1] } : { slotColumn: previous.slot }
|
|
766
|
+
}
|
|
580
767
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
768
|
+
for (const { fieldName, fieldDef, override } of entries) {
|
|
769
|
+
const allocation = this.#assignFieldSlot({ ...fieldDef, fieldName }, used, override, pools)
|
|
770
|
+
if (!allocation) continue
|
|
771
|
+
fieldRows.push(this.#fieldRow(fieldName, fieldDef, allocation))
|
|
772
|
+
if (allocation.relationshipRow) relationshipRows.push(this.#relationshipRow(allocation.relationshipRow))
|
|
773
|
+
}
|
|
584
774
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
775
|
+
for (const [relName, relDef] of Object.entries(relationships || {})) {
|
|
776
|
+
if (relDef?.type === 'manyToMany') {
|
|
777
|
+
relationshipRows.push(this.#relationshipRow({
|
|
778
|
+
name: relName,
|
|
779
|
+
type: 'manyToMany',
|
|
780
|
+
target: relDef.target || relName,
|
|
781
|
+
relationshipKey: relDef.relationship || `${tenant}:${resource}:${relName}`,
|
|
782
|
+
through: relDef.through,
|
|
783
|
+
foreignKey: relDef.foreignKey,
|
|
784
|
+
otherKey: relDef.otherKey,
|
|
785
|
+
alias: relDef.as || relName,
|
|
786
|
+
meta: relDef.meta || {},
|
|
787
|
+
}))
|
|
589
788
|
}
|
|
590
|
-
throw error
|
|
591
789
|
}
|
|
790
|
+
|
|
791
|
+
return { fieldRows, relationshipRows }
|
|
592
792
|
}
|
|
593
793
|
|
|
594
794
|
async #loadDescriptor (tenant, resource, trx, options = {}) {
|
|
595
|
-
|
|
795
|
+
return (await this.#loadState(tenant, resource, trx, options))?.descriptor || null
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async #loadState (tenant, resource, trx, options = {}) {
|
|
799
|
+
const resourceRow = await (trx || this.knex)('any_resource_configs')
|
|
596
800
|
.where({ tenant_id: tenant, resource })
|
|
597
801
|
.first()
|
|
598
|
-
|
|
599
|
-
const resourceRow = await query
|
|
600
802
|
if (!resourceRow) return null
|
|
601
803
|
|
|
602
804
|
const fieldRows = await (trx || this.knex)('any_field_configs')
|
|
@@ -642,8 +844,10 @@ export class AnyapiRegistry {
|
|
|
642
844
|
}
|
|
643
845
|
|
|
644
846
|
descriptor.canonicalFieldMap = canonicalFieldMap
|
|
847
|
+
descriptor.schemaVersion = Number(resourceRow.schema_version ?? 1)
|
|
848
|
+
descriptor.definition = resourceRow.definition_json ? JSON.parse(resourceRow.definition_json) : null
|
|
645
849
|
|
|
646
|
-
return descriptor
|
|
850
|
+
return { resourceRow, fieldRows, relationshipRows, descriptor }
|
|
647
851
|
}
|
|
648
852
|
|
|
649
853
|
#buildDescriptor ({ tenant, resource, schema, relationships, slotState, fieldRows, relationshipRows, idProperty }) {
|
|
@@ -761,9 +965,11 @@ export class AnyapiRegistry {
|
|
|
761
965
|
}
|
|
762
966
|
}
|
|
763
967
|
|
|
764
|
-
|
|
968
|
+
// Callers reserve explicit and preserved slots in `used` before assigning;
|
|
969
|
+
// other fields take the first free slot of their pool.
|
|
970
|
+
#assignFieldSlot (fieldDef, used, canonicalOverride, pools) {
|
|
765
971
|
if (fieldDef.belongsTo) {
|
|
766
|
-
return this.#assignBelongsTo(fieldDef,
|
|
972
|
+
return this.#assignBelongsTo(fieldDef, used, canonicalOverride, pools)
|
|
767
973
|
}
|
|
768
974
|
|
|
769
975
|
const { type } = fieldDef
|
|
@@ -774,7 +980,7 @@ export class AnyapiRegistry {
|
|
|
774
980
|
throw new Error(`Unsupported field type '${type}'`)
|
|
775
981
|
}
|
|
776
982
|
|
|
777
|
-
const pool =
|
|
983
|
+
const pool = slotPoolFor(fieldDef)
|
|
778
984
|
if (!pool) {
|
|
779
985
|
throw new Error(`No slot pool defined for type '${type}'`)
|
|
780
986
|
}
|
|
@@ -786,103 +992,58 @@ export class AnyapiRegistry {
|
|
|
786
992
|
if (!slotColumn) {
|
|
787
993
|
throw new Error(`Canonical mapping for field '${fieldDef.fieldName}' must provide a slot column`)
|
|
788
994
|
}
|
|
789
|
-
const { slotType, slotIndex } = parseSimpleSlotColumn(slotColumn)
|
|
995
|
+
const { slotType, slotIndex } = parseSimpleSlotColumn(slotColumn, pools)
|
|
790
996
|
if (slotType !== pool) {
|
|
791
997
|
throw new Error(
|
|
792
998
|
`Canonical slot '${slotColumn}' does not match expected type '${pool}' for field '${fieldDef.fieldName}'`
|
|
793
999
|
)
|
|
794
1000
|
}
|
|
795
|
-
|
|
796
|
-
return {
|
|
797
|
-
slotType: pool,
|
|
798
|
-
slotIndex,
|
|
799
|
-
slotColumn,
|
|
800
|
-
updatedState: slotState,
|
|
801
|
-
}
|
|
1001
|
+
used.add(slotColumn)
|
|
1002
|
+
return { slotType: pool, slotIndex, slotColumn }
|
|
802
1003
|
}
|
|
803
1004
|
|
|
804
|
-
const
|
|
805
|
-
if (
|
|
806
|
-
throw
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
slotState[pool] = nextIndex
|
|
810
|
-
const slotColumn = SLOT_POOLS[pool][nextIndex - 1]
|
|
811
|
-
|
|
812
|
-
return {
|
|
813
|
-
slotType: pool,
|
|
814
|
-
slotIndex: nextIndex,
|
|
815
|
-
slotColumn,
|
|
816
|
-
updatedState: slotState,
|
|
1005
|
+
const slotColumn = pools[pool].find(column => !used.has(column))
|
|
1006
|
+
if (!slotColumn) {
|
|
1007
|
+
throw slotsExhausted(pool)
|
|
817
1008
|
}
|
|
1009
|
+
used.add(slotColumn)
|
|
1010
|
+
return { slotType: pool, slotIndex: pools[pool].indexOf(slotColumn) + 1, slotColumn }
|
|
818
1011
|
}
|
|
819
1012
|
|
|
820
|
-
#assignBelongsTo (fieldDef,
|
|
1013
|
+
#assignBelongsTo (fieldDef, used, canonicalOverride, pools) {
|
|
821
1014
|
const alias = BELONGS_TO_ALIAS(fieldDef.fieldName || '', fieldDef)
|
|
822
1015
|
const relationshipKey = fieldDef.relationshipKey || null
|
|
823
1016
|
|
|
1017
|
+
let slotIndex
|
|
824
1018
|
if (canonicalOverride) {
|
|
825
1019
|
const normalized = typeof canonicalOverride === 'string'
|
|
826
1020
|
? { idSlot: canonicalOverride }
|
|
827
1021
|
: canonicalOverride
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
slotColumn: idColumn,
|
|
834
|
-
relationshipRow: {
|
|
835
|
-
name: alias,
|
|
836
|
-
type: 'belongsTo',
|
|
837
|
-
target: fieldDef.belongsTo,
|
|
838
|
-
slotIndex,
|
|
839
|
-
idColumn,
|
|
840
|
-
typeColumn,
|
|
841
|
-
relationshipKey,
|
|
842
|
-
alias,
|
|
843
|
-
},
|
|
844
|
-
belongsToInfo: {
|
|
845
|
-
alias,
|
|
846
|
-
target: fieldDef.belongsTo,
|
|
847
|
-
slotIndex,
|
|
848
|
-
idColumn,
|
|
849
|
-
typeColumn,
|
|
850
|
-
nullable: fieldDef.nullable === true,
|
|
851
|
-
},
|
|
852
|
-
updatedState: slotState,
|
|
1022
|
+
slotIndex = parseBelongsToSlots(normalized || {}, pools).slotIndex
|
|
1023
|
+
} else {
|
|
1024
|
+
slotIndex = pools.belongsTo.find(index => belongsToColumns(index).every(column => !used.has(column)))
|
|
1025
|
+
if (slotIndex === undefined) {
|
|
1026
|
+
throw slotsExhausted('belongsTo')
|
|
853
1027
|
}
|
|
854
1028
|
}
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
throw new Error('No available belongsTo slots remaining')
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
slotState.belongsTo = nextIndex
|
|
1029
|
+
const [idColumn, typeColumn] = belongsToColumns(slotIndex)
|
|
1030
|
+
used.add(idColumn)
|
|
1031
|
+
used.add(typeColumn)
|
|
862
1032
|
|
|
863
1033
|
return {
|
|
864
1034
|
slotType: 'belongsTo',
|
|
865
|
-
slotIndex
|
|
866
|
-
slotColumn:
|
|
1035
|
+
slotIndex,
|
|
1036
|
+
slotColumn: idColumn,
|
|
867
1037
|
relationshipRow: {
|
|
868
1038
|
name: alias,
|
|
869
1039
|
type: 'belongsTo',
|
|
870
1040
|
target: fieldDef.belongsTo,
|
|
871
|
-
slotIndex
|
|
872
|
-
idColumn
|
|
873
|
-
typeColumn
|
|
1041
|
+
slotIndex,
|
|
1042
|
+
idColumn,
|
|
1043
|
+
typeColumn,
|
|
874
1044
|
relationshipKey,
|
|
875
1045
|
alias,
|
|
876
1046
|
},
|
|
877
|
-
belongsToInfo: {
|
|
878
|
-
alias,
|
|
879
|
-
target: fieldDef.belongsTo,
|
|
880
|
-
slotIndex: nextIndex,
|
|
881
|
-
idColumn: `rel_${nextIndex}_id`,
|
|
882
|
-
typeColumn: `rel_${nextIndex}_type`,
|
|
883
|
-
nullable: fieldDef.nullable === true,
|
|
884
|
-
},
|
|
885
|
-
updatedState: slotState,
|
|
886
1047
|
}
|
|
887
1048
|
}
|
|
888
1049
|
}
|