json-rest-api 1.0.29 → 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 (292) 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 +744 -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} +74 -30
  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 -177
  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 +48 -317
  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 +134 -37
  91. package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
  92. package/plugins/core/lib/querying/sort-helpers.js +13 -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 +179 -194
  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 +100 -290
  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 +93 -38
  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 +128 -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 -2485
  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 -1077
  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 -395
  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 -148
  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 +36 -68
  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 +37 -81
  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 -1247
  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 -148
  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 -155
  251. package/tests/fixtures/api-configs.js +0 -2566
  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-boundaries.test.js +0 -154
  264. package/tests/pagination-contract.test.js +0 -122
  265. package/tests/pagination-cursor-multifield.test.js +0 -641
  266. package/tests/pagination-enhanced.test.js +0 -563
  267. package/tests/polymorphic-relationships.test.js +0 -347
  268. package/tests/polymorphic-simple.test.js +0 -148
  269. package/tests/positioning-advanced.test.js +0 -692
  270. package/tests/positioning-extended.test.js +0 -915
  271. package/tests/positioning-multifilter.test.js +0 -699
  272. package/tests/positioning.test.js +0 -560
  273. package/tests/post-commit-hooks.test.js +0 -120
  274. package/tests/projected-fields.test.js +0 -319
  275. package/tests/public-exports.test.js +0 -15
  276. package/tests/put-create-parity.test.js +0 -216
  277. package/tests/queries.test.js +0 -521
  278. package/tests/query-limits.test.js +0 -584
  279. package/tests/relationship-endpoints.test.js +0 -620
  280. package/tests/relationship-include-storage-adapter.test.js +0 -56
  281. package/tests/relationships.test.js +0 -681
  282. package/tests/rest-api.test.js +0 -337
  283. package/tests/return-record-settings.test.js +0 -184
  284. package/tests/row-policy.test.js +0 -489
  285. package/tests/s3-storage.test.js +0 -38
  286. package/tests/searchschema-knex-behavior.test.js +0 -187
  287. package/tests/searchschema-merge.test.js +0 -151
  288. package/tests/socketio.test.js +0 -860
  289. package/tests/storage-mapping.test.js +0 -362
  290. package/tests/temporal-boundaries.test.js +0 -208
  291. package/tests/test-development-guide.md +0 -370
  292. package/tests/virtual-fields.test.js +0 -404
@@ -1,303 +1,133 @@
1
- import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
2
1
  import { normalizeRelationshipIdentifiers } from '../querying-writing/resource-id-normalization.js'
3
-
4
- /**
5
- * Updates many-to-many relationships intelligently by synchronizing pivot table records
6
- *
7
- * @param {Object} scope - The scope object (not used directly, for consistency)
8
- * @param {Object} deps - Dependencies object
9
- * @returns {Promise<void>}
10
- *
11
- * @example
12
- * // Input: Article currently has tags [1, 2], want to change to [1, 3]
13
- * const deps = {
14
- * api,
15
- * context: {
16
- * resourceId: '100',
17
- * relDef: {
18
- * through: 'article_tags', // Pivot table
19
- * foreignKey: 'article_id', // Points to article
20
- * otherKey: 'tag_id' // Points to tag
21
- * },
22
- * relData: [
23
- * { type: 'tags', id: '1' }, // Keep this
24
- * { type: 'tags', id: '3' } // Add this
25
- * ],
26
- * transaction: trx
27
- * }
28
- * };
29
- *
30
- * // Before: article_tags table
31
- * // article_id | tag_id | created_at
32
- * // 100 | 1 | 2024-01-01
33
- * // 100 | 2 | 2024-01-02
34
- *
35
- * await updateManyToManyRelationship(null, deps);
36
- *
37
- * // After: article_tags table
38
- * // article_id | tag_id | created_at
39
- * // 100 | 1 | 2024-01-01 (preserved!)
40
- * // 100 | 3 | 2024-12-01 (new)
41
- * // Tag 2 was deleted, Tag 1 kept its metadata
42
- *
43
- * @example
44
- * // Input: Pivot table has extra fields to preserve
45
- * // article_tags has: article_id, tag_id, display_order, featured
46
- *
47
- * // Current data:
48
- * // article_id | tag_id | display_order | featured
49
- * // 100 | 1 | 1 | true
50
- * // 100 | 2 | 2 | false
51
- *
52
- * const deps = {
53
- * context: {
54
- * resourceId: '100',
55
- * relData: [
56
- * { type: 'tags', id: '1' }, // Keep tag 1
57
- * { type: 'tags', id: '5' } // Add tag 5
58
- * ]
59
- * }
60
- * };
61
- *
62
- * await updateManyToManyRelationship(null, deps);
63
- *
64
- * // Result:
65
- * // article_id | tag_id | display_order | featured
66
- * // 100 | 1 | 1 | true (preserved!)
67
- * // 100 | 5 | NULL | NULL (new with defaults)
68
- *
69
- * @example
70
- * // Input: Clear all relationships
71
- * const deps = {
72
- * context: {
73
- * resourceId: '100',
74
- * relData: [] // Empty array means remove all
75
- * }
76
- * };
77
- *
78
- * await updateManyToManyRelationship(null, deps);
79
- * // All article_tags records for article 100 are deleted
80
- *
81
- * @description
82
- * Used by:
83
- * - relationship-processor.js calls this for many-to-many updates
84
- * - Used in PATCH operations to sync relationships
85
- * - Also used in PUT operations (replaces deleteExistingPivotRecords pattern)
86
- *
87
- * Purpose:
88
- * - Intelligently syncs pivot table to match desired state
89
- * - Preserves existing pivot records that should remain (with their metadata)
90
- * - Only deletes records that should be removed
91
- * - Only creates records that are new
92
- * - Much better than delete-all-then-recreate pattern
93
- *
94
- * Data flow:
95
- * 1. Queries existing pivot records for the resource
96
- * 2. Compares existing IDs with desired IDs
97
- * 3. Calculates which to delete and which to add
98
- * 4. Validates new related resources exist (optional)
99
- * 5. Performs bulk delete for removed relationships
100
- * 6. Performs bulk insert for new relationships
101
- * 7. Records that exist in both are untouched (metadata preserved)
102
- */
103
- export const updateManyToManyRelationship = async (scope, deps) => {
104
- // Extract values from deps
105
- const { api, context } = deps
106
- const { resourceId, relDef, transaction: trx } = context
107
- const relData = normalizeRelationshipIdentifiers(context.relData || [], { api })
108
-
109
- // Get the knex instance from the pivot scope
110
- const pivotScope = api.resources[relDef.through]
111
- if (!pivotScope) {
112
- throw new Error(`Pivot table resource '${relDef.through}' not found`)
2
+ import { getIdColumn, getStorageColumn } from '../storage/storage-mapping.js'
3
+ import { lockRelationshipTargets } from './relationship-processor.js'
4
+ import { RELATIONSHIP_WRITE_BATCH_SIZE } from '../querying-writing/knex-constants.js'
5
+ import { whereInIdentifiers } from '../querying/identifier-query.js'
6
+ import { applyDatabaseReadOptions, databaseIdentityExpression } from '../querying-writing/database-value-normalizers.js'
7
+ import { assertVersionedRelationshipTransaction, hasVersionedInverse, invalidateManyToManyVersions } from './resource-version.js'
8
+
9
+ export async function invalidateDeletedPivotTargets (api, scopeName, context) {
10
+ const relationships = []
11
+ for (const [parentType, parent] of Object.entries(api.resources)) {
12
+ if (!parent.vars.schemaInfo?.versionField) continue
13
+ for (const relationship of Object.values(parent.vars.schemaInfo.schemaRelationships)) {
14
+ if (relationship.type !== 'manyToMany' || relationship.target !== scopeName) continue
15
+ relationships.push({ target: parentType, through: relationship.through, foreignKey: relationship.otherKey, otherKey: relationship.foreignKey })
16
+ }
113
17
  }
18
+ if (!relationships.length) return
19
+ assertVersionedRelationshipTransaction(api, relationships[0], context.transaction)
20
+ await lockRelationshipTargets(api, context.transaction, [{ type: scopeName, id: context.id }])
21
+ const id = api.knex.helpers.getStorageAdapter(scopeName).translateFilterValue('id', context.id)
22
+ for (const inverse of relationships) {
23
+ const pivot = getPivotStorage(api, inverse)
24
+ const query = context.transaction(pivot.tableName).where(pivot.foreignKey, id)
25
+ await invalidateRemovedPivotVersions(api, inverse, query, context.transaction)
26
+ }
27
+ }
114
28
 
115
- // Get the actual database table name (might be different from scope name)
116
- const tableName = pivotScope.vars.schemaInfo.tableName || relDef.through
117
-
118
- // Get existing pivot records directly from database
119
- const existingRecords = await trx(tableName)
120
- .where(relDef.foreignKey, resourceId)
121
- .select(relDef.otherKey)
122
-
123
- // Create sets for efficient comparison
124
- const existingIds = new Set(existingRecords.map(r => String(r[relDef.otherKey])))
125
- const newIds = new Set(relData.map(r => String(r.id)))
126
-
127
- // Determine what to delete and add
128
- const toDelete = [...existingIds].filter(id => !newIds.has(id))
129
- const toAdd = [...newIds].filter(id => !existingIds.has(id))
130
-
131
- // Validate related resources exist if needed (do this before any changes)
132
- if (relDef.validateExists !== false && toAdd.length > 0) {
133
- for (const relatedId of toAdd) {
134
- const related = relData.find(r => String(r.id) === relatedId)
135
- try {
136
- await api.resources[related.type].get({
137
- id: related.id,
138
- transaction: trx
139
- }, { ...context })
140
- } catch (error) {
141
- throw new RestApiResourceError(
142
- `Related ${related.type} with id ${related.id} not found`,
143
- {
144
- subtype: 'not_found',
145
- resourceType: related.type,
146
- resourceId: related.id
147
- }
148
- )
29
+ export async function deletePivotReferences (api, scopeName, context) {
30
+ const targets = new Map()
31
+ for (const [ownerType, owner] of Object.entries(api.resources)) {
32
+ for (const relationship of Object.values(owner.vars.schemaInfo?.schemaRelationships || {})) {
33
+ if (relationship.type !== 'manyToMany' || (ownerType !== scopeName && relationship.target !== scopeName)) continue
34
+ const pivot = getPivotStorage(api, relationship)
35
+ for (const column of [ownerType === scopeName ? pivot.foreignKey : null, relationship.target === scopeName ? pivot.otherKey : null]) {
36
+ if (column) targets.set(JSON.stringify([pivot.tableName, column]), { tableName: pivot.tableName, column })
149
37
  }
150
38
  }
151
39
  }
40
+ const id = api.knex.helpers.getStorageAdapter(scopeName).translateFilterValue('id', context.id)
41
+ const db = context.transaction || context.db || api.knex.instance
42
+ for (const { tableName, column } of targets.values()) await db(tableName).where(column, id).delete()
43
+ }
152
44
 
153
- // Bulk delete records that should be removed
154
- if (toDelete.length > 0) {
155
- await trx(tableName)
156
- .where(relDef.foreignKey, resourceId)
157
- .whereIn(relDef.otherKey, toDelete)
158
- .delete()
159
- }
160
-
161
- // Bulk insert new records
162
- if (toAdd.length > 0) {
163
- const recordsToInsert = toAdd.map(relatedId => ({
164
- [relDef.foreignKey]: resourceId,
165
- [relDef.otherKey]: relatedId
166
- }))
167
-
168
- await trx(tableName).insert(recordsToInsert)
45
+ export async function invalidateRemovedPivotVersions (api, relDef, query, transaction) {
46
+ if (!hasVersionedInverse(api, relDef)) return
47
+ assertVersionedRelationshipTransaction(api, relDef, transaction)
48
+ const pivot = getPivotStorage(api, relDef)
49
+ let afterId
50
+ while (true) {
51
+ const page = query.clone().orderBy(pivot.idColumn).limit(RELATIONSHIP_WRITE_BATCH_SIZE).forUpdate()
52
+ .select({ [pivot.idColumn]: databaseIdentityExpression(transaction, pivot.idColumn), [pivot.otherKey]: databaseIdentityExpression(transaction, pivot.otherKey) })
53
+ if (afterId !== undefined) page.where(pivot.idColumn, '>', afterId)
54
+ const rows = await applyDatabaseReadOptions(page)
55
+ if (!rows.length) break
56
+ await invalidateManyToManyVersions(api, relDef, rows.map(row => row[pivot.otherKey]), transaction)
57
+ afterId = rows.at(-1)[pivot.idColumn]
169
58
  }
170
-
171
- // Records that exist in both are automatically preserved with their pivot data
172
59
  }
173
60
 
174
- // Note: deleteExistingPivotRecords has been removed in favor of using
175
- // updateManyToManyRelationship for all sync operations (including PUT).
176
- // This aligns with industry standards where ORMs use intelligent sync
177
- // rather than delete-all-then-recreate patterns.
178
-
179
- /**
180
- * Creates new pivot table records for many-to-many relationships
181
- *
182
- * @param {Object} api - The API instance with access to resources
183
- * @param {string|number} resourceId - The ID of the primary resource
184
- * @param {Object} relDef - The relationship definition
185
- * @param {Array} relData - Array of related resources to link
186
- * @param {Object} trx - Database transaction object
187
- * @param {Object} [context] - Caller context forwarded to relationship validation
188
- * @returns {Promise<void>}
189
- *
190
- * @example
191
- * // Input: Create article-tag relationships
192
- * const relDef = {
193
- * through: 'article_tags',
194
- * foreignKey: 'article_id',
195
- * otherKey: 'tag_id'
196
- * };
197
- * const relData = [
198
- * { type: 'tags', id: '10' },
199
- * { type: 'tags', id: '20' },
200
- * { type: 'tags', id: '30' }
201
- * ];
202
- *
203
- * await createPivotRecords(api, '100', relDef, relData, trx);
204
- *
205
- * // Result: 3 new records in article_tags table
206
- * // article_id | tag_id
207
- * // 100 | 10
208
- * // 100 | 20
209
- * // 100 | 30
210
- *
211
- * @example
212
- * // Input: Validation ensures related resources exist
213
- * const relData = [
214
- * { type: 'tags', id: '999' } // Non-existent tag
215
- * ];
216
- *
217
- * try {
218
- * await createPivotRecords(api, '100', relDef, relData, trx);
219
- * } catch (error) {
220
- * console.log(error.message);
221
- * // "Related tags with id 999 not found"
222
- * // Transaction rolled back, no records created
223
- * }
224
- *
225
- * @example
226
- * // Input: Skip validation for performance
227
- * const relDef = {
228
- * through: 'user_permissions',
229
- * foreignKey: 'user_id',
230
- * otherKey: 'permission_id',
231
- * validateExists: false // Skip GET requests
232
- * };
233
- *
234
- * // With 100 permissions, saves 100 GET requests
235
- * await createPivotRecords(api, userId, relDef, permissions, trx);
236
- *
237
- * // Risk: Could create orphaned relationships if permissions don't exist
238
- * // Benefit: Much faster for bulk operations when you trust the data
239
- *
240
- * @description
241
- * Used by:
242
- * - relationship-processor.js for POST operations
243
- * - updateManyToManyRelationship internally for new relationships
244
- * - Any code that needs to create pivot records
245
- *
246
- * Purpose:
247
- * - Creates pivot table records to link resources
248
- * - Validates related resources exist by default (referential integrity)
249
- * - Supports bulk insert for efficiency
250
- * - Works within transactions for atomicity
251
- * - Allows skipping validation when performance matters
252
- *
253
- * Data flow:
254
- * 1. Validates pivot table resource exists
255
- * 2. Gets actual database table name
256
- * 3. Optionally validates each related resource exists (GET requests)
257
- * 4. Prepares bulk insert data with foreign keys
258
- * 5. Performs single INSERT with all records
259
- * 6. Returns (no data returned, throws on error)
260
- */
261
- export const createPivotRecords = async (api, resourceId, relDef, relData, trx, context = {}) => {
262
- relData = normalizeRelationshipIdentifiers(relData, { api })
263
- if (relData.length === 0) return // Early exit if nothing to create
264
-
265
- // Get pivot table info
61
+ const getPivotStorage = (api, relDef) => {
266
62
  const pivotScope = api.resources[relDef.through]
267
- if (!pivotScope) {
268
- throw new Error(`Pivot table resource '${relDef.through}' not found`)
63
+ if (!pivotScope) throw new Error(`Pivot table resource '${relDef.through}' not found`)
64
+ const { schemaInfo } = pivotScope.vars
65
+ return {
66
+ tableName: schemaInfo.tableName || relDef.through,
67
+ idColumn: getIdColumn(schemaInfo),
68
+ foreignKey: getStorageColumn(schemaInfo, relDef.foreignKey),
69
+ otherKey: getStorageColumn(schemaInfo, relDef.otherKey)
269
70
  }
71
+ }
270
72
 
271
- // Get the actual database table name (might be different from scope name)
272
- const tableName = pivotScope.vars.schemaInfo.tableName || relDef.through
273
-
274
- // Validate all related resources exist if needed (do this before any inserts)
275
- if (relDef.validateExists !== false) {
276
- for (const related of relData) {
277
- try {
278
- await api.resources[related.type].get({
279
- id: related.id,
280
- transaction: trx
281
- }, { ...context })
282
- } catch (error) {
283
- throw new RestApiResourceError(
284
- `Related ${related.type} with id ${related.id} not found`,
285
- {
286
- subtype: 'not_found',
287
- resourceType: related.type,
288
- resourceId: related.id
289
- }
290
- )
73
+ // Targets are already authorized and locked. Keep existing pivot rows intact.
74
+ const insertMissingPivotRecords = async (trx, { tableName, idColumn, foreignKey, otherKey }, resourceId, ids) => {
75
+ for (let offset = 0; offset < ids.length; offset += RELATIONSHIP_WRITE_BATCH_SIZE) {
76
+ const batch = ids.slice(offset, offset + RELATIONSHIP_WRITE_BATCH_SIZE)
77
+ const requestedIds = new Set(batch)
78
+ const existingIds = new Set()
79
+ let hasExisting = false
80
+ let afterId
81
+ // A pivot resource can contain duplicate edges; bound returned rows as well as IDs.
82
+ while (true) {
83
+ const query = trx(tableName).where(foreignKey, resourceId).whereIn(otherKey, batch)
84
+ .orderBy(`${tableName}.${idColumn}`).limit(RELATIONSHIP_WRITE_BATCH_SIZE + 1).forUpdate()
85
+ .select({ [idColumn]: databaseIdentityExpression(trx, idColumn), [otherKey]: databaseIdentityExpression(trx, otherKey) })
86
+ if (afterId !== undefined) query.where(idColumn, '>', afterId)
87
+ const existing = await applyDatabaseReadOptions(query)
88
+ hasExisting ||= existing.length > 0
89
+ for (const record of existing) {
90
+ const id = String(record[otherKey])
91
+ if (requestedIds.has(id)) existingIds.add(id)
291
92
  }
93
+ if (existing.length <= RELATIONSHIP_WRITE_BATCH_SIZE) break
94
+ afterId = existing.at(-1)[idColumn]
95
+ }
96
+ const toAdd = []
97
+ for (const id of batch) {
98
+ if (existingIds.has(id)) continue
99
+ // A returned spelling can match another requested ID under the column's collation.
100
+ if (hasExisting && await trx(tableName).where(foreignKey, resourceId).where(otherKey, id).forUpdate().first(otherKey)) continue
101
+ toAdd.push({ [foreignKey]: resourceId, [otherKey]: id })
292
102
  }
103
+ if (toAdd.length) await trx(tableName).insert(toAdd)
293
104
  }
105
+ }
294
106
 
295
- // Prepare records for bulk insert
296
- const recordsToInsert = relData.map(related => ({
297
- [relDef.foreignKey]: resourceId,
298
- [relDef.otherKey]: related.id
299
- }))
107
+ // The resource operation authorizes targets before calling these storage writers.
108
+ export const updateManyToManyRelationship = async (scope, { api, context }) => {
109
+ const { resourceId, relDef, transaction: trx } = context
110
+ assertVersionedRelationshipTransaction(api, relDef, trx)
111
+ const relData = normalizeRelationshipIdentifiers(context.relData || [], { api })
112
+ const pivot = getPivotStorage(api, relDef)
113
+ const targets = await lockRelationshipTargets(api, trx, relData)
114
+ const ids = [...new Set(targets.map(record => String(record.id)))]
115
+ // Negate the complete keep-list once; independent batch deletions lose wanted links.
116
+ const removals = trx(pivot.tableName).where(pivot.foreignKey, resourceId)
117
+ .whereNot(query => whereInIdentifiers(query, pivot.otherKey, ids))
118
+ await invalidateRemovedPivotVersions(api, relDef, removals, trx)
119
+ await removals.delete()
120
+ await insertMissingPivotRecords(trx, pivot, resourceId, ids)
121
+ await invalidateManyToManyVersions(api, relDef, ids, trx)
122
+ }
300
123
 
301
- // Bulk insert all pivot records in a single query
302
- await trx(tableName).insert(recordsToInsert)
124
+ export const createPivotRecords = async (api, resourceId, relDef, relData, trx) => {
125
+ resourceId = String(resourceId)
126
+ relData = [...new Map(normalizeRelationshipIdentifiers(relData, { api }).map(record => [record.id, record])).values()]
127
+ if (relData.length === 0) return
128
+ assertVersionedRelationshipTransaction(api, relDef, trx)
129
+ const pivot = getPivotStorage(api, relDef)
130
+ const targets = await lockRelationshipTargets(api, trx, relData)
131
+ await insertMissingPivotRecords(trx, pivot, resourceId, targets.map(record => record.id))
132
+ await invalidateManyToManyVersions(api, relDef, targets.map(record => record.id), trx)
303
133
  }