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.
@@ -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,114 @@ export class AnyapiRegistry {
118
217
  }
119
218
 
120
219
  async allocateField (params, options = {}) {
121
- const context = { method: 'allocateField' }
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, fieldName, definition, canonicalField } = params || {}
250
+ const { tenant, resource, fields, searchSchema } = readParams()
125
251
  Object.assign(context, { tenant, resource })
126
- if (!tenant || !resource || !fieldName || !definition) {
127
- throw new Error('allocateField requires tenant, resource, fieldName, and definition')
252
+ for (const [fieldName, { definition }] of Object.entries(fields)) {
253
+ validateSchemaFieldNames({ [fieldName]: definition }, resource)
128
254
  }
129
- validateSchemaFieldNames({ [fieldName]: definition }, resource)
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 descriptor = await this.#loadDescriptor(tenant, resource, trx)
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
- if (fieldName === descriptor.idProperty || Object.hasOwn(descriptor.schema, fieldName)) {
136
- throw new Error(`Field '${resource}.${fieldName}' already exists`)
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
- if (canonicalField && (definition.belongsToPolymorphic || definition.computed || definition.virtual)) {
139
- throw new Error(`canonicalFieldsMap should not include non-stored field '${fieldName}'`)
267
+ for (const row of plan.relationshipRows) {
268
+ await trx('any_relationship_configs').insert({ resource_config_id: resourceRow.id, ...row })
140
269
  }
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)
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
- const resourceRow = await trx('any_resource_configs')
147
- .where({ tenant_id: tenant, resource })
148
- .first()
298
+ throw wrapWriteError(error, context, context.transaction || options.transaction)
299
+ } finally {
300
+ endWriteOperation(context)
301
+ }
302
+ }
149
303
 
150
- if (!resourceRow) {
151
- throw new Error(`Resource '${resource}' not found in metadata`)
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
- } 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 }
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
- 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,
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
- await trx('any_relationship_configs').insert(relationshipRow)
364
+ reserveSlots(used, [slotColumn])
365
+ override = { slotColumn }
238
366
  }
239
367
  }
240
- await trx('any_resource_configs').where({ id: resourceRow.id }).update({
241
- schema_json: JSON.stringify({ ...descriptor.schema, [fieldName]: definition })
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
- 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
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
- // 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
- })
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
- 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,
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
- 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))
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
- 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
- }
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
- if (fieldDef?.computed) {
434
- if (canonicalOverrides?.has(fieldName)) {
435
- throw new Error(`canonicalFieldsMap should not include computed field '${fieldName}'`)
436
- }
437
- continue
438
- }
607
+ if (managed) {
608
+ await commitTransaction(trx, context)
609
+ }
439
610
 
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
- }
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
- 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
- }
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 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(),
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
- 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)
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
- usedSlotColumns.add(allocation.slotColumn)
526
- if (allocation.relationshipRow?.typeColumn) {
527
- usedSlotColumns.add(allocation.relationshipRow.typeColumn)
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
- 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
- })
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
- 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
- }
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
- 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
- }
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
- if (managed) {
582
- await commitTransaction(trx, context)
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
- return descriptor
586
- } catch (error) {
587
- if (managed) {
588
- await this.#handleWriteFailure(error, trx || context.transaction, context)
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
- const query = (trx || this.knex)('any_resource_configs')
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
- #assignFieldSlot (fieldDef, slotState, canonicalOverride = null) {
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, slotState, canonicalOverride)
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 = type === 'id' ? 'string' : TYPE_TO_POOL.get(type)
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
- slotState[pool] = Math.max(slotState[pool], slotIndex)
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 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,
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, slotState, canonicalOverride = null) {
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
- 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,
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
- 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
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: nextIndex,
866
- slotColumn: `rel_${nextIndex}_id`,
993
+ slotIndex,
994
+ slotColumn: idColumn,
867
995
  relationshipRow: {
868
996
  name: alias,
869
997
  type: 'belongsTo',
870
998
  target: fieldDef.belongsTo,
871
- slotIndex: nextIndex,
872
- idColumn: `rel_${nextIndex}_id`,
873
- typeColumn: `rel_${nextIndex}_type`,
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
  }