json-rest-api 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/docs/API.md +80 -0
  2. package/docs/GUIDE/17-row-policies.md +1 -0
  3. package/docs/GUIDE/21-schema-and-migrations.md +154 -14
  4. package/docs/GUIDE/30-backend-capabilities.md +8 -5
  5. package/docs/GUIDE/33-migrating-to-v2.md +27 -7
  6. package/index.d.ts +1 -0
  7. package/index.js +2 -0
  8. package/lib/knex-transaction.js +10 -4
  9. package/package.json +1 -1
  10. package/plugins/core/lib/anyapi/anyapi-registry.js +569 -408
  11. package/plugins/core/lib/anyapi/schema-utils.js +101 -51
  12. package/plugins/core/lib/anyapi/stored-definition.js +112 -0
  13. package/plugins/core/lib/querying/aggregate-query.js +170 -0
  14. package/plugins/core/lib/querying-writing/compile-schemas.js +17 -0
  15. package/plugins/core/lib/querying-writing/database-value-normalizers.js +16 -0
  16. package/plugins/core/lib/querying-writing/default-data-helpers.js +4 -0
  17. package/plugins/core/lib/querying-writing/field-types.js +43 -0
  18. package/plugins/core/lib/querying-writing/request-contracts.js +1 -1
  19. package/plugins/core/lib/storage/canonical-data-helpers.js +29 -0
  20. package/plugins/core/lib/storage/ordinary-data-helpers.js +25 -0
  21. package/plugins/core/lib/storage/storage-types.d.ts +21 -0
  22. package/plugins/core/rest-api-anyapi-knex-plugin.js +331 -54
  23. package/plugins/core/rest-api-plugin-methods/aggregate.js +41 -0
  24. package/plugins/core/rest-api-plugin-methods/common.js +16 -1
  25. package/plugins/core/rest-api-plugin.js +2 -0
  26. package/types/anyapi.d.ts +32 -0
  27. package/types/plugin-options.d.ts +12 -0
  28. package/types/resource-methods.d.ts +19 -0
  29. package/types/resource-schema.d.ts +5 -3
@@ -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, SLOT_LIMITS, SLOT_POOLS, TYPE_TO_POOL } from './schema-utils.js'
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 MAX_CACHED_DESCRIPTORS = 100
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 parseSimpleSlotColumn = (column) => {
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 (!SLOT_POOLS[type].includes(column)) {
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 (index < 1 || index > SLOT_LIMITS.belongsTo || idSlot !== `rel_${index}_id`) {
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
- constructor ({ knex, log } = {}) {
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 > MAX_CACHED_DESCRIPTORS) this.cache.delete(this.cache.keys().next().value)
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
- const context = { method: 'registerResource', tenant: definition?.tenant, resource: definition?.resource }
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
- const context = { method: 'allocateField' }
122
- let trx; let managed = false
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
- validateSchemaFieldNames({ [fieldName]: definition }, resource)
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 descriptor = await this.#loadDescriptor(tenant, resource, trx)
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
- if (fieldName === descriptor.idProperty || Object.hasOwn(descriptor.schema, fieldName)) {
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
- const usedSlots = new Set(Object.values(descriptor.fields).map(info => info.slot))
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
- } else if (!definition.computed && !definition.virtual) {
174
- let override = null
175
- if (canonicalField) {
176
- if (definition.belongsTo) {
177
- const normalized = typeof canonicalField === 'string'
178
- ? { idSlot: canonicalField }
179
- : canonicalField
180
- if (!normalized || !normalized.idSlot) {
181
- throw new Error(`canonicalFieldsMap for belongsTo field '${fieldName}' requires idSlot`)
182
- }
183
- const parsed = parseBelongsToSlots(normalized)
184
- if (usedSlots.has(parsed.idColumn) || usedSlots.has(parsed.typeColumn)) {
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
- const fieldSlot = this.#assignFieldSlot({ ...definition, fieldName }, descriptor.slotState, override)
205
- if (!fieldSlot) throw new Error(`Field '${resource}.${fieldName}' requires a supported type`)
206
- const meta = definition.meta ? JSON.stringify(definition.meta) : null
207
-
208
- await trx('any_field_configs').insert({
209
- resource_config_id: resourceRow.id,
210
- field_name: fieldName,
211
- slot_type: fieldSlot.slotType,
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
- await trx('any_relationship_configs').insert(relationshipRow)
400
+ reserveSlots(used, [slotColumn])
401
+ override = { slotColumn }
238
402
  }
239
403
  }
240
- await trx('any_resource_configs').where({ id: resourceRow.id }).update({
241
- schema_json: JSON.stringify({ ...descriptor.schema, [fieldName]: definition })
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
- let resourceRow = await trx('any_resource_configs')
346
- .where({ tenant_id: tenant, resource })
347
- .first()
348
-
349
- const schemaJson = JSON.stringify(schema)
350
- const relationshipsJson = JSON.stringify(relationships || {})
351
- let previousDescriptor
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
- // Validate persisted mappings before registration can replace migration evidence.
355
- previousDescriptor = await this.#loadDescriptor(tenant, resource, trx, { idProperty })
356
- await trx('any_resource_configs')
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
- const [inserted] = await applyInsertReturning(trx('any_resource_configs').insert({
365
- tenant_id: tenant,
366
- resource,
367
- schema_json: schemaJson,
368
- relationships_json: relationshipsJson,
369
- created_at: now,
370
- updated_at: now,
371
- }), 'id')
372
-
373
- resourceRow = {
374
- id: inserted && typeof inserted === 'object' ? inserted.id : inserted,
375
- tenant_id: tenant,
376
- resource,
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
- await trx('any_field_configs').where({ resource_config_id: resourceRow.id }).delete()
383
- await trx('any_relationship_configs').where({ resource_config_id: resourceRow.id }).delete()
384
-
385
- const slotState = this.#initializeSlotState()
386
- const usedSlotColumns = new Set()
387
- let canonicalOverrides = null
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
- // 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
- }
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
- if (fieldDef?.belongsToPolymorphic) {
407
- if (canonicalOverrides?.has(fieldName)) {
408
- throw new Error(`canonicalFieldsMap should not include polymorphic field '${fieldName}'`)
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
- if (fieldDef?.computed) {
434
- if (canonicalOverrides?.has(fieldName)) {
435
- throw new Error(`canonicalFieldsMap should not include computed field '${fieldName}'`)
436
- }
437
- continue
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
- if (fieldDef?.virtual === true) {
441
- if (canonicalOverrides?.has(fieldName)) {
442
- throw new Error(`canonicalFieldsMap should not include virtual field '${fieldName}'`)
443
- }
444
- continue
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
- let override = null
448
- if (canonicalOverrides) {
449
- if (!canonicalOverrides.has(fieldName)) {
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 allocation = this.#assignFieldSlot({ ...fieldDef, fieldName }, slotState, override)
486
- if (!allocation) continue
487
-
488
- const meta = fieldDef.meta ? JSON.stringify(fieldDef.meta) : null
489
- fieldInserts.push({
490
- resource_config_id: resourceRow.id,
491
- field_name: fieldName,
492
- slot_type: allocation.slotType,
493
- slot_index: allocation.slotIndex,
494
- slot_column: allocation.slotColumn,
495
- nullable: fieldDef.nullable === true,
496
- required: fieldDef.required === true,
497
- target_resource: fieldDef.belongsTo || null,
498
- alias: fieldDef.as || null,
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
- if (allocation.relationshipRow) {
505
- const relationshipRow = {
506
- resource_config_id: resourceRow.id,
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
- usedSlotColumns.add(allocation.slotColumn)
526
- if (allocation.relationshipRow?.typeColumn) {
527
- usedSlotColumns.add(allocation.relationshipRow.typeColumn)
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
- if (canonicalOverrides && canonicalOverrides.size > 0) {
532
- const unknownFields = [...canonicalOverrides.keys()].join(', ')
533
- throw new Error(`canonicalFieldsMap contains unknown fields: ${unknownFields}`)
534
- }
535
-
536
- for (const [relName, relDef] of Object.entries(relationships || {})) {
537
- if (relDef?.type === 'manyToMany') {
538
- relationshipInserts.push({
539
- resource_config_id: resourceRow.id,
540
- relationship_name: relName,
541
- relationship_type: 'manyToMany',
542
- target_resource: relDef.target || relName,
543
- relationship_key: relDef.relationship || `${tenant}:${resource}:${relName}`,
544
- through: relDef.through,
545
- foreign_key: relDef.foreignKey,
546
- other_key: relDef.otherKey,
547
- alias: relDef.as || relName,
548
- meta_json: JSON.stringify(relDef.meta || {}),
549
- created_at: this.knex.fn.now(),
550
- updated_at: this.knex.fn.now(),
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
- if (fieldInserts.length > 0) {
556
- await trx('any_field_configs').insert(fieldInserts)
557
- }
558
- if (relationshipInserts.length > 0) {
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
- const descriptor = await this.#loadDescriptor(tenant, resource, trx, { idProperty })
563
- const changedField = Object.keys(previousDescriptor?.fields || {}).find(fieldName => {
564
- const previous = previousDescriptor.fields[fieldName]
565
- const current = descriptor.fields[fieldName]
566
- if (!current || previous.slot !== current.slot || previous.slotType !== current.slotType) return true
567
- return previous.slotType === 'belongsTo' &&
568
- previousDescriptor.belongsTo[previous.alias]?.typeColumn !== descriptor.belongsTo[current.alias]?.typeColumn
569
- })
570
- if (changedField) {
571
- const { canonical } = previousDescriptor
572
- const storedRecord = await trx(canonical.tableName)
573
- .where(canonical.tenantColumn, tenant)
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
- if (managed) {
582
- await commitTransaction(trx, context)
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
- return descriptor
586
- } catch (error) {
587
- if (managed) {
588
- await this.#handleWriteFailure(error, trx || context.transaction, context)
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
- const query = (trx || this.knex)('any_resource_configs')
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
- #assignFieldSlot (fieldDef, slotState, canonicalOverride = null) {
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, slotState, canonicalOverride)
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 = type === 'id' ? 'string' : TYPE_TO_POOL.get(type)
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
- slotState[pool] = Math.max(slotState[pool], slotIndex)
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 nextIndex = slotState[pool] + 1
805
- if (nextIndex > SLOT_LIMITS[pool]) {
806
- throw new Error(`No available ${pool} slots remaining`)
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, slotState, canonicalOverride = null) {
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
- const { slotIndex, idColumn, typeColumn } = parseBelongsToSlots(normalized || {})
829
- slotState.belongsTo = Math.max(slotState.belongsTo, slotIndex)
830
- return {
831
- slotType: 'belongsTo',
832
- slotIndex,
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
- const nextIndex = slotState.belongsTo + 1
857
- if (nextIndex > SLOT_LIMITS.belongsTo) {
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: nextIndex,
866
- slotColumn: `rel_${nextIndex}_id`,
1035
+ slotIndex,
1036
+ slotColumn: idColumn,
867
1037
  relationshipRow: {
868
1038
  name: alias,
869
1039
  type: 'belongsTo',
870
1040
  target: fieldDef.belongsTo,
871
- slotIndex: nextIndex,
872
- idColumn: `rel_${nextIndex}_id`,
873
- typeColumn: `rel_${nextIndex}_type`,
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
  }