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,162 +1,91 @@
1
- import { RestApiValidationError } from '../../../../lib/rest-api-errors.js'
1
+ import { RestApiResourceError, RestApiValidationError } from '../../../../lib/rest-api-errors.js'
2
2
  import { validateRelationshipDataCardinality } from '../querying-writing/relationship-contracts.js'
3
+ import { RELATIONSHIP_WRITE_BATCH_SIZE } from '../querying-writing/knex-constants.js'
4
+ import { applyDatabaseReadOptions, databaseIdentityExpression } from '../querying-writing/database-value-normalizers.js'
5
+
6
+ export const lockRelationshipParent = async ({ context, helpers, scopeName }) => {
7
+ if (!context.transaction) return
8
+ const adapter = helpers.getStorageAdapter(scopeName)
9
+ const idColumn = adapter.getIdColumn()
10
+ const parent = () => adapter.buildBaseQuery({ transaction: context.transaction })
11
+ .where(idColumn, adapter.translateFilterValue('id', context.id))
12
+ // A row version change also rejects stale PostgreSQL repeatable-read writers.
13
+ const matched = await parent().update({ [idColumn]: context.transaction.ref(idColumn) })
14
+ // MySQL can report changed rows rather than matched rows when FOUND_ROWS is disabled.
15
+ if (!matched && !await parent().select(idColumn).forUpdate().first()) {
16
+ throw new RestApiResourceError('Resource not found', { subtype: 'not_found' })
17
+ }
18
+ }
19
+
20
+ export const lockRelationshipTargets = async (api, transaction, identifiers) => {
21
+ if (!transaction) return identifiers
22
+ const targets = []
23
+ const resolved = []
24
+ const resources = new Map()
25
+ for (const { type, id } of identifiers) {
26
+ if (!resources.has(type)) resources.set(type, { adapter: api.knex.helpers.getStorageAdapter(type), seen: new Set(), resolved: new Set() })
27
+ const { adapter, seen } = resources.get(type)
28
+ const value = adapter.translateFilterValue('id', id)
29
+ if (seen.has(String(value))) continue
30
+ seen.add(String(value))
31
+ targets.push({ type, id, value, adapter })
32
+ }
33
+ for (let offset = 0; offset < targets.length; offset += RELATIONSHIP_WRITE_BATCH_SIZE) {
34
+ const batch = targets.slice(offset, offset + RELATIONSHIP_WRITE_BATCH_SIZE)
35
+ const byType = new Map()
36
+ for (const target of batch) {
37
+ if (!byType.has(target.type)) byType.set(target.type, [])
38
+ byType.get(target.type).push(target)
39
+ }
40
+ const found = new Map()
41
+ for (const [type, group] of byType) {
42
+ const { adapter } = group[0]
43
+ const idColumn = adapter.getIdColumn()
44
+ const rows = await applyDatabaseReadOptions(adapter.buildBaseQuery({ transaction })
45
+ .whereIn(idColumn, group.map(target => target.value))
46
+ .orderBy(`${adapter.getTableName()}.${idColumn}`).forUpdate().select({ [idColumn]: databaseIdentityExpression(transaction, idColumn) }))
47
+ found.set(type, new Set(rows.map(row => String(row[idColumn]))))
48
+ }
49
+ for (const { type, id, value, adapter } of batch) {
50
+ let storedId = String(value)
51
+ // Let the database resolve alternate spellings under its column collation.
52
+ if (!found.get(type).has(storedId)) {
53
+ const record = await applyDatabaseReadOptions(adapter.buildBaseQuery({ transaction })
54
+ .where(adapter.getIdColumn(), value).forUpdate().first({ [adapter.getIdColumn()]: databaseIdentityExpression(transaction, adapter.getIdColumn()) }))
55
+ if (!record) {
56
+ throw new RestApiResourceError(`Related ${type} with id ${id} not found`, {
57
+ subtype: 'not_found', resourceType: type, resourceId: id
58
+ })
59
+ }
60
+ storedId = String(record[adapter.getIdColumn()])
61
+ }
62
+ const identities = resources.get(type).resolved
63
+ if (!identities.has(storedId)) {
64
+ identities.add(storedId)
65
+ resolved.push({ type, id })
66
+ }
67
+ }
68
+ }
69
+ // Return each database identity once, retaining its first submitted spelling.
70
+ return resolved
71
+ }
3
72
 
4
73
  /**
5
- * Processes JSON:API relationship data and converts to database operations
6
- *
7
- * @param {Object} scope - The scope object containing schema info
8
- * @param {Object} deps - Dependencies object
9
- * @returns {Object} Object with belongsToUpdates and manyToManyRelationships arrays
10
- *
11
- * @example
12
- * // Input: Simple belongsTo relationship
13
- * const inputRecord = {
14
- * data: {
15
- * type: 'articles',
16
- * attributes: { title: 'My Article' },
17
- * relationships: {
18
- * author: {
19
- * data: { type: 'users', id: '123' }
20
- * },
21
- * category: {
22
- * data: { type: 'categories', id: '5' }
23
- * }
24
- * }
25
- * }
26
- * };
27
- *
28
- * // Schema has:
29
- * // author_id: { belongsTo: 'users', as: 'author' }
30
- * // category_id: { belongsTo: 'categories', as: 'category' }
31
- *
32
- * const result = processRelationships(scope, { context: { inputRecord } });
33
- *
34
- * // Output: Foreign keys extracted
35
- * // {
36
- * // belongsToUpdates: {
37
- * // author_id: '123',
38
- * // category_id: '5'
39
- * // },
40
- * // manyToManyRelationships: []
41
- * // }
42
- *
43
- * @example
44
- * // Input: Polymorphic relationship
45
- * const inputRecord = {
46
- * data: {
47
- * type: 'comments',
48
- * relationships: {
49
- * commentable: {
50
- * data: { type: 'posts', id: '456' }
51
- * }
52
- * }
53
- * }
54
- * };
55
- *
56
- * // Schema relationships has:
57
- * // commentable: {
58
- * // belongsToPolymorphic: {
59
- * // types: ['posts', 'videos'],
60
- * // typeField: 'commentable_type',
61
- * // idField: 'commentable_id'
62
- * // }
63
- * // }
64
- *
65
- * const result = processRelationships(scope, { context: { inputRecord } });
66
- *
67
- * // Output: Both type and id fields set
68
- * // {
69
- * // belongsToUpdates: {
70
- * // commentable_type: 'posts',
71
- * // commentable_id: '456'
72
- * // },
73
- * // manyToManyRelationships: []
74
- * // }
75
- *
76
- * @example
77
- * // Input: Many-to-many relationship
78
- * const inputRecord = {
79
- * data: {
80
- * type: 'articles',
81
- * relationships: {
82
- * tags: {
83
- * data: [
84
- * { type: 'tags', id: '10' },
85
- * { type: 'tags', id: '20' }
86
- * ]
87
- * }
88
- * }
89
- * }
90
- * };
91
- *
92
- * // Schema relationships has:
93
- * // tags: {
94
- * // type: 'manyToMany',
95
- * // through: 'article_tags',
96
- * // foreignKey: 'article_id',
97
- * // otherKey: 'tag_id'
98
- * // }
99
- *
100
- * const result = processRelationships(scope, { context: { inputRecord } });
101
- *
102
- * // Output: Many-to-many data collected
103
- * // {
104
- * // belongsToUpdates: {},
105
- * // manyToManyRelationships: [{
106
- * // relName: 'tags',
107
- * // relDef: {
108
- * // through: 'article_tags',
109
- * // foreignKey: 'article_id',
110
- * // otherKey: 'tag_id'
111
- * // },
112
- * // relData: [
113
- * // { type: 'tags', id: '10' },
114
- * // { type: 'tags', id: '20' }
115
- * // ]
116
- * // }]
117
- * // }
118
- *
74
+ * Classify normalized linkage for the resource write lifecycle.
75
+ * Validates cardinality and polymorphic target types, then separates owner
76
+ * columns, referenced targets, pivot writes and reverse child writes. This
77
+ * helper prepares work; it does not write storage or establish authorization.
78
+ *
79
+ * @param {Object} scope - Resource with compiled schemaInfo in vars
80
+ * @param {Object} deps
81
+ * @param {Object} deps.context
82
+ * @param {Object} deps.context.inputRecord - Normalized JSON:API write document
83
+ * @returns {import('../../rest-api-plugin-methods/lifecycle-types.js').RelationshipPlan} belongsToUpdates map plus belongsToTargets,
84
+ * manyToManyRelationships and reverseRelationships arrays
119
85
  * @example
120
- * // Input: Clearing relationships
121
- * const inputRecord = {
122
- * data: {
123
- * relationships: {
124
- * author: { data: null }, // Clear belongsTo
125
- * tags: { data: [] } // Clear many-to-many
126
- * }
127
- * }
128
- * };
129
- *
130
- * const result = processRelationships(scope, { context: { inputRecord } });
131
- *
132
- * // Output: Null for belongsTo, empty array for many-to-many
133
- * // {
134
- * // belongsToUpdates: { author_id: null },
135
- * // manyToManyRelationships: [{
136
- * // relName: 'tags',
137
- * // relDef: { ... },
138
- * // relData: [] // Will delete all pivot records
139
- * // }]
140
- * // }
141
- *
142
- * @description
143
- * Used by:
144
- * - rest-api-knex-plugin's dataPut and dataPatch methods
145
- * - Called before database writes to prepare relationship updates
146
- *
147
- * Purpose:
148
- * - Converts JSON:API relationship format to database operations
149
- * - Handles all relationship types: belongsTo, polymorphic, many-to-many
150
- * - Validates polymorphic types against allowed values
151
- * - Separates concerns: foreign keys vs pivot table operations
152
- *
153
- * Data flow:
154
- * 1. Receives JSON:API document with relationships section
155
- * 2. Analyzes schema to determine relationship types
156
- * 3. For belongsTo: extracts foreign key values
157
- * 4. For polymorphic: extracts both type and id fields
158
- * 5. For many-to-many: collects data for pivot operations
159
- * 6. Returns structured data for database updates
86
+ * // An explicit author clear produces belongsToUpdates.author_id = null.
87
+ * // An explicit empty tags array produces a pivot operation with relData: [].
88
+ * // Omitting relationships produces empty work collections, not implicit clears.
160
89
  */
161
90
  export const processRelationships = (scope, deps) => {
162
91
  // Extract values from scope
@@ -170,10 +99,12 @@ export const processRelationships = (scope, deps) => {
170
99
  const { context } = deps
171
100
  const { inputRecord } = context
172
101
  const belongsToUpdates = {}
102
+ const belongsToTargets = []
173
103
  const manyToManyRelationships = []
104
+ const reverseRelationships = []
174
105
 
175
106
  if (!inputRecord.data.relationships) {
176
- return { belongsToUpdates, manyToManyRelationships }
107
+ return { belongsToUpdates, belongsToTargets, manyToManyRelationships, reverseRelationships }
177
108
  }
178
109
 
179
110
  for (const [relName, relData] of Object.entries(inputRecord.data.relationships)) {
@@ -201,6 +132,7 @@ export const processRelationships = (scope, deps) => {
201
132
  belongsToUpdates[fieldName] = null
202
133
  } else if (relData.data?.id) {
203
134
  belongsToUpdates[fieldName] = relData.data.id
135
+ belongsToTargets.push(relData.data)
204
136
  }
205
137
  }
206
138
  }
@@ -232,6 +164,7 @@ export const processRelationships = (scope, deps) => {
232
164
 
233
165
  belongsToUpdates[typeField] = type
234
166
  belongsToUpdates[idField] = id
167
+ belongsToTargets.push(relData.data)
235
168
  }
236
169
  }
237
170
 
@@ -240,6 +173,7 @@ export const processRelationships = (scope, deps) => {
240
173
  manyToManyRelationships.push({
241
174
  relName,
242
175
  relDef: {
176
+ target: relDef.target,
243
177
  through: relDef.through,
244
178
  foreignKey: relDef.foreignKey,
245
179
  otherKey: relDef.otherKey
@@ -247,8 +181,10 @@ export const processRelationships = (scope, deps) => {
247
181
  relData: relData.data
248
182
  })
249
183
  }
250
- // Note: hasOne and hasMany don't need processing here as they don't update the current record
184
+ if ((relDef?.type === 'hasOne' || relDef?.type === 'hasMany') && relData.data !== undefined) {
185
+ reverseRelationships.push({ relName, relDef, relData: relData.data })
186
+ }
251
187
  }
252
188
 
253
- return { belongsToUpdates, manyToManyRelationships }
189
+ return { belongsToUpdates, belongsToTargets, manyToManyRelationships, reverseRelationships }
254
190
  }
@@ -0,0 +1,172 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { RestApiResourceError, RestApiValidationError, RestApiVersionConflictError } from '../../../../lib/rest-api-errors.js'
3
+ import { applyDatabaseReadOptions, databaseIdentityExpression } from '../querying-writing/database-value-normalizers.js'
4
+ import { RELATIONSHIP_WRITE_BATCH_SIZE } from '../querying-writing/knex-constants.js'
5
+
6
+ export function hasVersionedInverse (api, relDef) {
7
+ const schemaInfo = api.resources[relDef.target]?.vars.schemaInfo
8
+ return Boolean(schemaInfo?.versionField && Object.values(schemaInfo.schemaRelationships).some(inverse =>
9
+ inverse.through === relDef.through && inverse.foreignKey === relDef.otherKey && inverse.otherKey === relDef.foreignKey))
10
+ }
11
+
12
+ export function assertVersionedRelationshipTransaction (api, relDef, transaction) {
13
+ if (hasVersionedInverse(api, relDef) && (!transaction?.isTransaction || transaction.isCompleted())) {
14
+ throw new RestApiValidationError('Versioned relationship writes require an active transaction', { fields: ['transaction'] })
15
+ }
16
+ }
17
+
18
+ export async function invalidateManyToManyVersions (api, relDef, ids, transaction) {
19
+ if (!hasVersionedInverse(api, relDef) || !ids.length) return
20
+ const adapter = api.knex.helpers.getStorageAdapter(relDef.target)
21
+ const idColumn = adapter.getIdColumn()
22
+ const versionColumn = adapter.translateColumn(api.resources[relDef.target].vars.schemaInfo.versionField)
23
+ const values = [...new Set(ids.map(id => adapter.translateFilterValue('id', id)))]
24
+ for (let offset = 0; offset < values.length; offset += RELATIONSHIP_WRITE_BATCH_SIZE) {
25
+ const batch = values.slice(offset, offset + RELATIONSHIP_WRITE_BATCH_SIZE)
26
+ const cases = []
27
+ const bindings = [idColumn]
28
+ for (const id of batch) {
29
+ cases.push('WHEN ? THEN ?')
30
+ bindings.push(id, randomUUID())
31
+ }
32
+ bindings.push(versionColumn)
33
+ const revisions = transaction.raw(`CASE ?? ${cases.join(' ')} ELSE ?? END`, bindings)
34
+ await adapter.buildBaseQuery({ transaction }).whereIn(idColumn, batch).update({ [versionColumn]: revisions })
35
+ }
36
+ }
37
+
38
+ async function readInverseReferences (state, context) {
39
+ const { adapter, fields } = state
40
+ const selectedFields = []
41
+ for (const field of fields) {
42
+ const column = adapter.translateColumn(field)
43
+ selectedFields.push([field, databaseIdentityExpression(context.transaction, column)])
44
+ }
45
+ const selection = Object.fromEntries(selectedFields)
46
+ return applyDatabaseReadOptions(adapter.buildBaseQuery({ transaction: context.transaction })
47
+ .where(adapter.getIdColumn(), adapter.translateFilterValue('id', context.id)).forUpdate().first(selection))
48
+ }
49
+
50
+ // Capture actual stored references after authorization; hooks/setters may change the input.
51
+ export async function captureInverseVersions ({ api, helpers, context, scopeName, isCreate = false }) {
52
+ const references = []
53
+ for (const [parentType, parent] of Object.entries(api.resources)) {
54
+ const schemaInfo = parent.vars.schemaInfo
55
+ if (!schemaInfo?.versionField) continue
56
+ for (const relationship of Object.values(schemaInfo.schemaRelationships)) {
57
+ if (!api.anyapi && relationship.type === 'manyToMany' && relationship.through === scopeName) {
58
+ references.push({ parentType, idField: relationship.foreignKey, valueField: relationship.otherKey })
59
+ continue
60
+ }
61
+ if (!['hasMany', 'hasOne'].includes(relationship.type) || relationship.target !== scopeName) continue
62
+ if (relationship.via) {
63
+ const inverse = context.schemaInfo.schemaRelationships[relationship.via]?.belongsToPolymorphic
64
+ if (inverse?.types.includes(parentType)) references.push({ parentType, idField: inverse.idField, typeField: inverse.typeField })
65
+ } else if (relationship.foreignKey) references.push({ parentType, idField: relationship.foreignKey })
66
+ }
67
+ }
68
+ if (!references.length) return undefined
69
+ const fields = new Set()
70
+ for (const { idField, typeField, valueField } of references) {
71
+ if (idField) fields.add(idField)
72
+ if (typeField) fields.add(typeField)
73
+ if (valueField) fields.add(valueField)
74
+ }
75
+ const state = { references, fields: [...fields], adapter: helpers.getStorageAdapter(scopeName) }
76
+ state.before = isCreate ? undefined : await readInverseReferences(state, context)
77
+ return state
78
+ }
79
+
80
+ export async function invalidateInverseVersions ({ state, context, helpers, api, isDelete = false }) {
81
+ if (!state) return
82
+ const after = isDelete ? undefined : await readInverseReferences(state, context)
83
+ const targets = new Map()
84
+ for (const { parentType, idField, typeField, valueField } of state.references) {
85
+ const readParentId = record => {
86
+ if (!record) return null
87
+ if (typeField && record[typeField] !== parentType) return null
88
+ return record[idField]
89
+ }
90
+ const oldId = readParentId(state.before)
91
+ const newId = readParentId(after)
92
+ const valueUnchanged = !valueField || state.before?.[valueField] === after?.[valueField]
93
+ if (oldId === newId && valueUnchanged) continue
94
+ for (const id of [oldId, newId]) {
95
+ if (id !== null && id !== undefined) targets.set(JSON.stringify([parentType, id]), { parentType, id })
96
+ }
97
+ }
98
+ // Update parents in a stable order so concurrent writes acquire locks in the same order.
99
+ const orderedTargets = [...targets].sort(([left], [right]) => left.localeCompare(right))
100
+ for (const [, { parentType, id }] of orderedTargets) {
101
+ const adapter = helpers.getStorageAdapter(parentType)
102
+ const field = api.resources[parentType].vars.schemaInfo.versionField
103
+ // An old dangling reference or a parent deleted in this transaction needs no revision.
104
+ await adapter.buildBaseQuery({ transaction: context.transaction })
105
+ .where(adapter.getIdColumn(), adapter.translateFilterValue('id', id))
106
+ .update({ [adapter.translateColumn(field)]: randomUUID() })
107
+ }
108
+ }
109
+
110
+ // Called after resource authorization, within the transaction containing the write.
111
+ export async function advanceResourceVersion ({ scopeName, context, helpers, expectedVersion, nextVersion = randomUUID() }) {
112
+ const field = context.schemaInfo.versionField
113
+ if (expectedVersion !== undefined && (!field || typeof expectedVersion !== 'string' || !expectedVersion.length || expectedVersion.length > 128)) {
114
+ throw new RestApiValidationError('expectedVersion requires a versioned resource and a non-empty token of at most 128 characters', { fields: ['expectedVersion'] })
115
+ }
116
+ if (!field) return undefined
117
+ if (!context.transaction) throw new Error('Versioned writes require a transaction')
118
+
119
+ const adapter = helpers.getStorageAdapter(scopeName)
120
+ const column = adapter.translateColumn(field)
121
+ const query = adapter.buildBaseQuery({ transaction: context.transaction })
122
+ .where(adapter.getIdColumn(), adapter.translateFilterValue('id', context.id))
123
+ if (expectedVersion !== undefined) {
124
+ // Revision tokens use byte equality, independent of column collation.
125
+ const client = context.transaction.client.config.client
126
+ if (client === 'pg') {
127
+ query.whereRaw("convert_to(??, 'UTF8') = convert_to(?, 'UTF8')", [column, expectedVersion])
128
+ } else if (client === 'mysql2' || client === 'mysql') {
129
+ query.whereRaw('CAST(?? AS BINARY) = CAST(? AS BINARY)', [column, expectedVersion])
130
+ } else if (client === 'better-sqlite3' || client === 'sqlite3') {
131
+ query.whereRaw('CAST(?? AS BLOB) = CAST(? AS BLOB)', [column, expectedVersion])
132
+ } else {
133
+ throw new Error(`Conditional revisions are not implemented for database client '${client}'`)
134
+ }
135
+ }
136
+ const matched = await query.update({ [column]: nextVersion })
137
+ if (!matched) {
138
+ throw new RestApiVersionConflictError({ resourceType: scopeName, resourceId: context.id })
139
+ }
140
+ return nextVersion
141
+ }
142
+
143
+ export function initializeResourceVersion ({ context, expectedVersion }) {
144
+ const field = context.schemaInfo.versionField
145
+ if (expectedVersion !== undefined && (!field || typeof expectedVersion !== 'string' || !expectedVersion.length || expectedVersion.length > 128 || context.method === 'post')) {
146
+ throw new RestApiValidationError('expectedVersion requires an existing versioned resource and a non-empty token of at most 128 characters', { fields: ['expectedVersion'] })
147
+ }
148
+ if (!field) return undefined
149
+ const attributes = context.inputRecord?.data?.attributes
150
+ if (context.method !== 'delete' && attributes && Object.hasOwn(attributes, field)) {
151
+ throw new RestApiValidationError('The version field is managed by the library; use expectedVersion', { fields: [`data.attributes.${field}`] })
152
+ }
153
+ const state = { field, expectedVersion, nextVersion: randomUUID() }
154
+ const data = context.inputRecord?.data
155
+ if (context.method !== 'delete' && data && typeof data === 'object' && !Array.isArray(data) &&
156
+ (attributes === undefined || (attributes && typeof attributes === 'object' && !Array.isArray(attributes)))) {
157
+ context.inputRecord = { ...context.inputRecord, data: { ...data, attributes: { ...attributes, [field]: state.nextVersion } } }
158
+ }
159
+ return state
160
+ }
161
+
162
+ export async function applyResourceVersion ({ state, context, helpers, scopeName, isCreate = false }) {
163
+ if (!state) return
164
+ if (isCreate) {
165
+ if (state.expectedVersion !== undefined) {
166
+ throw new RestApiResourceError(`Resource not found: ${scopeName}/${context.id}`, { subtype: 'not_found' })
167
+ }
168
+ } else {
169
+ await advanceResourceVersion({ scopeName, context, helpers, expectedVersion: state.expectedVersion, nextVersion: state.nextVersion })
170
+ }
171
+ if (context.method !== 'delete') context.inputRecord.data.attributes[state.field] = state.nextVersion
172
+ }
@@ -0,0 +1,110 @@
1
+ import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
2
+ import { applyDatabaseReadOptions, databaseIdentityExpression } from '../querying-writing/database-value-normalizers.js'
3
+ import { RELATIONSHIP_WRITE_BATCH_SIZE } from '../querying-writing/knex-constants.js'
4
+ import { whereInIdentifiers } from '../querying/identifier-query.js'
5
+ import { lockRelationshipTargets } from './relationship-processor.js'
6
+
7
+ // Read membership in bounded batches; each actual child write still goes through PATCH.
8
+ export async function updateReverseRelationship ({ api, helpers, context, scopeName, relDef, relData, operation = 'replace' }) {
9
+ const targetType = relDef.target
10
+ const targetScope = api.resources[targetType]
11
+ if (!targetScope) {
12
+ throw new RestApiResourceError(`Related resource type '${targetType}' not found`, { subtype: 'related_type_not_found' })
13
+ }
14
+ const schemaInfo = targetScope.vars.schemaInfo
15
+ let inverseName
16
+ let parentValues
17
+ if (relDef.via) {
18
+ const inverse = schemaInfo.schemaRelationships[relDef.via]?.belongsToPolymorphic
19
+ if (!inverse || !inverse.types.includes(scopeName)) {
20
+ throw new RestApiResourceError(`Invalid reverse polymorphic relationship '${relDef.via}' on '${targetType}'`, { subtype: 'invalid_via_relationship' })
21
+ }
22
+ inverseName = relDef.via
23
+ parentValues = { [inverse.typeField]: scopeName, [inverse.idField]: context.id }
24
+ } else {
25
+ const field = schemaInfo.schemaStructure[relDef.foreignKey]
26
+ if (!field) {
27
+ throw new RestApiResourceError(`Foreign key '${relDef.foreignKey}' not found on '${targetType}'`, { subtype: 'invalid_foreign_key' })
28
+ }
29
+ inverseName = field.belongsTo === scopeName ? field.as : undefined
30
+ parentValues = { [relDef.foreignKey]: context.id }
31
+ }
32
+
33
+ const adapter = helpers.getStorageAdapter(targetType)
34
+ const query = adapter.buildBaseQuery({ transaction: context.transaction })
35
+ for (const [field, value] of Object.entries(parentValues)) {
36
+ query.where(adapter.translateColumn(field), adapter.translateFilterValue(field, value))
37
+ }
38
+ if (context.transaction) query.forUpdate()
39
+ const idColumn = adapter.getIdColumn()
40
+ const selection = { [idColumn]: databaseIdentityExpression(context.db || api.knex.instance, idColumn) }
41
+ let identifiers = []
42
+ if (Array.isArray(relData)) {
43
+ identifiers = relData
44
+ } else if (relData !== null) {
45
+ identifiers = [relData]
46
+ }
47
+ const targets = operation === 'remove' ? identifiers : await lockRelationshipTargets(api, context.transaction, identifiers)
48
+ const requested = new Map()
49
+ for (const { id } of targets) {
50
+ const value = adapter.translateFilterValue('id', id)
51
+ if (!requested.has(String(value))) requested.set(String(value), { id: String(id), value })
52
+ }
53
+ const requestedIds = [...requested.values()]
54
+
55
+ const patchChild = async (id, linked) => {
56
+ const data = { type: targetType, id }
57
+ if (inverseName) {
58
+ data.relationships = { [inverseName]: { data: linked ? { type: scopeName, id: String(context.id) } : null } }
59
+ } else {
60
+ data.attributes = Object.fromEntries(Object.entries(parentValues).map(([field, value]) => [field, linked ? value : null]))
61
+ }
62
+ try {
63
+ await targetScope.patch({
64
+ id, document: { data }, transaction: context.transaction, format: 'jsonapi', returning: 'none'
65
+ }, { ...context })
66
+ } catch (error) {
67
+ if (!linked && operation === 'replace' && error.code === 'REST_API_RESOURCE' && ['not_found', 'forbidden'].includes(error.subtype)) {
68
+ const rejection = new RestApiResourceError('Cannot replace a relationship with inaccessible existing members', { subtype: 'forbidden' })
69
+ rejection.cause = error
70
+ throw rejection
71
+ }
72
+ throw error
73
+ }
74
+ }
75
+
76
+ if (operation === 'replace') {
77
+ // Exclude the whole keep-list once; independent exclusions would remove retained children.
78
+ const removals = query.clone().whereNot(builder => whereInIdentifiers(builder, idColumn, requestedIds.map(({ value }) => value)))
79
+ let afterId
80
+ while (true) {
81
+ const page = removals.clone().orderBy(`${adapter.getTableName()}.${idColumn}`).limit(RELATIONSHIP_WRITE_BATCH_SIZE + 1).select(selection)
82
+ if (afterId !== undefined) page.where(idColumn, '>', afterId)
83
+ const rows = await applyDatabaseReadOptions(page)
84
+ for (const row of rows) await patchChild(String(adapter.getFieldValue(row, 'id')), false)
85
+ if (rows.length <= RELATIONSHIP_WRITE_BATCH_SIZE) break
86
+ afterId = rows.at(-1)[idColumn]
87
+ }
88
+ }
89
+
90
+ // Free all removed hasOne slots before assigning replacements.
91
+ for (let offset = 0; offset < requestedIds.length; offset += RELATIONSHIP_WRITE_BATCH_SIZE) {
92
+ const batch = requestedIds.slice(offset, offset + RELATIONSHIP_WRITE_BATCH_SIZE)
93
+ const rows = await applyDatabaseReadOptions(query.clone().whereIn(idColumn, batch.map(({ value }) => value)).select(selection))
94
+ const existing = new Map(rows.map(row => [String(row[idColumn]), row]))
95
+ for (const { id, value } of batch) {
96
+ // Resolve alternate ID spellings using the database's equality rules.
97
+ let record = existing.get(String(value))
98
+ if (!record && existing.size) {
99
+ const lookup = query.clone().where(idColumn, value).first(selection)
100
+ record = await applyDatabaseReadOptions(lookup)
101
+ }
102
+ if (operation === 'remove') {
103
+ if (record) {
104
+ await patchChild(id, false)
105
+ existing.delete(String(record[idColumn]))
106
+ }
107
+ } else if (!record) await patchChild(id, true)
108
+ }
109
+ }
110
+ }