json-rest-api 1.0.10 → 1.0.11

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 +5 -9
  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,2285 @@
1
+ import { ensureAnyApiSchema } from './lib/anyapi/schema-utils.js';
2
+ import { AnyapiRegistry } from './lib/anyapi/anyapi-registry.js';
3
+ import { RestApiValidationError } from '../../lib/rest-api-errors.js';
4
+ import { createSchema } from 'json-rest-schema';
5
+ import {
6
+ DEFAULT_QUERY_LIMIT,
7
+ DEFAULT_MAX_QUERY_LIMIT,
8
+ } from './lib/querying-writing/knex-constants.js';
9
+ import {
10
+ calculatePaginationMeta,
11
+ generatePaginationLinks,
12
+ generateCursorPaginationLinks,
13
+ buildCursorMeta,
14
+ parseCursor,
15
+ } from './lib/querying/knex-pagination-helpers.js';
16
+ import { getUrlPrefix, buildResourceUrl } from './lib/querying/url-helpers.js';
17
+ import {
18
+ normalizeId,
19
+ resolveFieldInfo,
20
+ coerceValueForDefinition,
21
+ normalizeFilterValues,
22
+ ensureFilterableField,
23
+ } from './lib/anyapi/utils/descriptor-helpers.js';
24
+ import {
25
+ AnyapiQueryAdapter,
26
+ preloadRelatedDescriptors,
27
+ } from './lib/anyapi/query/anyapi-query-adapter.js';
28
+ import {
29
+ ensureSearchFieldsAreIndexed,
30
+ generateSearchSchemaFromSchema,
31
+ } from './lib/querying-writing/schema-helpers.js';
32
+ import { buildFieldSelection } from './lib/querying-writing/knex-field-helpers.js';
33
+ import {
34
+ polymorphicFiltersHook,
35
+ crossTableFiltersHook,
36
+ basicFiltersHook,
37
+ } from './lib/querying/knex-query-helpers.js';
38
+ import { createStorageAdapter } from './lib/storage/storage-adapter.js';
39
+
40
+ const DEFAULT_TENANT = 'default';
41
+ const LINKS_TABLE = 'any_links';
42
+
43
+ export const RestApiAnyapiKnexPlugin = {
44
+ name: 'rest-api-anyapi-knex',
45
+ dependencies: ['rest-api'],
46
+
47
+ async install({ helpers, vars, pluginOptions, api, log, addHook, addScopeMethod, scopes }) {
48
+ const options = pluginOptions || {};
49
+ const knex = options.knex;
50
+ const tenantId = options.tenantId || DEFAULT_TENANT;
51
+
52
+ if (!knex) {
53
+ throw new Error('AnyapiKnexPlugin requires a knex instance');
54
+ }
55
+
56
+ await ensureAnyApiSchema(knex);
57
+
58
+ const registry = new AnyapiRegistry({ knex, log });
59
+
60
+ api.anyapi = api.anyapi || {};
61
+ api.anyapi.registry = registry;
62
+ api.anyapi.tenantId = tenantId;
63
+ api.knex = {
64
+ instance: knex,
65
+ helpers: {},
66
+ };
67
+
68
+ const storageAdapterCache = new Map();
69
+
70
+ const getScopeStorageAdapter = (scopeName) => {
71
+ if (!scopeName) return null;
72
+ const resource = api.resources?.[scopeName] || scopes?.[scopeName];
73
+ const schemaInfo = resource?.vars?.schemaInfo;
74
+ if (!schemaInfo) return null;
75
+
76
+ const cached = storageAdapterCache.get(scopeName);
77
+ if (cached && cached.schemaInfo === schemaInfo) {
78
+ return cached.adapter;
79
+ }
80
+
81
+ const adapter = createStorageAdapter({ knex, schemaInfo });
82
+ storageAdapterCache.set(scopeName, { adapter, schemaInfo });
83
+ if (resource?.vars) {
84
+ resource.vars.storageAdapter = adapter;
85
+ }
86
+ return adapter;
87
+ };
88
+
89
+ api.knex.helpers.getStorageAdapter = getScopeStorageAdapter;
90
+ helpers.getStorageAdapter = getScopeStorageAdapter;
91
+
92
+ const scopeOptionsRegistry = new Map();
93
+
94
+ helpers.newTransaction = async () => knex.transaction();
95
+
96
+ addHook('release', 'anyapi-knex-release', {}, async ({ api }) => {
97
+ if (api.knex?.instance) {
98
+ await api.knex.instance.destroy();
99
+ }
100
+ });
101
+
102
+ const getDescriptor = async (scopeName) => {
103
+ const descriptor = await registry.getDescriptor(tenantId, scopeName);
104
+ if (!descriptor) {
105
+ throw new Error(`Descriptor not found for resource '${scopeName}'`);
106
+ }
107
+ return descriptor;
108
+ };
109
+
110
+ const applyFiltersToQuery = ({ query, filters, descriptor, searchSchema, adapter }) => {
111
+ if (!filters || Object.keys(filters).length === 0) return;
112
+
113
+ for (const [field, rawValue] of Object.entries(filters)) {
114
+ const searchDef = searchSchema ? searchSchema[field] : null;
115
+ if (searchDef) {
116
+ const isAdvanced = Boolean(
117
+ searchDef.actualField?.includes('.')
118
+ || (Array.isArray(searchDef.oneOf) && searchDef.oneOf.some((entry) => entry.includes('.')))
119
+ || searchDef.polymorphicField
120
+ || typeof searchDef.applyFilter === 'function'
121
+ );
122
+ if (!isAdvanced && adapter) {
123
+ const columnRef = adapter.translateColumn(searchDef.actualField || field);
124
+ const operator = (searchDef.filterOperator || '=').toLowerCase();
125
+ const normalizedValues = normalizeFilterValues(rawValue, searchDef, {
126
+ isRelationship: Boolean(searchDef.isRelationship),
127
+ });
128
+ const primaryValue = normalizedValues[0];
129
+ switch (operator) {
130
+ case 'like':
131
+ query.whereRaw(`${columnRef} like ?`, [`%${primaryValue}%`]);
132
+ break;
133
+ case 'in':
134
+ if (normalizedValues.includes(null) && normalizedValues.length === 1) {
135
+ query.whereNull(columnRef);
136
+ } else if (normalizedValues.length > 1) {
137
+ query.whereIn(columnRef, normalizedValues);
138
+ } else if (primaryValue === null) {
139
+ query.whereNull(columnRef);
140
+ } else {
141
+ query.whereRaw(`${columnRef} = ?`, [primaryValue]);
142
+ }
143
+ break;
144
+ case 'between':
145
+ if (normalizedValues.length === 2) {
146
+ query.whereBetween(columnRef, normalizedValues);
147
+ } else if (primaryValue === null) {
148
+ query.whereNull(columnRef);
149
+ } else {
150
+ query.whereRaw(`${columnRef} = ?`, [primaryValue]);
151
+ }
152
+ break;
153
+ default:
154
+ if (primaryValue === null) {
155
+ query.whereNull(columnRef);
156
+ } else {
157
+ query.whereRaw(`${columnRef} ${operator || '='} ?`, [primaryValue]);
158
+ }
159
+ break;
160
+ }
161
+ continue;
162
+ }
163
+ // Leave advanced search fields to downstream hooks
164
+ continue;
165
+ }
166
+ const fieldInfo = ensureFilterableField(descriptor, field);
167
+
168
+ const values = normalizeFilterValues(rawValue, fieldInfo.definition, {
169
+ isRelationship: fieldInfo.isRelationship,
170
+ });
171
+
172
+ const nonNullValues = values.filter((value) => value !== null);
173
+ const hasNull = values.length !== nonNullValues.length;
174
+
175
+ if (nonNullValues.length === 0 && hasNull) {
176
+ query.whereNull(fieldInfo.column);
177
+ continue;
178
+ }
179
+
180
+ if (nonNullValues.length === 1 && !hasNull) {
181
+ query.where(fieldInfo.column, nonNullValues[0]);
182
+ continue;
183
+ }
184
+
185
+ if (nonNullValues.length > 1 && !hasNull) {
186
+ query.whereIn(fieldInfo.column, nonNullValues);
187
+ continue;
188
+ }
189
+
190
+ if (nonNullValues.length > 0 && hasNull) {
191
+ query.where(function filterGroup() {
192
+ this.whereIn(fieldInfo.column, nonNullValues).orWhereNull(fieldInfo.column);
193
+ });
194
+ }
195
+ }
196
+ };
197
+
198
+ const applySortingToQuery = ({ query, sort, descriptor, scope }) => {
199
+ let sortList = Array.isArray(sort) ? sort : (sort ? [sort] : []);
200
+
201
+ if (sortList.length === 0 && scope?.vars?.defaultSort) {
202
+ const defaultSort = scope.vars.defaultSort;
203
+ if (Array.isArray(defaultSort)) {
204
+ sortList = defaultSort;
205
+ } else if (typeof defaultSort === 'string') {
206
+ sortList = [defaultSort];
207
+ } else if (defaultSort && typeof defaultSort === 'object') {
208
+ const field = defaultSort.field || defaultSort.column || 'id';
209
+ const direction = (defaultSort.direction || '').toLowerCase() === 'desc' ? '-' : '';
210
+ sortList = [`${direction}${field}`];
211
+ }
212
+ }
213
+
214
+ const effectiveSort = sortList.length > 0 ? sortList : ['id'];
215
+ const descriptors = [];
216
+
217
+ for (const entry of effectiveSort) {
218
+ const desc = typeof entry === 'string' && entry.startsWith('-');
219
+ const field = typeof entry === 'string' ? (desc ? entry.slice(1) : entry) : entry;
220
+ const fieldInfo = resolveFieldInfo(descriptor, field);
221
+ if (!fieldInfo?.column) continue;
222
+ query.orderBy(fieldInfo.column, desc ? 'desc' : 'asc');
223
+ descriptors.push({
224
+ field,
225
+ column: fieldInfo.column,
226
+ direction: desc ? 'desc' : 'asc',
227
+ definition: fieldInfo.definition || null,
228
+ isRelationship: fieldInfo.isRelationship || false,
229
+ });
230
+ }
231
+
232
+ if (descriptors.length === 0) {
233
+ descriptors.push({
234
+ field: 'id',
235
+ column: 'id',
236
+ direction: 'asc',
237
+ definition: { type: 'id' },
238
+ isRelationship: false,
239
+ });
240
+ }
241
+
242
+ return descriptors;
243
+ };
244
+
245
+ const buildQueryString = (queryParams = {}) => {
246
+ const buildParts = (prefix, value) => {
247
+ const parts = [];
248
+ if (Array.isArray(value)) {
249
+ if (value.length === 0) return parts;
250
+ parts.push(`${prefix}=${value.map((item) => encodeURIComponent(item)).join(',')}`);
251
+ return parts;
252
+ }
253
+ if (value && typeof value === 'object') {
254
+ for (const [key, subValue] of Object.entries(value)) {
255
+ if (subValue === undefined || subValue === null) continue;
256
+ const newPrefix = `${prefix}[${key}]`;
257
+ parts.push(...buildParts(newPrefix, subValue));
258
+ }
259
+ return parts;
260
+ }
261
+ if (value === undefined || value === null) return parts;
262
+ parts.push(`${prefix}=${encodeURIComponent(value)}`);
263
+ return parts;
264
+ };
265
+
266
+ const parts = [];
267
+ for (const [key, value] of Object.entries(queryParams)) {
268
+ parts.push(...buildParts(key, value));
269
+ }
270
+ return parts.length > 0 ? `?${parts.join('&')}` : '';
271
+ };
272
+
273
+ const applyPaginationToQuery = ({
274
+ query,
275
+ scope,
276
+ queryParams,
277
+ sortDescriptors,
278
+ adapter,
279
+ }) => {
280
+ const pageParams = queryParams?.page || {};
281
+ const scopeVars = scope?.vars || {};
282
+ const defaultLimit = scopeVars.queryDefaultLimit || DEFAULT_QUERY_LIMIT;
283
+ const maxLimit = scopeVars.queryMaxLimit || DEFAULT_MAX_QUERY_LIMIT;
284
+
285
+ const descriptors = (sortDescriptors && sortDescriptors.length > 0)
286
+ ? sortDescriptors
287
+ : [{
288
+ field: 'id',
289
+ column: 'id',
290
+ direction: 'asc',
291
+ definition: { type: 'id' },
292
+ isRelationship: false,
293
+ }];
294
+
295
+ const hasCursorParam = pageParams.after !== undefined || pageParams.before !== undefined;
296
+ const hasPageSize = pageParams.size !== undefined;
297
+ const hasPageNumber = pageParams.number !== undefined;
298
+
299
+ const parseCursorOrThrow = (rawCursor, paramName) => {
300
+ try {
301
+ return parseCursor(rawCursor);
302
+ } catch (error) {
303
+ throw new RestApiValidationError(
304
+ `Invalid cursor format in ${paramName} parameter`,
305
+ {
306
+ fields: [`page.${paramName}`],
307
+ violations: [{
308
+ field: `page.${paramName}`,
309
+ rule: 'invalid_cursor',
310
+ message: error.message,
311
+ }],
312
+ },
313
+ );
314
+ }
315
+ };
316
+
317
+ const coerceCursorValues = (cursorMap) => {
318
+ const typed = {};
319
+ for (const descriptor of descriptors) {
320
+ if (descriptor.field === undefined) continue;
321
+ const raw = cursorMap[descriptor.field];
322
+ if (raw === undefined) continue;
323
+ typed[descriptor.field] = coerceValueForDefinition(
324
+ raw,
325
+ descriptor.definition,
326
+ { isRelationship: descriptor.isRelationship },
327
+ );
328
+ }
329
+ return typed;
330
+ };
331
+
332
+ const buildCursorPredicate = (cursorValues, operatorSelector) => {
333
+ if (!descriptors || descriptors.length === 0) {
334
+ return;
335
+ }
336
+
337
+ if (descriptors.length === 1) {
338
+ const descriptor = descriptors[0];
339
+ const value = cursorValues[descriptor.field];
340
+ if (value === undefined) return;
341
+ const columnRef = adapter ? adapter.translateColumn(descriptor.column) : descriptor.column;
342
+ const operator = operatorSelector(descriptor.direction);
343
+ if (adapter) {
344
+ query.whereRaw(`${columnRef} ${operator} ?`, [value]);
345
+ } else {
346
+ query.where(columnRef, operator, value);
347
+ }
348
+ return;
349
+ }
350
+
351
+ const translatedConditions = [];
352
+
353
+ descriptors.forEach((descriptor, index) => {
354
+ const value = cursorValues[descriptor.field];
355
+ if (value === undefined) return;
356
+
357
+ const chain = [];
358
+ for (let i = 0; i < index; i += 1) {
359
+ const prev = descriptors[i];
360
+ const prevValue = cursorValues[prev.field];
361
+ if (prevValue === undefined) return;
362
+ const prevColumn = adapter ? adapter.translateColumn(prev.column) : prev.column;
363
+ chain.push({ column: prevColumn, operator: '=', value: prevValue });
364
+ }
365
+
366
+ const operator = operatorSelector(descriptor.direction);
367
+ const columnRef = adapter ? adapter.translateColumn(descriptor.column) : descriptor.column;
368
+ chain.push({ column: columnRef, operator, value });
369
+ translatedConditions.push(chain);
370
+ });
371
+
372
+ if (translatedConditions.length === 0) {
373
+ return;
374
+ }
375
+
376
+ const predicateParts = [];
377
+ const predicateBindings = [];
378
+ translatedConditions.forEach((chain) => {
379
+ const chainParts = chain.map(({ column, operator, value }) => {
380
+ predicateBindings.push(value);
381
+ return `${column} ${operator} ?`;
382
+ });
383
+ predicateParts.push(`(${chainParts.join(' AND ')})`);
384
+ });
385
+
386
+ if (predicateParts.length > 0) {
387
+ const combined = predicateParts.join(' OR ');
388
+ query.whereRaw(`(${combined})`, predicateBindings);
389
+ }
390
+ };
391
+
392
+ const ensurePageSize = () => {
393
+ const requestedSize = Number(pageParams.size ?? defaultLimit);
394
+ if (!Number.isFinite(requestedSize) || requestedSize <= 0) {
395
+ throw new RestApiValidationError('Page size must be greater than 0', {
396
+ fields: ['page.size'],
397
+ violations: [{
398
+ field: 'page.size',
399
+ rule: 'min_value',
400
+ message: 'Page size must be a positive number',
401
+ }],
402
+ });
403
+ }
404
+ return Math.min(Math.trunc(requestedSize), maxLimit);
405
+ };
406
+
407
+ if (hasCursorParam || (hasPageSize && !hasPageNumber)) {
408
+ if (pageParams.after && pageParams.before) {
409
+ throw new RestApiValidationError(
410
+ 'page[after] and page[before] cannot be used together',
411
+ {
412
+ fields: ['page.after', 'page.before'],
413
+ violations: [{
414
+ field: 'page.after',
415
+ rule: 'conflict',
416
+ message: 'Provide either page[after] or page[before], not both',
417
+ }],
418
+ },
419
+ );
420
+ }
421
+
422
+ const pageSize = ensurePageSize();
423
+ query.limit(pageSize + 1);
424
+
425
+ if (pageParams.after) {
426
+ const cursorMap = parseCursorOrThrow(pageParams.after, 'after');
427
+ const cursorValues = coerceCursorValues(cursorMap);
428
+ buildCursorPredicate(cursorValues, (direction) => (direction === 'desc' ? '<' : '>'));
429
+ } else if (pageParams.before) {
430
+ const cursorMap = parseCursorOrThrow(pageParams.before, 'before');
431
+ const cursorValues = coerceCursorValues(cursorMap);
432
+ buildCursorPredicate(cursorValues, (direction) => (direction === 'desc' ? '>' : '<'));
433
+ }
434
+
435
+ return {
436
+ mode: 'cursor',
437
+ pageSize,
438
+ sortDescriptors: descriptors,
439
+ };
440
+ }
441
+
442
+ if (hasPageSize || hasPageNumber) {
443
+ const pageSize = ensurePageSize();
444
+ const pageNumber = Math.trunc(Number(pageParams.number ?? 1));
445
+ const safePageNumber = Number.isFinite(pageNumber) && pageNumber > 0 ? pageNumber : 1;
446
+ const offset = (safePageNumber - 1) * pageSize;
447
+
448
+ query.limit(pageSize).offset(offset);
449
+
450
+ return {
451
+ mode: 'offset',
452
+ page: safePageNumber,
453
+ pageSize,
454
+ sortDescriptors: descriptors,
455
+ };
456
+ }
457
+
458
+ query.limit(defaultLimit);
459
+ return {
460
+ mode: 'default',
461
+ page: 1,
462
+ pageSize: defaultLimit,
463
+ sortDescriptors: descriptors,
464
+ };
465
+ };
466
+
467
+ const canonicalizeLinkPair = ({
468
+ tenantId,
469
+ relationshipKey,
470
+ inverseRelationshipKey,
471
+ leftResource,
472
+ leftId,
473
+ rightResource,
474
+ rightId,
475
+ }) => {
476
+ const normalizedLeftId = normalizeId(leftId);
477
+ const normalizedRightId = normalizeId(rightId);
478
+
479
+ if (normalizedLeftId == null || normalizedRightId == null) {
480
+ return null;
481
+ }
482
+
483
+ if (inverseRelationshipKey && inverseRelationshipKey < relationshipKey) {
484
+ return {
485
+ tenant_id: tenantId,
486
+ relationship: inverseRelationshipKey,
487
+ inverse_relationship: relationshipKey,
488
+ left_resource: rightResource,
489
+ left_id: normalizedRightId,
490
+ right_resource: leftResource,
491
+ right_id: normalizedLeftId,
492
+ };
493
+ }
494
+
495
+ return {
496
+ tenant_id: tenantId,
497
+ relationship: relationshipKey,
498
+ inverse_relationship: inverseRelationshipKey || null,
499
+ left_resource: leftResource,
500
+ left_id: normalizedLeftId,
501
+ right_resource: rightResource,
502
+ right_id: normalizedRightId,
503
+ };
504
+ };
505
+
506
+ const buildLinkIdentity = (canonicalRow) => ({
507
+ tenant_id: canonicalRow.tenant_id,
508
+ relationship: canonicalRow.relationship,
509
+ left_resource: canonicalRow.left_resource,
510
+ left_id: canonicalRow.left_id,
511
+ right_resource: canonicalRow.right_resource,
512
+ right_id: canonicalRow.right_id,
513
+ });
514
+
515
+ const findInverseManyToMany = async ({ descriptor, relInfo }) => {
516
+ if (!relInfo?.target) return null;
517
+
518
+ let targetDescriptor;
519
+ try {
520
+ targetDescriptor = await getDescriptor(relInfo.target);
521
+ } catch (error) {
522
+ return null;
523
+ }
524
+
525
+ const entries = Object.entries(targetDescriptor.manyToMany || {});
526
+ for (const [candidateName, candidateInfo] of entries) {
527
+ const candidateTarget = candidateInfo.target || descriptor.resource;
528
+ if (candidateTarget !== descriptor.resource) continue;
529
+
530
+ if (relInfo.through && candidateInfo.through && relInfo.through !== candidateInfo.through) {
531
+ continue;
532
+ }
533
+
534
+ if (
535
+ relInfo.foreignKey && relInfo.otherKey &&
536
+ candidateInfo.foreignKey && candidateInfo.otherKey
537
+ ) {
538
+ const foreignMatches = relInfo.foreignKey === candidateInfo.otherKey;
539
+ const otherMatches = relInfo.otherKey === candidateInfo.foreignKey;
540
+ if (!foreignMatches || !otherMatches) continue;
541
+ }
542
+
543
+ const relationshipKey = candidateInfo.relationship
544
+ || `${targetDescriptor.tenant}:${targetDescriptor.resource}:${candidateName}`;
545
+
546
+ return {
547
+ descriptor: targetDescriptor,
548
+ relName: candidateName,
549
+ relInfo: candidateInfo,
550
+ relationshipKey,
551
+ };
552
+ }
553
+
554
+ return null;
555
+ };
556
+
557
+ const getManyToManyInfo = async (scopeName, relName) => {
558
+ const descriptor = await getDescriptor(scopeName);
559
+ const relInfo = descriptor.manyToMany?.[relName];
560
+ if (!relInfo) return null;
561
+ const relationshipKey = relInfo.relationship || `${descriptor.tenant}:${descriptor.resource}:${relName}`;
562
+ const inverse = await findInverseManyToMany({ descriptor, relInfo });
563
+ const inverseRelationshipKey = inverse?.relationshipKey || null;
564
+ return { descriptor, relInfo, relationshipKey, inverseRelationshipKey };
565
+ };
566
+
567
+ const loadLinkRowsForResource = async ({ descriptor, relationshipKey, resourceId, db }) => {
568
+ const ownerId = normalizeId(resourceId);
569
+ if (ownerId == null) return new Map();
570
+
571
+ const rows = await db(LINKS_TABLE)
572
+ .where('tenant_id', descriptor.tenant)
573
+ .andWhere((builder) => {
574
+ builder
575
+ .where((q) => {
576
+ q.where('relationship', relationshipKey)
577
+ .andWhere('left_resource', descriptor.resource)
578
+ .andWhere('left_id', ownerId);
579
+ })
580
+ .orWhere((q) => {
581
+ q.where('inverse_relationship', relationshipKey)
582
+ .andWhere('right_resource', descriptor.resource)
583
+ .andWhere('right_id', ownerId);
584
+ });
585
+ })
586
+ .select('id', 'relationship', 'inverse_relationship', 'left_resource', 'left_id', 'right_resource', 'right_id');
587
+
588
+ const map = new Map();
589
+
590
+ for (const row of rows) {
591
+ let relatedResource;
592
+ let relatedId;
593
+ let otherKey = null;
594
+
595
+ if (row.relationship === relationshipKey && row.left_resource === descriptor.resource) {
596
+ relatedResource = row.right_resource;
597
+ relatedId = normalizeId(row.right_id);
598
+ otherKey = row.inverse_relationship || null;
599
+ } else if (row.inverse_relationship === relationshipKey && row.right_resource === descriptor.resource) {
600
+ relatedResource = row.left_resource;
601
+ relatedId = normalizeId(row.left_id);
602
+ otherKey = row.relationship || null;
603
+ } else {
604
+ continue;
605
+ }
606
+
607
+ if (relatedId == null) continue;
608
+
609
+ const canonical = canonicalizeLinkPair({
610
+ tenantId: descriptor.tenant,
611
+ relationshipKey,
612
+ inverseRelationshipKey: otherKey,
613
+ leftResource: descriptor.resource,
614
+ leftId: ownerId,
615
+ rightResource: relatedResource,
616
+ rightId: relatedId,
617
+ });
618
+
619
+ if (!canonical) continue;
620
+
621
+ map.set(relatedId, {
622
+ rowId: row.id,
623
+ relatedResource,
624
+ canonical,
625
+ otherKey,
626
+ });
627
+ }
628
+
629
+ return map;
630
+ };
631
+
632
+ const fetchLinksForParents = async ({ descriptor, relationshipKey, parentIds, db }) => {
633
+ const normalizedIds = parentIds
634
+ .map((id) => normalizeId(id))
635
+ .filter((id) => id !== null);
636
+
637
+ if (normalizedIds.length === 0) return [];
638
+
639
+ const rows = await db(LINKS_TABLE)
640
+ .where('tenant_id', descriptor.tenant)
641
+ .andWhere((builder) => {
642
+ builder
643
+ .where((q) => {
644
+ q.where('relationship', relationshipKey)
645
+ .andWhere('left_resource', descriptor.resource)
646
+ .whereIn('left_id', normalizedIds);
647
+ })
648
+ .orWhere((q) => {
649
+ q.where('inverse_relationship', relationshipKey)
650
+ .andWhere('right_resource', descriptor.resource)
651
+ .whereIn('right_id', normalizedIds);
652
+ });
653
+ })
654
+ .select('relationship', 'inverse_relationship', 'left_resource', 'left_id', 'right_resource', 'right_id');
655
+
656
+ const results = [];
657
+
658
+ for (const row of rows) {
659
+ let parentId;
660
+ let childId;
661
+ let childType;
662
+
663
+ if (row.relationship === relationshipKey && row.left_resource === descriptor.resource) {
664
+ parentId = normalizeId(row.left_id);
665
+ childId = normalizeId(row.right_id);
666
+ childType = row.right_resource;
667
+ } else if (row.inverse_relationship === relationshipKey && row.right_resource === descriptor.resource) {
668
+ parentId = normalizeId(row.right_id);
669
+ childId = normalizeId(row.left_id);
670
+ childType = row.left_resource;
671
+ } else {
672
+ continue;
673
+ }
674
+
675
+ if (parentId == null || childId == null) continue;
676
+
677
+ results.push({ parentId, childId, childType });
678
+ }
679
+
680
+ return results;
681
+ };
682
+
683
+ const ensureTargetsExist = async ({ relInfo, relData, transaction }) => {
684
+ const relArray = Array.isArray(relData) ? relData : [];
685
+ if (relArray.length === 0) return;
686
+ const targetScope = api.resources[relInfo.target];
687
+ if (!targetScope) {
688
+ throw new Error(`Target resource '${relInfo.target}' not found`);
689
+ }
690
+ for (const identifier of relArray) {
691
+ if (!identifier?.id) {
692
+ throw new Error('Relationship data requires resource identifier with id');
693
+ }
694
+ if (identifier.type && identifier.type !== relInfo.target) {
695
+ throw new Error(`Relationship expects type '${relInfo.target}' but received '${identifier.type}'`);
696
+ }
697
+ await targetScope.get({ id: identifier.id, transaction, simplified: false });
698
+ }
699
+ };
700
+
701
+ const attachLinks = async ({ descriptor, relInfo, relationshipKey, inverseRelationshipKey, leftId, relData, db }) => {
702
+ const relArray = Array.isArray(relData) ? relData : [];
703
+ if (relArray.length === 0) return;
704
+
705
+ for (const identifier of relArray) {
706
+ const rightId = normalizeId(identifier.id);
707
+ if (rightId == null) continue;
708
+
709
+ const canonical = canonicalizeLinkPair({
710
+ tenantId: descriptor.tenant,
711
+ relationshipKey,
712
+ inverseRelationshipKey,
713
+ leftResource: descriptor.resource,
714
+ leftId,
715
+ rightResource: relInfo.target,
716
+ rightId,
717
+ });
718
+
719
+ if (!canonical) continue;
720
+
721
+ const identity = buildLinkIdentity(canonical);
722
+
723
+ const existing = await db(LINKS_TABLE)
724
+ .where(identity)
725
+ .first();
726
+
727
+ if (existing) {
728
+ if (!existing.inverse_relationship && canonical.inverse_relationship) {
729
+ await db(LINKS_TABLE)
730
+ .where({ id: existing.id })
731
+ .update({
732
+ inverse_relationship: canonical.inverse_relationship,
733
+ updated_at: db.fn.now(),
734
+ });
735
+ }
736
+ continue;
737
+ }
738
+
739
+ await db(LINKS_TABLE).insert({
740
+ ...canonical,
741
+ payload: null,
742
+ created_at: db.fn.now(),
743
+ updated_at: db.fn.now(),
744
+ });
745
+ }
746
+ };
747
+
748
+ api.anyapi.links = {
749
+ attachMany: async ({ context, scopeName, relName, relDef, relData }) => {
750
+ const info = await getManyToManyInfo(scopeName, relName);
751
+ if (!info) {
752
+ throw new Error(`Many-to-many relationship '${relName}' not found on '${scopeName}'`);
753
+ }
754
+ const db = context.transaction || context.db || api.knex.instance;
755
+ const leftId = normalizeId(context.id);
756
+ await ensureTargetsExist({ relInfo: info.relInfo, relData, transaction: context.transaction });
757
+ await attachLinks({
758
+ descriptor: info.descriptor,
759
+ relInfo: info.relInfo,
760
+ relationshipKey: info.relationshipKey,
761
+ inverseRelationshipKey: info.inverseRelationshipKey,
762
+ leftId,
763
+ relData,
764
+ db,
765
+ });
766
+ },
767
+ syncMany: async ({ context, scopeName, relName, relDef, relData, isUpdate }) => {
768
+ const info = await getManyToManyInfo(scopeName, relName);
769
+ if (!info) {
770
+ throw new Error(`Many-to-many relationship '${relName}' not found on '${scopeName}'`);
771
+ }
772
+ const db = context.transaction || context.db || api.knex.instance;
773
+ const leftId = normalizeId(context.id);
774
+ await ensureTargetsExist({ relInfo: info.relInfo, relData, transaction: context.transaction });
775
+ if (isUpdate) {
776
+ await syncLinks({
777
+ descriptor: info.descriptor,
778
+ relInfo: info.relInfo,
779
+ relationshipKey: info.relationshipKey,
780
+ inverseRelationshipKey: info.inverseRelationshipKey,
781
+ leftId,
782
+ relData,
783
+ db,
784
+ });
785
+ } else {
786
+ await attachLinks({
787
+ descriptor: info.descriptor,
788
+ relInfo: info.relInfo,
789
+ relationshipKey: info.relationshipKey,
790
+ inverseRelationshipKey: info.inverseRelationshipKey,
791
+ leftId,
792
+ relData,
793
+ db,
794
+ });
795
+ }
796
+ },
797
+ removeMany: async ({ context, scopeName, relName, relData }) => {
798
+ const info = await getManyToManyInfo(scopeName, relName);
799
+ if (!info) {
800
+ throw new Error(`Many-to-many relationship '${relName}' not found on '${scopeName}'`);
801
+ }
802
+ const db = context.transaction || context.db || api.knex.instance;
803
+ const leftId = normalizeId(context.id);
804
+ await removeLinks({
805
+ descriptor: info.descriptor,
806
+ relInfo: info.relInfo,
807
+ relationshipKey: info.relationshipKey,
808
+ inverseRelationshipKey: info.inverseRelationshipKey,
809
+ leftId,
810
+ relData,
811
+ db,
812
+ });
813
+ },
814
+ listMany: async ({ context, scopeName, relName }) => {
815
+ const info = await getManyToManyInfo(scopeName, relName);
816
+ if (!info) {
817
+ throw new Error(`Many-to-many relationship '${relName}' not found on '${scopeName}'`);
818
+ }
819
+ const db = context.transaction || context.db || api.knex.instance;
820
+ const leftId = normalizeId(context.id);
821
+ return listLinks({
822
+ descriptor: info.descriptor,
823
+ relInfo: info.relInfo,
824
+ relationshipKey: info.relationshipKey,
825
+ inverseRelationshipKey: info.inverseRelationshipKey,
826
+ leftId,
827
+ db,
828
+ });
829
+ },
830
+ fetchManyToManyRows: async ({ scopeName, relName, parentIds, context }) => {
831
+ const info = await getManyToManyInfo(scopeName, relName);
832
+ if (!info) return [];
833
+ const db = context.db || context.transaction || api.knex.instance;
834
+ return fetchLinksForParents({
835
+ descriptor: info.descriptor,
836
+ relationshipKey: info.relationshipKey,
837
+ parentIds,
838
+ db,
839
+ });
840
+ },
841
+ };
842
+
843
+ const syncLinks = async ({ descriptor, relInfo, relationshipKey, inverseRelationshipKey, leftId, relData, db }) => {
844
+ const relArray = Array.isArray(relData) ? relData : [];
845
+ const existingMap = await loadLinkRowsForResource({
846
+ descriptor,
847
+ relationshipKey,
848
+ resourceId: leftId,
849
+ db,
850
+ });
851
+
852
+ const desired = new Set(
853
+ relArray
854
+ .map((identifier) => normalizeId(identifier.id))
855
+ .filter((id) => id !== null),
856
+ );
857
+
858
+ const existingIds = new Set(existingMap.keys());
859
+
860
+ const toAdd = [...desired].filter((id) => !existingIds.has(id));
861
+ const toRemove = [...existingIds].filter((id) => !desired.has(id));
862
+
863
+ if (toAdd.length > 0) {
864
+ const data = toAdd.map((id) => ({ id }));
865
+ await attachLinks({
866
+ descriptor,
867
+ relInfo,
868
+ relationshipKey,
869
+ inverseRelationshipKey,
870
+ leftId,
871
+ relData: data,
872
+ db,
873
+ });
874
+ }
875
+
876
+ if (toRemove.length > 0) {
877
+ for (const id of toRemove) {
878
+ const entry = existingMap.get(id);
879
+ if (!entry?.canonical) continue;
880
+ await db(LINKS_TABLE)
881
+ .where(buildLinkIdentity(entry.canonical))
882
+ .delete();
883
+ }
884
+ }
885
+ };
886
+
887
+ const removeLinks = async ({ descriptor, relInfo, relationshipKey, inverseRelationshipKey, leftId, relData, db }) => {
888
+ const relArray = Array.isArray(relData) ? relData : [];
889
+ if (relArray.length === 0) return;
890
+
891
+ const existingMap = await loadLinkRowsForResource({
892
+ descriptor,
893
+ relationshipKey,
894
+ resourceId: leftId,
895
+ db,
896
+ });
897
+
898
+ for (const identifier of relArray) {
899
+ const relatedId = normalizeId(identifier.id);
900
+ if (relatedId == null) continue;
901
+
902
+ const entry = existingMap.get(relatedId);
903
+ if (entry?.canonical) {
904
+ await db(LINKS_TABLE)
905
+ .where(buildLinkIdentity(entry.canonical))
906
+ .delete();
907
+ continue;
908
+ }
909
+
910
+ const canonical = canonicalizeLinkPair({
911
+ tenantId: descriptor.tenant,
912
+ relationshipKey,
913
+ inverseRelationshipKey,
914
+ leftResource: descriptor.resource,
915
+ leftId,
916
+ rightResource: relInfo.target,
917
+ rightId: relatedId,
918
+ });
919
+
920
+ if (!canonical) continue;
921
+
922
+ await db(LINKS_TABLE)
923
+ .where(buildLinkIdentity(canonical))
924
+ .delete();
925
+ }
926
+ };
927
+
928
+ const listLinks = async ({ descriptor, relInfo, relationshipKey, leftId, db }) => {
929
+ const existingMap = await loadLinkRowsForResource({
930
+ descriptor,
931
+ relationshipKey,
932
+ resourceId: leftId,
933
+ db,
934
+ });
935
+
936
+ return [...existingMap.entries()].map(([relatedId, entry]) => ({
937
+ type: entry.relatedResource || relInfo.target,
938
+ id: relatedId,
939
+ }));
940
+ };
941
+
942
+ helpers.dataExists = async ({ scopeName, context }) => {
943
+ const storageAdapter = getScopeStorageAdapter(scopeName);
944
+ if (storageAdapter) {
945
+ context.storageAdapter = storageAdapter;
946
+ }
947
+ const descriptor = await getDescriptor(scopeName);
948
+ const { canonical } = descriptor;
949
+ const id = context.id;
950
+
951
+ const row = await context.db(canonical.tableName)
952
+ .select('id')
953
+ .where(canonical.tenantColumn, descriptor.tenant)
954
+ .where(canonical.resourceColumn, descriptor.resource)
955
+ .where('id', id)
956
+ .first();
957
+
958
+ return !!row;
959
+ };
960
+
961
+ helpers.dataPost = async ({ scopeName, context }) => {
962
+ const storageAdapter = getScopeStorageAdapter(scopeName);
963
+ if (storageAdapter) {
964
+ context.storageAdapter = storageAdapter;
965
+ }
966
+ const descriptor = await getDescriptor(scopeName);
967
+ const { canonical } = descriptor;
968
+ const attributes = context.inputRecord?.data?.attributes || {};
969
+ const row = translateAttributesForStorage(attributes, descriptor);
970
+ row[canonical.tenantColumn] = descriptor.tenant;
971
+ row[canonical.resourceColumn] = descriptor.resource;
972
+
973
+ const result = await context.db(canonical.tableName)
974
+ .insert(row)
975
+ .returning('id');
976
+
977
+ const inserted = Array.isArray(result) ? result[0] : result;
978
+ if (inserted && typeof inserted === 'object' && 'id' in inserted) {
979
+ return inserted.id;
980
+ }
981
+ return inserted;
982
+ };
983
+
984
+ helpers.dataPut = async ({ scopeName, context }) => {
985
+ const storageAdapter = getScopeStorageAdapter(scopeName);
986
+ if (storageAdapter) {
987
+ context.storageAdapter = storageAdapter;
988
+ }
989
+ const descriptor = await getDescriptor(scopeName);
990
+ const { canonical } = descriptor;
991
+ const id = context.id;
992
+ const attributes = context.inputRecord?.data?.attributes || {};
993
+ const row = translateAttributesForStorage(attributes, descriptor);
994
+
995
+ row[canonical.resourceColumn] = descriptor.resource;
996
+ row[canonical.tenantColumn] = descriptor.tenant;
997
+
998
+ const updateRow = Object.fromEntries(
999
+ Object.entries(row).filter(([, value]) => value !== undefined)
1000
+ );
1001
+
1002
+ if (Object.keys(updateRow).length === 0) {
1003
+ return 0;
1004
+ }
1005
+
1006
+ const result = await context.db(canonical.tableName)
1007
+ .where('id', id)
1008
+ .where(canonical.resourceColumn, descriptor.resource)
1009
+ .where(canonical.tenantColumn, descriptor.tenant)
1010
+ .update(updateRow);
1011
+
1012
+ return result;
1013
+ };
1014
+
1015
+ helpers.dataPatch = async ({ scopeName, context }) => {
1016
+ const storageAdapter = getScopeStorageAdapter(scopeName);
1017
+ if (storageAdapter) {
1018
+ context.storageAdapter = storageAdapter;
1019
+ }
1020
+ const descriptor = await getDescriptor(scopeName);
1021
+ const { canonical } = descriptor;
1022
+ const id = context.id;
1023
+ const attributes = context.inputRecord?.data?.attributes || {};
1024
+ const row = translateAttributesForStorage(attributes, descriptor);
1025
+
1026
+ const updateRow = Object.fromEntries(
1027
+ Object.entries(row).filter(([, value]) => value !== undefined)
1028
+ );
1029
+
1030
+ if (Object.keys(updateRow).length === 0) {
1031
+ return 0;
1032
+ }
1033
+
1034
+ const result = await context.db(canonical.tableName)
1035
+ .where('id', id)
1036
+ .where(canonical.resourceColumn, descriptor.resource)
1037
+ .where(canonical.tenantColumn, descriptor.tenant)
1038
+ .update(updateRow);
1039
+
1040
+ return result;
1041
+ };
1042
+
1043
+ helpers.dataDelete = async ({ scopeName, context }) => {
1044
+ const storageAdapter = getScopeStorageAdapter(scopeName);
1045
+ if (storageAdapter) {
1046
+ context.storageAdapter = storageAdapter;
1047
+ }
1048
+ const descriptor = await getDescriptor(scopeName);
1049
+ const { canonical } = descriptor;
1050
+ const id = context.id;
1051
+
1052
+ const result = await context.db(canonical.tableName)
1053
+ .where('id', id)
1054
+ .where(canonical.resourceColumn, descriptor.resource)
1055
+ .where(canonical.tenantColumn, descriptor.tenant)
1056
+ .delete();
1057
+
1058
+ return result;
1059
+ };
1060
+
1061
+ helpers.dataGetMinimal = async ({ scopeName, context }) => {
1062
+ const storageAdapter = getScopeStorageAdapter(scopeName);
1063
+ if (storageAdapter) {
1064
+ context.storageAdapter = storageAdapter;
1065
+ }
1066
+ const descriptor = await getDescriptor(scopeName);
1067
+ const { canonical } = descriptor;
1068
+ const id = context.id;
1069
+ const scope = api.resources?.[scopeName];
1070
+
1071
+ const row = await context.db(canonical.tableName)
1072
+ .where('id', id)
1073
+ .where(canonical.resourceColumn, descriptor.resource)
1074
+ .where(canonical.tenantColumn, descriptor.tenant)
1075
+ .first();
1076
+
1077
+ if (!row) return null;
1078
+ const translated = translateRecordFromStorage(row, descriptor);
1079
+ const minimal = {
1080
+ type: descriptor.resource,
1081
+ id: String(row.id),
1082
+ attributes: translated.attributes,
1083
+ relationships: translated.relationships,
1084
+ };
1085
+ if (scope) {
1086
+ minimal.links = {
1087
+ self: buildResourceUrl(context, scope, scopeName, minimal.id),
1088
+ };
1089
+ }
1090
+ return minimal;
1091
+ };
1092
+
1093
+ const parseIncludeTree = (includeParam) => {
1094
+ const tree = {};
1095
+ if (!includeParam) return tree;
1096
+
1097
+ const pushPath = (raw) => {
1098
+ if (!raw) return;
1099
+ const segments = raw.split('.').map((segment) => segment.trim()).filter(Boolean);
1100
+ if (segments.length === 0) return;
1101
+ let node = tree;
1102
+ for (const segment of segments) {
1103
+ node[segment] = node[segment] || {};
1104
+ node = node[segment];
1105
+ }
1106
+ };
1107
+
1108
+ if (Array.isArray(includeParam)) {
1109
+ for (const entry of includeParam) {
1110
+ String(entry).split(',').forEach((part) => pushPath(part.trim()));
1111
+ }
1112
+ } else {
1113
+ String(includeParam).split(',').forEach((part) => pushPath(part.trim()));
1114
+ }
1115
+
1116
+ return tree;
1117
+ };
1118
+
1119
+ const collectIncludes = async ({ descriptor, resources, includeTree, context, includes, seen }) => {
1120
+ const entries = Object.entries(includeTree || {});
1121
+ if (entries.length === 0 || !resources || resources.length === 0) return;
1122
+
1123
+ const db = context.db || context.transaction || api.knex.instance;
1124
+ const addSelfLink = (resource, resourceType) => {
1125
+ const targetScope = api.resources?.[resourceType];
1126
+ if (!targetScope) return;
1127
+ resource.links = resource.links || {};
1128
+ resource.links.self = buildResourceUrl(context, targetScope, resourceType, resource.id);
1129
+ };
1130
+
1131
+ for (const [relName, childTree] of entries) {
1132
+ const childKeys = Object.keys(childTree || {});
1133
+
1134
+ const belongsToInfo = descriptor.belongsTo?.[relName];
1135
+ if (belongsToInfo) {
1136
+ const ids = [...new Set(resources
1137
+ .map((resource) => resource.relationships?.[relName]?.data?.id)
1138
+ .filter((id) => id !== undefined && id !== null))];
1139
+
1140
+ if (ids.length === 0) continue;
1141
+
1142
+ const targetDescriptor = await registry.getDescriptor(tenantId, belongsToInfo.target);
1143
+ if (!targetDescriptor) continue;
1144
+
1145
+ const rows = await db(targetDescriptor.canonical.tableName)
1146
+ .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
1147
+ .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
1148
+ .whereIn('id', ids);
1149
+
1150
+ const newResources = [];
1151
+ for (const row of rows) {
1152
+ const includeKey = `${targetDescriptor.resource}:${row.id}`;
1153
+ if (seen.has(includeKey)) continue;
1154
+ seen.add(includeKey);
1155
+ const translated = translateRecordFromStorage(row, targetDescriptor);
1156
+ const includeResource = {
1157
+ type: targetDescriptor.resource,
1158
+ id: String(row.id),
1159
+ attributes: translated.attributes,
1160
+ };
1161
+ if (translated.relationships && Object.keys(translated.relationships).length > 0) {
1162
+ includeResource.relationships = translated.relationships;
1163
+ }
1164
+ addSelfLink(includeResource, targetDescriptor.resource);
1165
+ includes.push(includeResource);
1166
+ newResources.push(includeResource);
1167
+ }
1168
+
1169
+ if (childKeys.length > 0 && newResources.length > 0) {
1170
+ await attachHasManyRelationships({ resources: newResources, descriptor: targetDescriptor, context });
1171
+ await attachManyToManyRelationships({ resources: newResources, descriptor: targetDescriptor, context });
1172
+ await collectIncludes({
1173
+ descriptor: targetDescriptor,
1174
+ resources: newResources,
1175
+ includeTree: childTree,
1176
+ context,
1177
+ includes,
1178
+ seen,
1179
+ });
1180
+ }
1181
+ continue;
1182
+ }
1183
+
1184
+ const polymorphicInfo = descriptor.polymorphicBelongsTo?.[relName];
1185
+ if (polymorphicInfo) {
1186
+ const typeToIds = new Map();
1187
+ for (const resource of resources) {
1188
+ const relData = resource.relationships?.[relName]?.data;
1189
+ if (!relData?.type || relData?.id == null) continue;
1190
+ const targetType = String(relData.type);
1191
+ const targetId = normalizeId(relData.id);
1192
+ if (targetId == null) continue;
1193
+ if (!typeToIds.has(targetType)) {
1194
+ typeToIds.set(targetType, new Set());
1195
+ }
1196
+ typeToIds.get(targetType).add(targetId);
1197
+ }
1198
+
1199
+ const newResources = [];
1200
+ for (const [targetType, idSet] of typeToIds.entries()) {
1201
+ if (idSet.size === 0) continue;
1202
+ let targetDescriptor;
1203
+ try {
1204
+ targetDescriptor = await getDescriptor(targetType);
1205
+ } catch (error) {
1206
+ continue;
1207
+ }
1208
+
1209
+ const rows = await db(targetDescriptor.canonical.tableName)
1210
+ .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
1211
+ .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
1212
+ .whereIn('id', [...idSet]);
1213
+
1214
+ for (const row of rows) {
1215
+ const includeKey = `${targetDescriptor.resource}:${row.id}`;
1216
+ if (seen.has(includeKey)) continue;
1217
+ seen.add(includeKey);
1218
+ const translated = translateRecordFromStorage(row, targetDescriptor);
1219
+ const includeResource = {
1220
+ type: targetDescriptor.resource,
1221
+ id: String(row.id),
1222
+ attributes: translated.attributes,
1223
+ };
1224
+ if (translated.relationships && Object.keys(translated.relationships).length > 0) {
1225
+ includeResource.relationships = translated.relationships;
1226
+ }
1227
+ addSelfLink(includeResource, targetDescriptor.resource);
1228
+ includes.push(includeResource);
1229
+ newResources.push(includeResource);
1230
+ }
1231
+ }
1232
+
1233
+ if (childKeys.length > 0 && newResources.length > 0) {
1234
+ const resourcesByType = new Map();
1235
+ for (const includeResource of newResources) {
1236
+ if (!resourcesByType.has(includeResource.type)) {
1237
+ resourcesByType.set(includeResource.type, []);
1238
+ }
1239
+ resourcesByType.get(includeResource.type).push(includeResource);
1240
+ }
1241
+
1242
+ for (const [targetType, groupedResources] of resourcesByType.entries()) {
1243
+ let targetDescriptor;
1244
+ try {
1245
+ targetDescriptor = await getDescriptor(targetType);
1246
+ } catch (error) {
1247
+ continue;
1248
+ }
1249
+
1250
+ await attachHasManyRelationships({ resources: groupedResources, descriptor: targetDescriptor, context });
1251
+ await attachManyToManyRelationships({ resources: groupedResources, descriptor: targetDescriptor, context });
1252
+ await collectIncludes({
1253
+ descriptor: targetDescriptor,
1254
+ resources: groupedResources,
1255
+ includeTree: childTree,
1256
+ context,
1257
+ includes,
1258
+ seen,
1259
+ });
1260
+ }
1261
+ }
1262
+
1263
+ continue;
1264
+ }
1265
+
1266
+ const hasManyInfo = descriptor.relationships?.[relName];
1267
+ if (hasManyInfo?.type === 'hasMany' && hasManyInfo.target && (hasManyInfo.foreignKey || hasManyInfo.via)) {
1268
+ let targetDescriptor;
1269
+ try {
1270
+ targetDescriptor = await getDescriptor(hasManyInfo.target);
1271
+ } catch (error) {
1272
+ continue;
1273
+ }
1274
+
1275
+ const parentIds = resources
1276
+ .map((resource) => normalizeId(resource.id))
1277
+ .filter((id) => id !== null);
1278
+
1279
+ if (parentIds.length === 0) continue;
1280
+
1281
+ const queryIds = Array.from(new Set([
1282
+ ...parentIds,
1283
+ ...parentIds
1284
+ .map((id) => {
1285
+ const numeric = Number(id);
1286
+ return Number.isFinite(numeric) ? numeric : null;
1287
+ })
1288
+ .filter((value) => value !== null),
1289
+ ]));
1290
+
1291
+ let rows = [];
1292
+ let groupingColumn = null;
1293
+
1294
+ if (hasManyInfo.foreignKey) {
1295
+ const foreignField = targetDescriptor.fields?.[hasManyInfo.foreignKey];
1296
+ if (!foreignField?.slot) {
1297
+ continue;
1298
+ }
1299
+ groupingColumn = foreignField.slot;
1300
+ rows = await db(targetDescriptor.canonical.tableName)
1301
+ .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
1302
+ .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
1303
+ .whereIn(groupingColumn, parentIds);
1304
+ } else if (hasManyInfo.via) {
1305
+ const polyInfo = targetDescriptor.polymorphicBelongsTo?.[hasManyInfo.via];
1306
+ if (!polyInfo?.idColumn || !polyInfo?.typeColumn) {
1307
+ continue;
1308
+ }
1309
+ groupingColumn = polyInfo.idColumn;
1310
+ rows = await db(targetDescriptor.canonical.tableName)
1311
+ .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
1312
+ .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
1313
+ .where(polyInfo.typeColumn, descriptor.resource)
1314
+ .whereIn(groupingColumn, queryIds);
1315
+ }
1316
+
1317
+ if (!groupingColumn || rows.length === 0) {
1318
+ continue;
1319
+ }
1320
+
1321
+ const newResources = [];
1322
+ for (const row of rows) {
1323
+ const includeKey = `${targetDescriptor.resource}:${row.id}`;
1324
+ if (seen.has(includeKey)) continue;
1325
+ seen.add(includeKey);
1326
+ const translated = translateRecordFromStorage(row, targetDescriptor);
1327
+ const includeResource = {
1328
+ type: targetDescriptor.resource,
1329
+ id: String(row.id),
1330
+ attributes: translated.attributes,
1331
+ };
1332
+ if (translated.relationships && Object.keys(translated.relationships).length > 0) {
1333
+ includeResource.relationships = translated.relationships;
1334
+ }
1335
+ addSelfLink(includeResource, targetDescriptor.resource);
1336
+ includes.push(includeResource);
1337
+ newResources.push(includeResource);
1338
+ }
1339
+
1340
+ if (childKeys.length > 0 && newResources.length > 0) {
1341
+ await attachHasManyRelationships({ resources: newResources, descriptor: targetDescriptor, context });
1342
+ await attachManyToManyRelationships({ resources: newResources, descriptor: targetDescriptor, context });
1343
+ await collectIncludes({
1344
+ descriptor: targetDescriptor,
1345
+ resources: newResources,
1346
+ includeTree: childTree,
1347
+ context,
1348
+ includes,
1349
+ seen,
1350
+ });
1351
+ }
1352
+
1353
+ continue;
1354
+ }
1355
+
1356
+ const manyInfo = descriptor.manyToMany?.[relName];
1357
+ if (manyInfo) {
1358
+ const info = await getManyToManyInfo(descriptor.resource, relName);
1359
+ if (!info) continue;
1360
+ const linkRows = await fetchLinksForParents({
1361
+ descriptor: info.descriptor,
1362
+ relationshipKey: info.relationshipKey,
1363
+ parentIds: resources.map((resource) => resource.id),
1364
+ db,
1365
+ });
1366
+
1367
+ const childIds = [...new Set(linkRows.map((row) => row.childId))];
1368
+ if (childIds.length === 0) continue;
1369
+
1370
+ const targetDescriptor = await registry.getDescriptor(tenantId, info.relInfo.target);
1371
+ if (!targetDescriptor) continue;
1372
+
1373
+ const rows = await db(targetDescriptor.canonical.tableName)
1374
+ .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
1375
+ .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
1376
+ .whereIn('id', childIds);
1377
+
1378
+ const newResources = [];
1379
+ for (const row of rows) {
1380
+ const includeKey = `${targetDescriptor.resource}:${row.id}`;
1381
+ if (seen.has(includeKey)) continue;
1382
+ seen.add(includeKey);
1383
+ const translated = translateRecordFromStorage(row, targetDescriptor);
1384
+ const includeResource = {
1385
+ type: targetDescriptor.resource,
1386
+ id: String(row.id),
1387
+ attributes: translated.attributes,
1388
+ };
1389
+ if (translated.relationships && Object.keys(translated.relationships).length > 0) {
1390
+ includeResource.relationships = translated.relationships;
1391
+ }
1392
+ addSelfLink(includeResource, targetDescriptor.resource);
1393
+ includes.push(includeResource);
1394
+ newResources.push(includeResource);
1395
+ }
1396
+
1397
+ if (childKeys.length > 0 && newResources.length > 0) {
1398
+ await attachHasManyRelationships({ resources: newResources, descriptor: targetDescriptor, context });
1399
+ await attachManyToManyRelationships({ resources: newResources, descriptor: targetDescriptor, context });
1400
+ await collectIncludes({
1401
+ descriptor: targetDescriptor,
1402
+ resources: newResources,
1403
+ includeTree: childTree,
1404
+ context,
1405
+ includes,
1406
+ seen,
1407
+ });
1408
+ }
1409
+ }
1410
+ }
1411
+ };
1412
+
1413
+ const buildIncludes = async ({ parentResources, descriptor, context }) => {
1414
+ const includeTree = parseIncludeTree(context.queryParams?.include);
1415
+ if (Object.keys(includeTree).length === 0) return [];
1416
+
1417
+ const includes = [];
1418
+ const seen = new Set();
1419
+
1420
+ await collectIncludes({
1421
+ descriptor,
1422
+ resources: parentResources,
1423
+ includeTree,
1424
+ context,
1425
+ includes,
1426
+ seen,
1427
+ });
1428
+
1429
+ return includes;
1430
+ };
1431
+
1432
+ const attachHasManyRelationships = async ({ resources, descriptor, context }) => {
1433
+ if (!resources || resources.length === 0) return;
1434
+ const hasManyEntries = Object.entries(descriptor.relationships || {})
1435
+ .filter(([, relDef]) => relDef?.type === 'hasMany' && relDef.target && (relDef.foreignKey || relDef.via));
1436
+
1437
+ if (hasManyEntries.length === 0) return;
1438
+
1439
+ const db = context.db || context.transaction || api.knex.instance;
1440
+ const parentIds = resources.map((resource) => resource.id);
1441
+ const normalizedParentIds = parentIds
1442
+ .map((id) => normalizeId(id))
1443
+ .filter((id) => id !== null);
1444
+
1445
+ if (normalizedParentIds.length === 0) return;
1446
+
1447
+ for (const [relName, relDef] of hasManyEntries) {
1448
+ let targetDescriptor;
1449
+ try {
1450
+ targetDescriptor = await getDescriptor(relDef.target);
1451
+ } catch (error) {
1452
+ continue;
1453
+ }
1454
+
1455
+ if (relDef.foreignKey) {
1456
+ const foreignField = targetDescriptor.fields?.[relDef.foreignKey];
1457
+ if (!foreignField?.slot) continue;
1458
+ const column = foreignField.slot;
1459
+
1460
+ const rows = await db(targetDescriptor.canonical.tableName)
1461
+ .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
1462
+ .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
1463
+ .whereIn(column, normalizedParentIds);
1464
+
1465
+ const grouped = rows.reduce((acc, row) => {
1466
+ const parentId = row[column];
1467
+ if (parentId == null) return acc;
1468
+ const key = String(parentId);
1469
+ acc[key] = acc[key] || [];
1470
+ acc[key].push({ type: targetDescriptor.resource, id: String(row.id) });
1471
+ return acc;
1472
+ }, {});
1473
+
1474
+ for (const resource of resources) {
1475
+ resource.relationships = resource.relationships || {};
1476
+ const related = grouped[String(resource.id)] || [];
1477
+ resource.relationships[relName] = { data: related };
1478
+ }
1479
+
1480
+ continue;
1481
+ }
1482
+
1483
+ if (relDef.via) {
1484
+ const polyInfo = targetDescriptor.polymorphicBelongsTo?.[relDef.via];
1485
+ if (!polyInfo?.idColumn || !polyInfo?.typeColumn) continue;
1486
+
1487
+ const queryIds = Array.from(new Set([
1488
+ ...normalizedParentIds,
1489
+ ...normalizedParentIds
1490
+ .map((id) => {
1491
+ const numeric = Number(id);
1492
+ return Number.isFinite(numeric) ? numeric : null;
1493
+ })
1494
+ .filter((value) => value !== null),
1495
+ ]));
1496
+
1497
+ const rows = await db(targetDescriptor.canonical.tableName)
1498
+ .where(targetDescriptor.canonical.tenantColumn, targetDescriptor.tenant)
1499
+ .where(targetDescriptor.canonical.resourceColumn, targetDescriptor.resource)
1500
+ .where(polyInfo.typeColumn, descriptor.resource)
1501
+ .whereIn(polyInfo.idColumn, queryIds);
1502
+
1503
+ const grouped = rows.reduce((acc, row) => {
1504
+ const parentId = row[polyInfo.idColumn];
1505
+ if (parentId == null) return acc;
1506
+ const key = String(parentId);
1507
+ acc[key] = acc[key] || [];
1508
+ acc[key].push({ type: targetDescriptor.resource, id: String(row.id) });
1509
+ return acc;
1510
+ }, {});
1511
+
1512
+ for (const resource of resources) {
1513
+ resource.relationships = resource.relationships || {};
1514
+ const related = grouped[String(resource.id)] || [];
1515
+ resource.relationships[relName] = { data: related };
1516
+ }
1517
+ }
1518
+ }
1519
+ };
1520
+
1521
+ const attachManyToManyRelationships = async ({ resources, descriptor, context }) => {
1522
+ if (!resources || resources.length === 0) return;
1523
+ const manyEntries = Object.entries(descriptor.manyToMany || {});
1524
+ if (manyEntries.length === 0) return;
1525
+
1526
+ const db = context.db || context.transaction || api.knex.instance;
1527
+ const parentIds = resources.map((resource) => resource.id);
1528
+
1529
+ for (const [relName, relInfo] of manyEntries) {
1530
+ const info = await getManyToManyInfo(descriptor.resource, relName);
1531
+ if (!info) continue;
1532
+ const rows = await fetchLinksForParents({
1533
+ descriptor: info.descriptor,
1534
+ relationshipKey: info.relationshipKey,
1535
+ parentIds,
1536
+ db,
1537
+ });
1538
+
1539
+ const grouped = rows.reduce((acc, row) => {
1540
+ const key = String(row.parentId);
1541
+ acc[key] = acc[key] || [];
1542
+ acc[key].push({ type: row.childType, id: String(row.childId) });
1543
+ return acc;
1544
+ }, {});
1545
+
1546
+ for (const resource of resources) {
1547
+ const related = grouped[resource.id] || [];
1548
+ resource.relationships = resource.relationships || {};
1549
+ resource.relationships[relName] = { data: related };
1550
+ }
1551
+ }
1552
+ };
1553
+
1554
+ helpers.dataGet = async ({ scopeName, context }) => {
1555
+ const storageAdapter = getScopeStorageAdapter(scopeName);
1556
+ if (storageAdapter) {
1557
+ context.storageAdapter = storageAdapter;
1558
+ }
1559
+ const descriptor = await getDescriptor(scopeName);
1560
+ const { canonical } = descriptor;
1561
+ const id = context.id;
1562
+ const scope = api.resources?.[scopeName];
1563
+
1564
+ let fieldSelectionInfo = null;
1565
+ if (scope) {
1566
+ fieldSelectionInfo = await buildFieldSelection(scope, { context });
1567
+ context.computedDependencies = fieldSelectionInfo.computedDependencies;
1568
+ } else {
1569
+ context.computedDependencies = [];
1570
+ }
1571
+
1572
+ const row = await context.db(canonical.tableName)
1573
+ .where('id', id)
1574
+ .where(canonical.resourceColumn, descriptor.resource)
1575
+ .where(canonical.tenantColumn, descriptor.tenant)
1576
+ .first();
1577
+
1578
+ if (!row) return null;
1579
+
1580
+ const record = translateRecordFromStorage(row, descriptor);
1581
+ const data = {
1582
+ type: descriptor.resource,
1583
+ id: String(row.id),
1584
+ attributes: record.attributes,
1585
+ };
1586
+
1587
+ if (record.relationships && Object.keys(record.relationships).length > 0) {
1588
+ data.relationships = record.relationships;
1589
+ }
1590
+
1591
+ await attachHasManyRelationships({
1592
+ resources: [data],
1593
+ descriptor,
1594
+ context,
1595
+ });
1596
+
1597
+ await attachManyToManyRelationships({
1598
+ resources: [data],
1599
+ descriptor,
1600
+ context,
1601
+ });
1602
+
1603
+ const included = await buildIncludes({
1604
+ parentResources: [data],
1605
+ descriptor,
1606
+ context,
1607
+ });
1608
+
1609
+ const response = { data };
1610
+ if (included.length > 0) {
1611
+ response.included = included;
1612
+ }
1613
+
1614
+ if (scope) {
1615
+ data.links = data.links || {};
1616
+ data.links.self = buildResourceUrl(context, scope, scopeName, data.id);
1617
+ response.links = {
1618
+ self: data.links.self,
1619
+ };
1620
+ }
1621
+
1622
+ if (included.length > 0) {
1623
+ for (const includeResource of included) {
1624
+ const targetScope = api.resources?.[includeResource.type];
1625
+ if (!targetScope) continue;
1626
+ includeResource.links = includeResource.links || {};
1627
+ includeResource.links.self = buildResourceUrl(
1628
+ context,
1629
+ targetScope,
1630
+ includeResource.type,
1631
+ includeResource.id,
1632
+ );
1633
+ }
1634
+ }
1635
+
1636
+ return response;
1637
+ };
1638
+
1639
+ const rehydrateSchemaInfo = async (scopeName) => {
1640
+ const scope = api.resources?.[scopeName] || scopes?.[scopeName] || {};
1641
+ const storedOptions = scopeOptionsRegistry.get(scopeName) || {};
1642
+
1643
+ if (!scope.scopeOptions) {
1644
+ scope.scopeOptions = {
1645
+ schema: storedOptions.schema || {},
1646
+ relationships: storedOptions.relationships || {},
1647
+ searchSchema: storedOptions.searchSchema || null,
1648
+ tableName: storedOptions.tableName || scopeName,
1649
+ idProperty: storedOptions.idProperty || scope?.vars?.idProperty || vars.idProperty || 'id',
1650
+ canonicalFieldsMap: storedOptions.canonicalFieldsMap || {},
1651
+ };
1652
+ }
1653
+
1654
+ const descriptor = await registry.getDescriptor(tenantId, scopeName);
1655
+ if (!descriptor) return;
1656
+
1657
+ const existingInfo = scope.vars?.schemaInfo || {};
1658
+
1659
+ const mergedSchema = { ...(existingInfo.schemaInstance?.structure || {}) };
1660
+ const computed = { ...(existingInfo.computed || {}) };
1661
+
1662
+ for (const [fieldName, definition] of Object.entries(descriptor.schema || {})) {
1663
+ if (definition?.computed) {
1664
+ if (!computed[fieldName]) {
1665
+ computed[fieldName] = { ...definition };
1666
+ }
1667
+ continue;
1668
+ }
1669
+ mergedSchema[fieldName] = {
1670
+ ...(mergedSchema[fieldName] || {}),
1671
+ ...definition,
1672
+ };
1673
+ }
1674
+
1675
+ if (!mergedSchema.id) {
1676
+ mergedSchema.id = { type: 'id' };
1677
+ }
1678
+
1679
+ const schemaInstance = createSchema(mergedSchema);
1680
+
1681
+ const rawSearchFields = generateSearchSchemaFromSchema(
1682
+ mergedSchema,
1683
+ scope.scopeOptions?.searchSchema || storedOptions.searchSchema || null,
1684
+ );
1685
+ if (rawSearchFields) {
1686
+ ensureSearchFieldsAreIndexed(rawSearchFields);
1687
+ }
1688
+ const searchSchemaInstance = createSchema(rawSearchFields || {});
1689
+
1690
+ const tableName = storedOptions.tableName
1691
+ || existingInfo.tableName
1692
+ || scope.scopeOptions?.tableName
1693
+ || scopeName;
1694
+
1695
+ const idProperty = storedOptions.idProperty
1696
+ || existingInfo.idProperty
1697
+ || scope.scopeOptions?.idProperty
1698
+ || scope.vars?.idProperty
1699
+ || vars.idProperty
1700
+ || 'id';
1701
+
1702
+ scope.vars.schemaInfo = {
1703
+ ...existingInfo,
1704
+ schemaInstance,
1705
+ schemaStructure: schemaInstance.structure,
1706
+ searchSchemaInstance,
1707
+ searchSchemaStructure: searchSchemaInstance.structure,
1708
+ schemaRelationships: descriptor.relationships
1709
+ || scope.scopeOptions?.relationships
1710
+ || storedOptions.relationships
1711
+ || existingInfo.schemaRelationships
1712
+ || {},
1713
+ computed,
1714
+ tableName,
1715
+ idProperty,
1716
+ descriptor,
1717
+ canonicalFieldMap: descriptor.canonicalFieldMap || {},
1718
+ };
1719
+
1720
+ getScopeStorageAdapter(scopeName);
1721
+
1722
+ scope.scopeOptions = {
1723
+ ...(scope.scopeOptions || {}),
1724
+ canonicalFieldsMap: descriptor.canonicalFieldMap || scope.scopeOptions?.canonicalFieldsMap || {},
1725
+ };
1726
+
1727
+ scopeOptionsRegistry.set(scopeName, {
1728
+ schema: storedOptions.schema || scope.scopeOptions?.schema || {},
1729
+ relationships: storedOptions.relationships || scope.scopeOptions?.relationships || {},
1730
+ searchSchema: scope.scopeOptions?.searchSchema || storedOptions.searchSchema || null,
1731
+ tableName,
1732
+ idProperty,
1733
+ canonicalFieldsMap: descriptor.canonicalFieldMap || {},
1734
+ });
1735
+ };
1736
+
1737
+ const buildTableNameMaps = () => {
1738
+ const resourceToTableName = new Map();
1739
+ const tableNameToResource = new Map();
1740
+ for (const [resourceName, resourceScope] of Object.entries(api.resources || {})) {
1741
+ const tableName = resourceScope?.vars?.schemaInfo?.tableName;
1742
+ if (!tableName) continue;
1743
+ resourceToTableName.set(resourceName, tableName);
1744
+ tableNameToResource.set(tableName, resourceName);
1745
+ }
1746
+ return { resourceToTableName, tableNameToResource };
1747
+ };
1748
+
1749
+ const translateSelectFieldsForAdapter = (fields, adapter) => {
1750
+ if (!adapter || !fields) return fields;
1751
+
1752
+ return fields.map((field) => {
1753
+ if (typeof field !== 'string') return field;
1754
+
1755
+ if (field === '*') return '*';
1756
+
1757
+ const aliasMatch = field.match(/\s+as\s+/i);
1758
+ if (aliasMatch) {
1759
+ const [source, alias] = field.split(/\s+as\s+/i);
1760
+ const translatedSource = adapter.translateColumn(source.trim()) || source.trim();
1761
+ return `${translatedSource} as ${alias.trim()}`;
1762
+ }
1763
+
1764
+ const translated = adapter.translateColumn(field);
1765
+ return translated || field;
1766
+ });
1767
+ };
1768
+
1769
+ const scopesProxy = new Proxy({}, {
1770
+ get(_target, prop) {
1771
+ if (typeof prop !== 'string') {
1772
+ return undefined;
1773
+ }
1774
+ const resource = api.resources?.[prop];
1775
+ if (resource?.vars?.schemaInfo) {
1776
+ return resource;
1777
+ }
1778
+ return scopes?.[prop];
1779
+ },
1780
+ });
1781
+
1782
+ const queryHookDependencies = { log, scopes: scopesProxy, knex, getStorageAdapter: getScopeStorageAdapter };
1783
+
1784
+ addHook('knexQueryFiltering', 'polymorphicFiltersHook', {}, async (hookParams) =>
1785
+ polymorphicFiltersHook(hookParams, queryHookDependencies)
1786
+ );
1787
+
1788
+ addHook('knexQueryFiltering', 'crossTableFiltersHook', {}, async (hookParams) =>
1789
+ crossTableFiltersHook(hookParams, queryHookDependencies)
1790
+ );
1791
+
1792
+ addHook('knexQueryFiltering', 'basicFiltersHook', {}, async (hookParams) =>
1793
+ basicFiltersHook(hookParams, queryHookDependencies)
1794
+ );
1795
+
1796
+ helpers.dataQuery = async ({ scopeName, context, runHooks }) => {
1797
+ const storageAdapter = getScopeStorageAdapter(scopeName);
1798
+ if (storageAdapter) {
1799
+ context.storageAdapter = storageAdapter;
1800
+ }
1801
+ const descriptor = await getDescriptor(scopeName);
1802
+ const db = context.db || context.transaction || api.knex.instance;
1803
+ const scope = api.resources?.[scopeName];
1804
+ const queryParams = context.queryParams || {};
1805
+
1806
+ let fieldSelectionInfo = null;
1807
+ if (scope) {
1808
+ fieldSelectionInfo = await buildFieldSelection(scope, { context });
1809
+ context.computedDependencies = fieldSelectionInfo.computedDependencies;
1810
+ } else {
1811
+ context.computedDependencies = [];
1812
+ }
1813
+
1814
+ const { resourceToTableName, tableNameToResource } = buildTableNameMaps();
1815
+ context.resourceToTableName = resourceToTableName;
1816
+ const descriptorsMap = await preloadRelatedDescriptors({ registry, descriptor });
1817
+ const adapter = new AnyapiQueryAdapter({
1818
+ descriptor,
1819
+ db,
1820
+ registry,
1821
+ descriptorsMap,
1822
+ resourceToTableName,
1823
+ tableNameToResource,
1824
+ log,
1825
+ });
1826
+ const queryBuilder = adapter.query;
1827
+
1828
+ const schemaInfo = scope?.vars?.schemaInfo;
1829
+ const tableNameForHooks = schemaInfo?.tableName || adapter.tableAlias;
1830
+
1831
+ applyFiltersToQuery({
1832
+ query: queryBuilder,
1833
+ filters: queryParams.filters,
1834
+ descriptor,
1835
+ searchSchema: schemaInfo?.searchSchemaStructure,
1836
+ adapter,
1837
+ });
1838
+
1839
+ context.knexQuery = {
1840
+ query: queryBuilder,
1841
+ filters: queryParams.filters,
1842
+ schemaInfo,
1843
+ scopeName,
1844
+ tableName: tableNameForHooks,
1845
+ db,
1846
+ isAnyApi: true,
1847
+ adapter,
1848
+ storageAdapter,
1849
+ };
1850
+
1851
+ if (runHooks) {
1852
+ await runHooks('knexQueryFiltering');
1853
+ }
1854
+
1855
+ delete context.knexQuery;
1856
+
1857
+ const countQuery = queryBuilder.clone();
1858
+
1859
+ const sortDescriptors = applySortingToQuery({
1860
+ query: queryBuilder,
1861
+ sort: queryParams.sort,
1862
+ descriptor,
1863
+ scope,
1864
+ });
1865
+
1866
+ if (queryParams?.page?.after) {
1867
+ }
1868
+
1869
+ const paginationInfo = applyPaginationToQuery({
1870
+ query: queryBuilder,
1871
+ scope,
1872
+ queryParams,
1873
+ sortDescriptors,
1874
+ adapter,
1875
+ });
1876
+
1877
+ if (queryParams?.page?.after) {
1878
+ }
1879
+
1880
+ if (fieldSelectionInfo && fieldSelectionInfo.fieldsToSelect && fieldSelectionInfo.fieldsToSelect.length > 0) {
1881
+ const translatedSelect = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, adapter);
1882
+ queryBuilder.select(translatedSelect);
1883
+ }
1884
+
1885
+ let rows = await queryBuilder;
1886
+ let cursorRecords = null;
1887
+ let hasMore = false;
1888
+
1889
+ if (paginationInfo.mode === 'cursor') {
1890
+ const { pageSize, sortDescriptors: cursorDescriptors } = paginationInfo;
1891
+ cursorRecords = rows.map((row) => {
1892
+ const record = {};
1893
+ for (const descriptorEntry of cursorDescriptors) {
1894
+ if (descriptorEntry.field === 'id') {
1895
+ record.id = row.id;
1896
+ } else if (descriptorEntry.column) {
1897
+ record[descriptorEntry.field] = row[descriptorEntry.column];
1898
+ }
1899
+ }
1900
+ return record;
1901
+ });
1902
+
1903
+ if (rows.length > pageSize) {
1904
+ hasMore = true;
1905
+ rows = rows.slice(0, pageSize);
1906
+ cursorRecords = cursorRecords.slice(0, pageSize);
1907
+ }
1908
+ }
1909
+
1910
+ const data = rows.map((row) => {
1911
+ const translated = translateRecordFromStorage(row, descriptor);
1912
+ const resource = {
1913
+ type: descriptor.resource,
1914
+ id: String(row.id),
1915
+ attributes: translated.attributes,
1916
+ };
1917
+ if (translated.relationships && Object.keys(translated.relationships).length > 0) {
1918
+ resource.relationships = translated.relationships;
1919
+ }
1920
+ const resourceScope = scope;
1921
+ if (resourceScope) {
1922
+ resource.links = resource.links || {};
1923
+ resource.links.self = buildResourceUrl(context, resourceScope, scopeName, resource.id);
1924
+ }
1925
+ if (fieldSelectionInfo?.computedDependencies?.length) {
1926
+ resource.__$jsonrestapi_computed_deps$__ = fieldSelectionInfo.computedDependencies;
1927
+ }
1928
+ return resource;
1929
+ });
1930
+
1931
+ await attachHasManyRelationships({
1932
+ resources: data,
1933
+ descriptor,
1934
+ context,
1935
+ });
1936
+
1937
+ await attachManyToManyRelationships({
1938
+ resources: data,
1939
+ descriptor,
1940
+ context,
1941
+ });
1942
+
1943
+ const included = await buildIncludes({
1944
+ parentResources: data,
1945
+ descriptor,
1946
+ context,
1947
+ });
1948
+
1949
+ const response = { data };
1950
+ if (included.length > 0) {
1951
+ response.included = included;
1952
+ }
1953
+
1954
+ context.returnMeta = context.returnMeta || {};
1955
+ context.returnMeta.queryString = buildQueryString(queryParams);
1956
+ delete context.returnMeta.paginationMeta;
1957
+ delete context.returnMeta.paginationLinks;
1958
+
1959
+ if (paginationInfo.mode === 'offset') {
1960
+ const { page, pageSize } = paginationInfo;
1961
+ let paginationMeta;
1962
+
1963
+ if (scope?.vars?.enablePaginationCounts) {
1964
+ const countResult = await countQuery.count({ count: '*' }).first();
1965
+ const total = Number(countResult?.count ?? countResult?.total ?? 0);
1966
+ paginationMeta = calculatePaginationMeta(total, page, pageSize);
1967
+ } else {
1968
+ paginationMeta = { page, pageSize };
1969
+ }
1970
+
1971
+ context.returnMeta.paginationMeta = paginationMeta;
1972
+ const urlPrefix = getUrlPrefix(context, scope);
1973
+ context.returnMeta.paginationLinks = generatePaginationLinks(
1974
+ urlPrefix,
1975
+ scopeName,
1976
+ queryParams,
1977
+ paginationMeta,
1978
+ );
1979
+ } else if (paginationInfo.mode === 'cursor' && cursorRecords) {
1980
+ const cursorFields = paginationInfo.sortDescriptors.map((descriptorEntry) => descriptorEntry.field);
1981
+ const paginationMeta = buildCursorMeta(
1982
+ cursorRecords,
1983
+ paginationInfo.pageSize,
1984
+ hasMore,
1985
+ cursorFields,
1986
+ );
1987
+ context.returnMeta.paginationMeta = paginationMeta;
1988
+ const urlPrefix = getUrlPrefix(context, scope);
1989
+ context.returnMeta.paginationLinks = generateCursorPaginationLinks(
1990
+ urlPrefix,
1991
+ scopeName,
1992
+ queryParams,
1993
+ cursorRecords,
1994
+ paginationInfo.pageSize,
1995
+ hasMore,
1996
+ cursorFields,
1997
+ );
1998
+ }
1999
+
2000
+ if (context.returnMeta.paginationMeta) {
2001
+ response.meta = {
2002
+ pagination: context.returnMeta.paginationMeta,
2003
+ };
2004
+ }
2005
+
2006
+ if (context.returnMeta.paginationLinks) {
2007
+ response.links = context.returnMeta.paginationLinks;
2008
+ } else if (scope) {
2009
+ const urlPrefix = getUrlPrefix(context, scope);
2010
+ response.links = {
2011
+ self: `${urlPrefix}/${scopeName}${context.returnMeta.queryString || ''}`,
2012
+ };
2013
+ }
2014
+
2015
+ return response;
2016
+ };
2017
+
2018
+ helpers.dataQueryCount = async ({ scopeName, context }) => {
2019
+ const storageAdapter = getScopeStorageAdapter(scopeName);
2020
+ if (storageAdapter) {
2021
+ context.storageAdapter = storageAdapter;
2022
+ }
2023
+ const descriptor = await getDescriptor(scopeName);
2024
+ const db = context.db || context.transaction || api.knex.instance;
2025
+ const { resourceToTableName, tableNameToResource } = buildTableNameMaps();
2026
+ const descriptorsMap = await preloadRelatedDescriptors({ registry, descriptor });
2027
+ const adapter = new AnyapiQueryAdapter({
2028
+ descriptor,
2029
+ db,
2030
+ registry,
2031
+ descriptorsMap,
2032
+ resourceToTableName,
2033
+ tableNameToResource,
2034
+ log,
2035
+ });
2036
+
2037
+ applyFiltersToQuery({
2038
+ query: adapter.query,
2039
+ filters: context.queryParams?.filters,
2040
+ descriptor,
2041
+ });
2042
+
2043
+ const [{ count }] = await adapter.query.count({ count: '*' });
2044
+ return Number(count);
2045
+ };
2046
+
2047
+ addHook('scope:added', 'anyapi-register-resource', { sequence: 50 }, async ({ context }) => {
2048
+ const { scopeName, scopeOptions = {} } = context;
2049
+ const scope = api.scopes?.[scopeName] || scopes?.[scopeName];
2050
+ const stored = scopeOptionsRegistry.get(scopeName) || {};
2051
+
2052
+ const baseSchema = scopeOptions.schema || scope?.scopeOptions?.schema || stored.schema || {};
2053
+ const relationships = scopeOptions.relationships || scope?.scopeOptions?.relationships || stored.relationships || {};
2054
+ const searchSchemaDef = scopeOptions.searchSchema || scope?.scopeOptions?.searchSchema || stored.searchSchema || null;
2055
+ const tableName = scopeOptions.tableName || scope?.scopeOptions?.tableName || stored.tableName || scopeName;
2056
+ const idProperty = scopeOptions.idProperty || scope?.scopeOptions?.idProperty || stored.idProperty || scope?.vars?.idProperty || vars.idProperty || 'id';
2057
+ const canonicalFieldsMap = scope?.scopeOptions?.canonicalFieldsMap || stored.canonicalFieldsMap || null;
2058
+
2059
+ const schema = { ...baseSchema };
2060
+ if (idProperty && idProperty !== 'id' && !schema[idProperty]) {
2061
+ schema[idProperty] = { type: 'number' };
2062
+ }
2063
+
2064
+ scopeOptionsRegistry.set(scopeName, {
2065
+ schema,
2066
+ relationships,
2067
+ searchSchema: searchSchemaDef,
2068
+ tableName,
2069
+ idProperty,
2070
+ canonicalFieldsMap,
2071
+ });
2072
+
2073
+ const descriptor = await registry.registerResource({
2074
+ tenant: tenantId,
2075
+ resource: scopeName,
2076
+ schema,
2077
+ relationships,
2078
+ canonicalFieldMap: canonicalFieldsMap,
2079
+ });
2080
+
2081
+ const updatedStored = scopeOptionsRegistry.get(scopeName) || {};
2082
+ updatedStored.canonicalFieldsMap = descriptor.canonicalFieldMap || updatedStored.canonicalFieldsMap || null;
2083
+ scopeOptionsRegistry.set(scopeName, updatedStored);
2084
+
2085
+ if (scope) {
2086
+ scope.scopeOptions = {
2087
+ ...(scope.scopeOptions || {}),
2088
+ schema,
2089
+ relationships,
2090
+ searchSchema: searchSchemaDef || undefined,
2091
+ tableName,
2092
+ idProperty,
2093
+ canonicalFieldsMap: descriptor.canonicalFieldMap || scope.scopeOptions?.canonicalFieldsMap || {},
2094
+ };
2095
+ }
2096
+
2097
+ await rehydrateSchemaInfo(scopeName);
2098
+ });
2099
+
2100
+ addScopeMethod('createKnexTable', async ({ scopeName, scopeOptions }) => {
2101
+ const scope = api.resources?.[scopeName] || scopes?.[scopeName];
2102
+ const schemaInput = scopeOptions?.schema || scope?.scopeOptions?.schema || {};
2103
+ const relationshipInput = scopeOptions?.relationships || scope?.scopeOptions?.relationships || {};
2104
+ const storedBefore = scopeOptionsRegistry.get(scopeName) || {};
2105
+ const idProperty = scopeOptions?.idProperty
2106
+ || scope?.scopeOptions?.idProperty
2107
+ || storedBefore.idProperty
2108
+ || scope?.vars?.schemaInfo?.idProperty
2109
+ || vars.idProperty
2110
+ || 'id';
2111
+
2112
+ const effectiveSchema = { ...schemaInput };
2113
+ if (idProperty && idProperty !== 'id' && !effectiveSchema[idProperty]) {
2114
+ effectiveSchema[idProperty] = { type: 'number' };
2115
+ }
2116
+
2117
+ if (scope) {
2118
+ const existingOptions = scope.scopeOptions || {};
2119
+ scope.scopeOptions = {
2120
+ ...existingOptions,
2121
+ schema: {
2122
+ ...(existingOptions.schema || {}),
2123
+ ...schemaInput,
2124
+ ...(idProperty && idProperty !== 'id' && !schemaInput[idProperty] ? { [idProperty]: { type: 'number' } } : {}),
2125
+ },
2126
+ relationships: {
2127
+ ...(existingOptions.relationships || {}),
2128
+ ...relationshipInput,
2129
+ },
2130
+ searchSchema: existingOptions.searchSchema || scopeOptions?.searchSchema,
2131
+ };
2132
+ }
2133
+ scopeOptionsRegistry.set(scopeName, {
2134
+ schema: scope?.scopeOptions?.schema || effectiveSchema || storedBefore.schema || {},
2135
+ relationships: scope?.scopeOptions?.relationships || relationshipInput || storedBefore.relationships || {},
2136
+ searchSchema: scope?.scopeOptions?.searchSchema || scopeOptions?.searchSchema || storedBefore.searchSchema || null,
2137
+ tableName: scope?.scopeOptions?.tableName || scopeOptions?.tableName || storedBefore.tableName || scopeName,
2138
+ idProperty: scope?.scopeOptions?.idProperty || scopeOptions?.idProperty || storedBefore.idProperty || scope?.vars?.idProperty || vars.idProperty || 'id',
2139
+ canonicalFieldsMap: storedBefore.canonicalFieldsMap || null,
2140
+ });
2141
+ const descriptor = await registry.registerResource({
2142
+ tenant: tenantId,
2143
+ resource: scopeName,
2144
+ schema: effectiveSchema,
2145
+ relationships: relationshipInput,
2146
+ canonicalFieldMap: scopeOptions?.canonicalFieldsMap
2147
+ || scope?.scopeOptions?.canonicalFieldsMap
2148
+ || null,
2149
+ });
2150
+ if (scope) {
2151
+ scope.scopeOptions = {
2152
+ ...(scope.scopeOptions || {}),
2153
+ canonicalFieldsMap: descriptor.canonicalFieldMap || scope.scopeOptions?.canonicalFieldsMap || {},
2154
+ };
2155
+ }
2156
+ const storedAfter = scopeOptionsRegistry.get(scopeName) || {};
2157
+ storedAfter.canonicalFieldsMap = descriptor.canonicalFieldMap || storedAfter.canonicalFieldsMap || null;
2158
+ scopeOptionsRegistry.set(scopeName, storedAfter);
2159
+ await rehydrateSchemaInfo(scopeName);
2160
+ });
2161
+
2162
+ addScopeMethod('addKnexFields', async ({ scopeName, params }) => {
2163
+ if (!params?.fields) return;
2164
+ const scope = api.resources?.[scopeName] || scopes?.[scopeName];
2165
+ if (scope) {
2166
+ const existingOptions = scope.scopeOptions || {};
2167
+ scope.scopeOptions = {
2168
+ ...existingOptions,
2169
+ schema: {
2170
+ ...(existingOptions.schema || {}),
2171
+ },
2172
+ relationships: existingOptions.relationships || {},
2173
+ searchSchema: existingOptions.searchSchema,
2174
+ };
2175
+ }
2176
+ const storedBefore = scopeOptionsRegistry.get(scopeName) || {};
2177
+ const canonicalEntries = params.canonicalFieldsMap ? { ...params.canonicalFieldsMap } : null;
2178
+ let latestDescriptor = null;
2179
+ for (const [fieldName, definition] of Object.entries(params.fields)) {
2180
+ if (scope) {
2181
+ scope.scopeOptions.schema[fieldName] = definition;
2182
+ }
2183
+ const canonicalEntry = canonicalEntries ? canonicalEntries[fieldName] : null;
2184
+ if (canonicalEntries) {
2185
+ delete canonicalEntries[fieldName];
2186
+ }
2187
+ latestDescriptor = await registry.allocateField({
2188
+ tenant: tenantId,
2189
+ resource: scopeName,
2190
+ fieldName,
2191
+ definition,
2192
+ canonicalField: canonicalEntry,
2193
+ });
2194
+ }
2195
+ if (canonicalEntries && Object.keys(canonicalEntries).length > 0) {
2196
+ const unknown = Object.keys(canonicalEntries).join(', ');
2197
+ throw new Error(`canonicalFieldsMap contains unknown fields for addKnexFields: ${unknown}`);
2198
+ }
2199
+ if (scope && latestDescriptor?.canonicalFieldMap) {
2200
+ scope.scopeOptions.canonicalFieldsMap = latestDescriptor.canonicalFieldMap;
2201
+ }
2202
+ if (scope && params.searchSchema) {
2203
+ scope.scopeOptions.searchSchema = {
2204
+ ...(scope.scopeOptions.searchSchema || {}),
2205
+ ...params.searchSchema,
2206
+ };
2207
+ }
2208
+ const updatedStored = {
2209
+ schema: scope?.scopeOptions?.schema || storedBefore.schema || {},
2210
+ relationships: scope?.scopeOptions?.relationships || storedBefore.relationships || {},
2211
+ searchSchema: scope?.scopeOptions?.searchSchema || storedBefore.searchSchema || null,
2212
+ tableName: scope?.scopeOptions?.tableName || storedBefore.tableName || scopeName,
2213
+ idProperty: scope?.scopeOptions?.idProperty || storedBefore.idProperty || scope?.vars?.idProperty || vars.idProperty || 'id',
2214
+ canonicalFieldsMap: scope?.scopeOptions?.canonicalFieldsMap || storedBefore.canonicalFieldsMap || null,
2215
+ };
2216
+ scopeOptionsRegistry.set(scopeName, updatedStored);
2217
+ await rehydrateSchemaInfo(scopeName);
2218
+ });
2219
+
2220
+ addScopeMethod('alterKnexFields', async () => {
2221
+ throw new Error('alterKnexFields is not supported by AnyAPI Knex plugin yet');
2222
+ });
2223
+ },
2224
+ };
2225
+
2226
+ const translateAttributesForStorage = (attributes, descriptor) => {
2227
+ const row = {};
2228
+ for (const [fieldName, value] of Object.entries(attributes)) {
2229
+ const slot = descriptor.fields[fieldName];
2230
+ if (!slot) continue;
2231
+
2232
+ if (slot.slotType === 'belongsTo') {
2233
+ row[slot.slot] = value == null ? null : String(value);
2234
+ } else {
2235
+ row[slot.slot] = value;
2236
+ }
2237
+
2238
+ if (slot.slotType === 'belongsTo') {
2239
+ const alias = slot.alias || descriptor.fields[fieldName]?.alias;
2240
+ const belongsToInfo = alias ? descriptor.belongsTo?.[alias] : null;
2241
+ if (belongsToInfo) {
2242
+ row[belongsToInfo.typeColumn] = value == null ? null : belongsToInfo.target;
2243
+ }
2244
+ }
2245
+ }
2246
+ return row;
2247
+ };
2248
+
2249
+ const translateRecordFromStorage = (row, descriptor) => {
2250
+ const attributes = {};
2251
+ for (const [slot, logical] of Object.entries(descriptor.reverseAttributes)) {
2252
+ if (slot in row) {
2253
+ attributes[logical] = row[slot];
2254
+ }
2255
+ }
2256
+
2257
+ const relationships = {};
2258
+ for (const [alias, info] of Object.entries(descriptor.belongsTo || {})) {
2259
+ const idValue = row[info.idColumn];
2260
+ const relationshipData = idValue == null
2261
+ ? null
2262
+ : { type: info.target, id: String(idValue) };
2263
+
2264
+ relationships[alias] = { data: relationshipData };
2265
+ }
2266
+
2267
+ for (const [alias, info] of Object.entries(descriptor.polymorphicBelongsTo || {})) {
2268
+ const typeValue = info.typeColumn ? row[info.typeColumn] : null;
2269
+ const idValue = info.idColumn ? row[info.idColumn] : null;
2270
+
2271
+ let relationshipData = null;
2272
+ if (typeValue != null && idValue != null) {
2273
+ relationshipData = {
2274
+ type: String(typeValue),
2275
+ id: String(idValue),
2276
+ };
2277
+ } else if (typeValue == null && idValue == null) {
2278
+ relationshipData = null;
2279
+ }
2280
+
2281
+ relationships[alias] = { data: relationshipData };
2282
+ }
2283
+
2284
+ return { attributes, relationships };
2285
+ };