json-rest-api 1.0.14 → 1.0.16

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 (118) hide show
  1. package/connectors_ideas/fastify.js +66 -68
  2. package/connectors_ideas/koa-plugin.js +50 -51
  3. package/eslint.config.js +11 -2
  4. package/examples/url-override-example.js +63 -63
  5. package/index.js +13 -13
  6. package/lib/enhanced-logger.js +51 -51
  7. package/lib/error-formatter.js +61 -61
  8. package/lib/rest-api-errors.js +28 -28
  9. package/package.json +1 -1
  10. package/plugins/core/bulk-operations-plugin.js +142 -145
  11. package/plugins/core/connectors/express-plugin.js +210 -217
  12. package/plugins/core/connectors/lib/busboy-detector.js +84 -84
  13. package/plugins/core/connectors/lib/formidable-detector.js +50 -50
  14. package/plugins/core/connectors/lib/request-helpers.js +74 -74
  15. package/plugins/core/file-handling-plugin.js +125 -125
  16. package/plugins/core/lib/anyapi/anyapi-registry.js +254 -254
  17. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +287 -287
  18. package/plugins/core/lib/anyapi/schema-utils.js +101 -101
  19. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +41 -41
  20. package/plugins/core/lib/dbTablesOperations.js +134 -135
  21. package/plugins/core/lib/fractional-positioning.js +74 -76
  22. package/plugins/core/lib/querying/knex-cross-table-search.js +320 -320
  23. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +144 -144
  24. package/plugins/core/lib/querying/knex-pagination-helpers.js +164 -164
  25. package/plugins/core/lib/querying/knex-process-includes.js +50 -50
  26. package/plugins/core/lib/querying/knex-query-helpers-base.js +31 -31
  27. package/plugins/core/lib/querying/knex-query-helpers.js +451 -452
  28. package/plugins/core/lib/querying/knex-relationship-includes.js +1160 -1170
  29. package/plugins/core/lib/querying/knex-window-queries.js +89 -89
  30. package/plugins/core/lib/querying/url-helpers.js +35 -35
  31. package/plugins/core/lib/querying-writing/compile-schemas.js +99 -99
  32. package/plugins/core/lib/querying-writing/connectors-query-parser.js +31 -31
  33. package/plugins/core/lib/querying-writing/database-capabilities.js +94 -94
  34. package/plugins/core/lib/querying-writing/database-value-normalizers.js +76 -76
  35. package/plugins/core/lib/querying-writing/default-data-helpers.js +27 -27
  36. package/plugins/core/lib/querying-writing/field-utils.js +44 -42
  37. package/plugins/core/lib/querying-writing/knex-constants.js +15 -16
  38. package/plugins/core/lib/querying-writing/knex-field-helpers.js +117 -115
  39. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +41 -41
  40. package/plugins/core/lib/querying-writing/payload-validators.js +275 -275
  41. package/plugins/core/lib/querying-writing/schema-helpers.js +129 -129
  42. package/plugins/core/lib/querying-writing/scope-validations.js +143 -146
  43. package/plugins/core/lib/querying-writing/simplified-helpers.js +133 -129
  44. package/plugins/core/lib/storage/storage-adapter.js +68 -68
  45. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +31 -31
  46. package/plugins/core/lib/writing/many-to-many-manipulations.js +81 -81
  47. package/plugins/core/lib/writing/relationship-processor.js +58 -58
  48. package/plugins/core/rest-api-access.js +11 -15
  49. package/plugins/core/rest-api-anyapi-knex-plugin.js +920 -920
  50. package/plugins/core/rest-api-cors-plugin.js +170 -171
  51. package/plugins/core/rest-api-knex-plugin.js +482 -493
  52. package/plugins/core/rest-api-multihome-plugin.js +247 -251
  53. package/plugins/core/rest-api-plugin-hooks/compile-resource-schemas.js +5 -5
  54. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +20 -21
  55. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +44 -44
  56. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +23 -23
  57. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +15 -15
  58. package/plugins/core/rest-api-plugin-methods/add-route.js +9 -9
  59. package/plugins/core/rest-api-plugin-methods/check-permissions.js +5 -6
  60. package/plugins/core/rest-api-plugin-methods/common.js +295 -297
  61. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +35 -37
  62. package/plugins/core/rest-api-plugin-methods/delete.js +57 -59
  63. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +172 -171
  64. package/plugins/core/rest-api-plugin-methods/get-related.js +96 -96
  65. package/plugins/core/rest-api-plugin-methods/get-relationship.js +16 -18
  66. package/plugins/core/rest-api-plugin-methods/get.js +86 -86
  67. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +16 -17
  68. package/plugins/core/rest-api-plugin-methods/patch.js +63 -64
  69. package/plugins/core/rest-api-plugin-methods/post-relationship.js +54 -55
  70. package/plugins/core/rest-api-plugin-methods/post.js +61 -62
  71. package/plugins/core/rest-api-plugin-methods/put.js +86 -87
  72. package/plugins/core/rest-api-plugin-methods/query.js +88 -88
  73. package/plugins/core/rest-api-plugin-methods/release.js +5 -6
  74. package/plugins/core/rest-api-plugin.js +76 -78
  75. package/plugins/core/rest-api-positioning-plugin.js +251 -252
  76. package/plugins/core/socketio-plugin.js +240 -236
  77. package/plugins/storage/local-storage.js +103 -103
  78. package/plugins/storage/s3-storage.js +41 -42
  79. package/quickTest.js +37 -37
  80. package/scripts/debug-anyapi-review-filter.mjs +18 -18
  81. package/scripts/inspect-anyapi-descriptor-country.mjs +10 -10
  82. package/scripts/inspect-anyapi-descriptor.mjs +10 -10
  83. package/scripts/run-docs.js +112 -113
  84. package/test-debug.js +28 -28
  85. package/tests/TEST_TEMPLATE.test.js +51 -51
  86. package/tests/anyapi-basic.test.js +42 -42
  87. package/tests/anyapi-cursor-pagination.test.js +48 -48
  88. package/tests/bulk-operations.test.js +208 -208
  89. package/tests/computed-fields.test.js +204 -204
  90. package/tests/cors.test.js +412 -413
  91. package/tests/curl.test.js +275 -276
  92. package/tests/custom-idproperty-relationships.test.js +266 -266
  93. package/tests/field-getters.test.js +132 -132
  94. package/tests/field-setters.test.js +110 -110
  95. package/tests/fixtures/api-configs.js +699 -699
  96. package/tests/helpers/storage-mode.js +32 -32
  97. package/tests/helpers/test-utils.js +76 -76
  98. package/tests/include-depth-validation.test.js +76 -76
  99. package/tests/includes.test.js +145 -145
  100. package/tests/multihome.test.js +175 -175
  101. package/tests/nested-includes.test.js +255 -255
  102. package/tests/pagination-cursor-multifield.test.js +164 -164
  103. package/tests/pagination-enhanced.test.js +225 -225
  104. package/tests/polymorphic-relationships.test.js +96 -96
  105. package/tests/polymorphic-simple.test.js +44 -44
  106. package/tests/positioning-advanced.test.js +226 -226
  107. package/tests/positioning-extended.test.js +399 -399
  108. package/tests/positioning-multifilter.test.js +204 -204
  109. package/tests/positioning.test.js +266 -266
  110. package/tests/queries.test.js +184 -184
  111. package/tests/query-limits.test.js +154 -155
  112. package/tests/relationship-endpoints.test.js +127 -127
  113. package/tests/relationships.test.js +178 -179
  114. package/tests/rest-api-access.test.js +86 -86
  115. package/tests/rest-api.test.js +103 -103
  116. package/tests/searchschema-merge.test.js +93 -93
  117. package/tests/socketio.test.js +326 -326
  118. package/tests/virtual-fields.test.js +164 -164
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Provides utilities for loading related resources according to JSON:API specification
3
- *
3
+ *
4
4
  * @description
5
5
  * This module implements the JSON:API include functionality, enabling compound documents
6
6
  * that contain both primary data and related resources. It handles all relationship types
7
7
  * and supports arbitrary nesting depths while preventing N+1 query problems.
8
- *
8
+ *
9
9
  * @example
10
10
  * // Input: Simple include request
11
11
  * // GET /articles?include=author
@@ -14,13 +14,13 @@
14
14
  * { id: 1, title: 'Article 1', author_id: 10 },
15
15
  * { id: 2, title: 'Article 2', author_id: 11 }
16
16
  * ]
17
- *
17
+ *
18
18
  * // Result: 2 queries total (articles + authors)
19
19
  * // Records after (with RELATIONSHIPS_KEY):
20
20
  * [
21
- * {
22
- * id: 1,
23
- * title: 'Article 1',
21
+ * {
22
+ * id: 1,
23
+ * title: 'Article 1',
24
24
  * author_id: 10,
25
25
  * __$jsonrestapi_rel$__: {
26
26
  * author: { data: { type: 'users', id: '10' } }
@@ -32,31 +32,31 @@
32
32
  * { type: 'users', id: '10', attributes: { name: 'John' } },
33
33
  * { type: 'users', id: '11', attributes: { name: 'Jane' } }
34
34
  * ]
35
- *
35
+ *
36
36
  * @example
37
37
  * // Input: Nested includes
38
38
  * // GET /articles?include=comments.author
39
39
  * // Articles → Comments → Authors (3 queries total)
40
- *
40
+ *
41
41
  * // Result: Comments loaded with their authors
42
42
  * // Included array contains both comments AND their authors
43
43
  * // No duplicate authors even if multiple comments by same person
44
- *
45
- * @example
44
+ *
45
+ * @example
46
46
  * // Input: Polymorphic includes
47
47
  * // GET /activities?include=trackable
48
48
  * // Where trackable can be 'posts', 'photos', or 'videos'
49
- *
49
+ *
50
50
  * // Groups by type and loads each type separately:
51
51
  * // Query 1: SELECT * FROM posts WHERE id IN (1, 3, 5)
52
52
  * // Query 2: SELECT * FROM photos WHERE id IN (2, 4)
53
53
  * // Query 3: SELECT * FROM videos WHERE id IN (6)
54
- *
54
+ *
55
55
  * Used by:
56
56
  * - rest-api-knex-plugin calls buildIncludedResources in dataGet and dataQuery
57
57
  * - Called after primary records are fetched but before response assembly
58
58
  * - Results go into the 'included' section of JSON:API response
59
- *
59
+ *
60
60
  * Purpose:
61
61
  * - Implements JSON:API compound documents with primary data and included resources
62
62
  * - Prevents N+1 queries through batch loading (1 query per resource type)
@@ -64,7 +64,7 @@
64
64
  * - Deduplicates resources automatically (each resource appears once)
65
65
  * - Handles circular references through processedPaths tracking
66
66
  * - Applies sparse fieldsets to reduce payload size
67
- *
67
+ *
68
68
  * Data flow:
69
69
  * 1. Parse include parameter into tree structure (author,comments.author)
70
70
  * 2. For each relationship, determine type (belongsTo, hasMany, polymorphic)
@@ -74,43 +74,43 @@
74
74
  * 6. Return array of unique included resources
75
75
  */
76
76
 
77
- import { buildFieldSelection } from '../querying-writing/knex-field-helpers.js';
78
- import { getForeignKeyFields } from '../querying-writing/field-utils.js';
79
- import { toJsonApiRecord } from './knex-json-api-transformers-querying.js';
80
- import { buildWindowedIncludeQuery, applyStandardIncludeConfig, buildOrderByClause } from './knex-window-queries.js';
81
- import { RELATIONSHIPS_KEY, RELATIONSHIP_METADATA_KEY, ROW_NUMBER_KEY, COMPUTED_DEPENDENCIES_KEY, DEFAULT_QUERY_LIMIT } from '../querying-writing/knex-constants.js';
82
- import { RestApiResourceError } from '../../../../lib/rest-api-errors.js';
77
+ import { buildFieldSelection } from '../querying-writing/knex-field-helpers.js'
78
+ import { getForeignKeyFields } from '../querying-writing/field-utils.js'
79
+ import { toJsonApiRecord } from './knex-json-api-transformers-querying.js'
80
+ import { buildWindowedIncludeQuery, applyStandardIncludeConfig, buildOrderByClause } from './knex-window-queries.js'
81
+ import { RELATIONSHIPS_KEY, RELATIONSHIP_METADATA_KEY, ROW_NUMBER_KEY, COMPUTED_DEPENDENCIES_KEY, DEFAULT_QUERY_LIMIT } from '../querying-writing/knex-constants.js'
82
+ import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
83
83
 
84
84
  const translateSelectFieldsForAdapter = (fields, adapter) => {
85
- if (!adapter || !fields) return fields;
85
+ if (!adapter || !fields) return fields
86
86
 
87
87
  const translateField = (field) => {
88
- if (typeof field !== 'string') return field;
88
+ if (typeof field !== 'string') return field
89
89
 
90
- if (field === '*') return '*';
90
+ if (field === '*') return '*'
91
91
 
92
- const aliasMatch = field.match(/\s+as\s+/i);
92
+ const aliasMatch = field.match(/\s+as\s+/i)
93
93
  if (aliasMatch) {
94
- const [source, alias] = field.split(/\s+as\s+/i);
95
- const translatedSource = adapter.translateColumn(source.trim()) || source.trim();
96
- return `${translatedSource} as ${alias.trim()}`;
94
+ const [source, alias] = field.split(/\s+as\s+/i)
95
+ const translatedSource = adapter.translateColumn(source.trim()) || source.trim()
96
+ return `${translatedSource} as ${alias.trim()}`
97
97
  }
98
98
 
99
- const translated = adapter.translateColumn(field);
100
- return translated || field;
101
- };
99
+ const translated = adapter.translateColumn(field)
100
+ return translated || field
101
+ }
102
102
 
103
- return fields.map(translateField);
104
- };
103
+ return fields.map(translateField)
104
+ }
105
105
 
106
106
  /**
107
107
  * Groups records by their polymorphic type for efficient batch loading
108
- *
108
+ *
109
109
  * @param {Array<Object>} records - Records containing polymorphic fields
110
110
  * @param {string} typeField - Name of the type field (e.g., 'commentable_type')
111
111
  * @param {string} idField - Name of the ID field (e.g., 'commentable_id')
112
112
  * @returns {Object<string, Array<number|string>>} Map of type to array of unique IDs
113
- *
113
+ *
114
114
  * @example
115
115
  * // Input: Comments with polymorphic commentable relationship
116
116
  * const records = [
@@ -119,15 +119,15 @@ const translateSelectFieldsForAdapter = (fields, adapter) => {
119
119
  * { id: 3, commentable_type: 'articles', commentable_id: 11 },
120
120
  * { id: 4, commentable_type: 'articles', commentable_id: 10 } // Duplicate
121
121
  * ];
122
- *
122
+ *
123
123
  * const grouped = groupByPolymorphicType(records, 'commentable_type', 'commentable_id');
124
- *
124
+ *
125
125
  * // Output: IDs grouped by type, duplicates removed
126
126
  * // {
127
127
  * // articles: [10, 11], // Only unique IDs
128
128
  * // videos: [20]
129
129
  * // }
130
- *
130
+ *
131
131
  * @example
132
132
  * // Input: Some records have null relationships
133
133
  * const records = [
@@ -136,26 +136,26 @@ const translateSelectFieldsForAdapter = (fields, adapter) => {
136
136
  * { id: 3, item_type: 'products', item_id: null }, // Ignored
137
137
  * { id: 4, item_type: 'categories', item_id: 5 }
138
138
  * ];
139
- *
139
+ *
140
140
  * const grouped = groupByPolymorphicType(records, 'item_type', 'item_id');
141
- *
141
+ *
142
142
  * // Output: Only records with both type AND id
143
143
  * // {
144
144
  * // products: [1],
145
145
  * // categories: [5]
146
146
  * // }
147
- *
147
+ *
148
148
  * @description
149
149
  * Used by:
150
150
  * - loadPolymorphicBelongsTo uses this to group records before batch loading
151
151
  * - Enables efficient loading with one query per type instead of per record
152
- *
152
+ *
153
153
  * Purpose:
154
154
  * - Minimizes database queries when loading polymorphic relationships
155
155
  * - Instead of N queries (one per record), makes T queries (one per type)
156
156
  * - Automatically deduplicates IDs within each type
157
157
  * - Safely handles null/undefined values by filtering them out
158
- *
158
+ *
159
159
  * Data flow:
160
160
  * 1. Receives array of records with polymorphic fields
161
161
  * 2. Groups records by their type field value
@@ -163,48 +163,48 @@ const translateSelectFieldsForAdapter = (fields, adapter) => {
163
163
  * 4. Returns map used for batch WHERE IN queries
164
164
  */
165
165
  export const groupByPolymorphicType = (records, typeField, idField) => {
166
- const grouped = {};
167
-
166
+ const grouped = {}
167
+
168
168
  records.forEach(record => {
169
- const type = record[typeField];
170
- const id = record[idField];
171
-
169
+ const type = record[typeField]
170
+ const id = record[idField]
171
+
172
172
  // Skip if either type or id is missing
173
- if (!type || !id) return;
174
-
173
+ if (!type || !id) return
174
+
175
175
  if (!grouped[type]) {
176
- grouped[type] = [];
176
+ grouped[type] = []
177
177
  }
178
-
178
+
179
179
  // Only add unique IDs
180
180
  if (!grouped[type].includes(id)) {
181
- grouped[type].push(id);
181
+ grouped[type].push(id)
182
182
  }
183
- });
184
-
185
- return grouped;
186
- };
183
+ })
184
+
185
+ return grouped
186
+ }
187
187
 
188
188
  /**
189
189
  * Parses the include parameter string into a tree structure
190
- *
190
+ *
191
191
  * @param {string|Array<string>} includeParam - The include parameter value
192
192
  * @returns {Object} Nested object representing the include tree
193
- *
193
+ *
194
194
  * @example
195
195
  * // Input: Simple comma-separated includes
196
196
  * const tree = parseIncludeTree('author,comments');
197
- *
197
+ *
198
198
  * // Output: Flat structure
199
199
  * // {
200
200
  * // author: {},
201
201
  * // comments: {}
202
202
  * // }
203
- *
203
+ *
204
204
  * @example
205
205
  * // Input: Nested includes with dot notation
206
206
  * const tree = parseIncludeTree('comments.author,comments.replies');
207
- *
207
+ *
208
208
  * // Output: Nested structure
209
209
  * // {
210
210
  * // comments: {
@@ -212,11 +212,11 @@ export const groupByPolymorphicType = (records, typeField, idField) => {
212
212
  * // replies: {}
213
213
  * // }
214
214
  * // }
215
- *
215
+ *
216
216
  * @example
217
217
  * // Input: Complex mixed-depth includes
218
218
  * const tree = parseIncludeTree('author,comments.author,comments.replies.author,tags');
219
- *
219
+ *
220
220
  * // Output: Multi-level nesting
221
221
  * // {
222
222
  * // author: {},
@@ -228,18 +228,18 @@ export const groupByPolymorphicType = (records, typeField, idField) => {
228
228
  * // },
229
229
  * // tags: {}
230
230
  * // }
231
- *
231
+ *
232
232
  * @description
233
233
  * Used by:
234
234
  * - buildIncludedResources parses the include parameter before processing
235
235
  * - processIncludes traverses this tree structure recursively
236
- *
236
+ *
237
237
  * Purpose:
238
238
  * - Converts flat string format into hierarchical structure for processing
239
239
  * - Enables recursive traversal of relationship includes
240
240
  * - Handles both string and pre-split array inputs
241
241
  * - Empty values are filtered out automatically
242
- *
242
+ *
243
243
  * Data flow:
244
244
  * 1. Split comma-separated string into individual paths
245
245
  * 2. For each path, split by dots to get nesting levels
@@ -247,34 +247,34 @@ export const groupByPolymorphicType = (records, typeField, idField) => {
247
247
  * 4. Return tree for recursive processing
248
248
  */
249
249
  export const parseIncludeTree = (includeParam) => {
250
- if (!includeParam) return {};
251
-
250
+ if (!includeParam) return {}
251
+
252
252
  // Handle array input (already split)
253
- const includes = Array.isArray(includeParam)
254
- ? includeParam
255
- : includeParam.split(',').map(s => s.trim()).filter(Boolean);
256
-
257
- const tree = {};
258
-
253
+ const includes = Array.isArray(includeParam)
254
+ ? includeParam
255
+ : includeParam.split(',').map(s => s.trim()).filter(Boolean)
256
+
257
+ const tree = {}
258
+
259
259
  includes.forEach(include => {
260
- const parts = include.split('.');
261
- let current = tree;
262
-
260
+ const parts = include.split('.')
261
+ let current = tree
262
+
263
263
  parts.forEach(part => {
264
264
  if (!current[part]) {
265
- current[part] = {};
265
+ current[part] = {}
266
266
  }
267
- current = current[part];
268
- });
269
- });
270
-
271
- return tree;
272
- };
267
+ current = current[part]
268
+ })
269
+ })
270
+
271
+ return tree
272
+ }
273
273
 
274
274
  /**
275
275
  * Loads relationship metadata for included resources
276
276
  * This ensures included resources have complete JSON:API representation
277
- *
277
+ *
278
278
  * @param {Object} scopes - The hooked-api scopes object
279
279
  * @param {Array<Object>} records - Records to add relationships to
280
280
  * @param {string} scopeName - The scope/resource type name
@@ -282,29 +282,29 @@ export const parseIncludeTree = (includeParam) => {
282
282
  const loadRelationshipMetadata = async (scopes, records, scopeName) => {
283
283
  try {
284
284
  // Get schema information
285
- const schemaInfo = scopes[scopeName]?.vars?.schemaInfo;
286
- if (!schemaInfo) return;
285
+ const schemaInfo = scopes[scopeName]?.vars?.schemaInfo
286
+ if (!schemaInfo) return
287
287
 
288
- const schema = schemaInfo.schemaInstance.structure || schemaInfo.schemaInstance;
289
- const relationships = schemaInfo.schemaRelationships || {};
288
+ const schema = schemaInfo.schemaInstance.structure || schemaInfo.schemaInstance
289
+ const relationships = schemaInfo.schemaRelationships || {}
290
290
 
291
291
  // Process each record
292
292
  records.forEach(record => {
293
- record[RELATIONSHIP_METADATA_KEY] = {};
293
+ record[RELATIONSHIP_METADATA_KEY] = {}
294
294
 
295
295
  // Process belongsTo relationships from schema
296
296
  for (const [fieldName, fieldDef] of Object.entries(schema)) {
297
297
  if (fieldDef.belongsTo && fieldDef.as) {
298
- const foreignKeyValue = record[fieldName];
298
+ const foreignKeyValue = record[fieldName]
299
299
  if (foreignKeyValue != null) {
300
300
  record[RELATIONSHIP_METADATA_KEY][fieldDef.as] = {
301
301
  data: {
302
302
  type: fieldDef.belongsTo,
303
303
  id: String(foreignKeyValue)
304
304
  }
305
- };
305
+ }
306
306
  } else {
307
- record[RELATIONSHIP_METADATA_KEY][fieldDef.as] = { data: null };
307
+ record[RELATIONSHIP_METADATA_KEY][fieldDef.as] = { data: null }
308
308
  }
309
309
  }
310
310
  }
@@ -313,44 +313,44 @@ const loadRelationshipMetadata = async (scopes, records, scopeName) => {
313
313
  for (const [relName, relDef] of Object.entries(relationships)) {
314
314
  if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
315
315
  // Add empty relationship data - will be populated if explicitly included
316
- record[RELATIONSHIP_METADATA_KEY][relName] = { data: [] };
316
+ record[RELATIONSHIP_METADATA_KEY][relName] = { data: [] }
317
317
  } else if (relDef.type === 'hasOne') {
318
318
  // hasOne expects a single object or null
319
- record[RELATIONSHIP_METADATA_KEY][relName] = { data: null };
319
+ record[RELATIONSHIP_METADATA_KEY][relName] = { data: null }
320
320
  } else if (relDef.belongsToPolymorphic) {
321
321
  // Process polymorphic relationships defined in relationships section
322
- const { typeField, idField } = relDef.belongsToPolymorphic;
323
- const type = record[typeField];
324
- const id = record[idField];
325
-
322
+ const { typeField, idField } = relDef.belongsToPolymorphic
323
+ const type = record[typeField]
324
+ const id = record[idField]
325
+
326
326
  if (type && id) {
327
327
  record[RELATIONSHIP_METADATA_KEY][relName] = {
328
328
  data: { type, id: String(id) }
329
- };
329
+ }
330
330
  } else {
331
- record[RELATIONSHIP_METADATA_KEY][relName] = { data: null };
331
+ record[RELATIONSHIP_METADATA_KEY][relName] = { data: null }
332
332
  }
333
333
  }
334
334
  }
335
- });
335
+ })
336
336
  } catch (error) {
337
337
  // Log error with context and re-throw
338
338
  const errorContext = {
339
339
  scopeName,
340
340
  recordCount: records?.length || 0,
341
341
  error: error.message
342
- };
343
- console.error('[loadRelationshipMetadata] Error loading relationship metadata:', errorContext);
344
- throw new Error(`Failed to load relationship metadata for scope '${scopeName}': ${error.message}`);
342
+ }
343
+ console.error('[loadRelationshipMetadata] Error loading relationship metadata:', errorContext)
344
+ throw new Error(`Failed to load relationship metadata for scope '${scopeName}': ${error.message}`)
345
345
  }
346
- };
346
+ }
347
347
 
348
348
  /**
349
349
  * Loads belongsTo relationships (many-to-one)
350
- *
350
+ *
351
351
  * This function loads the "parent" side of a relationship. For example, if comments
352
352
  * belong to articles, this loads the articles for a set of comments.
353
- *
353
+ *
354
354
  * @param {Object} scope - The hooked-api scope object containing:
355
355
  * - records: Array<Object> - Records to load relationships for
356
356
  * - fieldName: string - The foreign key field name (e.g., 'author_id')
@@ -369,169 +369,169 @@ const loadRelationshipMetadata = async (scopes, records, scopeName) => {
369
369
  * @returns {Promise<void>}
370
370
  */
371
371
  export const loadBelongsTo = async (scope, deps) => {
372
- const { records, scopeName, fieldName, fieldDef, includeName, subIncludes, included, processedPaths, currentPath, fields, idProperty } = scope;
373
- const { scopes, log, knex, capabilities } = deps.context;
372
+ const { records, scopeName, fieldName, fieldDef, includeName, subIncludes, included, processedPaths, currentPath, fields, idProperty } = scope
373
+ const { scopes, log, knex, capabilities } = deps.context
374
374
  try {
375
- log.trace('[INCLUDE] Loading belongsTo:', {
376
- fieldName,
377
- includeName,
378
- recordCount: records.length
379
- });
380
-
375
+ log.trace('[INCLUDE] Loading belongsTo:', {
376
+ fieldName,
377
+ includeName,
378
+ recordCount: records.length
379
+ })
380
+
381
381
  // Get the target scope name
382
- const targetScope = fieldDef.belongsTo;
382
+ const targetScope = fieldDef.belongsTo
383
383
  if (!scopes[targetScope]) {
384
- log.warn('[INCLUDE] Target scope not found:', targetScope);
385
- return;
384
+ log.warn('[INCLUDE] Target scope not found:', targetScope)
385
+ return
386
386
  }
387
-
387
+
388
388
  // Collect all foreign key values
389
389
  const foreignKeyValues = records
390
390
  .map(r => r[fieldName])
391
- .filter(val => val != null); // Filter out null/undefined
392
-
393
- const uniqueIds = [...new Set(foreignKeyValues)];
394
-
395
- log.debug('[INCLUDE] Loading belongsTo records:', {
396
- targetScope,
397
- uniqueIds
398
- });
399
-
391
+ .filter(val => val != null) // Filter out null/undefined
392
+
393
+ const uniqueIds = [...new Set(foreignKeyValues)]
394
+
395
+ log.debug('[INCLUDE] Loading belongsTo records:', {
396
+ targetScope,
397
+ uniqueIds
398
+ })
399
+
400
400
  if (uniqueIds.length === 0) {
401
401
  // No relationships to load, set all to null
402
402
  records.forEach(record => {
403
- if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {};
404
- const relationshipObject = { data: null };
405
-
403
+ if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
404
+ const relationshipObject = { data: null }
405
+
406
406
  // Add links if urlPrefix is configured
407
- const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || '';
407
+ const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
408
408
  if (scopeName) {
409
409
  relationshipObject.links = {
410
410
  self: `${urlPrefix}/${scopeName}/${record[idProperty]}/relationships/${includeName}`,
411
411
  related: `${urlPrefix}/${scopeName}/${record[idProperty]}/${includeName}`
412
- };
412
+ }
413
413
  }
414
-
415
- record[RELATIONSHIPS_KEY][includeName] = relationshipObject;
416
- });
417
- return;
414
+
415
+ record[RELATIONSHIPS_KEY][includeName] = relationshipObject
416
+ })
417
+ return
418
418
  }
419
-
419
+
420
420
  // Get target table info
421
421
  const targetTableName = scopes[targetScope].vars.schemaInfo.tableName
422
- const targetIdProperty = scopes[targetScope].vars.schemaInfo.idProperty
423
- const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance;
424
-
422
+ const targetIdProperty = scopes[targetScope].vars.schemaInfo.idProperty
423
+ const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
424
+
425
425
  // Build field selection for sparse fieldsets
426
- const targetScopeObject = scopes[targetScope];
427
- const storageAdapter = targetScopeObject.vars.storageAdapter;
428
- const fieldSelectionInfo = fields?.[targetScope] ?
429
- await buildFieldSelection(targetScopeObject, {
426
+ const targetScopeObject = scopes[targetScope]
427
+ const storageAdapter = targetScopeObject.vars.storageAdapter
428
+ const fieldSelectionInfo = fields?.[targetScope]
429
+ ? await buildFieldSelection(targetScopeObject, {
430
430
  context: {
431
431
  scopeName: targetScope,
432
432
  queryParams: { fields: { [targetScope]: fields[targetScope] } },
433
433
  schemaInfo: targetScopeObject.vars.schemaInfo,
434
434
  storageAdapter,
435
435
  }
436
- }) :
437
- null;
438
-
436
+ })
437
+ : null
438
+
439
439
  // Load the target records
440
- let query = knex(targetTableName).whereIn(targetIdProperty, uniqueIds);
440
+ let query = knex(targetTableName).whereIn(targetIdProperty, uniqueIds)
441
441
  if (fieldSelectionInfo) {
442
- const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter);
443
- query = query.select(selectFields);
442
+ const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
443
+ query = query.select(selectFields)
444
444
  } else if (targetIdProperty !== 'id') {
445
445
  // If no field selection but custom idProperty, we need to alias it
446
- query = query.select('*', `${targetIdProperty} as id`);
446
+ query = query.select('*', `${targetIdProperty} as id`)
447
447
  }
448
- const targetRecords = await query;
449
-
448
+ const targetRecords = await query
449
+
450
450
  // Load relationship metadata for all target records
451
- await loadRelationshipMetadata(scopes, targetRecords, targetScope);
452
-
451
+ await loadRelationshipMetadata(scopes, targetRecords, targetScope)
452
+
453
453
  // Create lookup map
454
- const targetById = {};
454
+ const targetById = {}
455
455
  targetRecords.forEach(record => {
456
- targetById[record.id || record[targetIdProperty]] = record;
457
- });
458
-
456
+ targetById[record.id || record[targetIdProperty]] = record
457
+ })
458
+
459
459
  // Set relationships on original records
460
- const targetRecordsToProcess = [];
461
-
460
+ const targetRecordsToProcess = []
461
+
462
462
  records.forEach(record => {
463
- if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {};
464
-
465
- const targetId = record[fieldName];
466
- const targetRecord = targetById[targetId];
467
-
463
+ if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
464
+
465
+ const targetId = record[fieldName]
466
+ const targetRecord = targetById[targetId]
467
+
468
468
  if (targetRecord) {
469
469
  // Convert to JSON:API format
470
470
  const jsonApiRecord = toJsonApiRecord(
471
471
  scopes[targetScope],
472
472
  targetRecord,
473
473
  targetScope
474
- );
474
+ )
475
475
 
476
476
  // Add relationships from metadata
477
477
  if (targetRecord[RELATIONSHIP_METADATA_KEY]) {
478
- jsonApiRecord.relationships = targetRecord[RELATIONSHIP_METADATA_KEY];
478
+ jsonApiRecord.relationships = targetRecord[RELATIONSHIP_METADATA_KEY]
479
479
  // Clean up the temporary property
480
- delete targetRecord[RELATIONSHIP_METADATA_KEY];
480
+ delete targetRecord[RELATIONSHIP_METADATA_KEY]
481
481
  }
482
-
482
+
483
483
  // Attach computed dependencies info if sparse fieldsets were used
484
484
  if (fieldSelectionInfo?.computedDependencies) {
485
- jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies;
485
+ jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies
486
486
  }
487
487
 
488
- const relationshipObject = { data: { type: targetScope, id: String(targetId) } };
489
-
488
+ const relationshipObject = { data: { type: targetScope, id: String(targetId) } }
489
+
490
490
  // Add links if urlPrefix is configured
491
- const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || '';
491
+ const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
492
492
  if (scopeName) {
493
493
  relationshipObject.links = {
494
494
  self: `${urlPrefix}/${scopeName}/${record[idProperty]}/relationships/${includeName}`,
495
495
  related: `${urlPrefix}/${scopeName}/${record[idProperty]}/${includeName}`
496
- };
496
+ }
497
497
  }
498
-
499
- record[RELATIONSHIPS_KEY][includeName] = relationshipObject;
500
-
498
+
499
+ record[RELATIONSHIPS_KEY][includeName] = relationshipObject
500
+
501
501
  // Add to included if not already there
502
- const resourceKey = `${targetScope}:${targetId}`;
502
+ const resourceKey = `${targetScope}:${targetId}`
503
503
  if (!included.has(resourceKey)) {
504
- included.set(resourceKey, jsonApiRecord); // Now includes relationships!
505
-
504
+ included.set(resourceKey, jsonApiRecord) // Now includes relationships!
505
+
506
506
  // Collect for nested processing
507
507
  if (Object.keys(subIncludes).length > 0) {
508
- targetRecordsToProcess.push(targetRecord);
508
+ targetRecordsToProcess.push(targetRecord)
509
509
  }
510
510
  }
511
511
  } else {
512
- const relationshipObject = { data: null };
513
-
512
+ const relationshipObject = { data: null }
513
+
514
514
  // Add links if urlPrefix is configured
515
- const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || '';
515
+ const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
516
516
  if (scopeName) {
517
517
  relationshipObject.links = {
518
518
  self: `${urlPrefix}/${scopeName}/${record[idProperty]}/relationships/${includeName}`,
519
519
  related: `${urlPrefix}/${scopeName}/${record[idProperty]}/${includeName}`
520
- };
520
+ }
521
521
  }
522
-
523
- record[RELATIONSHIPS_KEY][includeName] = relationshipObject;
522
+
523
+ record[RELATIONSHIPS_KEY][includeName] = relationshipObject
524
524
  }
525
- });
526
-
525
+ })
526
+
527
527
  // Process nested includes if any
528
528
  if (targetRecordsToProcess.length > 0) {
529
- const nextPath = `${currentPath}.${includeName}`;
529
+ const nextPath = `${currentPath}.${includeName}`
530
530
  if (!processedPaths.has(nextPath)) {
531
531
  await processIncludes(
532
532
  { records: targetRecordsToProcess, scopeName: targetScope, includeTree: subIncludes, included, processedPaths, currentPath: nextPath, fields, idProperty: targetIdProperty },
533
533
  { context: { scopes, log, knex, capabilities } }
534
- );
534
+ )
535
535
  }
536
536
  }
537
537
  } catch (error) {
@@ -543,25 +543,25 @@ export const loadBelongsTo = async (scope, deps) => {
543
543
  recordCount: records?.length || 0,
544
544
  error: error.message,
545
545
  stack: error.stack
546
- });
547
-
546
+ })
547
+
548
548
  // Re-throw with enhanced error message
549
549
  const enhancedError = new Error(
550
550
  `Failed to load belongsTo relationship '${includeName}' for field '${fieldName}': ${error.message}`
551
- );
552
- enhancedError.originalError = error;
553
- enhancedError.context = { fieldName, includeName, targetScope: fieldDef.belongsTo };
554
- throw enhancedError;
551
+ )
552
+ enhancedError.originalError = error
553
+ enhancedError.context = { fieldName, includeName, targetScope: fieldDef.belongsTo }
554
+ throw enhancedError
555
555
  }
556
- };
556
+ }
557
557
 
558
558
  /**
559
559
  * Loads hasMany relationships (one-to-many or many-to-many)
560
- *
560
+ *
561
561
  * This function loads the "child" side of a relationship. For example, if articles
562
562
  * have many comments, this loads all comments for a set of articles. It also handles
563
563
  * many-to-many relationships through a pivot table.
564
- *
564
+ *
565
565
  * @param {Object} scope - The hooked-api scope object containing:
566
566
  * - records: Array<Object> - Parent records to load relationships for
567
567
  * - scopeName: string - The parent scope name
@@ -579,449 +579,442 @@ export const loadBelongsTo = async (scope, deps) => {
579
579
  * @returns {Promise<void>}
580
580
  */
581
581
  export const loadHasMany = async (scope, deps) => {
582
- const { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields } = scope;
583
- const { scopes, log, knex, capabilities } = deps.context;
582
+ const { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields } = scope
583
+ const { scopes, log, knex, capabilities } = deps.context
584
584
  try {
585
- log.trace('[INCLUDE] Loading hasMany/manyToMany relationship:', {
586
- scopeName,
587
- includeName,
585
+ log.trace('[INCLUDE] Loading hasMany/manyToMany relationship:', {
586
+ scopeName,
587
+ includeName,
588
588
  recordCount: records.length,
589
589
  relDef,
590
590
  isManyToMany: relDef.type === 'manyToMany',
591
591
  hasMany: relDef.type === 'hasMany' ? relDef.target : undefined
592
- });
593
-
594
- // Collect all parent IDs
595
- const mainIds = records.map(r => r.id).filter(Boolean);
596
-
597
- if (mainIds.length === 0) {
598
- log.trace('[INCLUDE] No parent IDs found, skipping hasMany load');
599
- return;
600
- }
601
-
602
-
603
- // Check if this is a many-to-many relationship
604
- if (relDef.type === 'manyToMany') {
605
-
606
- // Handle many-to-many relationship
607
- const pivotTable = scopes[relDef.through].vars.schemaInfo.tableName;
608
- const foreignKey = relDef.foreignKey;
609
- const otherKey = relDef.otherKey;
610
-
611
- if (!foreignKey || !otherKey) {
612
- throw new Error(`Missing foreignKey or otherKey in many-to-many relationship '${relName}' for scope '${scopeName}'`);
592
+ })
593
+
594
+ // Collect all parent IDs
595
+ const mainIds = records.map(r => r.id).filter(Boolean)
596
+
597
+ if (mainIds.length === 0) {
598
+ log.trace('[INCLUDE] No parent IDs found, skipping hasMany load')
599
+ return
613
600
  }
614
-
615
- // For manyToMany, the target scope is the relationship name itself
616
- const targetScope = includeName;
617
- const targetTable = scopes[targetScope].vars.schemaInfo.tableName
618
-
619
- log.debug(`[INCLUDE] Loading pivot records from ${pivotTable}:`, {
620
- foreignKey,
621
- whereIn: mainIds
622
- });
623
-
624
- // Step 1: Query the pivot table
625
- const pivotRecords = await knex(pivotTable)
626
- .whereIn(foreignKey, mainIds)
627
- .orderBy(otherKey);
628
-
629
-
630
- if (pivotRecords.length === 0) {
601
+
602
+ // Check if this is a many-to-many relationship
603
+ if (relDef.type === 'manyToMany') {
604
+ // Handle many-to-many relationship
605
+ const pivotTable = scopes[relDef.through].vars.schemaInfo.tableName
606
+ const foreignKey = relDef.foreignKey
607
+ const otherKey = relDef.otherKey
608
+
609
+ if (!foreignKey || !otherKey) {
610
+ throw new Error(`Missing foreignKey or otherKey in many-to-many relationship '${relName}' for scope '${scopeName}'`)
611
+ }
612
+
613
+ // For manyToMany, the target scope is the relationship name itself
614
+ const targetScope = includeName
615
+ const targetTable = scopes[targetScope].vars.schemaInfo.tableName
616
+
617
+ log.debug(`[INCLUDE] Loading pivot records from ${pivotTable}:`, {
618
+ foreignKey,
619
+ whereIn: mainIds
620
+ })
621
+
622
+ // Step 1: Query the pivot table
623
+ const pivotRecords = await knex(pivotTable)
624
+ .whereIn(foreignKey, mainIds)
625
+ .orderBy(otherKey)
626
+
627
+ if (pivotRecords.length === 0) {
631
628
  // No relationships found, set empty arrays for all records
632
- records.forEach(record => {
633
- if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {};
634
- const relationshipObject = { data: [] };
635
-
636
- // Add links if urlPrefix is configured
637
- const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || '';
638
- if (scopeName) {
639
- relationshipObject.links = {
640
- self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
641
- related: `${urlPrefix}/${scopeName}/${record.id}/${includeName}`
642
- };
643
- }
644
-
645
- record[RELATIONSHIPS_KEY][includeName] = relationshipObject;
646
- });
647
- return;
648
- }
649
-
650
- // Step 2: Extract target IDs from pivot records
651
- const targetIds = [...new Set(pivotRecords.map(p => p[otherKey]).filter(Boolean))];
652
-
653
- log.debug(`[INCLUDE] Loading ${targetScope} records:`, {
654
- whereIn: targetIds,
655
- includeConfig: relDef.include
656
- });
657
-
658
- // Step 3: Build field selection for sparse fieldsets
659
- const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance;
660
- const targetScopeObject = scopes[targetScope];
661
- const storageAdapter = targetScopeObject.vars.storageAdapter;
662
- const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty;
663
- const fieldSelectionInfo = fields?.[targetScope] ?
664
- await buildFieldSelection(targetScopeObject, {
665
- context: {
666
- scopeName: targetScope,
667
- queryParams: { fields: { [targetScope]: fields[targetScope] } },
668
- schemaInfo: targetScopeObject.vars.schemaInfo,
669
- storageAdapter,
670
- }
671
- }) :
672
- null;
673
-
674
- // Step 4: For many-to-many with limits, we need a different approach
675
- let targetRecords;
629
+ records.forEach(record => {
630
+ if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
631
+ const relationshipObject = { data: [] }
632
+
633
+ // Add links if urlPrefix is configured
634
+ const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
635
+ if (scopeName) {
636
+ relationshipObject.links = {
637
+ self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
638
+ related: `${urlPrefix}/${scopeName}/${record.id}/${includeName}`
639
+ }
640
+ }
641
+
642
+ record[RELATIONSHIPS_KEY][includeName] = relationshipObject
643
+ })
644
+ return
645
+ }
646
+
647
+ // Step 2: Extract target IDs from pivot records
648
+ const targetIds = [...new Set(pivotRecords.map(p => p[otherKey]).filter(Boolean))]
649
+
650
+ log.debug(`[INCLUDE] Loading ${targetScope} records:`, {
651
+ whereIn: targetIds,
652
+ includeConfig: relDef.include
653
+ })
654
+
655
+ // Step 3: Build field selection for sparse fieldsets
656
+ const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
657
+ const targetScopeObject = scopes[targetScope]
658
+ const storageAdapter = targetScopeObject.vars.storageAdapter
659
+ const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty
660
+ const fieldSelectionInfo = fields?.[targetScope]
661
+ ? await buildFieldSelection(targetScopeObject, {
662
+ context: {
663
+ scopeName: targetScope,
664
+ queryParams: { fields: { [targetScope]: fields[targetScope] } },
665
+ schemaInfo: targetScopeObject.vars.schemaInfo,
666
+ storageAdapter,
667
+ }
668
+ })
669
+ : null
670
+
671
+ // Step 4: For many-to-many with limits, we need a different approach
672
+ let targetRecords
676
673
 
677
- if (relDef.include?.strategy === 'window') {
674
+ if (relDef.include?.strategy === 'window') {
678
675
  // For many-to-many with window functions, we need to limit per parent
679
676
  // This requires joining back through the pivot table
680
-
681
- // Get target scope vars for defaults
682
- const targetVars = scopes[targetScope].vars || {};
683
-
684
- // Calculate effective limit with defaults
685
- const effectiveLimit = relDef.include?.limit ?? targetVars.queryDefaultLimit ?? DEFAULT_QUERY_LIMIT;
686
-
687
- // Validate against max
688
- if (targetVars.queryMaxLimit && effectiveLimit > targetVars.queryMaxLimit) {
689
- throw new RestApiResourceError({
690
- title: 'Include Limit Exceeds Maximum',
691
- detail: `Requested include limit (${effectiveLimit}) exceeds queryMaxLimit (${targetVars.queryMaxLimit})`,
692
- status: 400
693
- });
694
- }
695
-
696
- const targetIdProperty = scopes[targetScope]?.vars?.schemaInfo?.idProperty || 'id';
697
- const windowQuery = knex
698
- .select(`${targetTable}.*`);
699
-
700
- // Add id alias if needed
701
- if (targetIdProperty !== 'id') {
702
- windowQuery.select(`${targetTable}.${targetIdProperty} as id`);
703
- }
704
-
705
- windowQuery.select(
677
+
678
+ // Get target scope vars for defaults
679
+ const targetVars = scopes[targetScope].vars || {}
680
+
681
+ // Calculate effective limit with defaults
682
+ const effectiveLimit = relDef.include?.limit ?? targetVars.queryDefaultLimit ?? DEFAULT_QUERY_LIMIT
683
+
684
+ // Validate against max
685
+ if (targetVars.queryMaxLimit && effectiveLimit > targetVars.queryMaxLimit) {
686
+ throw new RestApiResourceError({
687
+ title: 'Include Limit Exceeds Maximum',
688
+ detail: `Requested include limit (${effectiveLimit}) exceeds queryMaxLimit (${targetVars.queryMaxLimit})`,
689
+ status: 400
690
+ })
691
+ }
692
+
693
+ const targetIdProperty = scopes[targetScope]?.vars?.schemaInfo?.idProperty || 'id'
694
+ const windowQuery = knex
695
+ .select(`${targetTable}.*`)
696
+
697
+ // Add id alias if needed
698
+ if (targetIdProperty !== 'id') {
699
+ windowQuery.select(`${targetTable}.${targetIdProperty} as id`)
700
+ }
701
+
702
+ windowQuery.select(
706
703
  knex.raw(
707
- 'ROW_NUMBER() OVER (PARTITION BY pivot.?? ORDER BY ' +
708
- buildOrderByClause(relDef.include?.orderBy || [targetIdProperty], targetTable) +
704
+ 'ROW_NUMBER() OVER (PARTITION BY pivot.?? ORDER BY ' +
705
+ buildOrderByClause(relDef.include?.orderBy || [targetIdProperty], targetTable) +
709
706
  ') as ' + ROW_NUMBER_KEY,
710
707
  [foreignKey]
711
708
  )
712
709
  )
713
- .from(targetTable)
714
- .join(`${pivotTable} as pivot`, `${targetTable}.${targetIdProperty}`, 'pivot.' + otherKey)
715
- .whereIn(`pivot.${foreignKey}`, mainIds);
716
-
717
-
718
- // Wrap to filter by row number
719
- const limitedQuery = knex
720
- .select('*')
721
- .from(windowQuery.as('_windowed'))
722
- .where(ROW_NUMBER_KEY, '<=', effectiveLimit);
723
-
724
- targetRecords = await limitedQuery;
725
-
726
- // Remove row number column and restore proper pivot grouping
727
- targetRecords.forEach(record => delete record[ROW_NUMBER_KEY]);
728
-
729
- } else {
710
+ .from(targetTable)
711
+ .join(`${pivotTable} as pivot`, `${targetTable}.${targetIdProperty}`, 'pivot.' + otherKey)
712
+ .whereIn(`pivot.${foreignKey}`, mainIds)
713
+
714
+ // Wrap to filter by row number
715
+ const limitedQuery = knex
716
+ .select('*')
717
+ .from(windowQuery.as('_windowed'))
718
+ .where(ROW_NUMBER_KEY, '<=', effectiveLimit)
719
+
720
+ targetRecords = await limitedQuery
721
+
722
+ // Remove row number column and restore proper pivot grouping
723
+ targetRecords.forEach(record => delete record[ROW_NUMBER_KEY])
724
+ } else {
730
725
  // Standard query without per-parent limits
731
- const targetIdProperty = scopes[targetScope]?.vars?.schemaInfo?.idProperty || 'id';
732
- let query = knex(targetTable).whereIn(targetIdProperty, targetIds);
733
-
734
- if (fieldSelectionInfo) {
735
- const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter);
736
- query = query.select(selectFields);
737
- } else if (targetIdProperty !== 'id') {
726
+ const targetIdProperty = scopes[targetScope]?.vars?.schemaInfo?.idProperty || 'id'
727
+ let query = knex(targetTable).whereIn(targetIdProperty, targetIds)
728
+
729
+ if (fieldSelectionInfo) {
730
+ const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
731
+ query = query.select(selectFields)
732
+ } else if (targetIdProperty !== 'id') {
738
733
  // If no field selection but custom idProperty, we need to alias it
739
- query = query.select('*', `${targetIdProperty} as id`);
740
- }
741
-
742
- // Apply standard include config (global limits)
743
- if (relDef.include) {
744
- const targetVars = scopes[targetScope].vars;
745
- query = applyStandardIncludeConfig(query, relDef.include, targetVars, log);
746
- }
747
-
748
- targetRecords = await query;
749
- }
750
-
751
- log.trace('[INCLUDE] Loaded target records:', { count: targetRecords.length });
752
-
753
- // Step 5: Load relationship metadata for all target records
754
- await loadRelationshipMetadata(scopes, targetRecords, targetScope);
755
-
756
- // Step 6: Create lookup map for target records
757
- const targetById = {};
758
- targetRecords.forEach(record => {
759
- targetById[record.id] = record;
760
- });
761
-
762
- // Step 7: Group pivot records by parent ID
763
- const pivotsByParent = {};
764
- pivotRecords.forEach(pivot => {
765
- const parentId = pivot[foreignKey];
766
- if (!pivotsByParent[parentId]) {
767
- pivotsByParent[parentId] = [];
734
+ query = query.select('*', `${targetIdProperty} as id`)
735
+ }
736
+
737
+ // Apply standard include config (global limits)
738
+ if (relDef.include) {
739
+ const targetVars = scopes[targetScope].vars
740
+ query = applyStandardIncludeConfig(query, relDef.include, targetVars, log)
741
+ }
742
+
743
+ targetRecords = await query
768
744
  }
769
- pivotsByParent[parentId].push(pivot[otherKey]);
770
- });
771
-
772
- // Step 7: Set relationships on parent records
773
-
774
- records.forEach(record => {
775
- if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {};
776
-
777
- const childIds = pivotsByParent[record.id] || [];
778
-
779
- const relData = childIds
780
- .map(childId => targetById[childId])
781
- .filter(Boolean)
782
- .map(childRecord => {
745
+
746
+ log.trace('[INCLUDE] Loaded target records:', { count: targetRecords.length })
747
+
748
+ // Step 5: Load relationship metadata for all target records
749
+ await loadRelationshipMetadata(scopes, targetRecords, targetScope)
750
+
751
+ // Step 6: Create lookup map for target records
752
+ const targetById = {}
753
+ targetRecords.forEach(record => {
754
+ targetById[record.id] = record
755
+ })
756
+
757
+ // Step 7: Group pivot records by parent ID
758
+ const pivotsByParent = {}
759
+ pivotRecords.forEach(pivot => {
760
+ const parentId = pivot[foreignKey]
761
+ if (!pivotsByParent[parentId]) {
762
+ pivotsByParent[parentId] = []
763
+ }
764
+ pivotsByParent[parentId].push(pivot[otherKey])
765
+ })
766
+
767
+ // Step 7: Set relationships on parent records
768
+
769
+ records.forEach(record => {
770
+ if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
771
+
772
+ const childIds = pivotsByParent[record.id] || []
773
+
774
+ const relData = childIds
775
+ .map(childId => targetById[childId])
776
+ .filter(Boolean)
777
+ .map(childRecord => {
783
778
  // Add to included
784
- const resourceKey = `${targetScope}:${childRecord.id}`;
785
- if (!included.has(resourceKey)) {
786
- const jsonApiRecord = toJsonApiRecord(
787
- scopes[targetScope],
788
- childRecord,
789
- targetScope
790
- );
791
-
792
-
793
- // Add relationships from metadata
794
- if (childRecord[RELATIONSHIP_METADATA_KEY]) {
795
- jsonApiRecord.relationships = childRecord[RELATIONSHIP_METADATA_KEY];
796
- // Clean up the temporary property
797
- delete childRecord[RELATIONSHIP_METADATA_KEY];
798
- }
799
-
800
- // Attach computed dependencies info if sparse fieldsets were used
801
- if (fieldSelectionInfo?.computedDependencies) {
802
- jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies;
779
+ const resourceKey = `${targetScope}:${childRecord.id}`
780
+ if (!included.has(resourceKey)) {
781
+ const jsonApiRecord = toJsonApiRecord(
782
+ scopes[targetScope],
783
+ childRecord,
784
+ targetScope
785
+ )
786
+
787
+ // Add relationships from metadata
788
+ if (childRecord[RELATIONSHIP_METADATA_KEY]) {
789
+ jsonApiRecord.relationships = childRecord[RELATIONSHIP_METADATA_KEY]
790
+ // Clean up the temporary property
791
+ delete childRecord[RELATIONSHIP_METADATA_KEY]
792
+ }
793
+
794
+ // Attach computed dependencies info if sparse fieldsets were used
795
+ if (fieldSelectionInfo?.computedDependencies) {
796
+ jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies
797
+ }
798
+
799
+ included.set(resourceKey, jsonApiRecord)
803
800
  }
804
-
805
- included.set(resourceKey, jsonApiRecord);
801
+ return { type: targetScope, id: String(childRecord.id) }
802
+ })
803
+
804
+ const relationshipObject = { data: relData }
805
+
806
+ // Add links if urlPrefix is configured
807
+ const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
808
+ if (scopeName) {
809
+ relationshipObject.links = {
810
+ self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
811
+ related: `${urlPrefix}/${scopeName}/${record.id}/${includeName}`
806
812
  }
807
- return { type: targetScope, id: String(childRecord.id) };
808
- });
809
-
810
- const relationshipObject = { data: relData };
811
-
812
- // Add links if urlPrefix is configured
813
- const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || '';
814
- if (scopeName) {
815
- relationshipObject.links = {
816
- self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
817
- related: `${urlPrefix}/${scopeName}/${record.id}/${includeName}`
818
- };
819
- }
820
-
821
- record[RELATIONSHIPS_KEY][includeName] = relationshipObject;
822
-
823
- // Update the record in the included Map if it exists
824
- const recordKey = `${scopeName}:${record.id}`;
825
- if (included.has(recordKey)) {
826
- const existingRecord = included.get(recordKey);
827
- if (!existingRecord.relationships) {
828
- existingRecord.relationships = {};
829
813
  }
830
- existingRecord.relationships[includeName] = relationshipObject;
831
- }
832
- });
833
-
834
- // Step 8: Process nested includes if any
835
- if (Object.keys(subIncludes).length > 0 && targetRecords.length > 0) {
836
- const nextPath = `${currentPath}.${includeName}`;
837
- if (!processedPaths.has(nextPath)) {
838
- await processIncludes(
839
- { records: targetRecords, scopeName: targetScope, includeTree: subIncludes, included, processedPaths, currentPath: nextPath, fields, idProperty: targetIdProperty },
840
- { context: { scopes, log, knex, capabilities } }
841
- );
814
+
815
+ record[RELATIONSHIPS_KEY][includeName] = relationshipObject
816
+
817
+ // Update the record in the included Map if it exists
818
+ const recordKey = `${scopeName}:${record.id}`
819
+ if (included.has(recordKey)) {
820
+ const existingRecord = included.get(recordKey)
821
+ if (!existingRecord.relationships) {
822
+ existingRecord.relationships = {}
823
+ }
824
+ existingRecord.relationships[includeName] = relationshipObject
825
+ }
826
+ })
827
+
828
+ // Step 8: Process nested includes if any
829
+ if (Object.keys(subIncludes).length > 0 && targetRecords.length > 0) {
830
+ const nextPath = `${currentPath}.${includeName}`
831
+ if (!processedPaths.has(nextPath)) {
832
+ await processIncludes(
833
+ { records: targetRecords, scopeName: targetScope, includeTree: subIncludes, included, processedPaths, currentPath: nextPath, fields, idProperty: targetIdProperty },
834
+ { context: { scopes, log, knex, capabilities } }
835
+ )
836
+ }
842
837
  }
843
- }
844
-
845
- } else if (relDef.type === 'hasMany') {
838
+ } else if (relDef.type === 'hasMany') {
846
839
  // Handle one-to-many relationship
847
- const targetScope = relDef.target;
848
- const targetTable = scopes[targetScope].vars.schemaInfo.tableName;
849
- const foreignKey = relDef.foreignKey;
850
-
851
- if (!foreignKey) {
852
- throw new Error(`Missing foreignKey in hasMany relationship '${relName}' for scope '${scopeName}'`);
853
- }
854
-
855
- log.debug(`[INCLUDE] Loading ${targetScope} records with foreign key ${foreignKey}:`, {
856
- whereIn: mainIds,
857
- includeConfig: relDef.include
858
- });
859
-
860
- // Build field selection for sparse fieldsets
861
- const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance;
862
- const targetScopeObject = scopes[targetScope];
863
- const storageAdapter = targetScopeObject.vars.storageAdapter;
864
- const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty;
865
- const fieldSelectionInfo = fields?.[targetScope] ?
866
- await buildFieldSelection(targetScopeObject, {
867
- context: {
868
- scopeName: targetScope,
869
- queryParams: { fields: { [targetScope]: fields[targetScope] } },
870
- schemaInfo: targetScopeObject.vars.schemaInfo,
871
- storageAdapter,
872
- }
873
- }) :
874
- null;
875
-
876
- let query;
877
- let usingWindowFunction = false;
878
-
879
- // Check if we should use window functions
880
- if (relDef.include?.strategy === 'window') {
881
- try {
840
+ const targetScope = relDef.target
841
+ const targetTable = scopes[targetScope].vars.schemaInfo.tableName
842
+ const foreignKey = relDef.foreignKey
843
+
844
+ if (!foreignKey) {
845
+ throw new Error(`Missing foreignKey in hasMany relationship '${relName}' for scope '${scopeName}'`)
846
+ }
847
+
848
+ log.debug(`[INCLUDE] Loading ${targetScope} records with foreign key ${foreignKey}:`, {
849
+ whereIn: mainIds,
850
+ includeConfig: relDef.include
851
+ })
852
+
853
+ // Build field selection for sparse fieldsets
854
+ const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
855
+ const targetScopeObject = scopes[targetScope]
856
+ const storageAdapter = targetScopeObject.vars.storageAdapter
857
+ const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty
858
+ const fieldSelectionInfo = fields?.[targetScope]
859
+ ? await buildFieldSelection(targetScopeObject, {
860
+ context: {
861
+ scopeName: targetScope,
862
+ queryParams: { fields: { [targetScope]: fields[targetScope] } },
863
+ schemaInfo: targetScopeObject.vars.schemaInfo,
864
+ storageAdapter,
865
+ }
866
+ })
867
+ : null
868
+
869
+ let query
870
+ let usingWindowFunction = false
871
+
872
+ // Check if we should use window functions
873
+ if (relDef.include?.strategy === 'window') {
874
+ try {
882
875
  // Try to build window function query
883
- const selectFields = fieldSelectionInfo
884
- ? translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
885
- : null;
886
-
887
- query = buildWindowedIncludeQuery(
888
- knex,
889
- targetTable,
890
- foreignKey,
891
- mainIds,
892
- selectFields,
893
- relDef.include || {},
894
- capabilities,
895
- targetScopeObject.vars // Pass target scope vars
896
- );
897
- usingWindowFunction = true;
898
- log.debug('[INCLUDE] Using window function strategy with limits');
899
- } catch (error) {
876
+ const selectFields = fieldSelectionInfo
877
+ ? translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
878
+ : null
879
+
880
+ query = buildWindowedIncludeQuery(
881
+ knex,
882
+ targetTable,
883
+ foreignKey,
884
+ mainIds,
885
+ selectFields,
886
+ relDef.include || {},
887
+ capabilities,
888
+ targetScopeObject.vars // Pass target scope vars
889
+ )
890
+ usingWindowFunction = true
891
+ log.debug('[INCLUDE] Using window function strategy with limits')
892
+ } catch (error) {
900
893
  // If window functions not supported, this will throw a clear error
901
- if (error.details?.requiredFeature === 'window_functions') {
902
- throw error; // Re-throw the descriptive error
894
+ if (error.details?.requiredFeature === 'window_functions') {
895
+ throw error // Re-throw the descriptive error
896
+ }
897
+ // For other errors, fall back to standard query
898
+ log.warn('[INCLUDE] Window function query failed, falling back to standard query:', {
899
+ error: error.message,
900
+ stack: error.stack
901
+ })
902
+ usingWindowFunction = false
903
903
  }
904
- // For other errors, fall back to standard query
905
- log.warn('[INCLUDE] Window function query failed, falling back to standard query:', {
906
- error: error.message,
907
- stack: error.stack
908
- });
909
- usingWindowFunction = false;
910
904
  }
911
- }
912
-
913
- // Build standard query if not using window functions
914
- if (!usingWindowFunction) {
915
- query = knex(targetTable).whereIn(foreignKey, mainIds);
916
-
917
- if (fieldSelectionInfo) {
918
- const selectFieldsStd = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter);
919
- query = query.select(selectFieldsStd);
905
+
906
+ // Build standard query if not using window functions
907
+ if (!usingWindowFunction) {
908
+ query = knex(targetTable).whereIn(foreignKey, mainIds)
909
+
910
+ if (fieldSelectionInfo) {
911
+ const selectFieldsStd = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
912
+ query = query.select(selectFieldsStd)
913
+ }
914
+
915
+ // Apply standard config with defaults
916
+ const targetVars = scopes[targetScope].vars
917
+ query = applyStandardIncludeConfig(
918
+ query,
919
+ relDef.include || {},
920
+ targetVars,
921
+ log
922
+ )
920
923
  }
921
-
922
- // Apply standard config with defaults
923
- const targetVars = scopes[targetScope].vars;
924
- query = applyStandardIncludeConfig(
925
- query,
926
- relDef.include || {},
927
- targetVars,
928
- log
929
- );
930
- }
931
-
932
- // Execute query
933
- const targetRecords = await query;
934
-
935
- // If using window functions, remove the row number column
936
- if (usingWindowFunction) {
937
- targetRecords.forEach(record => delete record[ROW_NUMBER_KEY]);
938
- }
939
-
940
- log.trace('[INCLUDE] Loaded hasMany records:', {
941
- count: targetRecords.length,
942
- usingWindowFunction
943
- });
944
-
945
- // Load relationship metadata for all target records
946
- await loadRelationshipMetadata(scopes, targetRecords, targetScope);
947
-
948
- // Group by parent ID
949
- const childrenByParent = {};
950
- targetRecords.forEach(record => {
951
- const parentId = record[foreignKey];
952
- if (!childrenByParent[parentId]) {
953
- childrenByParent[parentId] = [];
924
+
925
+ // Execute query
926
+ const targetRecords = await query
927
+
928
+ // If using window functions, remove the row number column
929
+ if (usingWindowFunction) {
930
+ targetRecords.forEach(record => delete record[ROW_NUMBER_KEY])
954
931
  }
955
- childrenByParent[parentId].push(record);
956
- });
957
-
958
- // Set relationships on parent records
959
- records.forEach(record => {
960
- if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {};
961
-
962
- const children = childrenByParent[record.id] || [];
963
- const relData = children.map(childRecord => {
932
+
933
+ log.trace('[INCLUDE] Loaded hasMany records:', {
934
+ count: targetRecords.length,
935
+ usingWindowFunction
936
+ })
937
+
938
+ // Load relationship metadata for all target records
939
+ await loadRelationshipMetadata(scopes, targetRecords, targetScope)
940
+
941
+ // Group by parent ID
942
+ const childrenByParent = {}
943
+ targetRecords.forEach(record => {
944
+ const parentId = record[foreignKey]
945
+ if (!childrenByParent[parentId]) {
946
+ childrenByParent[parentId] = []
947
+ }
948
+ childrenByParent[parentId].push(record)
949
+ })
950
+
951
+ // Set relationships on parent records
952
+ records.forEach(record => {
953
+ if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
954
+
955
+ const children = childrenByParent[record.id] || []
956
+ const relData = children.map(childRecord => {
964
957
  // Add to included
965
- const resourceKey = `${targetScope}:${childRecord.id}`;
966
- if (!included.has(resourceKey)) {
967
- const jsonApiRecord = toJsonApiRecord(
968
- scopes[targetScope],
969
- childRecord,
970
- targetScope
971
- );
972
-
973
- // Add relationships from metadata
974
- if (childRecord[RELATIONSHIP_METADATA_KEY]) {
975
- jsonApiRecord.relationships = childRecord[RELATIONSHIP_METADATA_KEY];
976
- // Clean up the temporary property
977
- delete childRecord[RELATIONSHIP_METADATA_KEY];
958
+ const resourceKey = `${targetScope}:${childRecord.id}`
959
+ if (!included.has(resourceKey)) {
960
+ const jsonApiRecord = toJsonApiRecord(
961
+ scopes[targetScope],
962
+ childRecord,
963
+ targetScope
964
+ )
965
+
966
+ // Add relationships from metadata
967
+ if (childRecord[RELATIONSHIP_METADATA_KEY]) {
968
+ jsonApiRecord.relationships = childRecord[RELATIONSHIP_METADATA_KEY]
969
+ // Clean up the temporary property
970
+ delete childRecord[RELATIONSHIP_METADATA_KEY]
971
+ }
972
+
973
+ // Attach computed dependencies info if sparse fieldsets were used
974
+ if (fieldSelectionInfo?.computedDependencies) {
975
+ jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies
976
+ }
977
+
978
+ included.set(resourceKey, jsonApiRecord)
978
979
  }
979
-
980
- // Attach computed dependencies info if sparse fieldsets were used
981
- if (fieldSelectionInfo?.computedDependencies) {
982
- jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies;
980
+ return { type: targetScope, id: String(childRecord.id) }
981
+ })
982
+
983
+ const relationshipObject = { data: relData }
984
+
985
+ // Add links if urlPrefix is configured
986
+ const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
987
+ if (scopeName) {
988
+ relationshipObject.links = {
989
+ self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
990
+ related: `${urlPrefix}/${scopeName}/${record.id}/${includeName}`
983
991
  }
984
-
985
- included.set(resourceKey, jsonApiRecord);
986
992
  }
987
- return { type: targetScope, id: String(childRecord.id) };
988
- });
989
-
990
- const relationshipObject = { data: relData };
991
-
992
- // Add links if urlPrefix is configured
993
- const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || '';
994
- if (scopeName) {
995
- relationshipObject.links = {
996
- self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
997
- related: `${urlPrefix}/${scopeName}/${record.id}/${includeName}`
998
- };
999
- }
1000
-
1001
- record[RELATIONSHIPS_KEY][includeName] = relationshipObject;
1002
-
1003
- // Update the record in the included Map if it exists
1004
- const recordKey = `${scopeName}:${record.id}`;
1005
- if (included.has(recordKey)) {
1006
- const existingRecord = included.get(recordKey);
1007
- if (!existingRecord.relationships) {
1008
- existingRecord.relationships = {};
1009
- }
1010
- existingRecord.relationships[includeName] = relationshipObject;
1011
- }
1012
- });
1013
-
1014
- // Process nested includes
1015
- if (Object.keys(subIncludes).length > 0 && targetRecords.length > 0) {
1016
- const nextPath = `${currentPath}.${includeName}`;
1017
- if (!processedPaths.has(nextPath)) {
1018
- await processIncludes(
1019
- { records: targetRecords, scopeName: targetScope, includeTree: subIncludes, included, processedPaths, currentPath: nextPath, fields, idProperty: targetIdProperty },
1020
- { context: { scopes, log, knex, capabilities } }
1021
- );
993
+
994
+ record[RELATIONSHIPS_KEY][includeName] = relationshipObject
995
+
996
+ // Update the record in the included Map if it exists
997
+ const recordKey = `${scopeName}:${record.id}`
998
+ if (included.has(recordKey)) {
999
+ const existingRecord = included.get(recordKey)
1000
+ if (!existingRecord.relationships) {
1001
+ existingRecord.relationships = {}
1002
+ }
1003
+ existingRecord.relationships[includeName] = relationshipObject
1004
+ }
1005
+ })
1006
+
1007
+ // Process nested includes
1008
+ if (Object.keys(subIncludes).length > 0 && targetRecords.length > 0) {
1009
+ const nextPath = `${currentPath}.${includeName}`
1010
+ if (!processedPaths.has(nextPath)) {
1011
+ await processIncludes(
1012
+ { records: targetRecords, scopeName: targetScope, includeTree: subIncludes, included, processedPaths, currentPath: nextPath, fields, idProperty: targetIdProperty },
1013
+ { context: { scopes, log, knex, capabilities } }
1014
+ )
1015
+ }
1022
1016
  }
1023
1017
  }
1024
- }
1025
1018
  } catch (error) {
1026
1019
  // Log error with detailed context
1027
1020
  log.error('[INCLUDE] Error loading hasMany relationship:', {
@@ -1031,152 +1024,152 @@ export const loadHasMany = async (scope, deps) => {
1031
1024
  recordCount: records?.length || 0,
1032
1025
  error: error.message,
1033
1026
  stack: error.stack
1034
- });
1035
-
1027
+ })
1028
+
1036
1029
  // Re-throw with enhanced error message
1037
1030
  const enhancedError = new Error(
1038
1031
  `Failed to load hasMany relationship '${includeName}' for scope '${scopeName}': ${error.message}`
1039
- );
1040
- enhancedError.originalError = error;
1041
- enhancedError.context = { scopeName, includeName, hasThrough: !!relDef.through };
1042
- throw enhancedError;
1032
+ )
1033
+ enhancedError.originalError = error
1034
+ enhancedError.context = { scopeName, includeName, hasThrough: !!relDef.through }
1035
+ throw enhancedError
1043
1036
  }
1044
- };
1037
+ }
1045
1038
 
1046
1039
  /**
1047
1040
  * Loads hasOne relationships (one-to-one)
1048
- *
1041
+ *
1049
1042
  * Similar to hasMany but expects a single related record.
1050
1043
  * For example, a user that has one profile.
1051
- *
1044
+ *
1052
1045
  * @param {Object} scope - The hooked-api scope object
1053
1046
  * @param {Object} deps - Dependencies object
1054
1047
  */
1055
1048
  export const loadHasOne = async (scope, deps) => {
1056
- const { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields } = scope;
1057
- const { context: { scopes, log, knex, capabilities } } = deps;
1058
-
1059
- log = log?.child ? log.child({ method: 'loadHasOne' }) : console;
1060
-
1049
+ const { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields } = scope
1050
+ const { context: { scopes, log, knex, capabilities } } = deps
1051
+
1052
+ log = log?.child ? log.child({ method: 'loadHasOne' }) : console
1053
+
1061
1054
  if (!records || records.length === 0) {
1062
- log.trace('[INCLUDE] No records to load hasOne for');
1063
- return;
1055
+ log.trace('[INCLUDE] No records to load hasOne for')
1056
+ return
1064
1057
  }
1065
-
1058
+
1066
1059
  // Get parent IDs
1067
- const idProperty = scopes[scopeName]?.vars?.schemaInfo?.idProperty || 'id';
1068
- const mainIds = [...new Set(records.map(r => r[idProperty]).filter(Boolean))];
1069
-
1060
+ const idProperty = scopes[scopeName]?.vars?.schemaInfo?.idProperty || 'id'
1061
+ const mainIds = [...new Set(records.map(r => r[idProperty]).filter(Boolean))]
1062
+
1070
1063
  if (mainIds.length === 0) {
1071
- log.trace('[INCLUDE] No parent IDs found, skipping hasOne load');
1072
- return;
1064
+ log.trace('[INCLUDE] No parent IDs found, skipping hasOne load')
1065
+ return
1073
1066
  }
1074
-
1075
- const targetScope = relDef.target;
1076
- const targetTable = scopes[targetScope].vars.schemaInfo.tableName;
1077
- const foreignKey = relDef.foreignKey;
1078
-
1067
+
1068
+ const targetScope = relDef.target
1069
+ const targetTable = scopes[targetScope].vars.schemaInfo.tableName
1070
+ const foreignKey = relDef.foreignKey
1071
+
1079
1072
  if (!foreignKey) {
1080
- throw new Error(`Missing foreignKey in hasOne relationship '${includeName}' for scope '${scopeName}'`);
1073
+ throw new Error(`Missing foreignKey in hasOne relationship '${includeName}' for scope '${scopeName}'`)
1081
1074
  }
1082
-
1083
- log.debug(`[INCLUDE] Loading ${targetScope} records with foreign key ${foreignKey}:`, {
1075
+
1076
+ log.debug(`[INCLUDE] Loading ${targetScope} records with foreign key ${foreignKey}:`, {
1084
1077
  whereIn: mainIds
1085
- });
1086
-
1078
+ })
1079
+
1087
1080
  // Build query for hasOne - expects single result per parent
1088
- const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance;
1089
- const targetScopeObject = scopes[targetScope];
1090
- const storageAdapter = targetScopeObject.vars.storageAdapter;
1091
- const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty;
1092
-
1081
+ const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
1082
+ const targetScopeObject = scopes[targetScope]
1083
+ const storageAdapter = targetScopeObject.vars.storageAdapter
1084
+ const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty
1085
+
1093
1086
  // Build field selection for sparse fieldsets
1094
- const fieldSelectionInfo = fields?.[targetScope] ?
1095
- await buildFieldSelection(targetScopeObject, {
1087
+ const fieldSelectionInfo = fields?.[targetScope]
1088
+ ? await buildFieldSelection(targetScopeObject, {
1096
1089
  context: {
1097
1090
  scopeName: targetScope,
1098
1091
  queryParams: { fields: { [targetScope]: fields[targetScope] } },
1099
1092
  schemaInfo: targetScopeObject.vars.schemaInfo,
1100
1093
  storageAdapter,
1101
1094
  }
1102
- }) :
1103
- null;
1104
-
1095
+ })
1096
+ : null
1097
+
1105
1098
  // Query related records
1106
- let query = knex(targetTable).whereIn(foreignKey, mainIds);
1107
-
1099
+ let query = knex(targetTable).whereIn(foreignKey, mainIds)
1100
+
1108
1101
  if (fieldSelectionInfo?.fieldsToSelect) {
1109
- const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter);
1110
- query = query.select(selectFields);
1102
+ const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
1103
+ query = query.select(selectFields)
1111
1104
  }
1112
-
1113
- const relatedRecords = await query;
1114
-
1105
+
1106
+ const relatedRecords = await query
1107
+
1115
1108
  // Create a map of parent ID to related record (should be one-to-one)
1116
- const relatedByParentId = {};
1109
+ const relatedByParentId = {}
1117
1110
  for (const related of relatedRecords) {
1118
- const parentId = related[foreignKey];
1111
+ const parentId = related[foreignKey]
1119
1112
  if (relatedByParentId[parentId]) {
1120
- log.warn(`[INCLUDE] Multiple records found for hasOne relationship '${includeName}' with ${foreignKey}=${parentId}`);
1113
+ log.warn(`[INCLUDE] Multiple records found for hasOne relationship '${includeName}' with ${foreignKey}=${parentId}`)
1121
1114
  }
1122
- relatedByParentId[parentId] = related;
1115
+ relatedByParentId[parentId] = related
1123
1116
  }
1124
-
1117
+
1125
1118
  // Process each parent record
1126
1119
  for (const record of records) {
1127
- const recordId = record[idProperty];
1128
- const relatedRecord = relatedByParentId[recordId];
1129
-
1130
- if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {};
1131
-
1120
+ const recordId = record[idProperty]
1121
+ const relatedRecord = relatedByParentId[recordId]
1122
+
1123
+ if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
1124
+
1132
1125
  if (relatedRecord) {
1133
1126
  // Transform to JSON:API format
1134
1127
  const transformed = await toJsonApiRecord(
1135
1128
  targetScopeObject,
1136
- {
1137
- context: {
1129
+ {
1130
+ context: {
1138
1131
  record: relatedRecord,
1139
1132
  scopeName: targetScope,
1140
1133
  schemaInfo: targetScopeObject.vars.schemaInfo
1141
- }
1134
+ }
1142
1135
  }
1143
- );
1144
-
1136
+ )
1137
+
1145
1138
  // Set single object (not array) for hasOne
1146
1139
  record[RELATIONSHIPS_KEY][includeName] = {
1147
1140
  data: { type: targetScope, id: String(relatedRecord[targetIdProperty]) }
1148
- };
1149
-
1141
+ }
1142
+
1150
1143
  // Add to included if not already there
1151
- const key = `${targetScope}:${relatedRecord[targetIdProperty]}`;
1144
+ const key = `${targetScope}:${relatedRecord[targetIdProperty]}`
1152
1145
  if (!included.has(key)) {
1153
- included.set(key, transformed);
1146
+ included.set(key, transformed)
1154
1147
  }
1155
-
1148
+
1156
1149
  // Process nested includes if any
1157
1150
  if (subIncludes && Object.keys(subIncludes).length > 0) {
1158
- const newPath = `${currentPath}.${includeName}`;
1151
+ const newPath = `${currentPath}.${includeName}`
1159
1152
  if (!processedPaths.has(newPath)) {
1160
- processedPaths.add(newPath);
1153
+ processedPaths.add(newPath)
1161
1154
  await processIncludes(
1162
1155
  { records: [relatedRecord], scopeName: targetScope, includeTree: subIncludes, included, processedPaths, currentPath: newPath, fields },
1163
1156
  { context: { scopes, log, knex, capabilities } }
1164
- );
1157
+ )
1165
1158
  }
1166
1159
  }
1167
1160
  } else {
1168
1161
  // No related record found
1169
- record[RELATIONSHIPS_KEY][includeName] = { data: null };
1162
+ record[RELATIONSHIPS_KEY][includeName] = { data: null }
1170
1163
  }
1171
1164
  }
1172
- };
1165
+ }
1173
1166
 
1174
1167
  /**
1175
1168
  * Loads polymorphic belongsTo relationships
1176
- *
1169
+ *
1177
1170
  * Handles relationships where a record can belong to different types of parent records.
1178
1171
  * For example, comments that can belong to either articles or videos.
1179
- *
1172
+ *
1180
1173
  * @param {Object} scope - The hooked-api scope object containing:
1181
1174
  * - records: Array<Object> - Records with polymorphic relationships
1182
1175
  * - relName: string - The relationship name
@@ -1193,177 +1186,177 @@ export const loadHasOne = async (scope, deps) => {
1193
1186
  * @returns {Promise<void>}
1194
1187
  */
1195
1188
  export const loadPolymorphicBelongsTo = async (scope, deps) => {
1196
- const { records, scopeName, relName, relDef, subIncludes, included, processedPaths, currentPath, fields } = scope;
1197
- const { scopes, log, knex, capabilities } = deps.context;
1189
+ const { records, scopeName, relName, relDef, subIncludes, included, processedPaths, currentPath, fields } = scope
1190
+ const { scopes, log, knex, capabilities } = deps.context
1198
1191
  try {
1199
- log.trace('[INCLUDE] Loading polymorphic belongsTo:', {
1200
- relName,
1201
- recordCount: records.length
1202
- });
1203
-
1204
- const { typeField, idField, types } = relDef.belongsToPolymorphic;
1205
-
1206
- // Group records by their target type using helper
1207
- const grouped = groupByPolymorphicType(
1208
- records,
1209
- typeField,
1210
- idField
1211
- );
1212
-
1213
- log.trace('[INCLUDE] Grouped by type:', {
1214
- types: Object.keys(grouped),
1215
- counts: Object.entries(grouped).map(([t, ids]) => `${t}: ${ids.length}`)
1216
- });
1217
-
1218
- // Load each type separately
1219
- for (const [targetType, targetIds] of Object.entries(grouped)) {
1192
+ log.trace('[INCLUDE] Loading polymorphic belongsTo:', {
1193
+ relName,
1194
+ recordCount: records.length
1195
+ })
1196
+
1197
+ const { typeField, idField, types } = relDef.belongsToPolymorphic
1198
+
1199
+ // Group records by their target type using helper
1200
+ const grouped = groupByPolymorphicType(
1201
+ records,
1202
+ typeField,
1203
+ idField
1204
+ )
1205
+
1206
+ log.trace('[INCLUDE] Grouped by type:', {
1207
+ types: Object.keys(grouped),
1208
+ counts: Object.entries(grouped).map(([t, ids]) => `${t}: ${ids.length}`)
1209
+ })
1210
+
1211
+ // Load each type separately
1212
+ for (const [targetType, targetIds] of Object.entries(grouped)) {
1220
1213
  // Skip if type not allowed (shouldn't happen with validation, but be safe)
1221
- if (!types.includes(targetType)) {
1222
- log.warn('[INCLUDE] Skipping non-allowed type:', targetType);
1223
- continue;
1224
- }
1225
-
1226
- if (targetIds.length === 0) continue;
1227
-
1228
- // Get target table information
1229
- const targetSchemaInfo = scopes[targetType].vars.schemaInfo;
1230
- const targetTable = targetSchemaInfo?.tableName || targetType;
1231
-
1232
- // Build field selection for sparse fieldsets
1233
- const targetScopeObject = scopes[targetType];
1234
- // const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty;
1235
- const storageAdapter = targetScopeObject.vars.storageAdapter;
1236
- const fieldSelectionInfo = fields?.[targetType] ?
1237
- await buildFieldSelection(targetScopeObject, {
1238
- context: {
1239
- scopeName: targetType,
1240
- queryParams: { fields: { [targetType]: fields[targetType] } },
1241
- schemaInfo: targetScopeObject.vars.schemaInfo,
1242
- storageAdapter,
1243
- }
1244
- }) :
1245
- null;
1246
-
1247
- log.debug(`[INCLUDE] Loading ${targetType} records:`, {
1248
- ids: targetIds,
1249
- fields: fieldSelectionInfo ? fieldSelectionInfo.fieldsToSelect : '*'
1250
- });
1251
-
1252
- // Query for this type
1253
- const targetIdProperty = scopes[targetType]?.vars?.schemaInfo?.idProperty || 'id';
1254
- let query = knex(targetTable).whereIn(targetIdProperty, targetIds);
1255
- if (fieldSelectionInfo) {
1256
- const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter);
1257
- query = query.select(selectFields);
1258
- } else if (targetIdProperty !== 'id') {
1214
+ if (!types.includes(targetType)) {
1215
+ log.warn('[INCLUDE] Skipping non-allowed type:', targetType)
1216
+ continue
1217
+ }
1218
+
1219
+ if (targetIds.length === 0) continue
1220
+
1221
+ // Get target table information
1222
+ const targetSchemaInfo = scopes[targetType].vars.schemaInfo
1223
+ const targetTable = targetSchemaInfo?.tableName || targetType
1224
+
1225
+ // Build field selection for sparse fieldsets
1226
+ const targetScopeObject = scopes[targetType]
1227
+ // const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty;
1228
+ const storageAdapter = targetScopeObject.vars.storageAdapter
1229
+ const fieldSelectionInfo = fields?.[targetType]
1230
+ ? await buildFieldSelection(targetScopeObject, {
1231
+ context: {
1232
+ scopeName: targetType,
1233
+ queryParams: { fields: { [targetType]: fields[targetType] } },
1234
+ schemaInfo: targetScopeObject.vars.schemaInfo,
1235
+ storageAdapter,
1236
+ }
1237
+ })
1238
+ : null
1239
+
1240
+ log.debug(`[INCLUDE] Loading ${targetType} records:`, {
1241
+ ids: targetIds,
1242
+ fields: fieldSelectionInfo ? fieldSelectionInfo.fieldsToSelect : '*'
1243
+ })
1244
+
1245
+ // Query for this type
1246
+ const targetIdProperty = scopes[targetType]?.vars?.schemaInfo?.idProperty || 'id'
1247
+ let query = knex(targetTable).whereIn(targetIdProperty, targetIds)
1248
+ if (fieldSelectionInfo) {
1249
+ const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
1250
+ query = query.select(selectFields)
1251
+ } else if (targetIdProperty !== 'id') {
1259
1252
  // If no field selection but custom idProperty, we need to alias it
1260
- query = query.select('*', `${targetIdProperty} as id`);
1261
- }
1262
- const targetRecords = await query;
1263
-
1264
- // Load relationship metadata for all target records
1265
- await loadRelationshipMetadata(scopes, targetRecords, targetType);
1266
-
1267
- // Create lookup map
1268
- const targetById = {};
1269
- targetRecords.forEach(record => {
1270
- targetById[record.id] = record;
1271
- });
1272
-
1273
- // Add to included and set relationships
1274
- records.forEach(record => {
1275
- if (record[typeField] === targetType) {
1276
- const targetId = record[idField];
1277
- const targetRecord = targetById[targetId];
1278
-
1279
- if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {};
1280
-
1281
- if (targetRecord) {
1253
+ query = query.select('*', `${targetIdProperty} as id`)
1254
+ }
1255
+ const targetRecords = await query
1256
+
1257
+ // Load relationship metadata for all target records
1258
+ await loadRelationshipMetadata(scopes, targetRecords, targetType)
1259
+
1260
+ // Create lookup map
1261
+ const targetById = {}
1262
+ targetRecords.forEach(record => {
1263
+ targetById[record.id] = record
1264
+ })
1265
+
1266
+ // Add to included and set relationships
1267
+ records.forEach(record => {
1268
+ if (record[typeField] === targetType) {
1269
+ const targetId = record[idField]
1270
+ const targetRecord = targetById[targetId]
1271
+
1272
+ if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
1273
+
1274
+ if (targetRecord) {
1282
1275
  // Add to included
1283
- const resourceKey = `${targetType}:${targetId}`;
1284
- if (!included.has(resourceKey)) {
1285
- const jsonApiRecord = toJsonApiRecord(
1286
- scopes[targetType],
1287
- targetRecord,
1288
- targetType
1289
- );
1290
-
1291
- // Add relationships from metadata
1292
- if (targetRecord[RELATIONSHIP_METADATA_KEY]) {
1293
- jsonApiRecord.relationships = targetRecord[RELATIONSHIP_METADATA_KEY];
1294
- // Clean up the temporary property
1295
- delete targetRecord[RELATIONSHIP_METADATA_KEY];
1276
+ const resourceKey = `${targetType}:${targetId}`
1277
+ if (!included.has(resourceKey)) {
1278
+ const jsonApiRecord = toJsonApiRecord(
1279
+ scopes[targetType],
1280
+ targetRecord,
1281
+ targetType
1282
+ )
1283
+
1284
+ // Add relationships from metadata
1285
+ if (targetRecord[RELATIONSHIP_METADATA_KEY]) {
1286
+ jsonApiRecord.relationships = targetRecord[RELATIONSHIP_METADATA_KEY]
1287
+ // Clean up the temporary property
1288
+ delete targetRecord[RELATIONSHIP_METADATA_KEY]
1289
+ }
1290
+
1291
+ // Attach computed dependencies info if sparse fieldsets were used
1292
+ if (fieldSelectionInfo?.computedDependencies) {
1293
+ jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies
1294
+ }
1295
+
1296
+ included.set(resourceKey, jsonApiRecord)
1296
1297
  }
1297
-
1298
- // Attach computed dependencies info if sparse fieldsets were used
1299
- if (fieldSelectionInfo?.computedDependencies) {
1300
- jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies;
1298
+
1299
+ const relationshipObject = {
1300
+ data: { type: targetType, id: String(targetId) }
1301
1301
  }
1302
-
1303
- included.set(resourceKey, jsonApiRecord);
1304
- }
1305
-
1306
- const relationshipObject = {
1307
- data: { type: targetType, id: String(targetId) }
1308
- };
1309
-
1310
- // Add links if urlPrefix is configured
1311
- const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || '';
1312
- if (scopeName) {
1313
- relationshipObject.links = {
1314
- self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${relName}`,
1315
- related: `${urlPrefix}/${scopeName}/${record.id}/${relName}`
1316
- };
1317
- }
1318
-
1319
- record[RELATIONSHIPS_KEY][relName] = relationshipObject;
1320
- } else {
1321
- const relationshipObject = { data: null };
1322
-
1323
- // Add links if urlPrefix is configured
1324
- const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || '';
1325
- if (scopeName) {
1326
- relationshipObject.links = {
1327
- self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${relName}`,
1328
- related: `${urlPrefix}/${scopeName}/${record.id}/${relName}`
1329
- };
1302
+
1303
+ // Add links if urlPrefix is configured
1304
+ const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
1305
+ if (scopeName) {
1306
+ relationshipObject.links = {
1307
+ self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${relName}`,
1308
+ related: `${urlPrefix}/${scopeName}/${record.id}/${relName}`
1309
+ }
1310
+ }
1311
+
1312
+ record[RELATIONSHIPS_KEY][relName] = relationshipObject
1313
+ } else {
1314
+ const relationshipObject = { data: null }
1315
+
1316
+ // Add links if urlPrefix is configured
1317
+ const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
1318
+ if (scopeName) {
1319
+ relationshipObject.links = {
1320
+ self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${relName}`,
1321
+ related: `${urlPrefix}/${scopeName}/${record.id}/${relName}`
1322
+ }
1323
+ }
1324
+
1325
+ record[RELATIONSHIPS_KEY][relName] = relationshipObject
1330
1326
  }
1331
-
1332
- record[RELATIONSHIPS_KEY][relName] = relationshipObject;
1333
1327
  }
1334
- }
1335
- });
1336
-
1337
- // Process nested includes for this type
1338
- if (Object.keys(subIncludes).length > 0 && targetRecords.length > 0) {
1339
- const nextPath = `${currentPath}.${relName}`;
1340
- if (!processedPaths.has(nextPath)) {
1341
- await processIncludes(
1342
- { records: targetRecords, scopeName: targetType, includeTree: subIncludes, included, processedPaths, currentPath: nextPath, fields, idProperty: targetIdProperty },
1343
- { context: { scopes, log, knex, capabilities } }
1344
- );
1328
+ })
1329
+
1330
+ // Process nested includes for this type
1331
+ if (Object.keys(subIncludes).length > 0 && targetRecords.length > 0) {
1332
+ const nextPath = `${currentPath}.${relName}`
1333
+ if (!processedPaths.has(nextPath)) {
1334
+ await processIncludes(
1335
+ { records: targetRecords, scopeName: targetType, includeTree: subIncludes, included, processedPaths, currentPath: nextPath, fields, idProperty: targetIdProperty },
1336
+ { context: { scopes, log, knex, capabilities } }
1337
+ )
1338
+ }
1345
1339
  }
1346
1340
  }
1347
- }
1348
-
1349
- // Set null for records without relationships
1350
- records.forEach(record => {
1351
- if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {};
1352
- if (!record[RELATIONSHIPS_KEY][relName]) {
1353
- const relationshipObject = { data: null };
1354
-
1355
- // Add links if urlPrefix is configured
1356
- const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || '';
1357
- if (scopeName) {
1358
- relationshipObject.links = {
1359
- self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${relName}`,
1360
- related: `${urlPrefix}/${scopeName}/${record.id}/${relName}`
1361
- };
1341
+
1342
+ // Set null for records without relationships
1343
+ records.forEach(record => {
1344
+ if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
1345
+ if (!record[RELATIONSHIPS_KEY][relName]) {
1346
+ const relationshipObject = { data: null }
1347
+
1348
+ // Add links if urlPrefix is configured
1349
+ const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
1350
+ if (scopeName) {
1351
+ relationshipObject.links = {
1352
+ self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${relName}`,
1353
+ related: `${urlPrefix}/${scopeName}/${record.id}/${relName}`
1354
+ }
1355
+ }
1356
+
1357
+ record[RELATIONSHIPS_KEY][relName] = relationshipObject
1362
1358
  }
1363
-
1364
- record[RELATIONSHIPS_KEY][relName] = relationshipObject;
1365
- }
1366
- });
1359
+ })
1367
1360
  } catch (error) {
1368
1361
  // Log error with detailed context
1369
1362
  log.error('[INCLUDE] Error loading polymorphic belongsTo relationship:', {
@@ -1372,24 +1365,24 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
1372
1365
  types: relDef?.belongsToPolymorphic?.types,
1373
1366
  error: error.message,
1374
1367
  stack: error.stack
1375
- });
1376
-
1368
+ })
1369
+
1377
1370
  // Re-throw with enhanced error message
1378
1371
  const enhancedError = new Error(
1379
1372
  `Failed to load polymorphic belongsTo relationship '${relName}': ${error.message}`
1380
- );
1381
- enhancedError.originalError = error;
1382
- enhancedError.context = { relName, types: relDef?.belongsToPolymorphic?.types };
1383
- throw enhancedError;
1373
+ )
1374
+ enhancedError.originalError = error
1375
+ enhancedError.context = { relName, types: relDef?.belongsToPolymorphic?.types }
1376
+ throw enhancedError
1384
1377
  }
1385
- };
1378
+ }
1386
1379
 
1387
1380
  /**
1388
1381
  * Loads reverse polymorphic relationships (via)
1389
- *
1382
+ *
1390
1383
  * Handles loading "child" records that have a polymorphic relationship back to the parent.
1391
1384
  * For example, loading all comments (which can belong to articles or videos) for a specific article.
1392
- *
1385
+ *
1393
1386
  * @param {Object} scope - The hooked-api scope object containing:
1394
1387
  * - records: Array<Object> - Parent records
1395
1388
  * - scopeName: string - The parent scope name
@@ -1407,158 +1400,158 @@ export const loadPolymorphicBelongsTo = async (scope, deps) => {
1407
1400
  * @returns {Promise<void>}
1408
1401
  */
1409
1402
  export const loadReversePolymorphic = async (scope, deps) => {
1410
- const { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields } = scope;
1411
- const { scopes, log, knex, capabilities } = deps.context;
1403
+ const { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields } = scope
1404
+ const { scopes, log, knex, capabilities } = deps.context
1412
1405
  try {
1413
- log.trace('[INCLUDE] Loading reverse polymorphic (via):', {
1406
+ log.trace('[INCLUDE] Loading reverse polymorphic (via):', {
1414
1407
  scopeName,
1415
- includeName,
1408
+ includeName,
1416
1409
  via: relDef.via,
1417
- recordCount: records.length
1418
- });
1419
-
1420
- const targetScope = relDef.target;
1421
- const viaRelName = relDef.via;
1422
-
1423
- // Get the polymorphic field info from target scope
1424
- const targetRelationships = scopes[targetScope].vars.schemaInfo.schemaRelationships;
1425
- const viaRel = targetRelationships?.[viaRelName];
1426
-
1427
- if (!viaRel?.belongsToPolymorphic) {
1428
- log.warn('[INCLUDE] Via relationship not found or not polymorphic:', {
1429
- targetScope,
1430
- viaRelName
1431
- });
1432
- return;
1433
- }
1434
-
1435
- const { typeField, idField } = viaRel.belongsToPolymorphic;
1436
- const targetTable = scopes[targetScope].vars.schemaInfo.tableName;
1437
-
1438
- // Collect parent IDs
1439
- const parentIds = records.map(r => r.id).filter(Boolean);
1440
- if (parentIds.length === 0) return;
1441
-
1442
- log.debug(`[INCLUDE] Loading ${targetScope} records via ${viaRelName}:`, {
1443
- typeField,
1444
- idField,
1445
- scopeName,
1446
- parentIds
1447
- });
1448
-
1449
- // Build field selection for sparse fieldsets
1450
- const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance;
1451
- const targetScopeObject = scopes[targetScope];
1452
- const storageAdapter = targetScopeObject.vars.storageAdapter;
1453
- const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty;
1454
- const fieldSelectionInfo = fields?.[targetScope] ?
1455
- await buildFieldSelection(targetScopeObject, {
1456
- context: {
1457
- scopeName: targetScope,
1458
- queryParams: { fields: { [targetScope]: fields[targetScope] } },
1459
- schemaInfo: targetScopeObject.vars.schemaInfo,
1460
- storageAdapter,
1461
- }
1462
- }) :
1463
- null;
1464
-
1465
- // Query for records pointing back to our scope
1466
- let query = knex(targetTable)
1467
- .where(typeField, scopeName)
1468
- .whereIn(idField, parentIds);
1469
-
1470
- if (fieldSelectionInfo) {
1471
- const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter);
1472
- query = query.select(selectFields);
1473
- } else if (targetIdProperty !== 'id') {
1410
+ recordCount: records.length
1411
+ })
1412
+
1413
+ const targetScope = relDef.target
1414
+ const viaRelName = relDef.via
1415
+
1416
+ // Get the polymorphic field info from target scope
1417
+ const targetRelationships = scopes[targetScope].vars.schemaInfo.schemaRelationships
1418
+ const viaRel = targetRelationships?.[viaRelName]
1419
+
1420
+ if (!viaRel?.belongsToPolymorphic) {
1421
+ log.warn('[INCLUDE] Via relationship not found or not polymorphic:', {
1422
+ targetScope,
1423
+ viaRelName
1424
+ })
1425
+ return
1426
+ }
1427
+
1428
+ const { typeField, idField } = viaRel.belongsToPolymorphic
1429
+ const targetTable = scopes[targetScope].vars.schemaInfo.tableName
1430
+
1431
+ // Collect parent IDs
1432
+ const parentIds = records.map(r => r.id).filter(Boolean)
1433
+ if (parentIds.length === 0) return
1434
+
1435
+ log.debug(`[INCLUDE] Loading ${targetScope} records via ${viaRelName}:`, {
1436
+ typeField,
1437
+ idField,
1438
+ scopeName,
1439
+ parentIds
1440
+ })
1441
+
1442
+ // Build field selection for sparse fieldsets
1443
+ const targetSchema = scopes[targetScope].vars.schemaInfo.schemaInstance
1444
+ const targetScopeObject = scopes[targetScope]
1445
+ const storageAdapter = targetScopeObject.vars.storageAdapter
1446
+ const targetIdProperty = targetScopeObject.vars.schemaInfo.idProperty
1447
+ const fieldSelectionInfo = fields?.[targetScope]
1448
+ ? await buildFieldSelection(targetScopeObject, {
1449
+ context: {
1450
+ scopeName: targetScope,
1451
+ queryParams: { fields: { [targetScope]: fields[targetScope] } },
1452
+ schemaInfo: targetScopeObject.vars.schemaInfo,
1453
+ storageAdapter,
1454
+ }
1455
+ })
1456
+ : null
1457
+
1458
+ // Query for records pointing back to our scope
1459
+ let query = knex(targetTable)
1460
+ .where(typeField, scopeName)
1461
+ .whereIn(idField, parentIds)
1462
+
1463
+ if (fieldSelectionInfo) {
1464
+ const selectFields = translateSelectFieldsForAdapter(fieldSelectionInfo.fieldsToSelect, storageAdapter)
1465
+ query = query.select(selectFields)
1466
+ } else if (targetIdProperty !== 'id') {
1474
1467
  // If no field selection but custom idProperty, we need to alias it
1475
- query = query.select('*', `${targetIdProperty} as id`);
1476
- }
1477
-
1478
- // Apply include configuration (limits, ordering, etc.)
1479
- const targetVars = scopes[targetScope].vars;
1480
- query = applyStandardIncludeConfig(
1481
- query,
1482
- relDef.include || {},
1483
- targetVars,
1484
- log
1485
- );
1486
-
1487
- const targetRecords = await query;
1488
-
1489
- log.trace('[INCLUDE] Loaded reverse polymorphic records:', {
1490
- count: targetRecords.length
1491
- });
1492
-
1493
- // Load relationship metadata for all target records
1494
- await loadRelationshipMetadata(scopes, targetRecords, targetScope);
1495
-
1496
- // Group by parent ID
1497
- const childrenByParent = {};
1498
- targetRecords.forEach(record => {
1499
- const parentId = record[idField];
1500
- if (!childrenByParent[parentId]) {
1501
- childrenByParent[parentId] = [];
1468
+ query = query.select('*', `${targetIdProperty} as id`)
1502
1469
  }
1503
- childrenByParent[parentId].push(record);
1504
- });
1505
-
1506
- // Set relationships on parent records
1507
- records.forEach(record => {
1508
- if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {};
1509
-
1510
- const children = childrenByParent[record.id] || [];
1511
- const relData = children.map(childRecord => {
1470
+
1471
+ // Apply include configuration (limits, ordering, etc.)
1472
+ const targetVars = scopes[targetScope].vars
1473
+ query = applyStandardIncludeConfig(
1474
+ query,
1475
+ relDef.include || {},
1476
+ targetVars,
1477
+ log
1478
+ )
1479
+
1480
+ const targetRecords = await query
1481
+
1482
+ log.trace('[INCLUDE] Loaded reverse polymorphic records:', {
1483
+ count: targetRecords.length
1484
+ })
1485
+
1486
+ // Load relationship metadata for all target records
1487
+ await loadRelationshipMetadata(scopes, targetRecords, targetScope)
1488
+
1489
+ // Group by parent ID
1490
+ const childrenByParent = {}
1491
+ targetRecords.forEach(record => {
1492
+ const parentId = record[idField]
1493
+ if (!childrenByParent[parentId]) {
1494
+ childrenByParent[parentId] = []
1495
+ }
1496
+ childrenByParent[parentId].push(record)
1497
+ })
1498
+
1499
+ // Set relationships on parent records
1500
+ records.forEach(record => {
1501
+ if (!record[RELATIONSHIPS_KEY]) record[RELATIONSHIPS_KEY] = {}
1502
+
1503
+ const children = childrenByParent[record.id] || []
1504
+ const relData = children.map(childRecord => {
1512
1505
  // Add to included
1513
- const resourceKey = `${targetScope}:${childRecord.id}`;
1514
- if (!included.has(resourceKey)) {
1515
- const jsonApiRecord = toJsonApiRecord(
1516
- scopes[targetScope],
1517
- childRecord,
1518
- targetScope
1519
- );
1520
-
1521
- // Add relationships from metadata
1522
- if (childRecord[RELATIONSHIP_METADATA_KEY]) {
1523
- jsonApiRecord.relationships = childRecord[RELATIONSHIP_METADATA_KEY];
1524
- // Clean up the temporary property
1525
- delete childRecord[RELATIONSHIP_METADATA_KEY];
1506
+ const resourceKey = `${targetScope}:${childRecord.id}`
1507
+ if (!included.has(resourceKey)) {
1508
+ const jsonApiRecord = toJsonApiRecord(
1509
+ scopes[targetScope],
1510
+ childRecord,
1511
+ targetScope
1512
+ )
1513
+
1514
+ // Add relationships from metadata
1515
+ if (childRecord[RELATIONSHIP_METADATA_KEY]) {
1516
+ jsonApiRecord.relationships = childRecord[RELATIONSHIP_METADATA_KEY]
1517
+ // Clean up the temporary property
1518
+ delete childRecord[RELATIONSHIP_METADATA_KEY]
1519
+ }
1520
+
1521
+ // Attach computed dependencies info if sparse fieldsets were used
1522
+ if (fieldSelectionInfo?.computedDependencies) {
1523
+ jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies
1524
+ }
1525
+
1526
+ included.set(resourceKey, jsonApiRecord)
1526
1527
  }
1527
-
1528
- // Attach computed dependencies info if sparse fieldsets were used
1529
- if (fieldSelectionInfo?.computedDependencies) {
1530
- jsonApiRecord[COMPUTED_DEPENDENCIES_KEY] = fieldSelectionInfo.computedDependencies;
1528
+ return { type: targetScope, id: String(childRecord.id) }
1529
+ })
1530
+
1531
+ const relationshipObject = { data: relData }
1532
+
1533
+ // Add links if urlPrefix is configured
1534
+ const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || ''
1535
+ if (scopeName) {
1536
+ relationshipObject.links = {
1537
+ self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
1538
+ related: `${urlPrefix}/${scopeName}/${record.id}/${includeName}`
1531
1539
  }
1532
-
1533
- included.set(resourceKey, jsonApiRecord);
1534
1540
  }
1535
- return { type: targetScope, id: String(childRecord.id) };
1536
- });
1537
-
1538
- const relationshipObject = { data: relData };
1539
-
1540
- // Add links if urlPrefix is configured
1541
- const urlPrefix = scopes[scopeName]?.vars?.returnBasePath || scopes[scopeName]?.vars?.mountPath || '';
1542
- if (scopeName) {
1543
- relationshipObject.links = {
1544
- self: `${urlPrefix}/${scopeName}/${record.id}/relationships/${includeName}`,
1545
- related: `${urlPrefix}/${scopeName}/${record.id}/${includeName}`
1546
- };
1547
- }
1548
-
1549
- record[RELATIONSHIPS_KEY][includeName] = relationshipObject;
1550
- });
1551
-
1552
- // Process nested includes
1553
- if (Object.keys(subIncludes).length > 0 && targetRecords.length > 0) {
1554
- const nextPath = `${currentPath}.${includeName}`;
1555
- if (!processedPaths.has(nextPath)) {
1556
- await processIncludes(
1557
- { records: targetRecords, scopeName: targetScope, includeTree: subIncludes, included, processedPaths, currentPath: nextPath, fields, idProperty: targetIdProperty },
1558
- { context: { scopes, log, knex } }
1559
- );
1541
+
1542
+ record[RELATIONSHIPS_KEY][includeName] = relationshipObject
1543
+ })
1544
+
1545
+ // Process nested includes
1546
+ if (Object.keys(subIncludes).length > 0 && targetRecords.length > 0) {
1547
+ const nextPath = `${currentPath}.${includeName}`
1548
+ if (!processedPaths.has(nextPath)) {
1549
+ await processIncludes(
1550
+ { records: targetRecords, scopeName: targetScope, includeTree: subIncludes, included, processedPaths, currentPath: nextPath, fields, idProperty: targetIdProperty },
1551
+ { context: { scopes, log, knex } }
1552
+ )
1553
+ }
1560
1554
  }
1561
- }
1562
1555
  } catch (error) {
1563
1556
  // Log error with detailed context
1564
1557
  log.error('[INCLUDE] Error loading reverse polymorphic relationship:', {
@@ -1569,24 +1562,24 @@ export const loadReversePolymorphic = async (scope, deps) => {
1569
1562
  recordCount: records?.length || 0,
1570
1563
  error: error.message,
1571
1564
  stack: error.stack
1572
- });
1573
-
1565
+ })
1566
+
1574
1567
  // Re-throw with enhanced error message
1575
1568
  const enhancedError = new Error(
1576
1569
  `Failed to load reverse polymorphic relationship '${includeName}' via '${relDef?.via}' for scope '${scopeName}': ${error.message}`
1577
- );
1578
- enhancedError.originalError = error;
1579
- enhancedError.context = { scopeName, includeName, via: relDef?.via, targetScope: relDef?.target };
1580
- throw enhancedError;
1570
+ )
1571
+ enhancedError.originalError = error
1572
+ enhancedError.context = { scopeName, includeName, via: relDef?.via, targetScope: relDef?.target }
1573
+ throw enhancedError
1581
1574
  }
1582
- };
1575
+ }
1583
1576
 
1584
1577
  /**
1585
1578
  * Processes includes for a set of records
1586
- *
1579
+ *
1587
1580
  * This is the main recursive function that processes the include tree. It examines
1588
1581
  * the schema to determine relationship types and calls the appropriate loader function.
1589
- *
1582
+ *
1590
1583
  * @param {Object} scope - The hooked-api scope object containing:
1591
1584
  * - records: Array<Object> - Records to process includes for
1592
1585
  * - scopeName: string - The scope name of the records
@@ -1603,41 +1596,41 @@ export const loadReversePolymorphic = async (scope, deps) => {
1603
1596
  * @returns {Promise<void>}
1604
1597
  */
1605
1598
  export const processIncludes = async (scope, deps) => {
1606
- const { records, scopeName, includeTree, included, processedPaths, currentPath = '', fields = {}, idProperty } = scope;
1607
- const { scopes, log, knex, capabilities } = deps.context;
1599
+ const { records, scopeName, includeTree, included, processedPaths, currentPath = '', fields = {}, idProperty } = scope
1600
+ const { scopes, log, knex, capabilities } = deps.context
1608
1601
  try {
1609
- log.trace('[INCLUDE] Processing includes:', {
1610
- scopeName,
1602
+ log.trace('[INCLUDE] Processing includes:', {
1603
+ scopeName,
1611
1604
  includes: Object.keys(includeTree),
1612
1605
  recordCount: records.length,
1613
1606
  currentPath
1614
- });
1615
-
1616
- if (records.length === 0) return;
1617
-
1607
+ })
1608
+
1609
+ if (records.length === 0) return
1610
+
1618
1611
  // Get schema info for this scope
1619
- const schemaInfo = scopes[scopeName]?.vars?.schemaInfo;
1612
+ const schemaInfo = scopes[scopeName]?.vars?.schemaInfo
1620
1613
  if (!schemaInfo) {
1621
- log.warn('[INCLUDE] No schema info for scope:', scopeName);
1622
- return;
1614
+ log.warn('[INCLUDE] No schema info for scope:', scopeName)
1615
+ return
1623
1616
  }
1624
-
1625
- const { schemaInstance: schemaInstance, schemaRelationships } = schemaInfo;
1626
-
1617
+
1618
+ const { schemaInstance, schemaRelationships } = schemaInfo
1619
+
1627
1620
  // Process each include
1628
1621
  for (const [includeName, subIncludes] of Object.entries(includeTree)) {
1629
- const fullPath = currentPath ? `${currentPath}.${includeName}` : includeName;
1630
-
1622
+ const fullPath = currentPath ? `${currentPath}.${includeName}` : includeName
1623
+
1631
1624
  // Skip if already processed (prevents infinite loops)
1632
1625
  if (processedPaths.has(fullPath)) {
1633
- log.trace('[INCLUDE] Skipping already processed path:', fullPath);
1634
- continue;
1626
+ log.trace('[INCLUDE] Skipping already processed path:', fullPath)
1627
+ continue
1635
1628
  }
1636
- processedPaths.add(fullPath);
1637
-
1629
+ processedPaths.add(fullPath)
1630
+
1638
1631
  // Check if it's a schema field (belongsTo)
1639
- let handled = false;
1640
-
1632
+ let handled = false
1633
+
1641
1634
  try {
1642
1635
  // Look for belongsTo relationships in schema fields
1643
1636
  for (const [fieldName, fieldDef] of Object.entries(schemaInstance.structure || {})) {
@@ -1645,16 +1638,16 @@ export const processIncludes = async (scope, deps) => {
1645
1638
  await loadBelongsTo(
1646
1639
  { records, scopeName, fieldName, fieldDef, includeName, subIncludes, included, processedPaths, currentPath, fields, idProperty },
1647
1640
  { context: { scopes, log, knex, capabilities } }
1648
- );
1649
- handled = true;
1650
- break;
1641
+ )
1642
+ handled = true
1643
+ break
1651
1644
  }
1652
1645
  }
1653
-
1646
+
1654
1647
  // Check relationships
1655
1648
  if (!handled && schemaRelationships) {
1656
- const relDef = schemaRelationships[includeName];
1657
-
1649
+ const relDef = schemaRelationships[includeName]
1650
+
1658
1651
  if (relDef) {
1659
1652
  if (relDef.type === 'hasOne') {
1660
1653
  // Handle hasOne relationship
@@ -1662,19 +1655,19 @@ export const processIncludes = async (scope, deps) => {
1662
1655
  scopeName,
1663
1656
  includeName,
1664
1657
  target: relDef.target
1665
- });
1658
+ })
1666
1659
  await loadHasOne(
1667
1660
  { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields },
1668
1661
  { context: { scopes, log, knex, capabilities } }
1669
- );
1670
- handled = true;
1662
+ )
1663
+ handled = true
1671
1664
  } else if (relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
1672
1665
  // Check if it's a reverse polymorphic (via)
1673
1666
  if (relDef.via) {
1674
1667
  await loadReversePolymorphic(
1675
1668
  { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields },
1676
1669
  { context: { scopes, log, knex, capabilities } }
1677
- );
1670
+ )
1678
1671
  } else {
1679
1672
  log.debug('[INCLUDE] About to call loadHasMany:', {
1680
1673
  scopeName,
@@ -1682,19 +1675,19 @@ export const processIncludes = async (scope, deps) => {
1682
1675
  relDefKeys: Object.keys(relDef || {}),
1683
1676
  hasManyToMany: relDef.type === 'manyToMany',
1684
1677
  hasMany: relDef.type === 'hasMany' ? relDef.target : undefined
1685
- });
1678
+ })
1686
1679
  await loadHasMany(
1687
1680
  { records, scopeName, includeName, relDef, subIncludes, included, processedPaths, currentPath, fields },
1688
1681
  { context: { scopes, log, knex, capabilities } }
1689
- );
1682
+ )
1690
1683
  }
1691
- handled = true;
1684
+ handled = true
1692
1685
  } else if (relDef.belongsToPolymorphic) {
1693
1686
  await loadPolymorphicBelongsTo(
1694
1687
  { records, scopeName, relName: includeName, relDef, subIncludes, included, processedPaths, currentPath, fields },
1695
1688
  { context: { scopes, log, knex, capabilities } }
1696
- );
1697
- handled = true;
1689
+ )
1690
+ handled = true
1698
1691
  }
1699
1692
  }
1700
1693
  }
@@ -1705,18 +1698,18 @@ export const processIncludes = async (scope, deps) => {
1705
1698
  includeName,
1706
1699
  fullPath,
1707
1700
  error: includeError.message
1708
- });
1701
+ })
1709
1702
  // Re-throw to maintain existing behavior
1710
- throw includeError;
1703
+ throw includeError
1711
1704
  }
1712
-
1705
+
1713
1706
  if (!handled) {
1714
- log.warn('[INCLUDE] Unknown relationship:', {
1715
- scopeName,
1707
+ log.warn('[INCLUDE] Unknown relationship:', {
1708
+ scopeName,
1716
1709
  includeName,
1717
1710
  availableFields: Object.keys(schemaInstance.structure || {}).filter(k => schemaInstance.structure[k].as),
1718
1711
  availableRelationships: Object.keys(schemaRelationships || {})
1719
- });
1712
+ })
1720
1713
  }
1721
1714
  }
1722
1715
  } catch (error) {
@@ -1728,17 +1721,17 @@ export const processIncludes = async (scope, deps) => {
1728
1721
  recordCount: records?.length || 0,
1729
1722
  error: error.message,
1730
1723
  stack: error.stack
1731
- });
1732
- throw error; // Re-throw to maintain existing error propagation
1724
+ })
1725
+ throw error // Re-throw to maintain existing error propagation
1733
1726
  }
1734
- };
1727
+ }
1735
1728
 
1736
1729
  /**
1737
1730
  * Main entry point for building included resources
1738
- *
1731
+ *
1739
1732
  * Takes a set of records and an include parameter, loads all requested relationships,
1740
1733
  * and returns both the included resources and the records with relationship data attached.
1741
- *
1734
+ *
1742
1735
  * @param {Object} scope - The hooked-api scope object containing:
1743
1736
  * - records: Array<Object> - The main records to process
1744
1737
  * - scopeName: string - The scope name of the main resources
@@ -1750,7 +1743,7 @@ export const processIncludes = async (scope, deps) => {
1750
1743
  * - context.log: Object - Logger instance
1751
1744
  * - context.knex: Object - Knex instance
1752
1745
  * @returns {Promise<Object>} Object with included array and records with relationships
1753
- *
1746
+ *
1754
1747
  * @example
1755
1748
  * const result = await buildIncludedResources(
1756
1749
  * {
@@ -1762,66 +1755,66 @@ export const processIncludes = async (scope, deps) => {
1762
1755
  * },
1763
1756
  * { context: { scopes, log, knex } }
1764
1757
  * );
1765
- *
1758
+ *
1766
1759
  * // result.included = [
1767
1760
  * // { type: 'people', id: '1', attributes: {...} },
1768
1761
  * // { type: 'comments', id: '1', attributes: {...} },
1769
1762
  * // ...
1770
1763
  * // ]
1771
- *
1764
+ *
1772
1765
  * // result.recordsWithRelationships = original records with _relationships added
1773
1766
  */
1774
1767
  export const buildIncludedResources = async (scope, deps) => {
1775
- const { records, scopeName, includeParam, fields, idProperty } = scope;
1776
- const { scopes, log, knex, capabilities } = deps.context;
1768
+ const { records, scopeName, includeParam, fields, idProperty } = scope
1769
+ const { scopes, log, knex, capabilities } = deps.context
1777
1770
  try {
1778
- log.trace('[INCLUDE] Building included resources:', { scopeName, includeParam, recordCount: records.length });
1779
-
1771
+ log.trace('[INCLUDE] Building included resources:', { scopeName, includeParam, recordCount: records.length })
1772
+
1780
1773
  // Check if includes are empty or records are empty
1781
1774
  if (!includeParam || records.length === 0) {
1782
- log.trace('[INCLUDE] No includes requested or no records');
1775
+ log.trace('[INCLUDE] No includes requested or no records')
1783
1776
  return {
1784
1777
  included: [],
1785
1778
  recordsWithRelationships: records
1786
- };
1779
+ }
1787
1780
  }
1788
-
1781
+
1789
1782
  // Handle both string and array formats
1790
1783
  if (Array.isArray(includeParam) && includeParam.length === 0) {
1791
- log.trace('[INCLUDE] Empty include array, no relationships to load');
1784
+ log.trace('[INCLUDE] Empty include array, no relationships to load')
1792
1785
  return {
1793
1786
  included: [],
1794
1787
  recordsWithRelationships: records
1795
- };
1788
+ }
1796
1789
  }
1797
-
1790
+
1798
1791
  // Parse the include parameter
1799
- const includeTree = parseIncludeTree(includeParam);
1800
-
1801
- log.debug('[INCLUDE] Parsed include tree:', includeTree);
1802
-
1792
+ const includeTree = parseIncludeTree(includeParam)
1793
+
1794
+ log.debug('[INCLUDE] Parsed include tree:', includeTree)
1795
+
1803
1796
  // Use a Map to track included resources by type:id
1804
- const included = new Map();
1805
- const processedPaths = new Set(); // Prevent infinite loops
1806
-
1797
+ const included = new Map()
1798
+ const processedPaths = new Set() // Prevent infinite loops
1799
+
1807
1800
  // Process all includes
1808
1801
  await processIncludes(
1809
1802
  { records, scopeName, includeTree, included, processedPaths, currentPath: '', fields, idProperty },
1810
1803
  { context: { scopes, log, knex, capabilities } }
1811
- );
1812
-
1804
+ )
1805
+
1813
1806
  // Convert Map to array for JSON:API format
1814
- const includedArray = Array.from(included.values());
1815
-
1807
+ const includedArray = Array.from(included.values())
1808
+
1816
1809
  log.debug('[INCLUDE] Completed building includes:', {
1817
1810
  includedCount: includedArray.length,
1818
1811
  uniqueTypes: [...new Set(includedArray.map(r => r.type))]
1819
- });
1820
-
1812
+ })
1813
+
1821
1814
  return {
1822
1815
  included: includedArray,
1823
1816
  recordsWithRelationships: records
1824
- };
1817
+ }
1825
1818
  } catch (error) {
1826
1819
  // Log comprehensive error information
1827
1820
  log.error('[INCLUDE] Failed to build included resources:', {
@@ -1830,50 +1823,50 @@ export const buildIncludedResources = async (scope, deps) => {
1830
1823
  recordCount: records?.length || 0,
1831
1824
  error: error.message,
1832
1825
  stack: error.stack
1833
- });
1834
-
1826
+ })
1827
+
1835
1828
  // Re-throw with additional context
1836
- const enhancedError = new Error(`Failed to build included resources for scope '${scopeName}': ${error.message}`);
1837
- enhancedError.originalError = error;
1829
+ const enhancedError = new Error(`Failed to build included resources for scope '${scopeName}': ${error.message}`)
1830
+ enhancedError.originalError = error
1838
1831
  enhancedError.context = {
1839
1832
  scopeName,
1840
1833
  includeParam,
1841
1834
  recordCount: records?.length || 0
1842
- };
1843
- throw enhancedError;
1835
+ }
1836
+ throw enhancedError
1844
1837
  }
1845
- };
1838
+ }
1846
1839
 
1847
1840
  /**
1848
1841
  * Loads relationship identifiers for all hasMany relationships without fetching full related records.
1849
- *
1842
+ *
1850
1843
  * ## Purpose
1851
- * This function ensures that all hasMany relationships (one-to-many, many-to-many, and polymorphic)
1852
- * always return resource identifiers in the JSON:API response, even when the related resources
1844
+ * This function ensures that all hasMany relationships (one-to-many, many-to-many, and polymorphic)
1845
+ * always return resource identifiers in the JSON:API response, even when the related resources
1853
1846
  * are not included via the ?include parameter.
1854
- *
1847
+ *
1855
1848
  * ## Why this is needed
1856
1849
  * 1. **JSON:API Consistency**: The JSON:API spec allows servers to include relationship identifiers
1857
1850
  * without including the full related resources. This provides a consistent API surface where
1858
1851
  * clients always know what relationships exist and their IDs.
1859
- *
1860
- * 2. **Simplified Mode Support**: In simplified mode, relationship IDs are transformed into
1861
- * minimal objects (e.g., `reviews: [{id: '1'}, {id: '2'}, {id: '3'}]`). Without this function,
1852
+ *
1853
+ * 2. **Simplified Mode Support**: In simplified mode, relationship IDs are transformed into
1854
+ * minimal objects (e.g., `reviews: [{id: '1'}, {id: '2'}, {id: '3'}]`). Without this function,
1862
1855
  * these objects only appear when using ?include, creating an inconsistent API where fields appear/disappear.
1863
- *
1856
+ *
1864
1857
  * 3. **Performance Balance**: Loading just IDs is much cheaper than loading full records. This gives
1865
1858
  * clients the ability to know what relationships exist without the cost of fetching all data.
1866
- *
1859
+ *
1867
1860
  * ## What it does
1868
1861
  * - Runs ONE query per relationship type (not per record) to fetch all related IDs
1869
1862
  * - Populates the relationship data with resource identifiers: `{ type: 'resource', id: '123' }`
1870
1863
  * - Handles all relationship types: one-to-many, many-to-many, and polymorphic
1871
1864
  * - Works for both JSON:API and simplified modes (transformation happens elsewhere)
1872
- *
1865
+ *
1873
1866
  * ## When it runs
1874
1867
  * This runs after the main records are fetched but before includes are processed.
1875
1868
  * If includes ARE specified, they will overwrite these IDs with full data.
1876
- *
1869
+ *
1877
1870
  * @param {Array<Object>} records - The parent records to load relationships for
1878
1871
  * @param {string} scopeName - The parent scope name (e.g., 'authors')
1879
1872
  * @param {Object} scopes - All available scopes with their schemas
@@ -1881,113 +1874,110 @@ export const buildIncludedResources = async (scope, deps) => {
1881
1874
  * @returns {Promise<void>} Modifies records in place by adding relationship data
1882
1875
  */
1883
1876
  export const loadRelationshipIdentifiers = async (records, scopeName, scopes, knex) => {
1884
- if (!records.length) return;
1885
-
1886
- const schemaInfo = scopes[scopeName]?.vars?.schemaInfo;
1887
- if (!schemaInfo) return;
1888
-
1889
- const relationships = schemaInfo.schemaRelationships || {};
1890
- const recordIds = records.map(r => r.id);
1891
-
1892
-
1877
+ if (!records.length) return
1878
+
1879
+ const schemaInfo = scopes[scopeName]?.vars?.schemaInfo
1880
+ if (!schemaInfo) return
1881
+
1882
+ const relationships = schemaInfo.schemaRelationships || {}
1883
+ const recordIds = records.map(r => r.id)
1884
+
1893
1885
  // Process each hasMany relationship
1894
1886
  for (const [relName, relDef] of Object.entries(relationships)) {
1895
- let idsMap = {};
1896
-
1887
+ const idsMap = {}
1888
+
1897
1889
  if (relDef.type === 'hasMany' && !relDef.via) {
1898
1890
  // Regular one-to-many
1899
1891
  // Example: publisher hasMany authors
1900
- const foreignKey = relDef.foreignKey;
1901
-
1902
- if (!foreignKey) {
1903
- throw new Error(`Missing foreignKey in hasMany relationship '${relName}' for scope '${scopeName}'`);
1904
- }
1905
- const targetScope = scopes[relDef.target];
1906
- const targetTable = targetScope?.vars?.schemaInfo?.tableName || relDef.target;
1907
- const targetIdProperty = targetScope?.vars?.schemaInfo?.idProperty || 'id';
1908
-
1892
+ const foreignKey = relDef.foreignKey
1893
+
1894
+ if (!foreignKey) {
1895
+ throw new Error(`Missing foreignKey in hasMany relationship '${relName}' for scope '${scopeName}'`)
1896
+ }
1897
+ const targetScope = scopes[relDef.target]
1898
+ const targetTable = targetScope?.vars?.schemaInfo?.tableName || relDef.target
1899
+ const targetIdProperty = targetScope?.vars?.schemaInfo?.idProperty || 'id'
1900
+
1909
1901
  const results = await knex(targetTable)
1910
1902
  .whereIn(foreignKey, recordIds)
1911
- .select(targetIdProperty !== 'id' ? `${targetIdProperty} as id` : 'id', foreignKey);
1912
-
1903
+ .select(targetIdProperty !== 'id' ? `${targetIdProperty} as id` : 'id', foreignKey)
1904
+
1913
1905
  results.forEach(row => {
1914
- const parentId = String(row[foreignKey]);
1915
- if (!idsMap[parentId]) idsMap[parentId] = [];
1916
- idsMap[parentId].push(String(row.id));
1917
- });
1918
-
1906
+ const parentId = String(row[foreignKey])
1907
+ if (!idsMap[parentId]) idsMap[parentId] = []
1908
+ idsMap[parentId].push(String(row.id))
1909
+ })
1919
1910
  } else if (relDef.type === 'manyToMany') {
1920
1911
  // Many-to-many relationship
1921
1912
  // Example: { type: 'manyToMany', through: 'article_tags', foreignKey: 'article_id', otherKey: 'tag_id' }
1922
- const { through, foreignKey, otherKey } = relDef;
1923
- const fk = foreignKey;
1924
- const ok = otherKey;
1925
-
1913
+ const { through, foreignKey, otherKey } = relDef
1914
+ const fk = foreignKey
1915
+ const ok = otherKey
1916
+
1926
1917
  if (!fk || !ok) {
1927
- throw new Error(`Missing foreignKey or otherKey in manyToMany relationship '${relName}' for scope '${scopeName}'`);
1918
+ throw new Error(`Missing foreignKey or otherKey in manyToMany relationship '${relName}' for scope '${scopeName}'`)
1928
1919
  }
1929
-
1920
+
1930
1921
  // Get the actual table name from the pivot scope
1931
- const pivotScope = scopes[through];
1932
- const pivotTable = pivotScope?.vars?.schemaInfo?.tableName || through;
1933
-
1922
+ const pivotScope = scopes[through]
1923
+ const pivotTable = pivotScope?.vars?.schemaInfo?.tableName || through
1924
+
1934
1925
  const results = await knex(pivotTable)
1935
1926
  .whereIn(fk, recordIds)
1936
- .select(fk, ok);
1937
-
1927
+ .select(fk, ok)
1928
+
1938
1929
  results.forEach(row => {
1939
- const parentId = String(row[fk]);
1940
- const childId = String(row[ok]);
1941
- if (!idsMap[parentId]) idsMap[parentId] = [];
1942
- idsMap[parentId].push(childId);
1943
- });
1944
-
1930
+ const parentId = String(row[fk])
1931
+ const childId = String(row[ok])
1932
+ if (!idsMap[parentId]) idsMap[parentId] = []
1933
+ idsMap[parentId].push(childId)
1934
+ })
1945
1935
  } else if (relDef.type === 'hasMany' && relDef.via) {
1946
1936
  // Polymorphic reverse (via)
1947
1937
  // Example: publishers hasMany reviews via reviewable (where reviews.reviewable_type = 'publishers')
1948
- const targetScope = relDef.target;
1949
- const targetRelationships = scopes[targetScope]?.vars?.schemaInfo?.schemaRelationships;
1950
- const viaRel = targetRelationships?.[relDef.via];
1951
-
1938
+ const targetScope = relDef.target
1939
+ const targetRelationships = scopes[targetScope]?.vars?.schemaInfo?.schemaRelationships
1940
+ const viaRel = targetRelationships?.[relDef.via]
1941
+
1952
1942
  if (viaRel?.belongsToPolymorphic) {
1953
- const { typeField, idField } = viaRel.belongsToPolymorphic;
1954
-
1943
+ const { typeField, idField } = viaRel.belongsToPolymorphic
1944
+
1955
1945
  // Get the actual table name
1956
- const targetTable = scopes[targetScope]?.vars?.schemaInfo?.tableName || targetScope;
1957
- const targetIdProperty = scopes[targetScope]?.vars?.schemaInfo?.idProperty || 'id';
1958
-
1946
+ const targetTable = scopes[targetScope]?.vars?.schemaInfo?.tableName || targetScope
1947
+ const targetIdProperty = scopes[targetScope]?.vars?.schemaInfo?.idProperty || 'id'
1948
+
1959
1949
  const results = await knex(targetTable)
1960
1950
  .where(typeField, scopeName)
1961
1951
  .whereIn(idField, recordIds)
1962
- .select(targetIdProperty !== 'id' ? `${targetIdProperty} as id` : 'id', idField);
1963
-
1952
+ .select(targetIdProperty !== 'id' ? `${targetIdProperty} as id` : 'id', idField)
1953
+
1964
1954
  results.forEach(row => {
1965
- const parentId = String(row[idField]);
1966
- if (!idsMap[parentId]) idsMap[parentId] = [];
1967
- idsMap[parentId].push(String(row.id));
1968
- });
1955
+ const parentId = String(row[idField])
1956
+ if (!idsMap[parentId]) idsMap[parentId] = []
1957
+ idsMap[parentId].push(String(row.id))
1958
+ })
1969
1959
  }
1970
1960
  }
1971
-
1961
+
1972
1962
  // Apply the collected IDs to all records
1973
1963
  if (Object.keys(idsMap).length > 0 || relDef.type === 'hasMany' || relDef.type === 'manyToMany') {
1974
1964
  records.forEach(record => {
1975
1965
  if (!record[RELATIONSHIPS_KEY]) {
1976
- record[RELATIONSHIPS_KEY] = {};
1966
+ record[RELATIONSHIPS_KEY] = {}
1977
1967
  }
1978
-
1979
- const ids = idsMap[String(record.id)] || [];
1968
+
1969
+ const ids = idsMap[String(record.id)] || []
1980
1970
  // For manyToMany, the target type is the relationship name itself
1981
1971
  // For hasMany/hasOne, the target type is specified in the target property
1982
- const targetType = (relDef.type === 'manyToMany') ? relName : relDef.target;
1983
-
1972
+ const targetType = (relDef.type === 'manyToMany') ? relName : relDef.target
1973
+
1984
1974
  record[RELATIONSHIPS_KEY][relName] = {
1985
- data: ids.map(id => ({
1986
- type: targetType,
1987
- id
1975
+ data: ids.map(id => ({
1976
+ type: targetType,
1977
+ id
1988
1978
  }))
1989
- };
1990
- });
1979
+ }
1980
+ })
1991
1981
  }
1992
1982
  }
1993
- };
1983
+ }