json-rest-api 1.0.29 → 2.1.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 (294) 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 +733 -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 +103 -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 +3838 -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 +37 -0
  44. package/index.js +3 -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 +70 -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 +586 -418
  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 +120 -56
  71. package/plugins/core/lib/anyapi/stored-definition.js +109 -0
  72. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
  73. package/plugins/core/lib/dbIntrospection.js +228 -21
  74. package/plugins/core/lib/dbTablesOperations.js +409 -92
  75. package/plugins/core/lib/fractional-positioning.js +12 -48
  76. package/plugins/core/lib/querying/identifier-query.js +19 -0
  77. package/plugins/core/lib/querying/include-query-helpers.js +364 -0
  78. package/plugins/core/lib/querying/include-to-many.js +428 -0
  79. package/plugins/core/lib/querying/include-to-one.js +579 -0
  80. package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
  81. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
  82. package/plugins/core/lib/querying/knex-pagination-helpers.js +48 -317
  83. package/plugins/core/lib/querying/knex-process-includes.js +5 -110
  84. package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
  85. package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
  86. package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
  87. package/plugins/core/lib/querying/knex-window-queries.js +62 -253
  88. package/plugins/core/lib/querying/query-builder-utils.js +44 -4
  89. package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
  90. package/plugins/core/lib/querying/query-constraint.js +27 -0
  91. package/plugins/core/lib/querying/query-field-sort-helpers.js +134 -37
  92. package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
  93. package/plugins/core/lib/querying/sort-helpers.js +13 -1
  94. package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
  95. package/plugins/core/lib/querying/url-helpers.js +11 -4
  96. package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
  97. package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
  98. package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
  99. package/plugins/core/lib/querying-writing/database-value-normalizers.js +179 -194
  100. package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
  101. package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
  102. package/plugins/core/lib/querying-writing/field-utils.js +113 -92
  103. package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
  104. package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
  105. package/plugins/core/lib/querying-writing/knex-field-helpers.js +100 -290
  106. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
  107. package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
  108. package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
  109. package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
  110. package/plugins/core/lib/querying-writing/request-contracts.js +93 -38
  111. package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
  112. package/plugins/core/lib/querying-writing/response-options.js +35 -0
  113. package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
  114. package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
  115. package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
  116. package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
  117. package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
  118. package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
  119. package/plugins/core/lib/storage/storage-adapter.js +128 -62
  120. package/plugins/core/lib/storage/storage-mapping.js +78 -28
  121. package/plugins/core/lib/storage/storage-types.d.ts +502 -0
  122. package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
  123. package/plugins/core/lib/writing/relationship-processor.js +94 -158
  124. package/plugins/core/lib/writing/resource-version.js +172 -0
  125. package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
  126. package/plugins/core/rest-api-anyapi-knex-plugin.js +290 -2475
  127. package/plugins/core/rest-api-autofilter-plugin.js +17 -19
  128. package/plugins/core/rest-api-cors-plugin.js +52 -205
  129. package/plugins/core/rest-api-knex-plugin.js +45 -1077
  130. package/plugins/core/rest-api-label-plugin.js +25 -38
  131. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
  132. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
  133. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
  134. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
  135. package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
  136. package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
  137. package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
  138. package/plugins/core/rest-api-plugin-methods/common.js +291 -395
  139. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
  140. package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
  141. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -148
  142. package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
  143. package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
  144. package/plugins/core/rest-api-plugin-methods/get.js +36 -68
  145. package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
  146. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
  147. package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
  148. package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
  149. package/plugins/core/rest-api-plugin-methods/post.js +51 -51
  150. package/plugins/core/rest-api-plugin-methods/put.js +69 -148
  151. package/plugins/core/rest-api-plugin-methods/query.js +37 -81
  152. package/plugins/core/rest-api-plugin-methods/release.js +2 -1
  153. package/plugins/core/rest-api-plugin.js +32 -46
  154. package/plugins/core/rest-api-positioning-plugin.js +183 -628
  155. package/plugins/core/rest-api-query-projections-plugin.js +19 -102
  156. package/plugins/core/rest-api-row-policy-plugin.js +3 -3
  157. package/plugins/core/socketio-plugin.js +364 -344
  158. package/plugins/storage/local-storage.js +40 -36
  159. package/types/anyapi.d.ts +30 -0
  160. package/types/autofilter.d.ts +57 -0
  161. package/types/bulk-methods.d.ts +63 -0
  162. package/types/errors.d.ts +118 -0
  163. package/types/fastify-options.d.ts +19 -0
  164. package/types/file-detectors.d.ts +33 -0
  165. package/types/file-storage.d.ts +48 -0
  166. package/types/hook-context.d.ts +180 -0
  167. package/types/plugin-options.d.ts +65 -0
  168. package/types/relationship-methods.d.ts +74 -0
  169. package/types/representations.d.ts +50 -0
  170. package/types/resource-methods.d.ts +95 -0
  171. package/types/resource-schema.d.ts +157 -0
  172. package/types/row-policy.d.ts +31 -0
  173. package/types/runtime.d.ts +132 -0
  174. package/types/transactions.d.ts +21 -0
  175. package/.claude/agents/library-guide-expert.md +0 -30
  176. package/.claude/agents/rest-api-internals-expert.md +0 -54
  177. package/.claude/settings.local.json +0 -70
  178. package/AGENTS.md +0 -34
  179. package/CLAUDE.md +0 -160
  180. package/CRUSH.md +0 -55
  181. package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
  182. package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
  183. package/TODO.todo +0 -13
  184. package/connectors_ideas/fastify.js +0 -217
  185. package/connectors_ideas/koa-plugin.js +0 -112
  186. package/docs/.bundle/config +0 -2
  187. package/docs/COMPARISON.md +0 -433
  188. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
  189. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
  190. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
  191. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
  192. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
  193. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
  194. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
  195. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1247
  196. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
  197. package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
  198. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
  199. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
  200. package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
  201. package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
  202. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
  203. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
  204. package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
  205. package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
  206. package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
  207. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
  208. package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
  209. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
  210. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
  211. package/docs/Gemfile +0 -6
  212. package/docs/Gemfile.lock +0 -308
  213. package/docs/ONBOARDING.md +0 -315
  214. package/docs/_config.yml +0 -36
  215. package/docs/_layouts/default.html +0 -123
  216. package/docs/apple-touch-icon.svg +0 -4
  217. package/docs/assets/css/style.scss +0 -392
  218. package/docs/favicon.svg +0 -4
  219. package/docs/index.md +0 -164
  220. package/docs/mini-guides/file-uploads-complete.md +0 -161
  221. package/eslint.config.js +0 -13
  222. package/examples/url-override-example.js +0 -137
  223. package/fixing_plan.md +0 -112
  224. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
  225. package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
  226. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
  227. package/plugins/storage/s3-storage.js +0 -93
  228. package/quickTest.js +0 -136
  229. package/scripts/debug-anyapi-review-filter.mjs +0 -63
  230. package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
  231. package/scripts/inspect-anyapi-descriptor.mjs +0 -19
  232. package/scripts/run-docs.js +0 -133
  233. package/test-debug.js +0 -59
  234. package/tests/README.md +0 -202
  235. package/tests/TEST_TEMPLATE.test.js +0 -178
  236. package/tests/anyapi-basic.test.js +0 -105
  237. package/tests/anyapi-cursor-pagination.test.js +0 -148
  238. package/tests/anyapi-custom-idproperty.test.js +0 -163
  239. package/tests/autofilter.test.js +0 -463
  240. package/tests/bulk-operations.test.js +0 -657
  241. package/tests/computed-fields.test.js +0 -586
  242. package/tests/cors.test.js +0 -834
  243. package/tests/curl.test.js +0 -467
  244. package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
  245. package/tests/custom-id-simplified-post.test.js +0 -75
  246. package/tests/custom-idproperty-relationships.test.js +0 -932
  247. package/tests/db-introspection.test.js +0 -521
  248. package/tests/db-table-operations.test.js +0 -798
  249. package/tests/fastify-plugin.test.js +0 -184
  250. package/tests/field-getters.test.js +0 -370
  251. package/tests/field-setters.test.js +0 -306
  252. package/tests/file-handling.test.js +0 -155
  253. package/tests/fixtures/api-configs.js +0 -2566
  254. package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
  255. package/tests/helpers/fake-fastify.js +0 -99
  256. package/tests/helpers/storage-mode.js +0 -65
  257. package/tests/helpers/test-utils.js +0 -203
  258. package/tests/http-connectors-parity.test.js +0 -395
  259. package/tests/id-normalization.test.js +0 -444
  260. package/tests/include-depth-validation.test.js +0 -331
  261. package/tests/includes.test.js +0 -427
  262. package/tests/local-storage.test.js +0 -96
  263. package/tests/logical-id-write-validation.test.js +0 -151
  264. package/tests/nested-includes.test.js +0 -617
  265. package/tests/pagination-boundaries.test.js +0 -154
  266. package/tests/pagination-contract.test.js +0 -122
  267. package/tests/pagination-cursor-multifield.test.js +0 -641
  268. package/tests/pagination-enhanced.test.js +0 -563
  269. package/tests/polymorphic-relationships.test.js +0 -347
  270. package/tests/polymorphic-simple.test.js +0 -148
  271. package/tests/positioning-advanced.test.js +0 -692
  272. package/tests/positioning-extended.test.js +0 -915
  273. package/tests/positioning-multifilter.test.js +0 -699
  274. package/tests/positioning.test.js +0 -560
  275. package/tests/post-commit-hooks.test.js +0 -120
  276. package/tests/projected-fields.test.js +0 -319
  277. package/tests/public-exports.test.js +0 -15
  278. package/tests/put-create-parity.test.js +0 -216
  279. package/tests/queries.test.js +0 -521
  280. package/tests/query-limits.test.js +0 -584
  281. package/tests/relationship-endpoints.test.js +0 -620
  282. package/tests/relationship-include-storage-adapter.test.js +0 -56
  283. package/tests/relationships.test.js +0 -681
  284. package/tests/rest-api.test.js +0 -337
  285. package/tests/return-record-settings.test.js +0 -184
  286. package/tests/row-policy.test.js +0 -489
  287. package/tests/s3-storage.test.js +0 -38
  288. package/tests/searchschema-knex-behavior.test.js +0 -187
  289. package/tests/searchschema-merge.test.js +0 -151
  290. package/tests/socketio.test.js +0 -860
  291. package/tests/storage-mapping.test.js +0 -362
  292. package/tests/temporal-boundaries.test.js +0 -208
  293. package/tests/test-development-guide.md +0 -370
  294. package/tests/virtual-fields.test.js +0 -404
@@ -1,23 +1,83 @@
1
1
  import { RestApiValidationError } from '../../lib/rest-api-errors.js'
2
+ import { beginWriteTransaction, commitTransaction, onTransactionFinished, rollbackAfterError, withWriteOutcome } from '../../lib/error-context.js'
3
+ import { rejectRemovedOptions, resolveFormat, resolveReturning } from './lib/querying-writing/response-options.js'
4
+ import { selectWriteInput } from './lib/querying-writing/request-contracts.js'
5
+
6
+ function validateAtomic (atomic, field = 'atomic') {
7
+ if (typeof atomic !== 'boolean') {
8
+ throw new RestApiValidationError(`${field} must be a boolean`, { fields: [field] })
9
+ }
10
+ }
11
+
12
+ function validateTransactionMode (atomic, transaction) {
13
+ if (!atomic && transaction) {
14
+ throw new RestApiValidationError('A caller transaction requires atomic bulk operations', { fields: ['transaction'] })
15
+ }
16
+ }
17
+
18
+ function resolveBulkVersions (params, records, schemaInfo) {
19
+ if (params.expectedVersion !== undefined) {
20
+ throw new RestApiValidationError('Bulk writes require expectedVersions aligned with the batch', { fields: ['expectedVersion'] })
21
+ }
22
+ const versions = params.expectedVersions
23
+ if (versions === undefined) return undefined
24
+ if (!records || !schemaInfo?.versionField || !Array.isArray(versions) || versions.length !== records.length ||
25
+ Array.from(versions).some(token => typeof token !== 'string' || !token.length || token.length > 128)) {
26
+ throw new RestApiValidationError('expectedVersions requires a versioned update/delete batch with one non-empty token of at most 128 characters per item', { fields: ['expectedVersions'] })
27
+ }
28
+ return versions.slice()
29
+ }
30
+
31
+ function retainChildCleanup (context, recordContext, bulkIndex) {
32
+ let copiedErrors = 0
33
+ let copiedUploads = []
34
+ const collect = () => {
35
+ const errors = recordContext.cleanupErrors || []
36
+ if (errors.length > copiedErrors) {
37
+ const entries = context.cleanupErrors ||= []
38
+ for (const entry of errors.slice(copiedErrors)) entries.push({ ...entry, bulkIndex })
39
+ copiedErrors = errors.length
40
+ }
41
+ if (copiedUploads.length) context.fileHandlingUploads = context.fileHandlingUploads.filter(entry => !copiedUploads.includes(entry))
42
+ copiedUploads = (recordContext.fileHandlingUploads || []).map(entry => ({ ...entry, bulkIndex }))
43
+ if (copiedUploads.length) (context.fileHandlingUploads ||= []).push(...copiedUploads)
44
+ }
45
+ collect()
46
+ onTransactionFinished(recordContext.transaction, collect)
47
+ }
2
48
 
3
49
  export const BulkOperationsPlugin = {
4
50
  name: 'bulk-operations',
5
51
  dependencies: ['rest-api'],
6
52
 
7
- async install ({ api, log, addHook, addScopeMethod, helpers, pluginOptions }) {
53
+ async install ({ api, log, addHook, addResourceMethod, helpers, pluginOptions }) {
8
54
  const bulkOptions = pluginOptions || {}
9
55
  const {
10
56
  maxBulkOperations = 100,
11
- defaultAtomic = true,
12
- batchSize = 100,
13
- enableOptimizations = true
57
+ defaultAtomic = true
14
58
  } = bulkOptions
59
+ const unknown = Object.keys(bulkOptions).filter(name => !['maxBulkOperations', 'defaultAtomic'].includes(name))
60
+ if (unknown.length) {
61
+ throw new RestApiValidationError(`Unsupported bulk options: ${unknown.join(', ')}. Pass maxBulkOperations and defaultAtomic directly.`, { fields: unknown })
62
+ }
63
+ if (!Number.isSafeInteger(maxBulkOperations) || maxBulkOperations < 1) {
64
+ throw new RestApiValidationError('maxBulkOperations must be a positive safe integer', { fields: ['maxBulkOperations'] })
65
+ }
66
+ validateAtomic(defaultAtomic, 'defaultAtomic')
15
67
 
16
68
  log.info('Installing Bulk Operations plugin', { maxBulkOperations, defaultAtomic })
17
69
 
18
70
  // Add bulk methods to each scope
19
- addScopeMethod('bulkPost', async ({ scope, scopeName, params, context, runHooks }) => {
20
- const { inputRecords, atomic = defaultAtomic } = params
71
+ addResourceMethod('bulkPost', withWriteOutcome(async ({ scope, vars, params, context }) => {
72
+ const { atomic = defaultAtomic } = params
73
+ rejectRemovedOptions(params)
74
+ const inputKey = selectWriteInput(params)
75
+ const inputRecords = inputKey === 'document' ? params.document.data : params.data
76
+ validateAtomic(atomic)
77
+ validateTransactionMode(atomic, params.transaction)
78
+ resolveBulkVersions(params)
79
+ const format = resolveFormat(params.format, vars.format)
80
+ const returning = resolveReturning(params.returning, vars.returning)
21
81
 
22
82
  // Validate bulk size
23
83
  if (!Array.isArray(inputRecords) || inputRecords.length === 0) {
@@ -41,69 +101,65 @@ export const BulkOperationsPlugin = {
41
101
  const results = []
42
102
  const errors = []
43
103
  let transaction = null
104
+ const ownsTransaction = !params.transaction
44
105
 
45
106
  try {
46
107
  // Start transaction if atomic mode
47
- if (atomic && helpers.newTransaction) {
48
- transaction = await helpers.newTransaction()
108
+ if (atomic) {
109
+ transaction = await beginWriteTransaction(context, params.transaction, helpers.newTransaction)
49
110
  }
50
111
 
51
- // Process records in batches
52
- for (let i = 0; i < inputRecords.length; i += batchSize) {
53
- const batch = inputRecords.slice(i, i + batchSize)
54
-
55
- for (let j = 0; j < batch.length; j++) {
56
- const recordIndex = i + j
57
- const inputRecord = batch[j]
58
-
59
- try {
60
- // Create individual context for each record
61
- const recordContext = {
62
- ...context,
63
- bulkOperation: true,
64
- bulkIndex: recordIndex
65
- }
66
-
67
- // Use the existing post method with transaction
68
- const result = await scope.post({
69
- inputRecord: inputRecord.data ? inputRecord : { data: inputRecord },
70
- transaction
71
- }, recordContext)
112
+ for (const [recordIndex, inputRecord] of inputRecords.entries()) {
113
+ const recordContext = {
114
+ ...context,
115
+ cleanupErrors: undefined,
116
+ fileHandlingUploads: undefined,
117
+ bulkOperation: true,
118
+ bulkIndex: recordIndex
119
+ }
120
+ try {
121
+ // Use the existing post method with transaction
122
+ const result = await scope.post({
123
+ ...(inputKey === 'document'
124
+ ? { document: { ...params.document, data: inputRecord } }
125
+ : { data: inputRecord }),
126
+ transaction,
127
+ format,
128
+ returning
129
+ }, recordContext)
72
130
 
73
- results.push({
131
+ results.push({
132
+ index: recordIndex,
133
+ status: 'success',
134
+ data: format === 'jsonapi' ? result?.data : result
135
+ })
136
+ } catch (error) {
137
+ if (atomic) {
138
+ throw error
139
+ } else {
140
+ // In non-atomic mode, collect errors
141
+ errors.push({
74
142
  index: recordIndex,
75
- status: 'success',
76
- data: result.data
143
+ status: 'error',
144
+ error: {
145
+ code: error.code || 'UNKNOWN_ERROR',
146
+ message: error.message,
147
+ details: error.details,
148
+ transactionOutcome: error.transactionOutcome
149
+ }
77
150
  })
78
- } catch (error) {
79
- if (atomic) {
80
- // In atomic mode, rollback and throw
81
- if (transaction) await transaction.rollback()
82
- throw error
83
- } else {
84
- // In non-atomic mode, collect errors
85
- errors.push({
86
- index: recordIndex,
87
- status: 'error',
88
- error: {
89
- code: error.code || 'UNKNOWN_ERROR',
90
- message: error.message,
91
- details: error.details
92
- }
93
- })
94
- }
95
151
  }
96
- }
152
+ } finally { retainChildCleanup(context, recordContext, recordIndex) }
97
153
  }
98
154
 
99
155
  // Commit transaction if atomic
100
- if (transaction) {
101
- await transaction.commit()
156
+ if (transaction && ownsTransaction) {
157
+ await commitTransaction(transaction, context)
102
158
  }
103
159
 
104
160
  // Build response
105
161
  return {
106
- data: results.filter(r => r.status === 'success').map(r => r.data),
162
+ ...(returning === 'none' ? {} : { data: results.map(r => r.data) }),
107
163
  errors: errors.length > 0 ? errors : undefined,
108
164
  meta: {
109
165
  total: inputRecords.length,
@@ -113,16 +169,18 @@ export const BulkOperationsPlugin = {
113
169
  }
114
170
  }
115
171
  } catch (error) {
116
- // Ensure rollback on error
117
- if (transaction && !transaction.isCompleted()) {
118
- await transaction.rollback()
119
- }
172
+ await rollbackAfterError(error, context, ownsTransaction ? transaction : null)
120
173
  throw error
121
174
  }
122
- })
175
+ }))
123
176
 
124
- addScopeMethod('bulkPatch', async ({ scope, scopeName, params, context, runHooks }) => {
177
+ addResourceMethod('bulkPatch', withWriteOutcome(async ({ scope, vars, params, context }) => {
125
178
  const { operations, atomic = defaultAtomic } = params
179
+ rejectRemovedOptions(params)
180
+ validateAtomic(atomic)
181
+ validateTransactionMode(atomic, params.transaction)
182
+ const format = resolveFormat(params.format, vars.format)
183
+ const returning = resolveReturning(params.returning, vars.returning)
126
184
 
127
185
  // Validate operations
128
186
  if (!Array.isArray(operations) || operations.length === 0) {
@@ -143,76 +201,69 @@ export const BulkOperationsPlugin = {
143
201
  })
144
202
  }
145
203
 
204
+ const expectedVersions = resolveBulkVersions(params, operations, vars.schemaInfo)
146
205
  const results = []
147
206
  const errors = []
148
207
  let transaction = null
208
+ const ownsTransaction = !params.transaction
149
209
 
150
210
  try {
151
- if (atomic && helpers.newTransaction) {
152
- transaction = await helpers.newTransaction()
211
+ if (atomic) {
212
+ transaction = await beginWriteTransaction(context, params.transaction, helpers.newTransaction)
153
213
  }
154
214
 
155
215
  for (let i = 0; i < operations.length; i++) {
156
216
  const operation = operations[i]
157
217
 
158
218
  // Validate operation structure
159
- if (!operation.id || !operation.data) {
219
+ if (!operation || operation.id === undefined || operation.id === null) {
160
220
  errors.push({
161
221
  index: i,
162
222
  status: 'error',
163
223
  error: {
164
224
  code: 'INVALID_OPERATION',
165
- message: 'Operation must include id and data',
166
- details: { operation }
225
+ message: 'Operation must include id and exactly one of data or document',
226
+ details: { operation },
227
+ transactionOutcome: 'none'
167
228
  }
168
229
  })
169
230
  if (atomic) {
170
- if (transaction) await transaction.rollback()
171
231
  throw new RestApiValidationError('Invalid operation structure', {
172
232
  fields: [`operations[${i}]`],
173
233
  violations: [{
174
234
  field: `operations[${i}]`,
175
235
  rule: 'required_fields',
176
- message: 'Operation must include id and data'
236
+ message: 'Operation must include id and exactly one of data or document'
177
237
  }]
178
238
  })
179
239
  }
180
240
  continue
181
241
  }
182
242
 
243
+ const recordContext = {
244
+ ...context,
245
+ cleanupErrors: undefined,
246
+ fileHandlingUploads: undefined,
247
+ bulkOperation: true,
248
+ bulkIndex: i
249
+ }
183
250
  try {
184
- const recordContext = {
185
- ...context,
186
- bulkOperation: true,
187
- bulkIndex: i
188
- }
189
-
190
251
  const result = await scope.patch({
191
- id: operation.id,
192
- inputRecord: { data: operation.data },
193
- transaction
252
+ ...operation,
253
+ expectedVersion: expectedVersions?.[i],
254
+ transaction,
255
+ format,
256
+ returning
194
257
  }, recordContext)
195
258
 
196
- // If patch doesn't return a full record, fetch the updated record.
197
- const resultId = result?.data?.id || result?.id || operation.id
198
- let resultData = result?.data
199
- if (!resultData && resultId) {
200
- const fetchedRecord = await scope.get({
201
- id: resultId,
202
- transaction
203
- }, recordContext)
204
- resultData = fetchedRecord.data
205
- }
206
-
207
259
  results.push({
208
260
  index: i,
209
261
  id: operation.id,
210
262
  status: 'success',
211
- data: resultData
263
+ data: format === 'jsonapi' ? result?.data : result
212
264
  })
213
265
  } catch (error) {
214
266
  if (atomic) {
215
- if (transaction) await transaction.rollback()
216
267
  throw error
217
268
  } else {
218
269
  errors.push({
@@ -222,19 +273,20 @@ export const BulkOperationsPlugin = {
222
273
  error: {
223
274
  code: error.code || 'UNKNOWN_ERROR',
224
275
  message: error.message,
225
- details: error.details
276
+ details: error.details,
277
+ transactionOutcome: error.transactionOutcome
226
278
  }
227
279
  })
228
280
  }
229
- }
281
+ } finally { retainChildCleanup(context, recordContext, i) }
230
282
  }
231
283
 
232
- if (transaction) {
233
- await transaction.commit()
284
+ if (transaction && ownsTransaction) {
285
+ await commitTransaction(transaction, context)
234
286
  }
235
287
 
236
288
  return {
237
- data: results.filter(r => r.status === 'success').map(r => r.data),
289
+ ...(returning === 'none' ? {} : { data: results.map(r => r.data) }),
238
290
  errors: errors.length > 0 ? errors : undefined,
239
291
  meta: {
240
292
  total: operations.length,
@@ -244,15 +296,17 @@ export const BulkOperationsPlugin = {
244
296
  }
245
297
  }
246
298
  } catch (error) {
247
- if (transaction && !transaction.isCompleted()) {
248
- await transaction.rollback()
249
- }
299
+ await rollbackAfterError(error, context, ownsTransaction ? transaction : null)
250
300
  throw error
251
301
  }
252
- })
302
+ }))
253
303
 
254
- addScopeMethod('bulkDelete', async ({ scope, scopeName, params, context, runHooks }) => {
304
+ addResourceMethod('bulkDelete', withWriteOutcome(async ({ scope, vars, params, context }) => {
255
305
  const { ids, atomic = defaultAtomic } = params
306
+ rejectRemovedOptions(params)
307
+ validateAtomic(atomic)
308
+ validateTransactionMode(atomic, params.transaction)
309
+ if (params.format !== undefined) resolveFormat(params.format)
256
310
 
257
311
  // Validate IDs
258
312
  if (!Array.isArray(ids) || ids.length === 0) {
@@ -273,28 +327,32 @@ export const BulkOperationsPlugin = {
273
327
  })
274
328
  }
275
329
 
330
+ const expectedVersions = resolveBulkVersions(params, ids, vars.schemaInfo)
276
331
  const results = []
277
332
  const errors = []
278
333
  let transaction = null
334
+ const ownsTransaction = !params.transaction
279
335
 
280
336
  try {
281
- if (atomic && helpers.newTransaction) {
282
- transaction = await helpers.newTransaction()
337
+ if (atomic) {
338
+ transaction = await beginWriteTransaction(context, params.transaction, helpers.newTransaction)
283
339
  }
284
340
 
285
341
  // Process deletes
286
342
  for (let i = 0; i < ids.length; i++) {
287
343
  const id = ids[i]
288
344
 
345
+ const recordContext = {
346
+ ...context,
347
+ cleanupErrors: undefined,
348
+ fileHandlingUploads: undefined,
349
+ bulkOperation: true,
350
+ bulkIndex: i
351
+ }
289
352
  try {
290
- const recordContext = {
291
- ...context,
292
- bulkOperation: true,
293
- bulkIndex: i
294
- }
295
-
296
353
  await scope.delete({
297
354
  id,
355
+ expectedVersion: expectedVersions?.[i],
298
356
  transaction
299
357
  }, recordContext)
300
358
 
@@ -305,7 +363,6 @@ export const BulkOperationsPlugin = {
305
363
  })
306
364
  } catch (error) {
307
365
  if (atomic) {
308
- if (transaction) await transaction.rollback()
309
366
  throw error
310
367
  } else {
311
368
  errors.push({
@@ -315,15 +372,16 @@ export const BulkOperationsPlugin = {
315
372
  error: {
316
373
  code: error.code || 'UNKNOWN_ERROR',
317
374
  message: error.message,
318
- details: error.details
375
+ details: error.details,
376
+ transactionOutcome: error.transactionOutcome
319
377
  }
320
378
  })
321
379
  }
322
- }
380
+ } finally { retainChildCleanup(context, recordContext, i) }
323
381
  }
324
382
 
325
- if (transaction) {
326
- await transaction.commit()
383
+ if (transaction && ownsTransaction) {
384
+ await commitTransaction(transaction, context)
327
385
  }
328
386
 
329
387
  return {
@@ -337,23 +395,24 @@ export const BulkOperationsPlugin = {
337
395
  errors: errors.length > 0 ? errors : undefined
338
396
  }
339
397
  } catch (error) {
340
- if (transaction && !transaction.isCompleted()) {
341
- await transaction.rollback()
342
- }
398
+ await rollbackAfterError(error, context, ownsTransaction ? transaction : null)
343
399
  throw error
344
400
  }
345
- })
401
+ }))
346
402
 
347
403
  // Hook into scope creation to add bulk routes
348
- addHook('afterAddScope', 'bulkOperationsRoutes', {}, async ({ scopeName }) => {
349
- // Note: context is not available here during route registration
350
- // The urlPrefix will be calculated per-request in the handler
404
+ addHook('resource:added', 'bulkOperationsRoutes', { beforeFunction: 'registerScopeRoutes' }, async ({ context: { scopeName } }) => {
351
405
  const urlPrefix = api.vars.transport?.mountPath || ''
352
406
  const scopePath = `${urlPrefix}/${scopeName}`
353
407
 
354
408
  // Create route handlers
355
409
  const createBulkRouteHandler = (method) => {
356
- return async ({ context, body, query }) => {
410
+ return async ({ context, body, queryString }) => {
411
+ const query = Object.fromEntries(new URLSearchParams(queryString))
412
+ rejectRemovedOptions(query)
413
+ if (query.atomic !== undefined && query.atomic !== 'true' && query.atomic !== 'false') {
414
+ throw new RestApiValidationError('atomic must be true or false', { fields: ['atomic'] })
415
+ }
357
416
  // Parse query params for atomic mode override
358
417
  const atomic = query?.atomic !== undefined
359
418
  ? query.atomic === 'true'
@@ -363,13 +422,16 @@ export const BulkOperationsPlugin = {
363
422
  if (method === 'bulkPost') {
364
423
  // For bulk create, expect array in data field (JSON:API style)
365
424
  params = {
366
- inputRecords: body?.data || body,
425
+ document: body,
367
426
  atomic
368
427
  }
369
428
  } else if (method === 'bulkPatch') {
370
429
  // For bulk update, expect operations array
430
+ const operations = body?.operations || body
371
431
  params = {
372
- operations: body?.operations || body,
432
+ operations: Array.isArray(operations)
433
+ ? operations.map(operation => ({ id: operation?.id, document: { data: operation?.data } }))
434
+ : operations,
373
435
  atomic
374
436
  }
375
437
  } else if (method === 'bulkDelete') {
@@ -380,7 +442,14 @@ export const BulkOperationsPlugin = {
380
442
  }
381
443
  }
382
444
 
383
- return await api.scopes[scopeName][method](params)
445
+ const result = await api.resources[scopeName][method]({
446
+ ...params,
447
+ expectedVersion: body?.expectedVersion,
448
+ expectedVersions: body?.expectedVersions,
449
+ format: 'jsonapi',
450
+ returning: 'full'
451
+ }, context)
452
+ return { statusCode: method === 'bulkPost' ? 201 : 200, body: result }
384
453
  }
385
454
  }
386
455
 
@@ -406,68 +475,6 @@ export const BulkOperationsPlugin = {
406
475
  log.debug(`Added bulk operation routes for scope: ${scopeName}`)
407
476
  })
408
477
 
409
- // Add optimized bulk insert for Knex if available
410
- if (enableOptimizations) {
411
- addHook('beforeBulkPost', 'optimizedBulkInsert', {}, async ({ scope, params, context }) => {
412
- // Check if we can use optimized path
413
- if (params.atomic && api.knex?.instance && context.bulkOperation === undefined) {
414
- // This is the main bulk operation, not individual records
415
- const { inputRecords } = params
416
-
417
- // Transform records for direct insertion
418
- const recordsToInsert = []
419
- for (const inputRecord of inputRecords) {
420
- // Run validation
421
- const validated = await scope.validateInput({
422
- inputRecord,
423
- operation: 'create'
424
- })
425
-
426
- // Transform to database format
427
- const dbRecord = await scope.transformForDatabase({
428
- record: validated,
429
- operation: 'create'
430
- })
431
-
432
- recordsToInsert.push(dbRecord)
433
- }
434
-
435
- // Perform bulk insert
436
- const knex = api.knex.instance
437
- const tableName = scope.vars.schemaInfo.tableName
438
-
439
- try {
440
- const inserted = await knex(tableName)
441
- .insert(recordsToInsert)
442
- .returning('*')
443
-
444
- // Transform back to API format
445
- const results = []
446
- for (const record of inserted) {
447
- const apiRecord = await scope.transformFromDatabase({ record })
448
- results.push(apiRecord)
449
- }
450
-
451
- // Return optimized result
452
- return {
453
- data: results,
454
- meta: {
455
- total: inputRecords.length,
456
- succeeded: results.length,
457
- failed: 0,
458
- atomic: true,
459
- optimized: true
460
- },
461
- skipDefault: true // Tell the default handler to skip
462
- }
463
- } catch (error) {
464
- // Fall back to default handling
465
- log.warn('Optimized bulk insert failed, falling back to default', { error: error.message })
466
- }
467
- }
468
- })
469
- }
470
-
471
478
  log.info('Bulk Operations plugin installed successfully')
472
479
  }
473
480
  }