json-rest-api 1.0.10 → 1.0.13

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 (117) hide show
  1. package/.claude/settings.local.json +9 -1
  2. package/AGENTS.md +34 -0
  3. package/CLAUDE.md +26 -1
  4. package/CRUSH.md +55 -0
  5. package/README.md +3 -3
  6. package/TODO.todo +8 -0
  7. package/docs/API.md +17 -18
  8. package/docs/COMPARISON.md +18 -13
  9. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +17 -21
  10. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +33 -2
  11. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +61 -7
  12. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +58 -61
  13. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +37 -37
  14. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +103 -87
  15. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +37 -34
  16. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +45 -42
  17. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +3 -3
  18. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +1 -1
  19. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +5 -5
  20. package/docs/GUIDE/GUIDE_4_Authentication_And_Permissions.md +498 -755
  21. package/docs/GUIDE/GUIDE_6_Positioning.md +1 -3
  22. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +4 -5
  23. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +15 -6
  24. package/docs/GUIDE/GUIDE_X_Cors.md +2 -5
  25. package/docs/GUIDE/GUIDE_X_File_Uploads.md +2 -3
  26. package/docs/GUIDE/GUIDE_X_Multihome.md +1 -4
  27. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -2
  28. package/docs/GUIDE/GUIDE_X_URL_Management.md +317 -0
  29. package/docs/GUIDE/index.md +3 -0
  30. package/docs/QUICKSTART.md +12 -12
  31. package/docs/index.md +2 -2
  32. package/docs/mini-guides/file-uploads-complete.md +0 -1
  33. package/examples/url-override-example.js +137 -0
  34. package/index.js +7 -1
  35. package/package.json +7 -11
  36. package/plugins/core/bulk-operations-plugin.js +2 -0
  37. package/plugins/core/connectors/express-plugin.js +43 -24
  38. package/plugins/core/connectors/lib/request-helpers.js +2 -2
  39. package/plugins/core/lib/anyapi/anyapi-registry.js +821 -0
  40. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +647 -0
  41. package/plugins/core/lib/anyapi/schema-utils.js +186 -0
  42. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +113 -0
  43. package/plugins/core/lib/dbTablesOperations.js +305 -0
  44. package/plugins/core/lib/querying/knex-cross-table-search.js +64 -108
  45. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +21 -27
  46. package/plugins/core/lib/querying/knex-pagination-helpers.js +36 -20
  47. package/plugins/core/lib/querying/knex-query-helpers-base.js +49 -14
  48. package/plugins/core/lib/querying/knex-query-helpers.js +378 -222
  49. package/plugins/core/lib/querying/knex-relationship-includes.js +262 -87
  50. package/plugins/core/lib/querying/url-helpers.js +119 -0
  51. package/plugins/core/lib/querying-writing/compile-schemas.js +36 -28
  52. package/plugins/core/lib/querying-writing/database-capabilities.js +10 -9
  53. package/plugins/core/lib/querying-writing/knex-field-helpers.js +9 -3
  54. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +2 -2
  55. package/plugins/core/lib/querying-writing/payload-validators.js +10 -16
  56. package/plugins/core/lib/querying-writing/schema-helpers.js +52 -60
  57. package/plugins/core/lib/querying-writing/scope-validations.js +78 -22
  58. package/plugins/core/lib/querying-writing/simplified-helpers.js +208 -61
  59. package/plugins/core/lib/storage/storage-adapter.js +160 -0
  60. package/plugins/core/lib/writing/relationship-processor.js +4 -11
  61. package/plugins/core/rest-api-access.js +456 -0
  62. package/plugins/core/rest-api-anyapi-knex-plugin.js +2285 -0
  63. package/plugins/core/rest-api-knex-plugin.js +226 -87
  64. package/plugins/core/rest-api-multihome-plugin.js +89 -27
  65. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +0 -1
  66. package/plugins/core/rest-api-plugin-methods/check-permissions.js +1 -5
  67. package/plugins/core/rest-api-plugin-methods/common.js +46 -10
  68. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +31 -27
  69. package/plugins/core/rest-api-plugin-methods/delete.js +1 -4
  70. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +13 -1
  71. package/plugins/core/rest-api-plugin-methods/get-related.js +114 -56
  72. package/plugins/core/rest-api-plugin-methods/get-relationship.js +4 -3
  73. package/plugins/core/rest-api-plugin-methods/get.js +2 -5
  74. package/plugins/core/rest-api-plugin-methods/patch.js +15 -16
  75. package/plugins/core/rest-api-plugin-methods/post-relationship.js +21 -14
  76. package/plugins/core/rest-api-plugin-methods/post.js +23 -11
  77. package/plugins/core/rest-api-plugin-methods/put.js +18 -21
  78. package/plugins/core/rest-api-plugin-methods/query.js +3 -4
  79. package/plugins/core/rest-api-plugin-methods/release.js +13 -0
  80. package/plugins/core/rest-api-plugin.js +3 -4
  81. package/plugins/core/rest-api-positioning-plugin.js +253 -76
  82. package/plugins/core/socketio-plugin.js +436 -502
  83. package/quickTest.js +2 -2
  84. package/scripts/debug-anyapi-review-filter.mjs +63 -0
  85. package/scripts/inspect-anyapi-descriptor-country.mjs +19 -0
  86. package/scripts/inspect-anyapi-descriptor.mjs +19 -0
  87. package/test-debug.js +59 -0
  88. package/tests/README.md +7 -1
  89. package/tests/anyapi-basic.test.js +102 -0
  90. package/tests/anyapi-cursor-pagination.test.js +144 -0
  91. package/tests/computed-fields.test.js +27 -12
  92. package/tests/cors.test.js +5 -6
  93. package/tests/curl.test.js +1 -1
  94. package/tests/custom-idproperty-relationships.test.js +37 -29
  95. package/tests/field-getters.test.js +25 -7
  96. package/tests/fixtures/api-configs.js +576 -150
  97. package/tests/helpers/storage-mode.js +65 -0
  98. package/tests/helpers/test-utils.js +85 -3
  99. package/tests/include-depth-validation.test.js +35 -1
  100. package/tests/nested-includes.test.js +130 -103
  101. package/tests/pagination-cursor-multifield.test.js +17 -13
  102. package/tests/pagination-enhanced.test.js +19 -24
  103. package/tests/polymorphic-simple.test.js +9 -9
  104. package/tests/positioning-advanced.test.js +19 -19
  105. package/tests/positioning-extended.test.js +25 -25
  106. package/tests/positioning-multifilter.test.js +62 -62
  107. package/tests/positioning.test.js +7 -7
  108. package/tests/queries.test.js +5 -5
  109. package/tests/query-limits.test.js +77 -92
  110. package/tests/rest-api-access.test.js +206 -0
  111. package/tests/searchschema-merge.test.js +182 -0
  112. package/tests/socketio.test.js +33 -14
  113. package/tests/virtual-fields.test.js +0 -1
  114. package/docs/DOCS_ALL.md +0 -21894
  115. package/plugins/core/jwt-auth-plugin.js +0 -897
  116. package/plugins/core/lib/jwt-auth-helpers.js +0 -130
  117. package/tests/jwt-auth.test.js +0 -692
@@ -0,0 +1,821 @@
1
+ import { DEFAULT_CANONICAL_CONFIG, SLOT_LIMITS, SLOT_POOLS, TYPE_TO_POOL } from './schema-utils.js';
2
+
3
+ const clone = (value) => {
4
+ if (typeof structuredClone === 'function') {
5
+ return structuredClone(value);
6
+ }
7
+ return JSON.parse(JSON.stringify(value));
8
+ };
9
+
10
+ const SUPPORTED_TYPES = new Set([...TYPE_TO_POOL.keys(), 'id']);
11
+
12
+ const BELONGS_TO_ALIAS = (fieldName, fieldDef) => {
13
+ if (fieldDef.as) return fieldDef.as;
14
+ if (fieldName.endsWith('_id')) {
15
+ return fieldName.slice(0, -3);
16
+ }
17
+ return fieldName;
18
+ };
19
+
20
+ const SLOT_PATTERNS = {
21
+ string: /^string_(\d+)$/,
22
+ number: /^number_(\d+)$/,
23
+ boolean: /^boolean_(\d+)$/,
24
+ date: /^date_(\d+)$/,
25
+ json: /^json_(\d+)$/,
26
+ };
27
+
28
+ const BELONGS_TO_ID_PATTERN = /^rel_(\d+)_id$/;
29
+ const BELONGS_TO_TYPE_PATTERN = /^rel_(\d+)_type$/;
30
+
31
+ const parseSimpleSlotColumn = (column) => {
32
+ for (const [type, pattern] of Object.entries(SLOT_PATTERNS)) {
33
+ const match = pattern.exec(column);
34
+ if (match) {
35
+ return { slotType: type, slotIndex: Number(match[1]) };
36
+ }
37
+ }
38
+ throw new Error(`Invalid canonical slot column '${column}'`);
39
+ };
40
+
41
+ const parseBelongsToSlots = ({ idSlot, typeSlot }) => {
42
+ if (!idSlot || typeof idSlot !== 'string') {
43
+ throw new Error('BelongsTo canonical mapping requires idSlot');
44
+ }
45
+ const idMatch = BELONGS_TO_ID_PATTERN.exec(idSlot);
46
+ if (!idMatch) {
47
+ throw new Error(`Invalid belongsTo id slot '${idSlot}'`);
48
+ }
49
+ const index = Number(idMatch[1]);
50
+ const expectedTypeSlot = `rel_${index}_type`;
51
+ const actualTypeSlot = typeSlot || expectedTypeSlot;
52
+ const typeMatch = BELONGS_TO_TYPE_PATTERN.exec(actualTypeSlot);
53
+ if (!typeMatch || Number(typeMatch[1]) !== index) {
54
+ throw new Error(`BelongsTo type slot '${actualTypeSlot}' must correspond to id slot '${idSlot}'`);
55
+ }
56
+ return { slotType: 'belongsTo', slotIndex: index, idColumn: idSlot, typeColumn: actualTypeSlot };
57
+ };
58
+
59
+ export class AnyapiRegistry {
60
+ constructor({ knex, log } = {}) {
61
+ if (!knex) {
62
+ throw new Error('AnyapiRegistry requires a knex instance');
63
+ }
64
+ this.knex = knex;
65
+ this.log = log || console;
66
+ this.cache = new Map();
67
+ }
68
+
69
+ #key(tenant, resource) {
70
+ return `${tenant}::${resource}`;
71
+ }
72
+
73
+ async registerResource(definition, options = {}) {
74
+ this.#validateDefinition(definition);
75
+ const descriptor = await this.#register(definition, options.transaction);
76
+ const key = this.#key(descriptor.tenant, descriptor.resource);
77
+ this.cache.set(key, descriptor);
78
+ return clone(descriptor);
79
+ }
80
+
81
+ async allocateField({ tenant, resource, fieldName, definition, canonicalField }, options = {}) {
82
+ if (!tenant || !resource || !fieldName || !definition) {
83
+ throw new Error('allocateField requires tenant, resource, fieldName, and definition');
84
+ }
85
+
86
+ const descriptor = await this.getDescriptor(tenant, resource, { bypassCache: true });
87
+ if (!descriptor) {
88
+ throw new Error(`Resource '${resource}' not registered for tenant '${tenant}'`);
89
+ }
90
+
91
+ const trx = options.transaction || await this.knex.transaction();
92
+ const managed = !options.transaction;
93
+
94
+ descriptor.belongsTo = descriptor.belongsTo || {};
95
+ descriptor.canonicalFieldMap = descriptor.canonicalFieldMap || {};
96
+
97
+ const usedSlots = new Set();
98
+ Object.values(descriptor.fields || {}).forEach((info) => {
99
+ if (info?.slot) {
100
+ usedSlots.add(info.slot);
101
+ }
102
+ });
103
+ Object.values(descriptor.belongsTo || {}).forEach((info) => {
104
+ if (info?.typeColumn) {
105
+ usedSlots.add(info.typeColumn);
106
+ }
107
+ if (info?.idColumn) {
108
+ usedSlots.add(info.idColumn);
109
+ }
110
+ });
111
+
112
+ try {
113
+ const resourceRow = await trx('any_resource_configs')
114
+ .where({ tenant_id: tenant, resource })
115
+ .first();
116
+
117
+ if (!resourceRow) {
118
+ throw new Error(`Resource '${resource}' not found in metadata`);
119
+ }
120
+
121
+ if (definition?.belongsToPolymorphic) {
122
+ const alias = definition.as || fieldName;
123
+ const { typeField, idField, types = [] } = definition.belongsToPolymorphic;
124
+
125
+ await trx('any_relationship_configs').insert({
126
+ resource_config_id: resourceRow.id,
127
+ relationship_name: alias,
128
+ relationship_type: 'belongsToPolymorphic',
129
+ target_resource: null,
130
+ slot_index: null,
131
+ id_column: idField,
132
+ type_column: typeField,
133
+ relationship_key: null,
134
+ through: null,
135
+ foreign_key: null,
136
+ other_key: null,
137
+ alias,
138
+ meta_json: JSON.stringify({ types }),
139
+ });
140
+
141
+ if (managed) {
142
+ await trx.commit();
143
+ }
144
+
145
+ descriptor.polymorphicBelongsTo = descriptor.polymorphicBelongsTo || {};
146
+ descriptor.polymorphicBelongsTo[alias] = {
147
+ alias,
148
+ typeField,
149
+ idField,
150
+ types,
151
+ typeColumn: descriptor.fields?.[typeField]?.slot || null,
152
+ idColumn: descriptor.fields?.[idField]?.slot || null,
153
+ };
154
+ this.cache.set(this.#key(tenant, resource), descriptor);
155
+ return clone(descriptor);
156
+ }
157
+
158
+ let override = null;
159
+ if (canonicalField) {
160
+ if (definition.belongsTo) {
161
+ const normalized = typeof canonicalField === 'string'
162
+ ? { idSlot: canonicalField }
163
+ : canonicalField;
164
+ if (!normalized || !normalized.idSlot) {
165
+ throw new Error(`canonicalFieldsMap for belongsTo field '${fieldName}' requires idSlot`);
166
+ }
167
+ const parsed = parseBelongsToSlots(normalized);
168
+ if (usedSlots.has(parsed.idColumn) || usedSlots.has(parsed.typeColumn)) {
169
+ throw new Error(`Canonical slots '${parsed.idColumn}'/'${parsed.typeColumn}' already in use`);
170
+ }
171
+ usedSlots.add(parsed.idColumn);
172
+ usedSlots.add(parsed.typeColumn);
173
+ override = normalized;
174
+ } else {
175
+ const slotColumn = typeof canonicalField === 'string'
176
+ ? canonicalField
177
+ : canonicalField?.slot || canonicalField?.slotColumn;
178
+ if (!slotColumn) {
179
+ throw new Error(`canonicalFieldsMap for field '${fieldName}' must provide a slot column`);
180
+ }
181
+ if (usedSlots.has(slotColumn)) {
182
+ throw new Error(`Canonical slot '${slotColumn}' already in use`);
183
+ }
184
+ usedSlots.add(slotColumn);
185
+ override = { slotColumn };
186
+ }
187
+ }
188
+
189
+ const fieldSlot = this.#assignFieldSlot({ ...definition, fieldName }, descriptor.slotState, override);
190
+ const meta = definition.meta ? JSON.stringify(definition.meta) : null;
191
+
192
+ await trx('any_field_configs').insert({
193
+ resource_config_id: resourceRow.id,
194
+ field_name: fieldName,
195
+ slot_type: fieldSlot.slotType,
196
+ slot_index: fieldSlot.slotIndex,
197
+ slot_column: fieldSlot.slotColumn,
198
+ nullable: definition.nullable === true,
199
+ required: definition.required === true,
200
+ target_resource: definition.belongsTo || null,
201
+ alias: definition.as || null,
202
+ meta_json: meta,
203
+ });
204
+
205
+ if (fieldSlot.relationshipRow) {
206
+ const relationshipRow = {
207
+ resource_config_id: resourceRow.id,
208
+ relationship_name: fieldSlot.relationshipRow.name,
209
+ relationship_type: fieldSlot.relationshipRow.type,
210
+ target_resource: fieldSlot.relationshipRow.target,
211
+ slot_index: fieldSlot.relationshipRow.slotIndex,
212
+ id_column: fieldSlot.relationshipRow.idColumn,
213
+ type_column: fieldSlot.relationshipRow.typeColumn,
214
+ relationship_key: fieldSlot.relationshipRow.relationshipKey,
215
+ through: fieldSlot.relationshipRow.through,
216
+ foreign_key: fieldSlot.relationshipRow.foreignKey,
217
+ other_key: fieldSlot.relationshipRow.otherKey,
218
+ alias: fieldSlot.relationshipRow.alias,
219
+ meta_json: fieldSlot.relationshipRow.meta ? JSON.stringify(fieldSlot.relationshipRow.meta) : null,
220
+ };
221
+ await trx('any_relationship_configs').insert(relationshipRow);
222
+ }
223
+
224
+ if (managed) {
225
+ await trx.commit();
226
+ }
227
+
228
+ descriptor.fields[fieldName] = {
229
+ slot: fieldSlot.slotColumn,
230
+ slotType: fieldSlot.slotType,
231
+ slotIndex: fieldSlot.slotIndex,
232
+ nullable: definition.nullable === true,
233
+ required: definition.required === true,
234
+ target: definition.belongsTo || null,
235
+ alias: definition.as || null,
236
+ };
237
+
238
+ if (fieldSlot.belongsToInfo) {
239
+ descriptor.belongsTo[fieldSlot.belongsToInfo.alias] = fieldSlot.belongsToInfo;
240
+ descriptor.canonicalFieldMap = descriptor.canonicalFieldMap || {};
241
+ descriptor.canonicalFieldMap[fieldName] = {
242
+ idSlot: fieldSlot.slotColumn,
243
+ typeSlot: fieldSlot.belongsToInfo.typeColumn,
244
+ };
245
+ } else {
246
+ descriptor.canonicalFieldMap = descriptor.canonicalFieldMap || {};
247
+ descriptor.canonicalFieldMap[fieldName] = fieldSlot.slotColumn;
248
+ }
249
+
250
+ usedSlots.add(fieldSlot.slotColumn);
251
+ if (fieldSlot.relationshipRow?.typeColumn) {
252
+ usedSlots.add(fieldSlot.relationshipRow.typeColumn);
253
+ }
254
+
255
+ descriptor.slotState = fieldSlot.updatedState;
256
+ this.cache.set(this.#key(tenant, resource), descriptor);
257
+ return clone(descriptor);
258
+ } catch (error) {
259
+ if (managed) {
260
+ await trx.rollback();
261
+ }
262
+ throw error;
263
+ }
264
+ }
265
+
266
+ async getDescriptor(tenant, resource, options = {}) {
267
+ if (!tenant || !resource) return null;
268
+ const key = this.#key(tenant, resource);
269
+ if (!options.bypassCache && this.cache.has(key)) {
270
+ return clone(this.cache.get(key));
271
+ }
272
+
273
+ const descriptor = await this.#loadDescriptor(tenant, resource, options.transaction);
274
+ if (!descriptor) return null;
275
+
276
+ this.cache.set(key, descriptor);
277
+ return clone(descriptor);
278
+ }
279
+
280
+ async listResources(tenant) {
281
+ const query = this.knex('any_resource_configs');
282
+ if (tenant) {
283
+ query.where({ tenant_id: tenant });
284
+ }
285
+ const rows = await query.select('tenant_id', 'resource');
286
+ return rows.map((row) => ({ tenant: row.tenant_id, resource: row.resource }));
287
+ }
288
+
289
+ invalidateDescriptor(tenant, resource) {
290
+ this.cache.delete(this.#key(tenant, resource));
291
+ }
292
+
293
+ #validateDefinition(definition) {
294
+ const { tenant, resource, schema } = definition || {};
295
+ if (!tenant || typeof tenant !== 'string') {
296
+ throw new Error('AnyapiRegistry.registerResource requires a tenant string');
297
+ }
298
+ if (!resource || typeof resource !== 'string') {
299
+ throw new Error('AnyapiRegistry.registerResource requires a resource string');
300
+ }
301
+ if (!schema || typeof schema !== 'object') {
302
+ throw new Error('AnyapiRegistry.registerResource requires a schema object');
303
+ }
304
+ }
305
+
306
+ async #register(definition, externalTrx) {
307
+ const { tenant, resource, schema, relationships = {}, canonicalFieldMap = null } = definition;
308
+ const trx = externalTrx || await this.knex.transaction();
309
+ const managed = !externalTrx;
310
+
311
+ try {
312
+ const now = this.knex.fn.now();
313
+ let resourceRow = await trx('any_resource_configs')
314
+ .where({ tenant_id: tenant, resource })
315
+ .first();
316
+
317
+ const schemaJson = JSON.stringify(schema);
318
+ const relationshipsJson = JSON.stringify(relationships || {});
319
+
320
+ if (resourceRow) {
321
+ await trx('any_resource_configs')
322
+ .where({ id: resourceRow.id })
323
+ .update({
324
+ schema_json: schemaJson,
325
+ relationships_json: relationshipsJson,
326
+ updated_at: now,
327
+ });
328
+ } else {
329
+ const [insertedId] = await trx('any_resource_configs').insert({
330
+ tenant_id: tenant,
331
+ resource,
332
+ schema_json: schemaJson,
333
+ relationships_json: relationshipsJson,
334
+ created_at: now,
335
+ updated_at: now,
336
+ });
337
+
338
+ resourceRow = {
339
+ id: insertedId,
340
+ tenant_id: tenant,
341
+ resource,
342
+ schema_json: schemaJson,
343
+ relationships_json: relationshipsJson,
344
+ };
345
+ }
346
+
347
+ await trx('any_field_configs').where({ resource_config_id: resourceRow.id }).delete();
348
+ await trx('any_relationship_configs').where({ resource_config_id: resourceRow.id }).delete();
349
+
350
+ const slotState = this.#initializeSlotState();
351
+ const usedSlotColumns = new Set();
352
+ let canonicalOverrides = null;
353
+ if (canonicalFieldMap) {
354
+ canonicalOverrides = new Map(Object.entries(canonicalFieldMap));
355
+ }
356
+ const fieldInserts = [];
357
+ const relationshipInserts = [];
358
+
359
+ for (const [fieldName, fieldDef] of Object.entries(schema)) {
360
+ if (fieldName === 'id') {
361
+ if (canonicalOverrides) {
362
+ canonicalOverrides.delete(fieldName);
363
+ }
364
+ continue;
365
+ }
366
+
367
+ if (fieldDef?.belongsToPolymorphic) {
368
+ if (canonicalOverrides?.has(fieldName)) {
369
+ throw new Error(`canonicalFieldsMap should not include polymorphic field '${fieldName}'`);
370
+ }
371
+ const alias = fieldDef.as || fieldName;
372
+ const { typeField, idField, types = [] } = fieldDef.belongsToPolymorphic;
373
+
374
+ relationshipInserts.push({
375
+ resource_config_id: resourceRow.id,
376
+ relationship_name: alias,
377
+ relationship_type: 'belongsToPolymorphic',
378
+ target_resource: null,
379
+ slot_index: null,
380
+ id_column: idField,
381
+ type_column: typeField,
382
+ relationship_key: null,
383
+ through: null,
384
+ foreign_key: null,
385
+ other_key: null,
386
+ alias,
387
+ meta_json: JSON.stringify({ types }),
388
+ created_at: this.knex.fn.now(),
389
+ updated_at: this.knex.fn.now(),
390
+ });
391
+ continue;
392
+ }
393
+
394
+ if (fieldDef?.computed) {
395
+ if (canonicalOverrides?.has(fieldName)) {
396
+ throw new Error(`canonicalFieldsMap should not include computed field '${fieldName}'`);
397
+ }
398
+ continue;
399
+ }
400
+
401
+ if (fieldDef?.virtual === true) {
402
+ if (canonicalOverrides?.has(fieldName)) {
403
+ throw new Error(`canonicalFieldsMap should not include virtual field '${fieldName}'`);
404
+ }
405
+ continue;
406
+ }
407
+
408
+ let override = null;
409
+ if (canonicalOverrides) {
410
+ if (!canonicalOverrides.has(fieldName)) {
411
+ throw new Error(`canonicalFieldsMap missing entry for field '${fieldName}'`);
412
+ }
413
+ const rawOverride = canonicalOverrides.get(fieldName);
414
+ canonicalOverrides.delete(fieldName);
415
+
416
+ if (fieldDef.belongsTo) {
417
+ const normalized = typeof rawOverride === 'string'
418
+ ? { idSlot: rawOverride }
419
+ : rawOverride;
420
+ if (!normalized || !normalized.idSlot) {
421
+ throw new Error(`canonicalFieldsMap for belongsTo field '${fieldName}' requires idSlot`);
422
+ }
423
+ const parsed = parseBelongsToSlots(normalized);
424
+ if (usedSlotColumns.has(parsed.idColumn) || usedSlotColumns.has(parsed.typeColumn)) {
425
+ throw new Error(`Canonical slots '${parsed.idColumn}'/'${parsed.typeColumn}' already in use`);
426
+ }
427
+ usedSlotColumns.add(parsed.idColumn);
428
+ usedSlotColumns.add(parsed.typeColumn);
429
+ override = normalized;
430
+ } else {
431
+ const slotColumn = typeof rawOverride === 'string'
432
+ ? rawOverride
433
+ : rawOverride?.slot || rawOverride?.slotColumn;
434
+ if (!slotColumn) {
435
+ throw new Error(`canonicalFieldsMap for field '${fieldName}' must provide a slot column`);
436
+ }
437
+ const parsed = parseSimpleSlotColumn(slotColumn);
438
+ if (usedSlotColumns.has(slotColumn)) {
439
+ throw new Error(`Canonical slot '${slotColumn}' already in use`);
440
+ }
441
+ usedSlotColumns.add(slotColumn);
442
+ override = { slotColumn, slotType: parsed.slotType, slotIndex: parsed.slotIndex };
443
+ }
444
+ }
445
+
446
+ const allocation = this.#assignFieldSlot({ ...fieldDef, fieldName }, slotState, override);
447
+ if (!allocation) continue;
448
+
449
+ const meta = fieldDef.meta ? JSON.stringify(fieldDef.meta) : null;
450
+ fieldInserts.push({
451
+ resource_config_id: resourceRow.id,
452
+ field_name: fieldName,
453
+ slot_type: allocation.slotType,
454
+ slot_index: allocation.slotIndex,
455
+ slot_column: allocation.slotColumn,
456
+ nullable: fieldDef.nullable === true,
457
+ required: fieldDef.required === true,
458
+ target_resource: fieldDef.belongsTo || null,
459
+ alias: fieldDef.as || null,
460
+ meta_json: meta,
461
+ created_at: this.knex.fn.now(),
462
+ updated_at: this.knex.fn.now(),
463
+ });
464
+
465
+ if (allocation.relationshipRow) {
466
+ const relationshipRow = {
467
+ resource_config_id: resourceRow.id,
468
+ relationship_name: allocation.relationshipRow.name,
469
+ relationship_type: allocation.relationshipRow.type,
470
+ target_resource: allocation.relationshipRow.target,
471
+ slot_index: allocation.relationshipRow.slotIndex,
472
+ id_column: allocation.relationshipRow.idColumn,
473
+ type_column: allocation.relationshipRow.typeColumn,
474
+ relationship_key: allocation.relationshipRow.relationshipKey,
475
+ through: allocation.relationshipRow.through,
476
+ foreign_key: allocation.relationshipRow.foreignKey,
477
+ other_key: allocation.relationshipRow.otherKey,
478
+ alias: allocation.relationshipRow.alias,
479
+ meta_json: allocation.relationshipRow.meta ? JSON.stringify(allocation.relationshipRow.meta) : null,
480
+ created_at: this.knex.fn.now(),
481
+ updated_at: this.knex.fn.now(),
482
+ };
483
+ relationshipInserts.push(relationshipRow);
484
+ }
485
+
486
+ usedSlotColumns.add(allocation.slotColumn);
487
+ if (allocation.relationshipRow?.typeColumn) {
488
+ usedSlotColumns.add(allocation.relationshipRow.typeColumn);
489
+ }
490
+ }
491
+
492
+ if (canonicalOverrides && canonicalOverrides.size > 0) {
493
+ const unknownFields = [...canonicalOverrides.keys()].join(', ');
494
+ throw new Error(`canonicalFieldsMap contains unknown fields: ${unknownFields}`);
495
+ }
496
+
497
+ for (const [relName, relDef] of Object.entries(relationships || {})) {
498
+ if (relDef?.type === 'manyToMany') {
499
+ relationshipInserts.push({
500
+ resource_config_id: resourceRow.id,
501
+ relationship_name: relName,
502
+ relationship_type: 'manyToMany',
503
+ target_resource: relDef.target || relName,
504
+ relationship_key: relDef.relationship || `${tenant}:${resource}:${relName}`,
505
+ through: relDef.through,
506
+ foreign_key: relDef.foreignKey,
507
+ other_key: relDef.otherKey,
508
+ alias: relDef.as || relName,
509
+ meta_json: JSON.stringify(relDef.meta || {}),
510
+ created_at: this.knex.fn.now(),
511
+ updated_at: this.knex.fn.now(),
512
+ });
513
+ }
514
+ }
515
+
516
+ if (fieldInserts.length > 0) {
517
+ await trx('any_field_configs').insert(fieldInserts);
518
+ }
519
+ if (relationshipInserts.length > 0) {
520
+ await trx('any_relationship_configs').insert(relationshipInserts);
521
+ }
522
+
523
+ const descriptor = await this.#loadDescriptor(tenant, resource, trx);
524
+
525
+ if (managed) {
526
+ await trx.commit();
527
+ }
528
+
529
+ return descriptor;
530
+ } catch (error) {
531
+ if (managed) {
532
+ await trx.rollback();
533
+ }
534
+ throw error;
535
+ }
536
+ }
537
+
538
+ async #loadDescriptor(tenant, resource, trx) {
539
+ const query = (trx || this.knex)('any_resource_configs')
540
+ .where({ tenant_id: tenant, resource })
541
+ .first();
542
+
543
+ const resourceRow = await query;
544
+ if (!resourceRow) return null;
545
+
546
+ const fieldRows = await (trx || this.knex)('any_field_configs')
547
+ .where({ resource_config_id: resourceRow.id })
548
+ .select();
549
+
550
+ const relationshipRows = await (trx || this.knex)('any_relationship_configs')
551
+ .where({ resource_config_id: resourceRow.id })
552
+ .select();
553
+
554
+ const slotState = this.#initializeSlotState();
555
+ for (const row of fieldRows) {
556
+ if (row.slot_type === 'belongsTo') {
557
+ slotState.belongsTo = Math.max(slotState.belongsTo, row.slot_index);
558
+ } else if (row.slot_type in slotState) {
559
+ slotState[row.slot_type] = Math.max(slotState[row.slot_type], row.slot_index);
560
+ }
561
+ }
562
+
563
+ const descriptor = this.#buildDescriptor({
564
+ tenant,
565
+ resource,
566
+ schema: JSON.parse(resourceRow.schema_json),
567
+ relationships: JSON.parse(resourceRow.relationships_json || '{}'),
568
+ slotState,
569
+ fieldRows,
570
+ relationshipRows,
571
+ });
572
+
573
+ const canonicalFieldMap = {};
574
+ for (const [fieldName, fieldInfo] of Object.entries(descriptor.fields || {})) {
575
+ if (fieldInfo.slotType === 'belongsTo') {
576
+ const alias = fieldInfo.alias || BELONGS_TO_ALIAS(fieldName, descriptor.schema?.[fieldName] || {});
577
+ const belongsToInfo = descriptor.belongsTo?.[alias];
578
+ canonicalFieldMap[fieldName] = {
579
+ idSlot: fieldInfo.slot,
580
+ typeSlot: belongsToInfo?.typeColumn || `rel_${fieldInfo.slotIndex}_type`,
581
+ };
582
+ } else {
583
+ canonicalFieldMap[fieldName] = fieldInfo.slot;
584
+ }
585
+ }
586
+
587
+ descriptor.canonicalFieldMap = canonicalFieldMap;
588
+
589
+ return descriptor;
590
+ }
591
+
592
+ #buildDescriptor({ tenant, resource, schema, relationships, slotState, fieldRows, relationshipRows }) {
593
+ const fields = {};
594
+ const reverseAttributes = {};
595
+ const belongsTo = {};
596
+ const manyToMany = {};
597
+ const polymorphicBelongsTo = {};
598
+
599
+ for (const row of fieldRows) {
600
+ fields[row.field_name] = {
601
+ slot: row.slot_column,
602
+ slotType: row.slot_type,
603
+ slotIndex: row.slot_index,
604
+ nullable: row.nullable === 1 || row.nullable === true,
605
+ required: row.required === 1 || row.required === true,
606
+ target: row.target_resource,
607
+ alias: row.alias,
608
+ meta: row.meta_json ? JSON.parse(row.meta_json) : undefined,
609
+ };
610
+
611
+ reverseAttributes[row.slot_column] = row.field_name;
612
+ }
613
+
614
+ for (const row of relationshipRows) {
615
+ if (row.relationship_type === 'belongsTo') {
616
+ const alias = row.alias || row.relationship_name;
617
+ belongsTo[alias] = {
618
+ alias,
619
+ field: fields[row.relationship_name]?.field || row.relationship_name,
620
+ target: row.target_resource,
621
+ slotIndex: row.slot_index,
622
+ idColumn: row.id_column,
623
+ typeColumn: row.type_column,
624
+ };
625
+ } else if (row.relationship_type === 'manyToMany') {
626
+ manyToMany[row.relationship_name] = {
627
+ alias: row.alias || row.relationship_name,
628
+ relationship: row.relationship_key,
629
+ target: row.target_resource || row.relationship_name,
630
+ through: row.through,
631
+ foreignKey: row.foreign_key,
632
+ otherKey: row.other_key,
633
+ meta: row.meta_json ? JSON.parse(row.meta_json) : undefined,
634
+ };
635
+ }
636
+ }
637
+
638
+ const schemaObj = schema || {};
639
+ for (const [fieldName, fieldDef] of Object.entries(schemaObj)) {
640
+ if (!fieldDef?.belongsToPolymorphic) continue;
641
+ const alias = fieldDef.as || fieldName;
642
+ const { typeField, idField, types = [] } = fieldDef.belongsToPolymorphic;
643
+ const typeEntry = fields[typeField] || null;
644
+ const idEntry = fields[idField] || null;
645
+ polymorphicBelongsTo[alias] = {
646
+ alias,
647
+ typeField,
648
+ idField,
649
+ typeColumn: typeEntry?.slot || null,
650
+ idColumn: idEntry?.slot || null,
651
+ types,
652
+ };
653
+ }
654
+
655
+ for (const [relName, relDef] of Object.entries(relationships || {})) {
656
+ if (!relDef?.belongsToPolymorphic) continue;
657
+ const alias = relDef.as || relName;
658
+ const { typeField, idField, types = [] } = relDef.belongsToPolymorphic;
659
+ const typeEntry = fields[typeField] || null;
660
+ const idEntry = fields[idField] || null;
661
+ polymorphicBelongsTo[alias] = {
662
+ alias,
663
+ typeField,
664
+ idField,
665
+ typeColumn: typeEntry?.slot || null,
666
+ idColumn: idEntry?.slot || null,
667
+ types,
668
+ };
669
+ }
670
+
671
+ return {
672
+ tenant,
673
+ resource,
674
+ schema,
675
+ relationships,
676
+ canonical: DEFAULT_CANONICAL_CONFIG,
677
+ fields,
678
+ belongsTo,
679
+ manyToMany,
680
+ polymorphicBelongsTo,
681
+ reverseAttributes,
682
+ slotState,
683
+ };
684
+ }
685
+
686
+ #initializeSlotState() {
687
+ return {
688
+ string: 0,
689
+ number: 0,
690
+ boolean: 0,
691
+ date: 0,
692
+ json: 0,
693
+ belongsTo: 0,
694
+ };
695
+ }
696
+
697
+ #assignFieldSlot(fieldDef, slotState, canonicalOverride = null) {
698
+ if (fieldDef.belongsTo) {
699
+ return this.#assignBelongsTo(fieldDef, slotState, canonicalOverride);
700
+ }
701
+
702
+ const { type } = fieldDef;
703
+ if (!type || type === 'id') {
704
+ return null;
705
+ }
706
+ if (!SUPPORTED_TYPES.has(type)) {
707
+ throw new Error(`Unsupported field type '${type}'`);
708
+ }
709
+
710
+ const pool = TYPE_TO_POOL.get(type);
711
+ if (!pool) {
712
+ throw new Error(`No slot pool defined for type '${type}'`);
713
+ }
714
+
715
+ if (canonicalOverride) {
716
+ const slotColumn = typeof canonicalOverride === 'string'
717
+ ? canonicalOverride
718
+ : canonicalOverride.slot || canonicalOverride.slotColumn;
719
+ if (!slotColumn) {
720
+ throw new Error(`Canonical mapping for field '${fieldDef.fieldName}' must provide a slot column`);
721
+ }
722
+ const { slotType, slotIndex } = parseSimpleSlotColumn(slotColumn);
723
+ if (slotType !== pool) {
724
+ throw new Error(
725
+ `Canonical slot '${slotColumn}' does not match expected type '${pool}' for field '${fieldDef.fieldName}'`,
726
+ );
727
+ }
728
+ slotState[pool] = Math.max(slotState[pool], slotIndex);
729
+ return {
730
+ slotType: pool,
731
+ slotIndex,
732
+ slotColumn,
733
+ updatedState: slotState,
734
+ };
735
+ }
736
+
737
+ const nextIndex = slotState[pool] + 1;
738
+ if (nextIndex > SLOT_LIMITS[pool]) {
739
+ throw new Error(`No available ${pool} slots remaining`);
740
+ }
741
+
742
+ slotState[pool] = nextIndex;
743
+ const slotColumn = SLOT_POOLS[pool][nextIndex - 1];
744
+
745
+ return {
746
+ slotType: pool,
747
+ slotIndex: nextIndex,
748
+ slotColumn,
749
+ updatedState: slotState,
750
+ };
751
+ }
752
+
753
+ #assignBelongsTo(fieldDef, slotState, canonicalOverride = null) {
754
+ const alias = BELONGS_TO_ALIAS(fieldDef.fieldName || '', fieldDef);
755
+ const relationshipKey = fieldDef.relationshipKey || null;
756
+
757
+ if (canonicalOverride) {
758
+ const normalized = typeof canonicalOverride === 'string'
759
+ ? { idSlot: canonicalOverride }
760
+ : canonicalOverride;
761
+ const { slotIndex, idColumn, typeColumn } = parseBelongsToSlots(normalized || {});
762
+ slotState.belongsTo = Math.max(slotState.belongsTo, slotIndex);
763
+ return {
764
+ slotType: 'belongsTo',
765
+ slotIndex,
766
+ slotColumn: idColumn,
767
+ relationshipRow: {
768
+ name: alias,
769
+ type: 'belongsTo',
770
+ target: fieldDef.belongsTo,
771
+ slotIndex,
772
+ idColumn,
773
+ typeColumn,
774
+ relationshipKey,
775
+ alias,
776
+ },
777
+ belongsToInfo: {
778
+ alias,
779
+ target: fieldDef.belongsTo,
780
+ slotIndex,
781
+ idColumn,
782
+ typeColumn,
783
+ nullable: fieldDef.nullable === true,
784
+ },
785
+ updatedState: slotState,
786
+ };
787
+ }
788
+
789
+ const nextIndex = slotState.belongsTo + 1;
790
+ if (nextIndex > SLOT_LIMITS.belongsTo) {
791
+ throw new Error('No available belongsTo slots remaining');
792
+ }
793
+
794
+ slotState.belongsTo = nextIndex;
795
+
796
+ return {
797
+ slotType: 'belongsTo',
798
+ slotIndex: nextIndex,
799
+ slotColumn: `rel_${nextIndex}_id`,
800
+ relationshipRow: {
801
+ name: alias,
802
+ type: 'belongsTo',
803
+ target: fieldDef.belongsTo,
804
+ slotIndex: nextIndex,
805
+ idColumn: `rel_${nextIndex}_id`,
806
+ typeColumn: `rel_${nextIndex}_type`,
807
+ relationshipKey,
808
+ alias,
809
+ },
810
+ belongsToInfo: {
811
+ alias,
812
+ target: fieldDef.belongsTo,
813
+ slotIndex: nextIndex,
814
+ idColumn: `rel_${nextIndex}_id`,
815
+ typeColumn: `rel_${nextIndex}_type`,
816
+ nullable: fieldDef.nullable === true,
817
+ },
818
+ updatedState: slotState,
819
+ };
820
+ }
821
+ }