json-rest-api 1.0.28 → 2.0.0

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