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,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
  }