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
@@ -0,0 +1,507 @@
1
+ import { whereInIdentifiers } from '../querying/identifier-query.js'
2
+ import { applyScopeFiltersToIncludeQuery } from '../querying/include-query-helpers.js'
3
+ import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
4
+ import { lockRelationshipTargets } from '../writing/relationship-processor.js'
5
+ import {
6
+ assertVersionedRelationshipTransaction,
7
+ hasVersionedInverse,
8
+ invalidateManyToManyVersions,
9
+ } from '../writing/resource-version.js'
10
+ import { RELATIONSHIP_WRITE_BATCH_SIZE, RELATIONSHIP_READ_BATCH_SIZE } from '../querying-writing/knex-constants.js'
11
+ import { normalizeId } from './utils/descriptor-helpers.js'
12
+
13
+ const LINKS_TABLE = 'any_links'
14
+
15
+ // One instance per API; each operation supplies its own database/transaction.
16
+ export function createCanonicalLinkStore ({ api, getDescriptor, getScopeStorageAdapter }) {
17
+ const canonicalizeLinkPair = ({
18
+ tenantId,
19
+ relationshipKey,
20
+ inverseRelationshipKey,
21
+ leftResource,
22
+ leftId,
23
+ rightResource,
24
+ rightId,
25
+ }) => {
26
+ const normalizedLeftId = normalizeId(leftId)
27
+ const normalizedRightId = normalizeId(rightId)
28
+
29
+ if (normalizedLeftId == null || normalizedRightId == null) {
30
+ return null
31
+ }
32
+
33
+ if (inverseRelationshipKey && inverseRelationshipKey < relationshipKey) {
34
+ return {
35
+ tenant_id: tenantId,
36
+ relationship: inverseRelationshipKey,
37
+ inverse_relationship: relationshipKey,
38
+ left_resource: rightResource,
39
+ left_id: normalizedRightId,
40
+ right_resource: leftResource,
41
+ right_id: normalizedLeftId,
42
+ }
43
+ }
44
+
45
+ return {
46
+ tenant_id: tenantId,
47
+ relationship: relationshipKey,
48
+ inverse_relationship: inverseRelationshipKey || null,
49
+ left_resource: leftResource,
50
+ left_id: normalizedLeftId,
51
+ right_resource: rightResource,
52
+ right_id: normalizedRightId,
53
+ }
54
+ }
55
+
56
+ const findInverseManyToMany = ({ descriptor, relInfo }) => {
57
+ if (!relInfo?.target) return null
58
+
59
+ const targetDescriptor = getDescriptor(relInfo.target)
60
+
61
+ const entries = Object.entries(targetDescriptor.manyToMany || {})
62
+ for (const [candidateName, candidateInfo] of entries) {
63
+ const candidateTarget = candidateInfo.target || descriptor.resource
64
+ if (candidateTarget !== descriptor.resource) continue
65
+
66
+ if (relInfo.through && candidateInfo.through && relInfo.through !== candidateInfo.through) {
67
+ continue
68
+ }
69
+
70
+ if (
71
+ relInfo.foreignKey && relInfo.otherKey &&
72
+ candidateInfo.foreignKey && candidateInfo.otherKey
73
+ ) {
74
+ const foreignMatches = relInfo.foreignKey === candidateInfo.otherKey
75
+ const otherMatches = relInfo.otherKey === candidateInfo.foreignKey
76
+ if (!foreignMatches || !otherMatches) continue
77
+ }
78
+
79
+ const relationshipKey = candidateInfo.relationship ||
80
+ `${targetDescriptor.tenant}:${targetDescriptor.resource}:${candidateName}`
81
+
82
+ return {
83
+ descriptor: targetDescriptor,
84
+ relName: candidateName,
85
+ relInfo: candidateInfo,
86
+ relationshipKey,
87
+ }
88
+ }
89
+
90
+ return null
91
+ }
92
+
93
+ const getManyToManyInfo = (scopeName, relName) => {
94
+ const descriptor = getDescriptor(scopeName)
95
+ const relInfo = Object.hasOwn(descriptor.manyToMany || {}, relName) ? descriptor.manyToMany[relName] : undefined
96
+ if (!relInfo) return null
97
+ const relationshipKey = relInfo.relationship || `${descriptor.tenant}:${descriptor.resource}:${relName}`
98
+ const inverse = findInverseManyToMany({ descriptor, relInfo })
99
+ const inverseRelationshipKey = inverse?.relationshipKey || null
100
+ return { descriptor, relInfo, relationshipKey, inverseRelationshipKey }
101
+ }
102
+
103
+ const buildRelatedLinkQuery = ({ descriptor, relInfo, relationshipKey, parentIds, db, visibleTargets, allParents = false }) => {
104
+ const forward = db(LINKS_TABLE).select({ parentId: 'left_id', childId: 'right_id' }).where({
105
+ tenant_id: descriptor.tenant,
106
+ relationship: relationshipKey,
107
+ left_resource: descriptor.resource,
108
+ right_resource: relInfo.target
109
+ })
110
+ const reverse = db(LINKS_TABLE).select({ parentId: 'right_id', childId: 'left_id' }).where({
111
+ tenant_id: descriptor.tenant,
112
+ inverse_relationship: relationshipKey,
113
+ right_resource: descriptor.resource,
114
+ left_resource: relInfo.target
115
+ })
116
+ if (!allParents) {
117
+ forward.modify(whereInIdentifiers, 'left_id', parentIds)
118
+ reverse.modify(whereInIdentifiers, 'right_id', parentIds)
119
+ }
120
+ if (visibleTargets) {
121
+ forward.whereIn('right_id', visibleTargets.clone())
122
+ reverse.whereIn('left_id', visibleTargets.clone())
123
+ }
124
+ return forward.union(reverse)
125
+ }
126
+
127
+ const buildVisibleLinkTargets = async ({ descriptor, relInfo, relationshipKey, parentIds, db, context }) => {
128
+ const adapter = getScopeStorageAdapter(relInfo.target)
129
+ if (!adapter) throw new RestApiResourceError(`Related resource '${relInfo.target}' not found`, { subtype: 'related_type_not_found' })
130
+ const tableName = adapter.getTableName()
131
+ const column = `${tableName}.${adapter.getIdColumn()}`
132
+ const links = buildRelatedLinkQuery({ descriptor, relInfo, relationshipKey, parentIds, db })
133
+ const query = adapter.buildBaseQuery({ transaction: context.transaction })
134
+ .whereIn(column, db.select('childId').from(links.as('related_identifiers')))
135
+ .distinct(column)
136
+ return applyScopeFiltersToIncludeQuery({
137
+ query,
138
+ scopes: api.resources,
139
+ scopeName: relInfo.target,
140
+ requestContext: context,
141
+ db,
142
+ tableName,
143
+ queryPurpose: 'relationship-identifiers'
144
+ })
145
+ }
146
+
147
+ /** @type {import('../storage/storage-types.js').CanonicalDataRelatedIdsQuery} */
148
+ const dataRelatedIdsQuery = async ({ context, scopeName }) => {
149
+ const info = getManyToManyInfo(scopeName, context.relationshipName)
150
+ const query = buildRelatedLinkQuery({ ...info, parentIds: [normalizeId(context.id)], db: context.db })
151
+ return { query: context.db.select('childId').from(query.as('related_links')) }
152
+ }
153
+
154
+ const buildLinkMutationQuery = ({ descriptor, relInfo, relationshipKey, inverseRelationshipKey, leftId, db, allParents = false }, constrainIds) => {
155
+ return db(LINKS_TABLE).where('tenant_id', descriptor.tenant).where(owner => {
156
+ owner.where(forward => {
157
+ forward.where({
158
+ relationship: relationshipKey,
159
+ left_resource: descriptor.resource,
160
+ ...(allParents ? {} : { left_id: leftId }),
161
+ right_resource: relInfo.target
162
+ })
163
+ constrainIds(forward, 'right_id')
164
+ }).orWhere(reverse => {
165
+ reverse.where({
166
+ right_resource: descriptor.resource,
167
+ ...(allParents ? {} : { right_id: leftId }),
168
+ left_resource: relInfo.target
169
+ }).where(keys => {
170
+ keys.where('inverse_relationship', relationshipKey)
171
+ // A canonical link can predate publication of its inverse metadata.
172
+ if (inverseRelationshipKey) keys.orWhere('relationship', inverseRelationshipKey)
173
+ })
174
+ constrainIds(reverse, 'left_id')
175
+ })
176
+ })
177
+ }
178
+
179
+ const fetchLinksForParents = async ({ descriptor, relationshipKey, targetResource, parentIds, db, visibleTargets }) => {
180
+ const normalizedIds = [...new Set(parentIds
181
+ .map((id) => normalizeId(id))
182
+ .filter((id) => id !== null))]
183
+
184
+ if (normalizedIds.length === 0) return []
185
+
186
+ const results = []
187
+ for (let offset = 0; offset < normalizedIds.length; offset += RELATIONSHIP_READ_BATCH_SIZE) {
188
+ const batchIds = normalizedIds.slice(offset, offset + RELATIONSHIP_READ_BATCH_SIZE)
189
+ let afterId
190
+ // One parent can have many links; bound returned rows as well as requested IDs.
191
+ while (true) {
192
+ const query = db(LINKS_TABLE)
193
+ .where('tenant_id', descriptor.tenant)
194
+ .andWhere((builder) => {
195
+ builder
196
+ .where((q) => {
197
+ q.where('relationship', relationshipKey)
198
+ .andWhere('left_resource', descriptor.resource)
199
+ .andWhere('right_resource', targetResource)
200
+ .whereIn('left_id', batchIds)
201
+ if (visibleTargets) q.whereIn('right_id', visibleTargets.clone())
202
+ })
203
+ .orWhere((q) => {
204
+ q.where('inverse_relationship', relationshipKey)
205
+ .andWhere('right_resource', descriptor.resource)
206
+ .andWhere('left_resource', targetResource)
207
+ .whereIn('right_id', batchIds)
208
+ if (visibleTargets) q.whereIn('left_id', visibleTargets.clone())
209
+ })
210
+ })
211
+ .select('id', 'relationship', 'inverse_relationship', 'left_resource', 'left_id', 'right_resource', 'right_id')
212
+ .orderBy('id').limit(RELATIONSHIP_READ_BATCH_SIZE + 1)
213
+ if (afterId !== undefined) query.where('id', '>', afterId)
214
+ const rows = await query
215
+ for (const row of rows) {
216
+ let parentId
217
+ let childId
218
+ let childType
219
+
220
+ if (row.relationship === relationshipKey && row.left_resource === descriptor.resource) {
221
+ parentId = normalizeId(row.left_id)
222
+ childId = normalizeId(row.right_id)
223
+ childType = row.right_resource
224
+ } else if (row.inverse_relationship === relationshipKey && row.right_resource === descriptor.resource) {
225
+ parentId = normalizeId(row.right_id)
226
+ childId = normalizeId(row.left_id)
227
+ childType = row.left_resource
228
+ } else {
229
+ continue
230
+ }
231
+
232
+ if (parentId == null || childId == null) continue
233
+
234
+ results.push({ parentId, childId, childType })
235
+ }
236
+ if (rows.length <= RELATIONSHIP_READ_BATCH_SIZE) break
237
+ afterId = rows.at(-1).id
238
+ }
239
+ }
240
+
241
+ return results
242
+ }
243
+
244
+ const attachLinks = async (options) => {
245
+ const { descriptor, relInfo, relationshipKey, inverseRelationshipKey, leftId, relData, db } = options
246
+ const relArray = Array.isArray(relData) ? relData : []
247
+ if (relArray.length === 0) return
248
+ assertVersionedRelationshipTransaction(api, relInfo, db)
249
+
250
+ await lockRelationshipTargets(api, db, relArray.map(identifier => ({ type: relInfo.target, id: identifier.id })))
251
+
252
+ const ids = [...new Set(relArray.map(identifier => normalizeId(identifier.id)).filter(id => id !== null))]
253
+ const readLinks = batchIds => buildLinkMutationQuery(options, (query, column) => query.whereIn(column, batchIds))
254
+ .select('id', 'left_id', 'right_id', 'inverse_relationship', db.raw(
255
+ 'case when ?? = ? and ?? = ? and ?? = ? then 1 else 0 end as ??',
256
+ ['relationship', relationshipKey, 'left_resource', descriptor.resource, 'left_id', leftId, 'owner_left']
257
+ )).orderBy('id').forUpdate()
258
+ for (let offset = 0; offset < ids.length; offset += RELATIONSHIP_WRITE_BATCH_SIZE) {
259
+ const batch = ids.slice(offset, offset + RELATIONSHIP_WRITE_BATCH_SIZE)
260
+ const requested = new Set(batch)
261
+ const existingById = new Map()
262
+ let hasExistingRows = false
263
+ let afterId
264
+ // Duplicate stored edges must not turn a bounded target batch into an unbounded read.
265
+ while (true) {
266
+ const query = readLinks(batch).limit(RELATIONSHIP_READ_BATCH_SIZE + 1)
267
+ if (afterId !== undefined) query.where('id', '>', afterId)
268
+ const rows = await query
269
+ hasExistingRows ||= rows.length > 0
270
+ for (const row of rows) {
271
+ const targetId = normalizeId(row.owner_left ? row.right_id : row.left_id)
272
+ if (requested.has(targetId) && !existingById.has(targetId)) existingById.set(targetId, row)
273
+ }
274
+ if (rows.length <= RELATIONSHIP_READ_BATCH_SIZE) break
275
+ afterId = rows.at(-1).id
276
+ }
277
+ let inserted = false
278
+ for (const rightId of batch) {
279
+ let existing = existingById.get(rightId)
280
+ // Unmatched spellings can equal another existing/new ID under the database collation.
281
+ if (!existing && (hasExistingRows || inserted)) {
282
+ existing = await readLinks([rightId]).first()
283
+ }
284
+ if (existing) {
285
+ const inverseKey = existing.owner_left ? inverseRelationshipKey : relationshipKey
286
+ if (!existing.inverse_relationship && inverseKey) {
287
+ await db(LINKS_TABLE).where({ id: existing.id }).update({
288
+ inverse_relationship: inverseKey, updated_at: db.fn.now()
289
+ })
290
+ existing.inverse_relationship = inverseKey
291
+ }
292
+ continue
293
+ }
294
+ const canonical = canonicalizeLinkPair({
295
+ tenantId: descriptor.tenant,
296
+ relationshipKey,
297
+ inverseRelationshipKey,
298
+ leftResource: descriptor.resource,
299
+ leftId,
300
+ rightResource: relInfo.target,
301
+ rightId,
302
+ })
303
+ if (!canonical) continue
304
+ await db(LINKS_TABLE).insert({
305
+ ...canonical,
306
+ payload: null,
307
+ created_at: db.fn.now(),
308
+ updated_at: db.fn.now(),
309
+ })
310
+ inserted = true
311
+ }
312
+ }
313
+ await invalidateManyToManyVersions(api, relInfo, ids, db)
314
+ }
315
+
316
+ /** @type {import('../storage/storage-types.js').CanonicalLinkHelpers} */
317
+ const methods = {
318
+ attachMany: async ({ context, scopeName, relName, relDef, relData }) => {
319
+ const info = getManyToManyInfo(scopeName, relName)
320
+ if (!info) {
321
+ throw new Error(`Many-to-many relationship '${relName}' not found on '${scopeName}'`)
322
+ }
323
+ const db = context.transaction || context.db || api.knex.instance
324
+ const leftId = normalizeId(context.id)
325
+ await attachLinks({
326
+ descriptor: info.descriptor,
327
+ relInfo: info.relInfo,
328
+ relationshipKey: info.relationshipKey,
329
+ inverseRelationshipKey: info.inverseRelationshipKey,
330
+ leftId,
331
+ relData,
332
+ db,
333
+ })
334
+ },
335
+ syncMany: async ({ context, scopeName, relName, relDef, relData, isUpdate }) => {
336
+ const info = getManyToManyInfo(scopeName, relName)
337
+ if (!info) {
338
+ throw new Error(`Many-to-many relationship '${relName}' not found on '${scopeName}'`)
339
+ }
340
+ const db = context.transaction || context.db || api.knex.instance
341
+ const leftId = normalizeId(context.id)
342
+ if (isUpdate) {
343
+ await syncLinks({
344
+ descriptor: info.descriptor,
345
+ relInfo: info.relInfo,
346
+ relationshipKey: info.relationshipKey,
347
+ inverseRelationshipKey: info.inverseRelationshipKey,
348
+ leftId,
349
+ relData,
350
+ db,
351
+ })
352
+ } else {
353
+ await attachLinks({
354
+ descriptor: info.descriptor,
355
+ relInfo: info.relInfo,
356
+ relationshipKey: info.relationshipKey,
357
+ inverseRelationshipKey: info.inverseRelationshipKey,
358
+ leftId,
359
+ relData,
360
+ db,
361
+ })
362
+ }
363
+ },
364
+ removeMany: async ({ context, scopeName, relName, relData }) => {
365
+ const info = getManyToManyInfo(scopeName, relName)
366
+ if (!info) {
367
+ throw new Error(`Many-to-many relationship '${relName}' not found on '${scopeName}'`)
368
+ }
369
+ const db = context.transaction || context.db || api.knex.instance
370
+ const leftId = normalizeId(context.id)
371
+ await removeLinks({
372
+ descriptor: info.descriptor,
373
+ relInfo: info.relInfo,
374
+ relationshipKey: info.relationshipKey,
375
+ inverseRelationshipKey: info.inverseRelationshipKey,
376
+ leftId,
377
+ relData,
378
+ db,
379
+ })
380
+ },
381
+ listMany: async ({ context, scopeName, relName }) => {
382
+ const info = getManyToManyInfo(scopeName, relName)
383
+ if (!info) {
384
+ throw new Error(`Many-to-many relationship '${relName}' not found on '${scopeName}'`)
385
+ }
386
+ const db = context.transaction || context.db || api.knex.instance
387
+ const leftId = normalizeId(context.id)
388
+ return listLinks({
389
+ descriptor: info.descriptor,
390
+ relInfo: info.relInfo,
391
+ relationshipKey: info.relationshipKey,
392
+ inverseRelationshipKey: info.inverseRelationshipKey,
393
+ leftId,
394
+ db,
395
+ context
396
+ })
397
+ },
398
+ fetchManyToManyRows: async ({ scopeName, relName, parentIds, context }) => {
399
+ const info = getManyToManyInfo(scopeName, relName)
400
+ if (!info) return []
401
+ const db = context.db || context.transaction || api.knex.instance
402
+ return fetchLinksForParents({
403
+ descriptor: info.descriptor,
404
+ relationshipKey: info.relationshipKey,
405
+ targetResource: info.relInfo.target,
406
+ parentIds,
407
+ db,
408
+ })
409
+ },
410
+ }
411
+
412
+ const invalidateLinkQueryVersions = async ({ query, db, relDef, resolveId }) => {
413
+ if (!hasVersionedInverse(api, relDef)) return
414
+ let afterId
415
+ while (true) {
416
+ const page = query.clone().orderBy('id').limit(RELATIONSHIP_WRITE_BATCH_SIZE).forUpdate()
417
+ .select('id', 'relationship', 'left_resource', 'left_id', 'right_id')
418
+ if (afterId !== undefined) page.where('id', '>', afterId)
419
+ const rows = await page
420
+ if (!rows.length) break
421
+ await invalidateManyToManyVersions(api, relDef, rows.map(resolveId), db)
422
+ afterId = rows.at(-1).id
423
+ }
424
+ }
425
+
426
+ const invalidateRemovedLinkVersions = (options, query) => invalidateLinkQueryVersions({
427
+ query,
428
+ db: options.db,
429
+ relDef: options.relInfo,
430
+ resolveId: row => row.relationship === options.relationshipKey && row.left_resource === options.descriptor.resource && normalizeId(row.left_id) === options.leftId ? row.right_id : row.left_id
431
+ })
432
+
433
+ const invalidateDeletedLinkTargets = async (scopeName, context) => {
434
+ const relationships = []
435
+ for (const [parentType, parent] of Object.entries(api.resources)) {
436
+ if (!parent.vars.schemaInfo?.versionField) continue
437
+ for (const [relName, relationship] of Object.entries(parent.vars.schemaInfo.schemaRelationships)) {
438
+ if (relationship.type !== 'manyToMany' || relationship.target !== scopeName) continue
439
+ const inverse = { target: parentType, through: relationship.through, foreignKey: relationship.otherKey, otherKey: relationship.foreignKey }
440
+ relationships.push({ inverse, info: getManyToManyInfo(parentType, relName) })
441
+ }
442
+ }
443
+ if (!relationships.length) return
444
+ assertVersionedRelationshipTransaction(api, relationships[0].inverse, context.transaction)
445
+ await lockRelationshipTargets(api, context.transaction, [{ type: scopeName, id: context.id }])
446
+ const id = normalizeId(context.id)
447
+ for (const { inverse, info } of relationships) {
448
+ const query = buildLinkMutationQuery({ ...info, db: context.transaction, allParents: true }, (query, column) => query.where(column, id))
449
+ await invalidateLinkQueryVersions({
450
+ query,
451
+ db: context.transaction,
452
+ relDef: inverse,
453
+ resolveId: row => row.left_resource === scopeName && normalizeId(row.left_id) === id ? row.right_id : row.left_id
454
+ })
455
+ }
456
+ }
457
+
458
+ const syncLinks = async (options) => {
459
+ assertVersionedRelationshipTransaction(api, options.relInfo, options.db)
460
+ const relArray = Array.isArray(options.relData) ? options.relData : []
461
+ const ids = [...new Set(relArray.map(identifier => normalizeId(identifier.id)).filter(id => id !== null))]
462
+ await attachLinks(options)
463
+ // The entire keep-list belongs in one predicate, even when it exceeds a read batch.
464
+ const removals = buildLinkMutationQuery(options, (query, column) => {
465
+ query.whereNot(removed => removed.modify(whereInIdentifiers, column, ids))
466
+ })
467
+ await invalidateRemovedLinkVersions(options, removals)
468
+ await removals.delete()
469
+ }
470
+
471
+ const removeLinks = async (options) => {
472
+ assertVersionedRelationshipTransaction(api, options.relInfo, options.db)
473
+ const relArray = Array.isArray(options.relData) ? options.relData : []
474
+ const ids = [...new Set(relArray.map(identifier => normalizeId(identifier.id)).filter(id => id !== null))]
475
+ for (let offset = 0; offset < ids.length; offset += RELATIONSHIP_WRITE_BATCH_SIZE) {
476
+ const batch = ids.slice(offset, offset + RELATIONSHIP_WRITE_BATCH_SIZE)
477
+ const removals = buildLinkMutationQuery(options, (query, column) => query.whereIn(column, batch))
478
+ await invalidateRemovedLinkVersions(options, removals)
479
+ await removals.delete()
480
+ }
481
+ }
482
+
483
+ const listLinks = async ({ descriptor, relInfo, relationshipKey, leftId, db, context }) => {
484
+ if (leftId == null) return []
485
+ const parentIds = [leftId]
486
+ const { query: visibleTargets } = await buildVisibleLinkTargets({ descriptor, relInfo, relationshipKey, parentIds, db, context })
487
+ const rows = await buildRelatedLinkQuery({ descriptor, relInfo, relationshipKey, parentIds, db, visibleTargets })
488
+ return rows.map(row => ({ type: relInfo.target, id: normalizeId(row.childId) }))
489
+ }
490
+
491
+ const deleteResourceLinks = async ({ descriptor, scopeName, id, db }) => {
492
+ await db(LINKS_TABLE).where('tenant_id', descriptor.tenant).where(query => {
493
+ query.where({ left_resource: scopeName, left_id: id }).orWhere({ right_resource: scopeName, right_id: id })
494
+ }).delete()
495
+ }
496
+
497
+ return {
498
+ methods,
499
+ dataRelatedIdsQuery,
500
+ getManyToManyInfo,
501
+ buildRelatedLinkQuery,
502
+ buildVisibleLinkTargets,
503
+ fetchLinksForParents,
504
+ invalidateDeletedLinkTargets,
505
+ deleteResourceLinks
506
+ }
507
+ }