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
|
@@ -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,114 @@ export class AnyapiRegistry {
|
|
|
118
217
|
}
|
|
119
218
|
|
|
120
219
|
async allocateField (params, options = {}) {
|
|
121
|
-
|
|
220
|
+
return this.#allocate(options, 'allocateField', () => {
|
|
221
|
+
const { tenant, resource, fieldName, definition, declaration, canonicalField } = params || {}
|
|
222
|
+
if (!tenant || !resource || !fieldName || !definition) {
|
|
223
|
+
throw new Error('allocateField requires tenant, resource, fieldName, and definition')
|
|
224
|
+
}
|
|
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
|
+
async #allocate (options, method, readParams) {
|
|
247
|
+
const context = { method, [readCommittedTransaction]: true }
|
|
122
248
|
let trx; let managed = false
|
|
123
249
|
try {
|
|
124
|
-
const { tenant, resource,
|
|
250
|
+
const { tenant, resource, fields, searchSchema } = readParams()
|
|
125
251
|
Object.assign(context, { tenant, resource })
|
|
126
|
-
|
|
127
|
-
|
|
252
|
+
for (const [fieldName, { definition }] of Object.entries(fields)) {
|
|
253
|
+
validateSchemaFieldNames({ [fieldName]: definition }, resource)
|
|
128
254
|
}
|
|
129
|
-
|
|
255
|
+
assertFieldNameMap(searchSchema, `added search schema in '${resource}'`)
|
|
130
256
|
|
|
131
257
|
managed = !options.transaction
|
|
132
258
|
trx = await beginWriteTransaction(context, options.transaction, ownerContext => createKnexTransaction(this.knex, ownerContext))
|
|
133
|
-
const
|
|
259
|
+
const resourceRow = await this.#lockResourceRow(trx, tenant, resource)
|
|
260
|
+
const descriptor = resourceRow && await this.#loadDescriptor(tenant, resource, trx)
|
|
134
261
|
if (!descriptor) throw new Error(`Resource '${resource}' not registered for tenant '${tenant}'`)
|
|
135
|
-
|
|
136
|
-
|
|
262
|
+
const plan = await this.#withSlotPools(trx, pools => this.#planAllocation(descriptor, fields, pools))
|
|
263
|
+
|
|
264
|
+
for (const row of plan.fieldRows) {
|
|
265
|
+
await trx('any_field_configs').insert({ resource_config_id: resourceRow.id, ...row })
|
|
137
266
|
}
|
|
138
|
-
|
|
139
|
-
|
|
267
|
+
for (const row of plan.relationshipRows) {
|
|
268
|
+
await trx('any_relationship_configs').insert({ resource_config_id: resourceRow.id, ...row })
|
|
140
269
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
270
|
+
|
|
271
|
+
const stored = descriptor.definition || { options: null, callbacks: [] }
|
|
272
|
+
const runtime = clone(stored.runtime || EMPTY_RUNTIME)
|
|
273
|
+
for (const [kind, entries] of [
|
|
274
|
+
['schema', Object.entries(fields).map(([name, field]) => [name, field.declaration ?? field.definition])],
|
|
275
|
+
['searchSchema', Object.entries(searchSchema || {})]
|
|
276
|
+
]) {
|
|
277
|
+
for (const [name, value] of entries) {
|
|
278
|
+
const { json, callbacks } = toStoredJson(value)
|
|
279
|
+
runtime[kind][name] = json
|
|
280
|
+
runtime.callbacks.push(...callbacks.map(path => [kind, name, ...path]))
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
await this.#bumpVersion(trx, resourceRow, {
|
|
284
|
+
schema_json: JSON.stringify({ ...descriptor.schema, ...plan.definitions }),
|
|
285
|
+
definition_json: JSON.stringify({ ...stored, runtime }),
|
|
286
|
+
updated_at: this.knex.fn.now()
|
|
287
|
+
})
|
|
288
|
+
const updated = await this.#loadDescriptor(tenant, resource, trx)
|
|
289
|
+
if (managed) {
|
|
290
|
+
await commitTransaction(trx, context)
|
|
291
|
+
this.#rememberDescriptor(updated)
|
|
292
|
+
}
|
|
293
|
+
return clone(updated)
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (managed) {
|
|
296
|
+
await this.#handleWriteFailure(error, trx || context.transaction, context)
|
|
145
297
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
298
|
+
throw wrapWriteError(error, context, context.transaction || options.transaction)
|
|
299
|
+
} finally {
|
|
300
|
+
endWriteOperation(context)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
149
303
|
|
|
150
|
-
|
|
151
|
-
|
|
304
|
+
#planAllocation (descriptor, fields, pools) {
|
|
305
|
+
const { resource } = descriptor
|
|
306
|
+
const used = new Set(Object.values(descriptor.fields).map(info => info.slot))
|
|
307
|
+
for (const info of Object.values(descriptor.belongsTo)) {
|
|
308
|
+
used.add(info.idColumn)
|
|
309
|
+
used.add(info.typeColumn)
|
|
310
|
+
}
|
|
311
|
+
const fieldRows = []
|
|
312
|
+
const relationshipRows = []
|
|
313
|
+
const definitions = {}
|
|
314
|
+
|
|
315
|
+
for (const [fieldName, { definition, canonicalField }] of Object.entries(fields)) {
|
|
316
|
+
if (fieldName === descriptor.idProperty || Object.hasOwn(descriptor.schema, fieldName) || Object.hasOwn(definitions, fieldName)) {
|
|
317
|
+
throw new Error(`Field '${resource}.${fieldName}' already exists`)
|
|
318
|
+
}
|
|
319
|
+
if (canonicalField && (definition.belongsToPolymorphic || definition.computed || definition.virtual)) {
|
|
320
|
+
throw new Error(`canonicalFieldsMap should not include non-stored field '${fieldName}'`)
|
|
152
321
|
}
|
|
322
|
+
definitions[fieldName] = definition
|
|
153
323
|
|
|
154
324
|
if (definition?.belongsToPolymorphic) {
|
|
155
325
|
const alias = definition.as || fieldName
|
|
156
326
|
const { typeField, idField, types = [] } = definition.belongsToPolymorphic
|
|
157
|
-
|
|
158
|
-
await trx('any_relationship_configs').insert({
|
|
159
|
-
resource_config_id: resourceRow.id,
|
|
327
|
+
relationshipRows.push({
|
|
160
328
|
relationship_name: alias,
|
|
161
329
|
relationship_type: 'belongsToPolymorphic',
|
|
162
330
|
target_resource: null,
|
|
@@ -170,90 +338,103 @@ export class AnyapiRegistry {
|
|
|
170
338
|
alias,
|
|
171
339
|
meta_json: JSON.stringify({ types }),
|
|
172
340
|
})
|
|
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 }
|
|
341
|
+
continue
|
|
342
|
+
}
|
|
343
|
+
if (definition.computed || definition.virtual) continue
|
|
344
|
+
|
|
345
|
+
let override = null
|
|
346
|
+
if (canonicalField) {
|
|
347
|
+
if (definition.belongsTo) {
|
|
348
|
+
const normalized = typeof canonicalField === 'string'
|
|
349
|
+
? { idSlot: canonicalField }
|
|
350
|
+
: canonicalField
|
|
351
|
+
if (!normalized || !normalized.idSlot) {
|
|
352
|
+
throw new Error(`canonicalFieldsMap for belongsTo field '${fieldName}' requires idSlot`)
|
|
202
353
|
}
|
|
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,
|
|
354
|
+
const parsed = parseBelongsToSlots(normalized, pools)
|
|
355
|
+
reserveSlots(used, [parsed.idColumn, parsed.typeColumn])
|
|
356
|
+
override = normalized
|
|
357
|
+
} else {
|
|
358
|
+
const slotColumn = typeof canonicalField === 'string'
|
|
359
|
+
? canonicalField
|
|
360
|
+
: canonicalField?.slot || canonicalField?.slotColumn
|
|
361
|
+
if (!slotColumn) {
|
|
362
|
+
throw new Error(`canonicalFieldsMap for field '${fieldName}' must provide a slot column`)
|
|
236
363
|
}
|
|
237
|
-
|
|
364
|
+
reserveSlots(used, [slotColumn])
|
|
365
|
+
override = { slotColumn }
|
|
238
366
|
}
|
|
239
367
|
}
|
|
240
|
-
|
|
241
|
-
|
|
368
|
+
const fieldSlot = this.#assignFieldSlot({ ...definition, fieldName }, used, override, pools)
|
|
369
|
+
if (!fieldSlot) throw new Error(`Field '${resource}.${fieldName}' requires a supported type`)
|
|
370
|
+
fieldRows.push(this.#fieldRow(fieldName, definition, fieldSlot))
|
|
371
|
+
if (fieldSlot.relationshipRow) relationshipRows.push(this.#relationshipRow(fieldSlot.relationshipRow))
|
|
372
|
+
}
|
|
373
|
+
return { fieldRows, relationshipRows, definitions }
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
#fieldRow (fieldName, fieldDef, allocation) {
|
|
377
|
+
return {
|
|
378
|
+
field_name: fieldName,
|
|
379
|
+
slot_type: allocation.slotType,
|
|
380
|
+
slot_index: allocation.slotIndex,
|
|
381
|
+
slot_column: allocation.slotColumn,
|
|
382
|
+
nullable: fieldDef.nullable === true,
|
|
383
|
+
required: fieldDef.required === true,
|
|
384
|
+
target_resource: fieldDef.belongsTo || null,
|
|
385
|
+
alias: fieldDef.as || null,
|
|
386
|
+
meta_json: fieldDef.meta ? JSON.stringify(fieldDef.meta) : null,
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
#relationshipRow (relationship) {
|
|
391
|
+
return {
|
|
392
|
+
relationship_name: relationship.name,
|
|
393
|
+
relationship_type: relationship.type,
|
|
394
|
+
target_resource: relationship.target,
|
|
395
|
+
slot_index: relationship.slotIndex,
|
|
396
|
+
id_column: relationship.idColumn,
|
|
397
|
+
type_column: relationship.typeColumn,
|
|
398
|
+
relationship_key: relationship.relationshipKey,
|
|
399
|
+
through: relationship.through,
|
|
400
|
+
foreign_key: relationship.foreignKey,
|
|
401
|
+
other_key: relationship.otherKey,
|
|
402
|
+
alias: relationship.alias,
|
|
403
|
+
meta_json: relationship.meta ? JSON.stringify(relationship.meta) : null,
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Concurrent schema changes to one resource queue on its metadata row.
|
|
408
|
+
#lockResourceRow (trx, tenant, resource) {
|
|
409
|
+
return trx('any_resource_configs').where({ tenant_id: tenant, resource }).forUpdate().first()
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async #bumpVersion (trx, resourceRow, values) {
|
|
413
|
+
const version = Number(resourceRow.schema_version ?? 1)
|
|
414
|
+
const updated = await trx('any_resource_configs')
|
|
415
|
+
.where({ id: resourceRow.id, schema_version: version })
|
|
416
|
+
.update({ ...values, schema_version: version + 1 })
|
|
417
|
+
if (Number(updated) !== 1) {
|
|
418
|
+
throw new RestApiResourceError(`AnyAPI resource '${resourceRow.resource}' changed concurrently; retry the schema change`, {
|
|
419
|
+
subtype: 'conflict', resourceType: 'anyapi_resource', resourceId: resourceRow.resource
|
|
242
420
|
})
|
|
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
421
|
}
|
|
422
|
+
return version + 1
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async #hasRecords (trx, descriptor) {
|
|
426
|
+
const { canonical } = descriptor
|
|
427
|
+
return Boolean(await trx(canonical.tableName)
|
|
428
|
+
.where(canonical.tenantColumn, descriptor.tenant)
|
|
429
|
+
.where(canonical.resourceColumn, descriptor.resource)
|
|
430
|
+
.first('id'))
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async getSchemaVersion (tenant, resource, { transaction } = {}) {
|
|
434
|
+
const row = await (transaction || this.knex)('any_resource_configs')
|
|
435
|
+
.where({ tenant_id: tenant, resource })
|
|
436
|
+
.first('schema_version')
|
|
437
|
+
return row ? Number(row.schema_version) : null
|
|
257
438
|
}
|
|
258
439
|
|
|
259
440
|
async getDescriptor (tenant, resource, options = {}) {
|
|
@@ -342,261 +523,240 @@ export class AnyapiRegistry {
|
|
|
342
523
|
try {
|
|
343
524
|
trx = await beginWriteTransaction(context, externalTrx, ownerContext => createKnexTransaction(this.knex, ownerContext))
|
|
344
525
|
const now = this.knex.fn.now()
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
const
|
|
351
|
-
let
|
|
352
|
-
|
|
526
|
+
const resourceRow = await this.#lockResourceRow(trx, tenant, resource)
|
|
527
|
+
// Validate persisted mappings before registration can replace migration evidence.
|
|
528
|
+
const previous = resourceRow ? await this.#loadState(tenant, resource, trx, { idProperty }) : null
|
|
529
|
+
const previousDescriptor = previous?.descriptor
|
|
530
|
+
const stored = previousDescriptor?.definition || null
|
|
531
|
+
const runtime = clone(stored?.runtime || EMPTY_RUNTIME)
|
|
532
|
+
let populated
|
|
533
|
+
const isPopulated = () => (populated ??= this.#hasRecords(trx, previousDescriptor))
|
|
534
|
+
|
|
535
|
+
// Registration never drops a mapping other servers may still use. Runtime
|
|
536
|
+
// additions stay published; a populated field the declaration omits keeps
|
|
537
|
+
// its slot but is no longer published. Metadata written before
|
|
538
|
+
// definitions were stored cannot tell the two apart, so it keeps both.
|
|
539
|
+
const retained = {}
|
|
540
|
+
for (const [fieldName, fieldDef] of Object.entries(previousDescriptor?.schema || {})) {
|
|
541
|
+
if (Object.hasOwn(schema, fieldName)) continue
|
|
542
|
+
if (!stored?.options && !Object.hasOwn(runtime.schema, fieldName)) runtime.schema[fieldName] = fieldDef
|
|
543
|
+
if (Object.hasOwn(runtime.schema, fieldName) || await isPopulated()) retained[fieldName] = fieldDef
|
|
544
|
+
}
|
|
545
|
+
const storageSchema = { ...schema, ...retained }
|
|
546
|
+
|
|
547
|
+
const plan = await this.#withSlotPools(trx, pools => this.#planRegistration({
|
|
548
|
+
tenant, resource, schema, storageSchema, relationships, canonicalFieldMap, idProperty, previousDescriptor, pools
|
|
549
|
+
}))
|
|
550
|
+
const declaration = toStoredJson(definition.declaration ?? { schema, relationships })
|
|
551
|
+
const row = {
|
|
552
|
+
schema_json: JSON.stringify(storageSchema),
|
|
553
|
+
relationships_json: JSON.stringify(relationships || {}),
|
|
554
|
+
definition_json: JSON.stringify({ options: declaration.json, callbacks: declaration.callbacks, runtime }),
|
|
555
|
+
}
|
|
556
|
+
const unchanged = previous &&
|
|
557
|
+
Object.entries(row).every(([column, value]) => previous.resourceRow[column] === value) &&
|
|
558
|
+
rowsSignature(previous.fieldRows, FIELD_ROW_COLUMNS) === rowsSignature(plan.fieldRows, FIELD_ROW_COLUMNS) &&
|
|
559
|
+
rowsSignature(previous.relationshipRows, RELATIONSHIP_ROW_COLUMNS) === rowsSignature(plan.relationshipRows, RELATIONSHIP_ROW_COLUMNS)
|
|
560
|
+
if (unchanged) {
|
|
561
|
+
if (managed) await commitTransaction(trx, context)
|
|
562
|
+
return { descriptor: previousDescriptor, changed: false }
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
let resourceConfigId = resourceRow?.id
|
|
353
566
|
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
|
-
})
|
|
567
|
+
await trx('any_field_configs').where({ resource_config_id: resourceRow.id }).delete()
|
|
568
|
+
await trx('any_relationship_configs').where({ resource_config_id: resourceRow.id }).delete()
|
|
569
|
+
await this.#bumpVersion(trx, resourceRow, { ...row, updated_at: now })
|
|
363
570
|
} else {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
schema_json: schemaJson,
|
|
378
|
-
relationships_json: relationshipsJson,
|
|
571
|
+
try {
|
|
572
|
+
const [inserted] = await applyInsertReturning(trx('any_resource_configs').insert({
|
|
573
|
+
tenant_id: tenant,
|
|
574
|
+
resource,
|
|
575
|
+
...row,
|
|
576
|
+
schema_version: 1,
|
|
577
|
+
created_at: now,
|
|
578
|
+
updated_at: now,
|
|
579
|
+
}), 'id')
|
|
580
|
+
resourceConfigId = inserted && typeof inserted === 'object' ? inserted.id : inserted
|
|
581
|
+
} catch (error) {
|
|
582
|
+
if (isWriteConflict(error)) creationRaces.add(error)
|
|
583
|
+
throw error
|
|
379
584
|
}
|
|
380
585
|
}
|
|
381
586
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
if (canonicalFieldMap) {
|
|
389
|
-
canonicalOverrides = new Map(Object.entries(canonicalFieldMap))
|
|
587
|
+
const stamp = { resource_config_id: resourceConfigId, created_at: now, updated_at: now }
|
|
588
|
+
if (plan.fieldRows.length > 0) {
|
|
589
|
+
await trx('any_field_configs').insert(plan.fieldRows.map(fieldRow => ({ ...fieldRow, ...stamp })))
|
|
590
|
+
}
|
|
591
|
+
if (plan.relationshipRows.length > 0) {
|
|
592
|
+
await trx('any_relationship_configs').insert(plan.relationshipRows.map(relationshipRow => ({ ...relationshipRow, ...stamp })))
|
|
390
593
|
}
|
|
391
|
-
const fieldInserts = []
|
|
392
|
-
const relationshipInserts = []
|
|
393
|
-
|
|
394
|
-
// ID attributes previously had no slot; append them without moving existing slots.
|
|
395
|
-
const fields = Object.entries(schema).sort(([, left], [, right]) =>
|
|
396
|
-
Number(left?.type === 'id' && !left.belongsTo) - Number(right?.type === 'id' && !right.belongsTo)
|
|
397
|
-
)
|
|
398
|
-
for (const [fieldName, fieldDef] of fields) {
|
|
399
|
-
if (fieldName === 'id' || (fieldName === idProperty && fieldDef?.type === 'id')) {
|
|
400
|
-
if (canonicalOverrides) {
|
|
401
|
-
canonicalOverrides.delete(fieldName)
|
|
402
|
-
}
|
|
403
|
-
continue
|
|
404
|
-
}
|
|
405
594
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
}
|
|
595
|
+
const descriptor = await this.#loadDescriptor(tenant, resource, trx, { idProperty })
|
|
596
|
+
const changedField = Object.keys(previousDescriptor?.fields || {}).find(fieldName => {
|
|
597
|
+
const previousField = previousDescriptor.fields[fieldName]
|
|
598
|
+
const current = descriptor.fields[fieldName]
|
|
599
|
+
if (!current || previousField.slot !== current.slot || previousField.slotType !== current.slotType) return true
|
|
600
|
+
return previousField.slotType === 'belongsTo' &&
|
|
601
|
+
previousDescriptor.belongsTo[previousField.alias]?.typeColumn !== descriptor.belongsTo[current.alias]?.typeColumn
|
|
602
|
+
})
|
|
603
|
+
if (changedField && await isPopulated()) {
|
|
604
|
+
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.`)
|
|
605
|
+
}
|
|
432
606
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
}
|
|
437
|
-
continue
|
|
438
|
-
}
|
|
607
|
+
if (managed) {
|
|
608
|
+
await commitTransaction(trx, context)
|
|
609
|
+
}
|
|
439
610
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
611
|
+
return { descriptor, changed: true }
|
|
612
|
+
} catch (error) {
|
|
613
|
+
if (managed) {
|
|
614
|
+
await this.#handleWriteFailure(error, trx || context.transaction, context)
|
|
615
|
+
}
|
|
616
|
+
throw error
|
|
617
|
+
}
|
|
618
|
+
}
|
|
446
619
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
}
|
|
620
|
+
#planRegistration ({ tenant, resource, schema, storageSchema, relationships, canonicalFieldMap, idProperty, previousDescriptor, pools }) {
|
|
621
|
+
const used = new Set()
|
|
622
|
+
const canonicalOverrides = canonicalFieldMap ? new Map(Object.entries(canonicalFieldMap)) : null
|
|
623
|
+
const entries = []
|
|
624
|
+
const fieldRows = []
|
|
625
|
+
const relationshipRows = []
|
|
626
|
+
|
|
627
|
+
// ID attributes previously had no slot; append them without moving existing slots.
|
|
628
|
+
const fields = Object.entries(storageSchema).sort(([, left], [, right]) =>
|
|
629
|
+
Number(left?.type === 'id' && !left.belongsTo) - Number(right?.type === 'id' && !right.belongsTo)
|
|
630
|
+
)
|
|
631
|
+
for (const [fieldName, fieldDef] of fields) {
|
|
632
|
+
if (fieldName === 'id' || (fieldName === idProperty && fieldDef?.type === 'id')) {
|
|
633
|
+
canonicalOverrides?.delete(fieldName)
|
|
634
|
+
continue
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (fieldDef?.belongsToPolymorphic) {
|
|
638
|
+
if (canonicalOverrides?.has(fieldName)) {
|
|
639
|
+
throw new Error(`canonicalFieldsMap should not include polymorphic field '${fieldName}'`)
|
|
483
640
|
}
|
|
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(),
|
|
641
|
+
const alias = fieldDef.as || fieldName
|
|
642
|
+
const { typeField, idField, types = [] } = fieldDef.belongsToPolymorphic
|
|
643
|
+
relationshipRows.push({
|
|
644
|
+
relationship_name: alias,
|
|
645
|
+
relationship_type: 'belongsToPolymorphic',
|
|
646
|
+
target_resource: null,
|
|
647
|
+
slot_index: null,
|
|
648
|
+
id_column: idField,
|
|
649
|
+
type_column: typeField,
|
|
650
|
+
relationship_key: null,
|
|
651
|
+
through: null,
|
|
652
|
+
foreign_key: null,
|
|
653
|
+
other_key: null,
|
|
654
|
+
alias,
|
|
655
|
+
meta_json: JSON.stringify({ types }),
|
|
502
656
|
})
|
|
657
|
+
continue
|
|
658
|
+
}
|
|
503
659
|
|
|
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)
|
|
660
|
+
if (fieldDef?.computed) {
|
|
661
|
+
if (canonicalOverrides?.has(fieldName)) {
|
|
662
|
+
throw new Error(`canonicalFieldsMap should not include computed field '${fieldName}'`)
|
|
523
663
|
}
|
|
664
|
+
continue
|
|
665
|
+
}
|
|
524
666
|
|
|
525
|
-
|
|
526
|
-
if (
|
|
527
|
-
|
|
667
|
+
if (fieldDef?.virtual === true) {
|
|
668
|
+
if (canonicalOverrides?.has(fieldName)) {
|
|
669
|
+
throw new Error(`canonicalFieldsMap should not include virtual field '${fieldName}'`)
|
|
528
670
|
}
|
|
671
|
+
continue
|
|
529
672
|
}
|
|
530
673
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
674
|
+
let override = null
|
|
675
|
+
// Retained fields are not part of the declaration, so its map does not cover them.
|
|
676
|
+
if (canonicalOverrides && Object.hasOwn(schema, fieldName)) {
|
|
677
|
+
if (!canonicalOverrides.has(fieldName)) {
|
|
678
|
+
throw new Error(`canonicalFieldsMap missing entry for field '${fieldName}'`)
|
|
679
|
+
}
|
|
680
|
+
const rawOverride = canonicalOverrides.get(fieldName)
|
|
681
|
+
canonicalOverrides.delete(fieldName)
|
|
682
|
+
|
|
683
|
+
if (fieldDef.belongsTo) {
|
|
684
|
+
const normalized = typeof rawOverride === 'string'
|
|
685
|
+
? { idSlot: rawOverride }
|
|
686
|
+
: rawOverride
|
|
687
|
+
if (!normalized || !normalized.idSlot) {
|
|
688
|
+
throw new Error(`canonicalFieldsMap for belongsTo field '${fieldName}' requires idSlot`)
|
|
689
|
+
}
|
|
690
|
+
const parsed = parseBelongsToSlots(normalized, pools)
|
|
691
|
+
reserveSlots(used, [parsed.idColumn, parsed.typeColumn])
|
|
692
|
+
override = normalized
|
|
693
|
+
} else {
|
|
694
|
+
const slotColumn = typeof rawOverride === 'string'
|
|
695
|
+
? rawOverride
|
|
696
|
+
: rawOverride?.slot || rawOverride?.slotColumn
|
|
697
|
+
if (!slotColumn) {
|
|
698
|
+
throw new Error(`canonicalFieldsMap for field '${fieldName}' must provide a slot column`)
|
|
699
|
+
}
|
|
700
|
+
parseSimpleSlotColumn(slotColumn, pools)
|
|
701
|
+
reserveSlots(used, [slotColumn])
|
|
702
|
+
override = { slotColumn }
|
|
552
703
|
}
|
|
553
704
|
}
|
|
705
|
+
entries.push({ fieldName, fieldDef, override })
|
|
706
|
+
}
|
|
554
707
|
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
await trx('any_relationship_configs').insert(relationshipInserts)
|
|
560
|
-
}
|
|
708
|
+
if (canonicalOverrides && canonicalOverrides.size > 0) {
|
|
709
|
+
const unknownFields = [...canonicalOverrides.keys()].join(', ')
|
|
710
|
+
throw new Error(`canonicalFieldsMap contains unknown fields: ${unknownFields}`)
|
|
711
|
+
}
|
|
561
712
|
|
|
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
|
-
}
|
|
713
|
+
// Stored fields keep their slots, so declaration order never moves data.
|
|
714
|
+
for (const entry of entries) {
|
|
715
|
+
const previous = previousDescriptor?.fields?.[entry.fieldName]
|
|
716
|
+
if (entry.override || !previous || previous.slotType !== slotPoolFor(entry.fieldDef)) continue
|
|
717
|
+
const columns = previous.slotType === 'belongsTo' ? belongsToColumns(previous.slotIndex) : [previous.slot]
|
|
718
|
+
const available = previous.slotType === 'belongsTo'
|
|
719
|
+
? pools.belongsTo.includes(previous.slotIndex)
|
|
720
|
+
: pools[previous.slotType]?.includes(previous.slot)
|
|
721
|
+
if (!available || columns.some(column => used.has(column))) continue
|
|
722
|
+
columns.forEach(column => used.add(column))
|
|
723
|
+
entry.override = previous.slotType === 'belongsTo' ? { idSlot: columns[0], typeSlot: columns[1] } : { slotColumn: previous.slot }
|
|
724
|
+
}
|
|
580
725
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
726
|
+
for (const { fieldName, fieldDef, override } of entries) {
|
|
727
|
+
const allocation = this.#assignFieldSlot({ ...fieldDef, fieldName }, used, override, pools)
|
|
728
|
+
if (!allocation) continue
|
|
729
|
+
fieldRows.push(this.#fieldRow(fieldName, fieldDef, allocation))
|
|
730
|
+
if (allocation.relationshipRow) relationshipRows.push(this.#relationshipRow(allocation.relationshipRow))
|
|
731
|
+
}
|
|
584
732
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
733
|
+
for (const [relName, relDef] of Object.entries(relationships || {})) {
|
|
734
|
+
if (relDef?.type === 'manyToMany') {
|
|
735
|
+
relationshipRows.push(this.#relationshipRow({
|
|
736
|
+
name: relName,
|
|
737
|
+
type: 'manyToMany',
|
|
738
|
+
target: relDef.target || relName,
|
|
739
|
+
relationshipKey: relDef.relationship || `${tenant}:${resource}:${relName}`,
|
|
740
|
+
through: relDef.through,
|
|
741
|
+
foreignKey: relDef.foreignKey,
|
|
742
|
+
otherKey: relDef.otherKey,
|
|
743
|
+
alias: relDef.as || relName,
|
|
744
|
+
meta: relDef.meta || {},
|
|
745
|
+
}))
|
|
589
746
|
}
|
|
590
|
-
throw error
|
|
591
747
|
}
|
|
748
|
+
|
|
749
|
+
return { fieldRows, relationshipRows }
|
|
592
750
|
}
|
|
593
751
|
|
|
594
752
|
async #loadDescriptor (tenant, resource, trx, options = {}) {
|
|
595
|
-
|
|
753
|
+
return (await this.#loadState(tenant, resource, trx, options))?.descriptor || null
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
async #loadState (tenant, resource, trx, options = {}) {
|
|
757
|
+
const resourceRow = await (trx || this.knex)('any_resource_configs')
|
|
596
758
|
.where({ tenant_id: tenant, resource })
|
|
597
759
|
.first()
|
|
598
|
-
|
|
599
|
-
const resourceRow = await query
|
|
600
760
|
if (!resourceRow) return null
|
|
601
761
|
|
|
602
762
|
const fieldRows = await (trx || this.knex)('any_field_configs')
|
|
@@ -642,8 +802,10 @@ export class AnyapiRegistry {
|
|
|
642
802
|
}
|
|
643
803
|
|
|
644
804
|
descriptor.canonicalFieldMap = canonicalFieldMap
|
|
805
|
+
descriptor.schemaVersion = Number(resourceRow.schema_version ?? 1)
|
|
806
|
+
descriptor.definition = resourceRow.definition_json ? JSON.parse(resourceRow.definition_json) : null
|
|
645
807
|
|
|
646
|
-
return descriptor
|
|
808
|
+
return { resourceRow, fieldRows, relationshipRows, descriptor }
|
|
647
809
|
}
|
|
648
810
|
|
|
649
811
|
#buildDescriptor ({ tenant, resource, schema, relationships, slotState, fieldRows, relationshipRows, idProperty }) {
|
|
@@ -761,9 +923,11 @@ export class AnyapiRegistry {
|
|
|
761
923
|
}
|
|
762
924
|
}
|
|
763
925
|
|
|
764
|
-
|
|
926
|
+
// Callers reserve explicit and preserved slots in `used` before assigning;
|
|
927
|
+
// other fields take the first free slot of their pool.
|
|
928
|
+
#assignFieldSlot (fieldDef, used, canonicalOverride, pools) {
|
|
765
929
|
if (fieldDef.belongsTo) {
|
|
766
|
-
return this.#assignBelongsTo(fieldDef,
|
|
930
|
+
return this.#assignBelongsTo(fieldDef, used, canonicalOverride, pools)
|
|
767
931
|
}
|
|
768
932
|
|
|
769
933
|
const { type } = fieldDef
|
|
@@ -774,7 +938,7 @@ export class AnyapiRegistry {
|
|
|
774
938
|
throw new Error(`Unsupported field type '${type}'`)
|
|
775
939
|
}
|
|
776
940
|
|
|
777
|
-
const pool =
|
|
941
|
+
const pool = slotPoolFor(fieldDef)
|
|
778
942
|
if (!pool) {
|
|
779
943
|
throw new Error(`No slot pool defined for type '${type}'`)
|
|
780
944
|
}
|
|
@@ -786,103 +950,58 @@ export class AnyapiRegistry {
|
|
|
786
950
|
if (!slotColumn) {
|
|
787
951
|
throw new Error(`Canonical mapping for field '${fieldDef.fieldName}' must provide a slot column`)
|
|
788
952
|
}
|
|
789
|
-
const { slotType, slotIndex } = parseSimpleSlotColumn(slotColumn)
|
|
953
|
+
const { slotType, slotIndex } = parseSimpleSlotColumn(slotColumn, pools)
|
|
790
954
|
if (slotType !== pool) {
|
|
791
955
|
throw new Error(
|
|
792
956
|
`Canonical slot '${slotColumn}' does not match expected type '${pool}' for field '${fieldDef.fieldName}'`
|
|
793
957
|
)
|
|
794
958
|
}
|
|
795
|
-
|
|
796
|
-
return {
|
|
797
|
-
slotType: pool,
|
|
798
|
-
slotIndex,
|
|
799
|
-
slotColumn,
|
|
800
|
-
updatedState: slotState,
|
|
801
|
-
}
|
|
959
|
+
used.add(slotColumn)
|
|
960
|
+
return { slotType: pool, slotIndex, slotColumn }
|
|
802
961
|
}
|
|
803
962
|
|
|
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,
|
|
963
|
+
const slotColumn = pools[pool].find(column => !used.has(column))
|
|
964
|
+
if (!slotColumn) {
|
|
965
|
+
throw slotsExhausted(pool)
|
|
817
966
|
}
|
|
967
|
+
used.add(slotColumn)
|
|
968
|
+
return { slotType: pool, slotIndex: pools[pool].indexOf(slotColumn) + 1, slotColumn }
|
|
818
969
|
}
|
|
819
970
|
|
|
820
|
-
#assignBelongsTo (fieldDef,
|
|
971
|
+
#assignBelongsTo (fieldDef, used, canonicalOverride, pools) {
|
|
821
972
|
const alias = BELONGS_TO_ALIAS(fieldDef.fieldName || '', fieldDef)
|
|
822
973
|
const relationshipKey = fieldDef.relationshipKey || null
|
|
823
974
|
|
|
975
|
+
let slotIndex
|
|
824
976
|
if (canonicalOverride) {
|
|
825
977
|
const normalized = typeof canonicalOverride === 'string'
|
|
826
978
|
? { idSlot: canonicalOverride }
|
|
827
979
|
: 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,
|
|
980
|
+
slotIndex = parseBelongsToSlots(normalized || {}, pools).slotIndex
|
|
981
|
+
} else {
|
|
982
|
+
slotIndex = pools.belongsTo.find(index => belongsToColumns(index).every(column => !used.has(column)))
|
|
983
|
+
if (slotIndex === undefined) {
|
|
984
|
+
throw slotsExhausted('belongsTo')
|
|
853
985
|
}
|
|
854
986
|
}
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
throw new Error('No available belongsTo slots remaining')
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
slotState.belongsTo = nextIndex
|
|
987
|
+
const [idColumn, typeColumn] = belongsToColumns(slotIndex)
|
|
988
|
+
used.add(idColumn)
|
|
989
|
+
used.add(typeColumn)
|
|
862
990
|
|
|
863
991
|
return {
|
|
864
992
|
slotType: 'belongsTo',
|
|
865
|
-
slotIndex
|
|
866
|
-
slotColumn:
|
|
993
|
+
slotIndex,
|
|
994
|
+
slotColumn: idColumn,
|
|
867
995
|
relationshipRow: {
|
|
868
996
|
name: alias,
|
|
869
997
|
type: 'belongsTo',
|
|
870
998
|
target: fieldDef.belongsTo,
|
|
871
|
-
slotIndex
|
|
872
|
-
idColumn
|
|
873
|
-
typeColumn
|
|
999
|
+
slotIndex,
|
|
1000
|
+
idColumn,
|
|
1001
|
+
typeColumn,
|
|
874
1002
|
relationshipKey,
|
|
875
1003
|
alias,
|
|
876
1004
|
},
|
|
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
1005
|
}
|
|
887
1006
|
}
|
|
888
1007
|
}
|