json-rest-api 1.0.29 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (292) hide show
  1. package/LICENSE +10 -670
  2. package/LICENSE-GPL-3.0 +674 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +39 -30
  5. package/docs/API.md +744 -1273
  6. package/docs/GUIDE/01-getting-started.md +338 -0
  7. package/docs/GUIDE/02-resources-and-relationships.md +33 -0
  8. package/docs/GUIDE/03-running-example.md +87 -0
  9. package/docs/GUIDE/04-creating-and-querying.md +181 -0
  10. package/docs/GUIDE/05-belongs-to.md +177 -0
  11. package/docs/GUIDE/06-has-many.md +184 -0
  12. package/docs/GUIDE/07-polymorphic-relationships.md +191 -0
  13. package/docs/GUIDE/08-many-to-many.md +184 -0
  14. package/docs/GUIDE/09-pagination-and-sorting.md +149 -0
  15. package/docs/GUIDE/10-put-and-patch.md +174 -0
  16. package/docs/GUIDE/11-relationship-endpoints.md +174 -0
  17. package/docs/GUIDE/12-field-transformations.md +287 -0
  18. package/docs/GUIDE/13-hooks-and-lifecycle.md +479 -0
  19. package/docs/GUIDE/14-custom-resource-methods.md +125 -0
  20. package/docs/GUIDE/15-query-projections.md +266 -0
  21. package/docs/GUIDE/{GUIDE_X_Autofiltering.md → 16-autofiltering.md} +77 -15
  22. package/docs/GUIDE/{GUIDE_X_Row_Policies.md → 17-row-policies.md} +137 -44
  23. package/docs/GUIDE/18-labels.md +86 -0
  24. package/docs/GUIDE/19-managed-transactions.md +179 -0
  25. package/docs/GUIDE/20-transaction-outcomes.md +207 -0
  26. package/docs/GUIDE/21-schema-and-migrations.md +634 -0
  27. package/docs/GUIDE/22-version-field-migrations.md +208 -0
  28. package/docs/GUIDE/23-fastify.md +179 -0
  29. package/docs/GUIDE/24-cors.md +172 -0
  30. package/docs/GUIDE/{GUIDE_X_URL_Management.md → 25-url-management.md} +7 -1
  31. package/docs/GUIDE/26-file-uploads.md +278 -0
  32. package/docs/GUIDE/27-bulk-operations.md +277 -0
  33. package/docs/GUIDE/28-socketio.md +325 -0
  34. package/docs/GUIDE/29-writing-plugins.md +278 -0
  35. package/docs/GUIDE/30-backend-capabilities.md +100 -0
  36. package/docs/GUIDE/31-positioning.md +181 -0
  37. package/docs/GUIDE/{GUIDE_Y_Appendices.md → 32-date-and-time.md} +74 -30
  38. package/docs/GUIDE/33-migrating-to-v2.md +3827 -0
  39. package/docs/GUIDE/index.md +51 -59
  40. package/docs/QUICKSTART.md +120 -287
  41. package/docs/architecture.md +75 -0
  42. package/docs/contributing.md +178 -0
  43. package/index.d.ts +36 -0
  44. package/index.js +1 -22
  45. package/lib/enhanced-logger.js +91 -85
  46. package/lib/error-context.js +445 -5
  47. package/lib/error-formatter.js +219 -59
  48. package/lib/knex-transaction.js +64 -0
  49. package/lib/logger-types.d.ts +22 -0
  50. package/lib/missing-package.js +4 -0
  51. package/lib/rest-api-errors.js +103 -8
  52. package/lib/runtime/json-rest-api.js +172 -0
  53. package/lib/transaction-types.d.ts +59 -0
  54. package/package.json +97 -27
  55. package/plugins/core/bulk-operations-plugin.js +191 -184
  56. package/plugins/core/connectors/express-plugin.js +148 -219
  57. package/plugins/core/connectors/fastify-plugin.js +125 -82
  58. package/plugins/core/connectors/lib/busboy-detector.js +74 -156
  59. package/plugins/core/connectors/lib/connector-core.js +144 -20
  60. package/plugins/core/connectors/lib/formidable-detector.js +63 -120
  61. package/plugins/core/connectors/lib/http-validators.js +77 -0
  62. package/plugins/core/connectors/lib/multipart-helpers.js +18 -0
  63. package/plugins/core/connectors/lib/request-helpers.js +10 -1
  64. package/plugins/core/connectors/lib/transport-http-helpers.js +89 -8
  65. package/plugins/core/connectors/lib/transport-route-schemas.js +7 -4
  66. package/plugins/core/file-handling-plugin.js +87 -177
  67. package/plugins/core/lib/anyapi/anyapi-registry.js +213 -164
  68. package/plugins/core/lib/anyapi/canonical-link-store.js +507 -0
  69. package/plugins/core/lib/anyapi/canonical-relationship-reader.js +598 -0
  70. package/plugins/core/lib/anyapi/schema-utils.js +25 -16
  71. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
  72. package/plugins/core/lib/dbIntrospection.js +228 -21
  73. package/plugins/core/lib/dbTablesOperations.js +409 -92
  74. package/plugins/core/lib/fractional-positioning.js +12 -48
  75. package/plugins/core/lib/querying/identifier-query.js +19 -0
  76. package/plugins/core/lib/querying/include-query-helpers.js +364 -0
  77. package/plugins/core/lib/querying/include-to-many.js +428 -0
  78. package/plugins/core/lib/querying/include-to-one.js +579 -0
  79. package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
  80. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
  81. package/plugins/core/lib/querying/knex-pagination-helpers.js +48 -317
  82. package/plugins/core/lib/querying/knex-process-includes.js +5 -110
  83. package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
  84. package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
  85. package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
  86. package/plugins/core/lib/querying/knex-window-queries.js +62 -253
  87. package/plugins/core/lib/querying/query-builder-utils.js +44 -4
  88. package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
  89. package/plugins/core/lib/querying/query-constraint.js +27 -0
  90. package/plugins/core/lib/querying/query-field-sort-helpers.js +134 -37
  91. package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
  92. package/plugins/core/lib/querying/sort-helpers.js +13 -1
  93. package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
  94. package/plugins/core/lib/querying/url-helpers.js +11 -4
  95. package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
  96. package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
  97. package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
  98. package/plugins/core/lib/querying-writing/database-value-normalizers.js +179 -194
  99. package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
  100. package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
  101. package/plugins/core/lib/querying-writing/field-utils.js +113 -92
  102. package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
  103. package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
  104. package/plugins/core/lib/querying-writing/knex-field-helpers.js +100 -290
  105. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
  106. package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
  107. package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
  108. package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
  109. package/plugins/core/lib/querying-writing/request-contracts.js +93 -38
  110. package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
  111. package/plugins/core/lib/querying-writing/response-options.js +35 -0
  112. package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
  113. package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
  114. package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
  115. package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
  116. package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
  117. package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
  118. package/plugins/core/lib/storage/storage-adapter.js +128 -62
  119. package/plugins/core/lib/storage/storage-mapping.js +78 -28
  120. package/plugins/core/lib/storage/storage-types.d.ts +502 -0
  121. package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
  122. package/plugins/core/lib/writing/relationship-processor.js +94 -158
  123. package/plugins/core/lib/writing/resource-version.js +172 -0
  124. package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
  125. package/plugins/core/rest-api-anyapi-knex-plugin.js +116 -2485
  126. package/plugins/core/rest-api-autofilter-plugin.js +17 -19
  127. package/plugins/core/rest-api-cors-plugin.js +52 -205
  128. package/plugins/core/rest-api-knex-plugin.js +45 -1077
  129. package/plugins/core/rest-api-label-plugin.js +25 -38
  130. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
  131. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
  132. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
  133. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
  134. package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
  135. package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
  136. package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
  137. package/plugins/core/rest-api-plugin-methods/common.js +291 -395
  138. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
  139. package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
  140. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -148
  141. package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
  142. package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
  143. package/plugins/core/rest-api-plugin-methods/get.js +36 -68
  144. package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
  145. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
  146. package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
  147. package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
  148. package/plugins/core/rest-api-plugin-methods/post.js +51 -51
  149. package/plugins/core/rest-api-plugin-methods/put.js +69 -148
  150. package/plugins/core/rest-api-plugin-methods/query.js +37 -81
  151. package/plugins/core/rest-api-plugin-methods/release.js +2 -1
  152. package/plugins/core/rest-api-plugin.js +32 -46
  153. package/plugins/core/rest-api-positioning-plugin.js +183 -628
  154. package/plugins/core/rest-api-query-projections-plugin.js +19 -102
  155. package/plugins/core/rest-api-row-policy-plugin.js +3 -3
  156. package/plugins/core/socketio-plugin.js +364 -344
  157. package/plugins/storage/local-storage.js +40 -36
  158. package/types/autofilter.d.ts +57 -0
  159. package/types/bulk-methods.d.ts +63 -0
  160. package/types/errors.d.ts +118 -0
  161. package/types/fastify-options.d.ts +19 -0
  162. package/types/file-detectors.d.ts +33 -0
  163. package/types/file-storage.d.ts +48 -0
  164. package/types/hook-context.d.ts +180 -0
  165. package/types/plugin-options.d.ts +53 -0
  166. package/types/relationship-methods.d.ts +74 -0
  167. package/types/representations.d.ts +50 -0
  168. package/types/resource-methods.d.ts +95 -0
  169. package/types/resource-schema.d.ts +157 -0
  170. package/types/row-policy.d.ts +31 -0
  171. package/types/runtime.d.ts +132 -0
  172. package/types/transactions.d.ts +21 -0
  173. package/.claude/agents/library-guide-expert.md +0 -30
  174. package/.claude/agents/rest-api-internals-expert.md +0 -54
  175. package/.claude/settings.local.json +0 -70
  176. package/AGENTS.md +0 -34
  177. package/CLAUDE.md +0 -160
  178. package/CRUSH.md +0 -55
  179. package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
  180. package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
  181. package/TODO.todo +0 -13
  182. package/connectors_ideas/fastify.js +0 -217
  183. package/connectors_ideas/koa-plugin.js +0 -112
  184. package/docs/.bundle/config +0 -2
  185. package/docs/COMPARISON.md +0 -433
  186. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
  187. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
  188. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
  189. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
  190. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
  191. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
  192. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
  193. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1247
  194. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
  195. package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
  196. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
  197. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
  198. package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
  199. package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
  200. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
  201. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
  202. package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
  203. package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
  204. package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
  205. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
  206. package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
  207. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
  208. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
  209. package/docs/Gemfile +0 -6
  210. package/docs/Gemfile.lock +0 -308
  211. package/docs/ONBOARDING.md +0 -315
  212. package/docs/_config.yml +0 -36
  213. package/docs/_layouts/default.html +0 -123
  214. package/docs/apple-touch-icon.svg +0 -4
  215. package/docs/assets/css/style.scss +0 -392
  216. package/docs/favicon.svg +0 -4
  217. package/docs/index.md +0 -164
  218. package/docs/mini-guides/file-uploads-complete.md +0 -161
  219. package/eslint.config.js +0 -13
  220. package/examples/url-override-example.js +0 -137
  221. package/fixing_plan.md +0 -112
  222. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
  223. package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
  224. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
  225. package/plugins/storage/s3-storage.js +0 -93
  226. package/quickTest.js +0 -136
  227. package/scripts/debug-anyapi-review-filter.mjs +0 -63
  228. package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
  229. package/scripts/inspect-anyapi-descriptor.mjs +0 -19
  230. package/scripts/run-docs.js +0 -133
  231. package/test-debug.js +0 -59
  232. package/tests/README.md +0 -202
  233. package/tests/TEST_TEMPLATE.test.js +0 -178
  234. package/tests/anyapi-basic.test.js +0 -105
  235. package/tests/anyapi-cursor-pagination.test.js +0 -148
  236. package/tests/anyapi-custom-idproperty.test.js +0 -163
  237. package/tests/autofilter.test.js +0 -463
  238. package/tests/bulk-operations.test.js +0 -657
  239. package/tests/computed-fields.test.js +0 -586
  240. package/tests/cors.test.js +0 -834
  241. package/tests/curl.test.js +0 -467
  242. package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
  243. package/tests/custom-id-simplified-post.test.js +0 -75
  244. package/tests/custom-idproperty-relationships.test.js +0 -932
  245. package/tests/db-introspection.test.js +0 -521
  246. package/tests/db-table-operations.test.js +0 -798
  247. package/tests/fastify-plugin.test.js +0 -184
  248. package/tests/field-getters.test.js +0 -370
  249. package/tests/field-setters.test.js +0 -306
  250. package/tests/file-handling.test.js +0 -155
  251. package/tests/fixtures/api-configs.js +0 -2566
  252. package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
  253. package/tests/helpers/fake-fastify.js +0 -99
  254. package/tests/helpers/storage-mode.js +0 -65
  255. package/tests/helpers/test-utils.js +0 -203
  256. package/tests/http-connectors-parity.test.js +0 -395
  257. package/tests/id-normalization.test.js +0 -444
  258. package/tests/include-depth-validation.test.js +0 -331
  259. package/tests/includes.test.js +0 -427
  260. package/tests/local-storage.test.js +0 -96
  261. package/tests/logical-id-write-validation.test.js +0 -151
  262. package/tests/nested-includes.test.js +0 -617
  263. package/tests/pagination-boundaries.test.js +0 -154
  264. package/tests/pagination-contract.test.js +0 -122
  265. package/tests/pagination-cursor-multifield.test.js +0 -641
  266. package/tests/pagination-enhanced.test.js +0 -563
  267. package/tests/polymorphic-relationships.test.js +0 -347
  268. package/tests/polymorphic-simple.test.js +0 -148
  269. package/tests/positioning-advanced.test.js +0 -692
  270. package/tests/positioning-extended.test.js +0 -915
  271. package/tests/positioning-multifilter.test.js +0 -699
  272. package/tests/positioning.test.js +0 -560
  273. package/tests/post-commit-hooks.test.js +0 -120
  274. package/tests/projected-fields.test.js +0 -319
  275. package/tests/public-exports.test.js +0 -15
  276. package/tests/put-create-parity.test.js +0 -216
  277. package/tests/queries.test.js +0 -521
  278. package/tests/query-limits.test.js +0 -584
  279. package/tests/relationship-endpoints.test.js +0 -620
  280. package/tests/relationship-include-storage-adapter.test.js +0 -56
  281. package/tests/relationships.test.js +0 -681
  282. package/tests/rest-api.test.js +0 -337
  283. package/tests/return-record-settings.test.js +0 -184
  284. package/tests/row-policy.test.js +0 -489
  285. package/tests/s3-storage.test.js +0 -38
  286. package/tests/searchschema-knex-behavior.test.js +0 -187
  287. package/tests/searchschema-merge.test.js +0 -151
  288. package/tests/socketio.test.js +0 -860
  289. package/tests/storage-mapping.test.js +0 -362
  290. package/tests/temporal-boundaries.test.js +0 -208
  291. package/tests/test-development-guide.md +0 -370
  292. package/tests/virtual-fields.test.js +0 -404
@@ -1,172 +1,135 @@
1
+ // @ts-check
2
+ /** @import {
3
+ * CompletedWriteContext, Field, Identifier, IdentityContext, InputDocument, LifecycleApi,
4
+ * LifecycleArguments, LifecycleContext, LifecycleHelpers, LifecycleResource, LifecycleSchema,
5
+ * LifecycleVars, ProcessingContext, RelationshipContext, Resource, ResourceContext, RunHooks,
6
+ * Schema, WriteContext
7
+ * } from './lifecycle-types.js' */
8
+ /** @import { StorageRow } from '../lib/storage/storage-types.js' */
9
+ /** @import { DiagnosticWriter, EnhancedDiagnosticLogger } from '../../../lib/logger-types.js' */
10
+ /** @import { RuntimeLogger } from '../../../types/runtime.js' */
11
+ import { initializeResourceVersion } from '../lib/writing/resource-version.js'
1
12
  import {
13
+ RestApiIncludeError,
2
14
  RestApiResourceError,
3
15
  RestApiValidationError
4
16
  } from '../../../lib/rest-api-errors.js'
5
17
  import { transformSimplifiedToJsonApi } from '../lib/querying-writing/simplified-helpers.js'
6
18
  import { normalizeRelationshipIdentifiers } from '../lib/querying-writing/resource-id-normalization.js'
7
19
  import { createEnhancedLogger } from '../../../lib/enhanced-logger.js'
8
- import {
9
- normalizeReturnRecordMode,
10
- normalizeReturnRecordSetting
11
- } from '../lib/querying-writing/return-record-settings.js'
20
+ import { rejectRemovedOptions, resolveFormat, resolveReturning } from '../lib/querying-writing/response-options.js'
12
21
  import {
13
22
  getRequestContracts,
23
+ selectWriteInput,
14
24
  validateRequestContractOrThrow
15
25
  } from '../lib/querying-writing/request-contracts.js'
16
26
  import { normalizeRecordAttributes } from '../lib/querying-writing/database-value-normalizers.js'
17
- import { isRestApiError } from '../../../lib/error-context.js'
27
+ import { beginWriteTransaction, commitTransaction, getOperationDiagnosticContext, isTransactionOwner, rollbackAfterError, wrapUnexpectedError } from '../../../lib/error-context.js'
28
+ import { filterVisibleIdentifiers } from '../lib/querying/include-query-helpers.js'
29
+ import { findRelationshipDefinition } from '../lib/querying-writing/relationship-contracts.js'
30
+ import { RELATIONSHIP_READ_BATCH_SIZE } from '../lib/querying-writing/knex-constants.js'
31
+ import { getResourceFieldset, parseFieldset } from '../lib/querying-writing/field-utils.js'
18
32
 
19
- export { normalizeReturnRecordMode as normalizeReturnValue }
33
+ // A trusted connector callback, evaluated after normal write validation and authorization.
34
+ export const writePrecondition = Symbol('writePrecondition')
20
35
 
21
36
  /**
22
37
  * Gets an enhanced logger instance with full error details and stack traces
23
- * @param {Object} log - The base logger instance
24
- * @returns {Object} Enhanced logger instance
38
+ * @param {RuntimeLogger} log - The base logger instance
39
+ * @param {{ schemaInfo?: LifecycleSchema }} context - Request context carrying compiled field visibility
40
+ * @returns {EnhancedDiagnosticLogger & { error: DiagnosticWriter }} Enhanced logger instance
25
41
  */
26
- export const getEnhancedLogger = (log) => {
42
+ export const getEnhancedLogger = (log, context = {}) => {
27
43
  return createEnhancedLogger(log, {
28
44
  logFullErrors: true,
29
- includeStack: true
45
+ includeStack: true,
46
+ schemaInfo: context.schemaInfo
30
47
  })
31
48
  }
32
49
 
33
50
  /**
34
- * Cascades configuration values through multiple sources with fallback to default
35
- * @param {string} settingName - The setting name to look for
36
- * @param {Array} sources - Array of objects to search through
37
- * @param {*} defaultValue - Default value if not found
38
- * @returns {*} The first defined value found or the default
51
+ * Resolves write input, response options and the managed transaction.
52
+ * @param {Pick<LifecycleArguments, 'params' | 'vars' | 'scopes' | 'scopeName' | 'api' | 'helpers' | 'runHooks'> & { context: LifecycleContext<Resource | Resource[] | null, unknown> }} options
39
53
  */
40
- export const cascadeConfig = (settingName, sources, defaultValue) =>
41
- sources.find(source => source?.[settingName] !== undefined)?.[settingName] ?? defaultValue
42
-
43
- /**
44
- * Sets up common request context for REST API methods
45
- * Handles simplified mode, transaction setup, and initial validation
46
- *
47
- * @param {Object} params - The method parameters
48
- * @param {Object} context - The request context
49
- * @param {Object} vars - Plugin variables
50
- * @param {Object} scopes - All available scopes
51
- * @param {Object} scopeOptions - Scope-specific options
52
- * @param {string} scopeName - The name of the current scope
53
- * @param {Object} api - The API instance
54
- * @param {Object} helpers - Helper functions
55
- * @returns {Object} An object containing schema-related shortcuts
56
- */
57
- export async function setupCommonRequest ({ params, context, vars, scopes, scopeOptions, scopeName, api, helpers }) {
58
- // Determine which simplified setting to use based on transport
59
- const isTransport = params.isTransport === true
60
-
61
- // Use vars which automatically cascade from scope to global
62
- const defaultSimplified = isTransport ? vars.simplifiedTransport : vars.simplifiedApi
63
-
64
- // Get simplified setting - from params only (per-call override) or use default
65
- context.simplified = params.simplified !== undefined ? params.simplified : defaultSimplified
66
-
67
- // Special case: if no inputRecord provided, force simplified mode
68
- if (!params.inputRecord && context.simplified === false) {
69
- context.simplified = true
54
+ export async function setupCommonRequest ({ params, context, vars, scopes, scopeName, api, helpers, runHooks }) {
55
+ context.id = undefined
56
+ context.originalInputAttributes = undefined
57
+ context.minimalRecord = undefined
58
+ context.schemaInfo = /** @type {LifecycleResource} */ (scopes[scopeName]).vars.schemaInfo
59
+ rejectRemovedOptions(params)
60
+ context.format = resolveFormat(params.format, vars.format)
61
+ context.simplified = context.format === 'plain'
62
+ context.returning = resolveReturning(params.returning, vars.returning)
63
+ context.params = params
64
+ const inputKey = selectWriteInput(params)
65
+ context.inputRecord = params[inputKey]
66
+ if (!context.inputRecord || typeof context.inputRecord !== 'object' || Array.isArray(context.inputRecord)) {
67
+ throw new RestApiValidationError(`${inputKey} must be a record object`, { fields: [inputKey] })
70
68
  }
71
69
 
72
- // Params is totally bypassed in simplified mode
73
- if (context.simplified) {
74
- if (params.inputRecord) {
75
- context.inputRecord = params.inputRecord
76
- context.params = params
77
- } else {
78
- context.inputRecord = params
79
- // Preserve returnFullRecord if specified in params
80
- context.params = params.returnFullRecord !== undefined ? { returnFullRecord: params.returnFullRecord } : {}
81
- }
82
- } else {
83
- context.inputRecord = params.inputRecord
84
- context.params = params
85
- }
86
-
87
- // Assign common context properties
88
- context.schemaInfo = scopes[scopeName].vars.schemaInfo
89
-
90
- // Use vars which automatically cascade from scope to global
91
- const defaultReturnFullRecord = isTransport ? vars.returnRecordTransport : vars.returnRecordApi
92
-
93
- // Get return record setting - from params only (per-call override) or use default
94
- context.returnRecordSetting = normalizeReturnRecordSetting(
95
- context.params.returnFullRecord,
96
- defaultReturnFullRecord
97
- )
98
-
99
- // These only make sense as parameter per query, not in vars etc.
100
70
  context.queryParams = params.queryParams || {}
101
- context.queryParams.fields = cascadeConfig('fields', [context.queryParams], {})
102
- context.queryParams.include = cascadeConfig('include', [context.queryParams], [])
71
+ context.queryParams.fields = context.queryParams.fields ?? {}
72
+ if (context.queryParams.include == null) delete context.queryParams.include
103
73
 
104
74
  context.scopeName = scopeName
105
75
 
106
- // Transaction handling
107
- context.transaction = params.transaction ||
108
- (helpers.newTransaction && !params.transaction ? await helpers.newTransaction() : null)
109
- context.shouldCommit = !params.transaction && !!context.transaction
76
+ await beginWriteTransaction(context, params.transaction, helpers.newTransaction, runHooks)
110
77
  context.db = context.transaction || api.knex.instance
111
78
 
112
- // These are just shortcuts used in this function and will be returned
113
79
  const schema = context.schemaInfo.schemaInstance
114
80
  const schemaStructure = context.schemaInfo.schemaInstance.structure
115
81
  const schemaRelationships = context.schemaInfo.schemaRelationships
116
82
 
117
- // Transform input if in simplified mode
118
- if (context.simplified) {
83
+ // Both input forms enter the same validation and hook pipeline.
84
+ if (inputKey === 'data') {
119
85
  context.inputRecord = transformSimplifiedToJsonApi(
120
86
  { inputRecord: context.inputRecord },
121
87
  { context: { scopeName, schemaStructure, schemaRelationships } }
122
88
  )
123
89
  }
124
90
 
125
- // Return key schema-related objects for direct use in the main methods
126
- return { schema, schemaStructure, schemaRelationships }
91
+ const versionState = initializeResourceVersion({ context, expectedVersion: params.expectedVersion })
92
+ const preparedContext = /** @type {ProcessingContext} */ (context)
93
+ return { context: preparedContext, schema, schemaStructure, schemaRelationships, versionState }
127
94
  }
128
95
 
129
96
  /**
130
97
  * Handles error cleanup and logging for write methods (POST, PUT, PATCH)
131
98
  *
132
- * @param {Error} error - The error that was caught
133
- * @param {Object} context - The request context
99
+ * @param {unknown} error - The value that was thrown
100
+ * @param {LifecycleContext} context - The request context
134
101
  * @param {string} method - The HTTP method name (POST, PUT, PATCH)
135
102
  * @param {string} scopeName - The name of the resource scope
136
- * @param {Object} log - The logger instance
137
- * @param {Function} runHooks - Function to run hooks
138
- * @throws {Error} Re-throws the original error after cleanup
103
+ * @param {RuntimeLogger} log - The logger instance
104
+ * @throws {unknown} Re-throws the original value after cleanup
139
105
  */
140
- export const handleWriteMethodError = async (error, context, method, scopeName, log, runHooks) => {
141
- // Rollback transaction if we created it
142
- if (context.shouldCommit && !context.transactionCommitted) {
143
- await context.transaction.rollback()
144
- await runHooks('afterRollback')
106
+ export const handleWriteMethodError = async (error, context, method, scopeName, log) => {
107
+ await rollbackAfterError(error, context, context.transaction)
108
+ const cleanupErrors = context.cleanupErrors || (context.cleanupErrors = [])
109
+
110
+ try {
111
+ await getEnhancedLogger(log, context).logError(`Error in ${method} method`, error, {
112
+ ...getOperationDiagnosticContext(context, { phase: 'writeFailure', scopeName, method: context.method ?? method.toLowerCase() }),
113
+ inputRecord: context.inputRecord,
114
+ cleanupErrors
115
+ })
116
+ } catch (error) {
117
+ cleanupErrors.push({ phase: 'logging', error })
145
118
  }
146
119
 
147
- // Create enhanced logger for error logging
148
- const enhancedLog = getEnhancedLogger(log)
149
-
150
- // Log the full error details
151
- enhancedLog.logError(`Error in ${method} method`, error, {
152
- scopeName,
153
- method: method.toLowerCase(),
154
- inputRecord: context.inputRecord
155
- })
156
-
157
120
  throw error
158
121
  }
159
122
 
160
- export const commitOwnedTransaction = async (context, runHooks) => {
161
- if (!context.shouldCommit) {
123
+ /** @param {LifecycleContext} context */
124
+ export const commitOwnedTransaction = async (context) => {
125
+ if (!isTransactionOwner(context)) {
162
126
  return
163
127
  }
164
128
 
165
- await context.transaction.commit()
166
- context.transactionCommitted = true
167
- await runHooks('afterCommit')
129
+ await commitTransaction(/** @type {import('../../../lib/transaction-types.js').OwnedTransaction} */ (context.transaction), context)
168
130
  }
169
131
 
132
+ /** @param {{ context: RelationshipContext, vars: LifecycleVars, scopeName: string, operation: 'postRelationship' | 'patchRelationship' | 'deleteRelationship', relationshipData: unknown }} options */
170
133
  export const validateRelationshipRoutePayload = ({
171
134
  context,
172
135
  vars,
@@ -187,19 +150,30 @@ export const validateRelationshipRoutePayload = ({
187
150
  `${operation} relationship request body is invalid`
188
151
  )
189
152
 
190
- return validated.data
153
+ const record = validateRequestContractOrThrow(
154
+ contracts.patch,
155
+ {
156
+ data: {
157
+ type: scopeName,
158
+ id: context.id,
159
+ relationships: { [context.relationshipName]: { data: validated.data } }
160
+ }
161
+ },
162
+ `${operation} relationship data is invalid`
163
+ )
164
+ return record.data.relationships[context.relationshipName].data
191
165
  }
192
166
 
193
167
  /**
194
168
  * Validates that a pivot resource exists for many-to-many relationships
195
169
  *
196
- * @param {Object} scopes - All available scopes/resources
197
- * @param {Object} relDef - The relationship definition
170
+ * @param {Record<string, LifecycleResource>} scopes - All available scopes/resources
171
+ * @param {Field} relDef - The relationship definition
198
172
  * @param {string} relName - The relationship name
199
173
  * @throws {RestApiValidationError} If the pivot resource doesn't exist
200
174
  */
201
175
  export const validatePivotResource = (scopes, relDef, relName) => {
202
- if (!scopes[relDef.through]) {
176
+ if (!scopes[/** @type {string} */ (relDef.through)]) {
203
177
  throw new RestApiValidationError(
204
178
  `Pivot resource '${relDef.through}' not found for relationship '${relName}'`,
205
179
  {
@@ -224,6 +198,7 @@ export const getMethodHookSuffix = (method) => {
224
198
  return method.charAt(0).toUpperCase() + method.slice(1)
225
199
  }
226
200
 
201
+ /** @param {{ context: WriteContext, belongsToUpdates: StorageRow }} options */
227
202
  export const validateCompleteReplacePayload = ({
228
203
  context,
229
204
  belongsToUpdates
@@ -237,13 +212,12 @@ export const validateCompleteReplacePayload = ({
237
212
  for (const [fieldName, fieldDef] of Object.entries(schemaStructure)) {
238
213
  if (!fieldDef) continue
239
214
  if (fieldName === idProperty) continue
215
+ if (Object.hasOwn(belongsToUpdates, fieldName)) continue
240
216
  if (fieldDef.computed === true || fieldDef.virtual === true) continue
241
217
  if (fieldDef.type === undefined) continue
242
218
 
243
219
  if (fieldDef.belongsTo && fieldDef.as) {
244
- if (Object.hasOwn(belongsToUpdates, fieldName)) continue
245
-
246
- const existingRelationshipData = context.minimalRecord?.relationships?.[fieldDef.as]?.data
220
+ const existingRelationshipData = /** @type {Identifier | null | undefined} */ (context.minimalRecord?.relationships?.[fieldDef.as]?.data)
247
221
  if (existingRelationshipData?.id !== undefined && existingRelationshipData?.id !== null) {
248
222
  missingFields.push({
249
223
  field: `data.relationships.${fieldDef.as}.data.id`,
@@ -283,11 +257,11 @@ export const validateCompleteReplacePayload = ({
283
257
  * Validates resource attributes before write operations
284
258
  *
285
259
  * @param {Object} params - Validation parameters
286
- * @param {Object} params.context - The request context
287
- * @param {Object} params.schema - The resource schema
288
- * @param {Object} params.belongsToUpdates - BelongsTo relationship updates
289
- * @param {Function} params.runHooks - Function to run hooks
290
- * @param {boolean} params.isPartialValidation - Whether this is partial validation (for PATCH)
260
+ * @param {WriteContext} params.context - The request context
261
+ * @param {Schema} params.schema - The resource schema
262
+ * @param {StorageRow} params.belongsToUpdates - BelongsTo relationship updates
263
+ * @param {RunHooks} params.runHooks - Function to run hooks
264
+ * @param {boolean} [params.isPartialValidation] - Whether this is partial validation (for PATCH)
291
265
  * @throws {RestApiValidationError} If validation fails
292
266
  */
293
267
  export const validateResourceAttributesBeforeWrite = async ({
@@ -297,11 +271,10 @@ export const validateResourceAttributesBeforeWrite = async ({
297
271
  runHooks,
298
272
  isPartialValidation = false
299
273
  }) => {
300
- // Dynamically get the method suffix
301
- const methodSpecificHookSuffix = getMethodHookSuffix(context.method)
274
+ const hookSuffix = getMethodHookSuffix(context.method)
302
275
 
303
276
  await runHooks('beforeSchemaValidate')
304
- await runHooks(`beforeSchemaValidate${methodSpecificHookSuffix}`)
277
+ await runHooks(`beforeSchemaValidate${hookSuffix}`)
305
278
 
306
279
  // Store original input attributes before validation adds defaults or casts.
307
280
  if (!context.originalInputAttributes) {
@@ -319,97 +292,47 @@ export const validateResourceAttributesBeforeWrite = async ({
319
292
  ...(hasLogicalIdField && resourceId !== undefined ? { id: resourceId } : {})
320
293
  }
321
294
 
322
- // Extract computed fields and foreign key fields before validation
323
- const computedFields = {}
324
- const foreignKeyFields = {}
325
- Object.entries(attributesToValidate).forEach(([key, value]) => {
326
- const fieldDef = schemaStructure[key]
327
- // Computed fields should be stripped from input
328
- if (fieldDef && fieldDef.computed === true && value !== undefined) {
329
- computedFields[key] = value
330
- }
331
- // Foreign key fields (belongsTo with 'as' property) should NOT be in attributes
332
- // They should only come through relationships or belongsToUpdates
333
- // Check if key exists in belongsToUpdates (not just truthy value)
334
- if (fieldDef && fieldDef.belongsTo && fieldDef.as && value !== undefined && !(key in belongsToUpdates)) {
335
- foreignKeyFields[key] = value
336
- }
337
- })
338
-
339
- // Warn if computed fields were sent in the input
340
- if (Object.keys(computedFields).length > 0) {
341
- const fieldNames = Object.keys(computedFields).join(', ')
342
- console.warn(`Computed fields [${fieldNames}] were sent in input for resource '${context.scopeName}' but will be ignored as they are output-only`)
343
- }
344
-
345
- // Reject if foreign key fields were sent directly in attributes
346
- if (Object.keys(foreignKeyFields).length > 0) {
347
- const violations = Object.keys(foreignKeyFields).map(field => {
348
- const fieldDef = schemaStructure[field]
349
- return {
350
- field: `data.attributes.${field}`,
351
- rule: 'foreign_key_in_attributes',
352
- message: `Foreign key field '${field}' should not be in attributes. Use 'data.relationships.${fieldDef.as}' instead.`
353
- }
295
+ // Physical foreign keys may enter storage only through relationship planning.
296
+ /** @type {StorageRow} */
297
+ const attributesForValidation = { ...attributesToValidate }
298
+ const foreignKeyViolations = []
299
+ for (const [field, value] of Object.entries(attributesToValidate)) {
300
+ const definition = schemaStructure[field]
301
+ if (!definition?.belongsTo || !definition.as || Object.hasOwn(belongsToUpdates, field)) continue
302
+ delete attributesForValidation[field]
303
+ if (value === undefined) continue
304
+ foreignKeyViolations.push({
305
+ field: `data.attributes.${field}`,
306
+ rule: 'foreign_key_in_attributes',
307
+ message: `Foreign key field '${field}' should not be in attributes. Use 'data.relationships.${definition.as}' instead.`
354
308
  })
355
-
356
- throw new RestApiValidationError(
357
- 'Foreign key fields cannot be set directly in attributes',
358
- {
359
- fields: violations.map(v => v.field),
360
- violations
361
- }
362
- )
363
309
  }
364
-
365
- // Filter out computed fields and foreign key fields from validation
366
- // Virtual fields MUST go through validation
367
- const attributesForValidation = Object.entries(attributesToValidate)
368
- .filter(([key, _]) => {
369
- const fieldDef = schemaStructure[key]
370
- // Exclude computed fields
371
- if (fieldDef && fieldDef.computed === true) {
372
- return false
373
- }
374
- // Exclude foreign key fields that weren't provided via belongsToUpdates
375
- if (fieldDef && fieldDef.belongsTo && fieldDef.as && !belongsToUpdates[key]) {
376
- return false
377
- }
378
- return true
310
+ if (foreignKeyViolations.length) {
311
+ throw new RestApiValidationError('Foreign key fields cannot be set directly in attributes', {
312
+ fields: foreignKeyViolations.map(violation => violation.field),
313
+ violations: foreignKeyViolations
379
314
  })
380
- .reduce((acc, [key, value]) => {
381
- acc[key] = value
382
- return acc
383
- }, {})
315
+ }
384
316
 
385
- const validationMethod = isPartialValidation
386
- ? 'patch'
387
- : context.method === 'put'
388
- ? 'replace'
389
- : 'create'
317
+ /** @type {'create' | 'patch' | 'replace'} */
318
+ let validationMethod = 'create'
319
+ if (isPartialValidation) validationMethod = 'patch'
320
+ else if (context.method === 'put') validationMethod = 'replace'
390
321
 
391
322
  const { validatedObject, errors } = await schema[validationMethod](attributesForValidation)
392
323
 
393
324
  if (Object.keys(errors).length > 0) {
394
325
  const violations = Object.entries(errors).map(([field, error]) => {
395
- let fieldPath = `data.attributes.${field}` // Default path for attributes
326
+ let fieldPath = `data.attributes.${field}`
396
327
 
397
328
  if (field === 'id' && hasLogicalIdField) {
398
329
  fieldPath = 'data.id'
399
330
  }
400
331
 
401
- // Check if this field is a foreign key that has an 'as' alias
402
332
  const fieldDef = schemaStructure[field]
403
333
  if (fieldDef && fieldDef.belongsTo && fieldDef.as) {
404
- // If it's a belongsTo field with an alias, rewrite the path to the relationship alias
405
334
  fieldPath = `data.relationships.${fieldDef.as}.data.id`
406
335
  }
407
- // For many-to-many relationships, the original `transformSimplifiedToJsonApi`
408
- // already puts them under `relationships.relName.data`, so `field` here
409
- // would already be the relationship name, not a foreign key.
410
- // However, if a validation error somehow slips through for a pivot table field
411
- // that doesn't have an 'as' alias but is a foreign key, you might need
412
- // more sophisticated mapping. For now, this covers belongsTo.
413
336
 
414
337
  return {
415
338
  field: fieldPath,
@@ -421,7 +344,7 @@ export const validateResourceAttributesBeforeWrite = async ({
421
344
  throw new RestApiValidationError(
422
345
  'Schema validation failed for resource attributes',
423
346
  {
424
- fields: violations.map(v => v.field), // Use the potentially rewritten fields
347
+ fields: violations.map(v => v.field),
425
348
  violations
426
349
  }
427
350
  )
@@ -432,21 +355,19 @@ export const validateResourceAttributesBeforeWrite = async ({
432
355
  }
433
356
  const { id: _validatedId, ...validatedAttributes } = validatedObject
434
357
 
435
- // Update attributes with validated values
436
- // Virtual fields have now been validated and cast properly
437
358
  context.inputRecord.data.attributes = validatedAttributes
438
359
 
439
- await runHooks(`afterSchemaValidate${methodSpecificHookSuffix}`)
360
+ await runHooks(`afterSchemaValidate${hookSuffix}`)
440
361
  await runHooks('afterSchemaValidate')
441
362
  }
442
363
 
443
364
  /**
444
365
  * Validates that the user has access to all resources referenced in relationships
445
366
  *
446
- * @param {object} context - The context object containing authentication info
447
- * @param {object} inputRecord - The input record containing relationships to validate
448
- * @param {object} helpers - Data helpers including dataGetMinimal
449
- * @param {object} api - API instance to access resources
367
+ * @param {ResourceContext} context - The context object containing authentication info
368
+ * @param {InputDocument} inputRecord - The input record containing relationships to validate
369
+ * @param {LifecycleHelpers} helpers - Data helpers including dataGetMinimal
370
+ * @param {LifecycleApi} api - API instance to access resources
450
371
  * @throws {Error} If user doesn't have access to any related resource
451
372
  */
452
373
  export const validateRelationshipAccess = async (context, inputRecord, helpers, api) => {
@@ -455,7 +376,7 @@ export const validateRelationshipAccess = async (context, inputRecord, helpers,
455
376
  for (const [relName, relData] of Object.entries(inputRecord.data.relationships)) {
456
377
  if (!relData?.data) continue
457
378
 
458
- const normalizedRelationshipData = normalizeRelationshipIdentifiers(relData.data, { api })
379
+ const normalizedRelationshipData = /** @type {Identifier | Identifier[]} */ (normalizeRelationshipIdentifiers(relData.data, { api }))
459
380
  inputRecord.data.relationships[relName] = {
460
381
  ...relData,
461
382
  data: normalizedRelationshipData
@@ -466,62 +387,67 @@ export const validateRelationshipAccess = async (context, inputRecord, helpers,
466
387
  ? normalizedRelationshipData
467
388
  : [normalizedRelationshipData]
468
389
 
469
- for (const item of relatedItems) {
470
- // Get the scope for the related resource
471
- const relatedScope = api.resources[item.type]
472
- if (!relatedScope) {
473
- throw new Error(`Unknown resource type: ${item.type}`)
474
- }
475
-
476
- // Create context for dataGetMinimal
477
- const getContext = {
478
- ...context,
479
- id: item.id,
480
- schemaInfo: relatedScope.vars.schemaInfo,
481
- scopeName: item.type,
482
- queryParams: {},
483
- method: 'get', // We're checking read permission
484
- isUpdate: false
390
+ const unique = new Map()
391
+ for (const item of relatedItems) unique.set(JSON.stringify([item.type, item.id]), item)
392
+ const targets = [...unique.values()]
393
+ for (let offset = 0; offset < targets.length; offset += RELATIONSHIP_READ_BATCH_SIZE) {
394
+ const batch = targets.slice(offset, offset + RELATIONSHIP_READ_BATCH_SIZE)
395
+ /** @type {Map<string, Identifier[]>} */
396
+ const byType = new Map()
397
+ for (const item of batch) {
398
+ const items = byType.get(item.type)
399
+ if (items) items.push(item)
400
+ else byType.set(item.type, [item])
485
401
  }
486
-
487
- // Get the minimal record
488
- const record = await helpers.dataGetMinimal({
489
- scopeName: item.type,
490
- context: getContext,
491
- applyQueryFilters: relatedScope.applyQueryFilters
492
- ? (filterParams) => relatedScope.applyQueryFilters({
493
- ...filterParams,
494
- schemaInfo: getContext.schemaInfo
495
- }, getContext)
496
- : undefined,
497
- filters: {},
498
- queryPurpose: 'relationship-validation'
499
- })
500
-
501
- if (!record) {
502
- throw new RestApiResourceError(
503
- `Cannot create relationship to non-existent ${item.type} with id ${item.id}`,
504
- {
505
- subtype: 'not_found',
506
- resourceType: item.type,
507
- resourceId: item.id
508
- }
509
- )
510
- }
511
-
512
- // Check permissions using the related scope's checkPermissions
513
- await relatedScope.checkPermissions({
514
- method: 'get',
515
- originalContext: {
402
+ const lookups = new Map()
403
+ for (const [type, items] of byType) {
404
+ const relatedScope = api.resources[type]
405
+ if (!relatedScope) throw new Error(`Unknown resource type: ${type}`)
406
+ const getContext = {
516
407
  ...context,
517
- id: item.id,
518
- minimalRecord: record
408
+ id: undefined,
409
+ minimalRecord: undefined,
410
+ schemaInfo: relatedScope.vars.schemaInfo,
411
+ scopeName: type,
412
+ queryParams: {},
413
+ method: 'get',
414
+ isUpdate: false
519
415
  }
520
- })
416
+ const lookup = {
417
+ scopeName: type,
418
+ context: getContext,
419
+ /** @param {import('../lib/storage/storage-types.js').QueryFilteringState} filterParams */
420
+ applyQueryFilters: filterParams => relatedScope.applyQueryFilters({
421
+ ...filterParams, schemaInfo: getContext.schemaInfo
422
+ }, getContext),
423
+ filters: {},
424
+ queryPurpose: 'relationship-validation'
425
+ }
426
+ const records = await helpers.dataGetMinimal({ ...lookup, ids: items.map(item => item.id) })
427
+ lookups.set(type, { relatedScope, lookup, records: new Map(records.map(record => [String(record.id), record])) })
428
+ }
429
+ for (const item of batch) {
430
+ const { relatedScope, lookup, records } = lookups.get(item.type)
431
+ // A database collation can equate IDs with different spellings.
432
+ const record = records.get(String(item.id)) || await helpers.dataGetMinimal({
433
+ ...lookup, context: { ...lookup.context, id: item.id }
434
+ })
435
+ if (!record) {
436
+ throw new RestApiResourceError(
437
+ `Cannot create relationship to non-existent ${item.type} with id ${item.id}`,
438
+ { subtype: 'not_found', resourceType: item.type, resourceId: item.id }
439
+ )
440
+ }
441
+ await relatedScope.checkPermissions({
442
+ method: 'get',
443
+ originalContext: { ...lookup.context, id: item.id, minimalRecord: record }
444
+ })
445
+ }
521
446
  }
522
447
  }
523
448
  }
524
449
 
450
+ /** @param {Pick<LifecycleArguments, 'helpers' | 'scopeName' | 'runHooks'> & { context: IdentityContext }} options */
525
451
  export const getVisibleRelationshipParent = async ({
526
452
  context,
527
453
  helpers,
@@ -539,30 +465,29 @@ export const getVisibleRelationshipParent = async ({
539
465
  }
540
466
 
541
467
  /**
542
- * Applies field setters to transform attribute values before storage
543
- * Setters are applied in dependency order to handle interdependent fields
544
- *
545
- * @param {object} attributes - The validated attributes to transform
546
- * @param {object} schemaInfo - Schema information including fieldSetters
547
- * @param {object} context - The request context
548
- * @param {object} api - The API instance
549
- * @param {object} helpers - Helper functions
550
- * @returns {object} Transformed attributes ready for storage
468
+ * Replaces validated write attributes with setter results in dependency order.
469
+ * Assigns results only after every setter succeeds; does not complete transactions.
470
+ * @param {WriteContext} context - Write context with inputRecord and schemaInfo
471
+ * @param {LifecycleApi} api - The API instance
472
+ * @param {LifecycleHelpers} helpers - Helper functions
473
+ * @returns {Promise<void>}
551
474
  */
552
- export const applyFieldSetters = async (attributes, schemaInfo, context, api, helpers) => {
475
+ export const applyFieldSetters = async (context, api, helpers) => {
476
+ const attributes = context.inputRecord?.data?.attributes
477
+ if (!attributes) return
478
+ const schemaInfo = context.schemaInfo
553
479
  const fieldSetters = schemaInfo.fieldSetters || {}
554
480
  const sortedSetterFields = schemaInfo.sortedSetterFields || []
555
481
 
556
- // No setters to apply
557
482
  if (sortedSetterFields.length === 0) {
558
- return attributes
483
+ return
559
484
  }
560
485
 
561
486
  const transformedAttributes = { ...attributes }
562
487
 
563
488
  for (const fieldName of sortedSetterFields) {
564
- const setterInfo = fieldSetters[fieldName]
565
- const fieldPresent = fieldName in transformedAttributes
489
+ const setterInfo = /** @type {NonNullable<LifecycleSchema['fieldSetters'][string]>} */ (fieldSetters[fieldName])
490
+ const fieldPresent = Object.hasOwn(transformedAttributes, fieldName)
566
491
  const hasDependencies = Array.isArray(setterInfo.runSetterAfter) && setterInfo.runSetterAfter.length > 0
567
492
 
568
493
  if (!fieldPresent && !hasDependencies) continue
@@ -571,7 +496,7 @@ export const applyFieldSetters = async (attributes, schemaInfo, context, api, he
571
496
  const setterContext = {
572
497
  attributes: transformedAttributes, // Current state with previous setters applied
573
498
  fieldName,
574
- originalValue: attributes[fieldName],
499
+ originalValue: Object.hasOwn(attributes, fieldName) ? attributes[fieldName] : undefined,
575
500
  originalAttributes: attributes,
576
501
  scopeName: context.scopeName,
577
502
  method: context.method,
@@ -580,153 +505,124 @@ export const applyFieldSetters = async (attributes, schemaInfo, context, api, he
580
505
  auth: context.auth
581
506
  }
582
507
  transformedAttributes[fieldName] = await setterInfo.setter(
583
- transformedAttributes[fieldName],
508
+ fieldPresent ? transformedAttributes[fieldName] : undefined,
584
509
  setterContext
585
510
  )
586
511
  } catch (error) {
587
- if (isRestApiError(error)) throw error
588
- const message = error instanceof Error ? error.message : String(error)
589
- throw new RestApiValidationError(
590
- `Setter for field '${fieldName}' failed: ${message}`,
591
- {
592
- fields: [`data.attributes.${fieldName}`],
593
- violations: [{
594
- field: `data.attributes.${fieldName}`,
595
- rule: 'setter_failed',
596
- message
597
- }]
598
- }
599
- )
600
- }
601
- }
602
-
603
- return transformedAttributes
604
- }
605
-
606
- export async function handleRecordReturnAfterWrite ({
607
- context,
608
- scopeName,
609
- api,
610
- scopes,
611
- schemaStructure,
612
- schemaRelationships,
613
- scopeOptions,
614
- vars,
615
- runHooks,
616
- helpers,
617
- log
618
- }) {
619
- // Create enhanced logger for warnings
620
- const enhancedLog = getEnhancedLogger(log)
621
- const methodSpecificHookSuffix = getMethodHookSuffix(context.method)
622
-
623
- // Step 1: Set up record state for hooks
624
- // Handle originalMinimalRecord and minimalRecord based on method type
625
- if (context.method === 'DELETE') {
626
- // For DELETE, keep the deleted record reference
627
- if (context.minimalRecord) {
628
- context.originalMinimalRecord = context.minimalRecord
629
- }
630
- } else {
631
- // For POST, PUT, PATCH - save the original state if it exists
632
- if (context.minimalRecord) {
633
- context.originalMinimalRecord = context.minimalRecord
634
- }
635
-
636
- // Fetch the current state of the record after the write operation
637
- try {
638
- const currentRecord = await helpers.dataGetMinimal({
639
- scopeName,
640
- context,
641
- runHooks
512
+ throw wrapUnexpectedError(error, {
513
+ message: `Setter for field '${fieldName}' failed`,
514
+ context: { scopeName: context.scopeName, fieldName, phase: 'setter' }
642
515
  })
643
- context.minimalRecord = currentRecord
644
- } catch (error) {
645
- if (isRestApiError(error)) throw error
646
- enhancedLog.warn(`Could not fetch minimal record after ${context.method} operation`, { error, id: context.id })
647
516
  }
648
517
  }
649
518
 
650
- // Step 2: Determine what to return based on configuration
651
- const returnMode = context.returnRecordSetting[context.method]
652
-
653
- // Case 1: Return nothing (204 No Content)
654
- if (returnMode === 'no') {
655
- context.responseRecord = undefined
656
- await runHooks('finish')
657
- await runHooks(`finish${methodSpecificHookSuffix}`)
658
- return undefined
659
- }
519
+ context.inputRecord.data.attributes = transformedAttributes
520
+ }
660
521
 
661
- // Case 2: Return minimal record (just type and id)
662
- if (returnMode === 'minimal') {
663
- if (context.simplified) {
664
- context.responseRecord = {
665
- id: String(context.id),
666
- type: scopeName
667
- }
668
- } else {
669
- context.responseRecord = {
670
- data: {
671
- type: scopeName,
672
- id: String(context.id)
673
- }
674
- }
675
- }
676
- await runHooks('finish')
677
- await runHooks(`finish${methodSpecificHookSuffix}`)
678
- context.responseRecord = normalizeRecordAttributes(context.responseRecord, scopes, {
679
- source: 'response',
680
- simplified: context.simplified,
681
- resourceType: scopeName
522
+ /**
523
+ * Refreshes post-write state and prepares POST/PATCH/PUT responses before commit.
524
+ * Runs finish hooks once and normalizes their result; borrows the write transaction.
525
+ * @param {Pick<LifecycleArguments, 'scopeName' | 'api' | 'scopes' | 'runHooks' | 'helpers'> & { context: CompletedWriteContext }} options
526
+ */
527
+ export async function handleRecordReturnAfterWrite ({ context, scopeName, api, scopes, runHooks, helpers }) {
528
+ const hookSuffix = getMethodHookSuffix(context.method)
529
+ if (context.minimalRecord) context.originalMinimalRecord = context.minimalRecord
530
+
531
+ try {
532
+ context.minimalRecord = await helpers.dataGetMinimal({ scopeName, context, runHooks })
533
+ } catch (error) {
534
+ throw wrapUnexpectedError(error, {
535
+ message: `Failed to refresh record after ${context.method}`,
536
+ context: { scopeName, phase: 'postWriteRead' }
682
537
  })
683
- return context.responseRecord
684
538
  }
685
539
 
686
- // Case 3: Return full record
687
- if (returnMode === 'full') {
688
- // Fetch the complete record using the GET method
689
- const fullRecord = await api.resources[scopeName].get({
540
+ const returnMode = context.returning
541
+ if (returnMode === 'none') {
542
+ context.responseRecord = undefined
543
+ } else if (returnMode === 'minimal') {
544
+ const identifier = { type: scopeName, id: String(context.id) }
545
+ context.responseRecord = context.simplified ? identifier : { data: identifier }
546
+ } else if (returnMode === 'full') {
547
+ const fullRecord = await /** @type {LifecycleResource} */ (api.resources[scopeName]).get({
690
548
  id: context.id,
691
549
  queryParams: context.queryParams,
692
550
  transaction: context.transaction,
693
- simplified: context.simplified
694
- }, { ...context })
695
-
551
+ format: context.format
552
+ }, {
553
+ ...context,
554
+ inputRecord: {
555
+ ...context.inputRecord,
556
+ data: { ...context.inputRecord.data, id: String(context.id) }
557
+ }
558
+ })
696
559
  context.responseRecord = fullRecord || undefined
560
+ } else {
561
+ throw new Error(`Invalid returnMode: ${returnMode}`)
562
+ }
697
563
 
698
- // Run finish hooks
699
- await runHooks('finish')
700
- await runHooks(`finish${methodSpecificHookSuffix}`)
564
+ await runHooks('finish')
565
+ await runHooks(`finish${hookSuffix}`)
566
+ if (returnMode === 'none') return undefined
701
567
 
702
- context.responseRecord = normalizeRecordAttributes(context.responseRecord, scopes, {
703
- source: 'response',
704
- simplified: context.simplified,
705
- resourceType: scopeName
706
- })
568
+ context.responseRecord = normalizeRecordAttributes(context.responseRecord, scopes, {
569
+ source: 'response',
570
+ simplified: context.simplified,
571
+ resourceType: scopeName,
572
+ fields: returnMode === 'full' ? context.queryParams?.fields : undefined
573
+ })
574
+ // After-commit observers must not mutate the prepared response.
575
+ return structuredClone(context.responseRecord)
576
+ }
707
577
 
708
- // No transformation needed - GET already returns the correct format
709
- // based on context.simplified
710
- return context.responseRecord
578
+ /** @param {ResourceContext} context @param {Record<string, LifecycleResource>} scopes */
579
+ export const filterToOneResponseLinkage = async (context, scopes) => {
580
+ const document = context.record
581
+ const resources = [document?.data, document?.included].flat().filter(resource => resource != null)
582
+ const linkages = []
583
+ for (const resource of resources) {
584
+ const schemaInfo = scopes[resource.type]?.vars?.schemaInfo
585
+ if (!schemaInfo) continue
586
+ const requestedFields = parseFieldset(getResourceFieldset(context.queryParams?.fields, resource.type))
587
+ for (const [name, relationship] of Object.entries(resource.relationships || {})) {
588
+ if (requestedFields !== null && !requestedFields.includes(name)) continue
589
+ const definition = findRelationshipDefinition(schemaInfo, name)
590
+ if ((definition?.belongsTo || definition?.belongsToPolymorphic) && relationship.data) linkages.push(relationship)
591
+ }
592
+ }
593
+ if (linkages.length === 0) return
594
+ const visible = new Set(await filterVisibleIdentifiers({
595
+ identifiers: linkages.map(relationship => relationship.data), scopes, knex: context.db, context
596
+ }))
597
+ for (const relationship of linkages) {
598
+ if (!visible.has(relationship.data)) relationship.data = null
711
599
  }
712
-
713
- // This should never be reached, but just in case
714
- throw new Error(`Invalid returnMode: ${returnMode}`)
715
600
  }
716
601
 
717
- export const findRelationshipDefinition = (schemaInfo, relationshipName) => {
718
- // First check schemaRelationships (for relationships defined in relationships object)
719
- const relDef = schemaInfo.schemaRelationships?.[relationshipName]
720
- if (relDef) {
721
- return relDef
602
+ /** @param {ResourceContext} context @param {Record<string, LifecycleResource>} scopes @param {string[]} [resourceTypes] */
603
+ export const validateRequestedIncludes = (context, scopes, resourceTypes = [context.scopeName]) => {
604
+ const paths = context.queryParams?.include || []
605
+ if (!Array.isArray(paths) || paths.some(path => typeof path !== 'string')) {
606
+ throw new RestApiValidationError('Include must be an array of relationship paths')
722
607
  }
723
-
724
- // Then check schema fields for belongsTo relationships with matching 'as' property
725
- for (const [, fieldDef] of Object.entries(schemaInfo.schemaStructure || {})) {
726
- if (fieldDef.as === relationshipName && fieldDef.belongsTo) {
727
- return fieldDef
608
+ for (const path of paths) {
609
+ let candidates = new Set(resourceTypes)
610
+ for (const name of path.split('.')) {
611
+ const targets = new Set()
612
+ for (const resourceType of candidates) {
613
+ const schemaInfo = Object.hasOwn(scopes, resourceType) && scopes[resourceType]?.vars?.schemaInfo
614
+ if (!schemaInfo) continue
615
+ const relationship = findRelationshipDefinition(schemaInfo, name)
616
+ if (!relationship) continue
617
+ const types = relationship.belongsToPolymorphic?.types ||
618
+ [relationship.belongsTo || relationship.target || (relationship.type === 'manyToMany' ? name : undefined)]
619
+ for (const type of types) {
620
+ if (type !== undefined && Object.hasOwn(scopes, type) && scopes[type]?.vars?.schemaInfo) targets.add(type)
621
+ }
622
+ }
623
+ if (targets.size === 0) throw new RestApiIncludeError({ path, resourceType: context.scopeName })
624
+ // A polymorphic path may apply to a subset of the declared target types.
625
+ candidates = targets
728
626
  }
729
627
  }
730
-
731
- return null
732
628
  }