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,171 +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'
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'
17
32
 
18
- export { normalizeReturnRecordMode as normalizeReturnValue }
33
+ // A trusted connector callback, evaluated after normal write validation and authorization.
34
+ export const writePrecondition = Symbol('writePrecondition')
19
35
 
20
36
  /**
21
37
  * Gets an enhanced logger instance with full error details and stack traces
22
- * @param {Object} log - The base logger instance
23
- * @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
24
41
  */
25
- export const getEnhancedLogger = (log) => {
42
+ export const getEnhancedLogger = (log, context = {}) => {
26
43
  return createEnhancedLogger(log, {
27
44
  logFullErrors: true,
28
- includeStack: true
45
+ includeStack: true,
46
+ schemaInfo: context.schemaInfo
29
47
  })
30
48
  }
31
49
 
32
50
  /**
33
- * Cascades configuration values through multiple sources with fallback to default
34
- * @param {string} settingName - The setting name to look for
35
- * @param {Array} sources - Array of objects to search through
36
- * @param {*} defaultValue - Default value if not found
37
- * @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
38
53
  */
39
- export const cascadeConfig = (settingName, sources, defaultValue) =>
40
- sources.find(source => source?.[settingName] !== undefined)?.[settingName] ?? defaultValue
41
-
42
- /**
43
- * Sets up common request context for REST API methods
44
- * Handles simplified mode, transaction setup, and initial validation
45
- *
46
- * @param {Object} params - The method parameters
47
- * @param {Object} context - The request context
48
- * @param {Object} vars - Plugin variables
49
- * @param {Object} scopes - All available scopes
50
- * @param {Object} scopeOptions - Scope-specific options
51
- * @param {string} scopeName - The name of the current scope
52
- * @param {Object} api - The API instance
53
- * @param {Object} helpers - Helper functions
54
- * @returns {Object} An object containing schema-related shortcuts
55
- */
56
- export async function setupCommonRequest ({ params, context, vars, scopes, scopeOptions, scopeName, api, helpers }) {
57
- // Determine which simplified setting to use based on transport
58
- const isTransport = params.isTransport === true
59
-
60
- // Use vars which automatically cascade from scope to global
61
- const defaultSimplified = isTransport ? vars.simplifiedTransport : vars.simplifiedApi
62
-
63
- // Get simplified setting - from params only (per-call override) or use default
64
- context.simplified = params.simplified !== undefined ? params.simplified : defaultSimplified
65
-
66
- // Special case: if no inputRecord provided, force simplified mode
67
- if (!params.inputRecord && context.simplified === false) {
68
- context.simplified = true
69
- }
70
-
71
- // Params is totally bypassed in simplified mode
72
- if (context.simplified) {
73
- if (params.inputRecord) {
74
- context.inputRecord = params.inputRecord
75
- context.params = params
76
- } else {
77
- context.inputRecord = params
78
- // Preserve returnFullRecord if specified in params
79
- context.params = params.returnFullRecord !== undefined ? { returnFullRecord: params.returnFullRecord } : {}
80
- }
81
- } else {
82
- context.inputRecord = params.inputRecord
83
- context.params = params
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] })
84
68
  }
85
69
 
86
- // Assign common context properties
87
- context.schemaInfo = scopes[scopeName].vars.schemaInfo
88
-
89
- // Use vars which automatically cascade from scope to global
90
- const defaultReturnFullRecord = isTransport ? vars.returnRecordTransport : vars.returnRecordApi
91
-
92
- // Get return record setting - from params only (per-call override) or use default
93
- context.returnRecordSetting = normalizeReturnRecordSetting(
94
- context.params.returnFullRecord,
95
- defaultReturnFullRecord
96
- )
97
-
98
- // These only make sense as parameter per query, not in vars etc.
99
70
  context.queryParams = params.queryParams || {}
100
- context.queryParams.fields = cascadeConfig('fields', [context.queryParams], {})
101
- context.queryParams.include = cascadeConfig('include', [context.queryParams], [])
71
+ context.queryParams.fields = context.queryParams.fields ?? {}
72
+ if (context.queryParams.include == null) delete context.queryParams.include
102
73
 
103
74
  context.scopeName = scopeName
104
75
 
105
- // Transaction handling
106
- context.transaction = params.transaction ||
107
- (helpers.newTransaction && !params.transaction ? await helpers.newTransaction() : null)
108
- context.shouldCommit = !params.transaction && !!context.transaction
76
+ await beginWriteTransaction(context, params.transaction, helpers.newTransaction, runHooks)
109
77
  context.db = context.transaction || api.knex.instance
110
78
 
111
- // These are just shortcuts used in this function and will be returned
112
79
  const schema = context.schemaInfo.schemaInstance
113
80
  const schemaStructure = context.schemaInfo.schemaInstance.structure
114
81
  const schemaRelationships = context.schemaInfo.schemaRelationships
115
82
 
116
- // Transform input if in simplified mode
117
- if (context.simplified) {
83
+ // Both input forms enter the same validation and hook pipeline.
84
+ if (inputKey === 'data') {
118
85
  context.inputRecord = transformSimplifiedToJsonApi(
119
86
  { inputRecord: context.inputRecord },
120
87
  { context: { scopeName, schemaStructure, schemaRelationships } }
121
88
  )
122
89
  }
123
90
 
124
- // Return key schema-related objects for direct use in the main methods
125
- 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 }
126
94
  }
127
95
 
128
96
  /**
129
97
  * Handles error cleanup and logging for write methods (POST, PUT, PATCH)
130
98
  *
131
- * @param {Error} error - The error that was caught
132
- * @param {Object} context - The request context
99
+ * @param {unknown} error - The value that was thrown
100
+ * @param {LifecycleContext} context - The request context
133
101
  * @param {string} method - The HTTP method name (POST, PUT, PATCH)
134
102
  * @param {string} scopeName - The name of the resource scope
135
- * @param {Object} log - The logger instance
136
- * @param {Function} runHooks - Function to run hooks
137
- * @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
138
105
  */
139
- export const handleWriteMethodError = async (error, context, method, scopeName, log, runHooks) => {
140
- // Rollback transaction if we created it
141
- if (context.shouldCommit && !context.transactionCommitted) {
142
- await context.transaction.rollback()
143
- 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 })
144
118
  }
145
119
 
146
- // Create enhanced logger for error logging
147
- const enhancedLog = getEnhancedLogger(log)
148
-
149
- // Log the full error details
150
- enhancedLog.logError(`Error in ${method} method`, error, {
151
- scopeName,
152
- method: method.toLowerCase(),
153
- inputRecord: context.inputRecord
154
- })
155
-
156
120
  throw error
157
121
  }
158
122
 
159
- export const commitOwnedTransaction = async (context, runHooks) => {
160
- if (!context.shouldCommit) {
123
+ /** @param {LifecycleContext} context */
124
+ export const commitOwnedTransaction = async (context) => {
125
+ if (!isTransactionOwner(context)) {
161
126
  return
162
127
  }
163
128
 
164
- await context.transaction.commit()
165
- context.transactionCommitted = true
166
- await runHooks('afterCommit')
129
+ await commitTransaction(/** @type {import('../../../lib/transaction-types.js').OwnedTransaction} */ (context.transaction), context)
167
130
  }
168
131
 
132
+ /** @param {{ context: RelationshipContext, vars: LifecycleVars, scopeName: string, operation: 'postRelationship' | 'patchRelationship' | 'deleteRelationship', relationshipData: unknown }} options */
169
133
  export const validateRelationshipRoutePayload = ({
170
134
  context,
171
135
  vars,
@@ -186,19 +150,30 @@ export const validateRelationshipRoutePayload = ({
186
150
  `${operation} relationship request body is invalid`
187
151
  )
188
152
 
189
- 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
190
165
  }
191
166
 
192
167
  /**
193
168
  * Validates that a pivot resource exists for many-to-many relationships
194
169
  *
195
- * @param {Object} scopes - All available scopes/resources
196
- * @param {Object} relDef - The relationship definition
170
+ * @param {Record<string, LifecycleResource>} scopes - All available scopes/resources
171
+ * @param {Field} relDef - The relationship definition
197
172
  * @param {string} relName - The relationship name
198
173
  * @throws {RestApiValidationError} If the pivot resource doesn't exist
199
174
  */
200
175
  export const validatePivotResource = (scopes, relDef, relName) => {
201
- if (!scopes[relDef.through]) {
176
+ if (!scopes[/** @type {string} */ (relDef.through)]) {
202
177
  throw new RestApiValidationError(
203
178
  `Pivot resource '${relDef.through}' not found for relationship '${relName}'`,
204
179
  {
@@ -223,6 +198,7 @@ export const getMethodHookSuffix = (method) => {
223
198
  return method.charAt(0).toUpperCase() + method.slice(1)
224
199
  }
225
200
 
201
+ /** @param {{ context: WriteContext, belongsToUpdates: StorageRow }} options */
226
202
  export const validateCompleteReplacePayload = ({
227
203
  context,
228
204
  belongsToUpdates
@@ -236,13 +212,12 @@ export const validateCompleteReplacePayload = ({
236
212
  for (const [fieldName, fieldDef] of Object.entries(schemaStructure)) {
237
213
  if (!fieldDef) continue
238
214
  if (fieldName === idProperty) continue
215
+ if (Object.hasOwn(belongsToUpdates, fieldName)) continue
239
216
  if (fieldDef.computed === true || fieldDef.virtual === true) continue
240
217
  if (fieldDef.type === undefined) continue
241
218
 
242
219
  if (fieldDef.belongsTo && fieldDef.as) {
243
- if (Object.hasOwn(belongsToUpdates, fieldName)) continue
244
-
245
- const existingRelationshipData = context.minimalRecord?.relationships?.[fieldDef.as]?.data
220
+ const existingRelationshipData = /** @type {Identifier | null | undefined} */ (context.minimalRecord?.relationships?.[fieldDef.as]?.data)
246
221
  if (existingRelationshipData?.id !== undefined && existingRelationshipData?.id !== null) {
247
222
  missingFields.push({
248
223
  field: `data.relationships.${fieldDef.as}.data.id`,
@@ -282,11 +257,11 @@ export const validateCompleteReplacePayload = ({
282
257
  * Validates resource attributes before write operations
283
258
  *
284
259
  * @param {Object} params - Validation parameters
285
- * @param {Object} params.context - The request context
286
- * @param {Object} params.schema - The resource schema
287
- * @param {Object} params.belongsToUpdates - BelongsTo relationship updates
288
- * @param {Function} params.runHooks - Function to run hooks
289
- * @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)
290
265
  * @throws {RestApiValidationError} If validation fails
291
266
  */
292
267
  export const validateResourceAttributesBeforeWrite = async ({
@@ -296,11 +271,10 @@ export const validateResourceAttributesBeforeWrite = async ({
296
271
  runHooks,
297
272
  isPartialValidation = false
298
273
  }) => {
299
- // Dynamically get the method suffix
300
- const methodSpecificHookSuffix = getMethodHookSuffix(context.method)
274
+ const hookSuffix = getMethodHookSuffix(context.method)
301
275
 
302
276
  await runHooks('beforeSchemaValidate')
303
- await runHooks(`beforeSchemaValidate${methodSpecificHookSuffix}`)
277
+ await runHooks(`beforeSchemaValidate${hookSuffix}`)
304
278
 
305
279
  // Store original input attributes before validation adds defaults or casts.
306
280
  if (!context.originalInputAttributes) {
@@ -318,97 +292,47 @@ export const validateResourceAttributesBeforeWrite = async ({
318
292
  ...(hasLogicalIdField && resourceId !== undefined ? { id: resourceId } : {})
319
293
  }
320
294
 
321
- // Extract computed fields and foreign key fields before validation
322
- const computedFields = {}
323
- const foreignKeyFields = {}
324
- Object.entries(attributesToValidate).forEach(([key, value]) => {
325
- const fieldDef = schemaStructure[key]
326
- // Computed fields should be stripped from input
327
- if (fieldDef && fieldDef.computed === true && value !== undefined) {
328
- computedFields[key] = value
329
- }
330
- // Foreign key fields (belongsTo with 'as' property) should NOT be in attributes
331
- // They should only come through relationships or belongsToUpdates
332
- // Check if key exists in belongsToUpdates (not just truthy value)
333
- if (fieldDef && fieldDef.belongsTo && fieldDef.as && value !== undefined && !(key in belongsToUpdates)) {
334
- foreignKeyFields[key] = value
335
- }
336
- })
337
-
338
- // Warn if computed fields were sent in the input
339
- if (Object.keys(computedFields).length > 0) {
340
- const fieldNames = Object.keys(computedFields).join(', ')
341
- console.warn(`Computed fields [${fieldNames}] were sent in input for resource '${context.scopeName}' but will be ignored as they are output-only`)
342
- }
343
-
344
- // Reject if foreign key fields were sent directly in attributes
345
- if (Object.keys(foreignKeyFields).length > 0) {
346
- const violations = Object.keys(foreignKeyFields).map(field => {
347
- const fieldDef = schemaStructure[field]
348
- return {
349
- field: `data.attributes.${field}`,
350
- rule: 'foreign_key_in_attributes',
351
- message: `Foreign key field '${field}' should not be in attributes. Use 'data.relationships.${fieldDef.as}' instead.`
352
- }
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.`
353
308
  })
354
-
355
- throw new RestApiValidationError(
356
- 'Foreign key fields cannot be set directly in attributes',
357
- {
358
- fields: violations.map(v => v.field),
359
- violations
360
- }
361
- )
362
309
  }
363
-
364
- // Filter out computed fields and foreign key fields from validation
365
- // Virtual fields MUST go through validation
366
- const attributesForValidation = Object.entries(attributesToValidate)
367
- .filter(([key, _]) => {
368
- const fieldDef = schemaStructure[key]
369
- // Exclude computed fields
370
- if (fieldDef && fieldDef.computed === true) {
371
- return false
372
- }
373
- // Exclude foreign key fields that weren't provided via belongsToUpdates
374
- if (fieldDef && fieldDef.belongsTo && fieldDef.as && !belongsToUpdates[key]) {
375
- return false
376
- }
377
- 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
378
314
  })
379
- .reduce((acc, [key, value]) => {
380
- acc[key] = value
381
- return acc
382
- }, {})
315
+ }
383
316
 
384
- const validationMethod = isPartialValidation
385
- ? 'patch'
386
- : context.method === 'put'
387
- ? 'replace'
388
- : 'create'
317
+ /** @type {'create' | 'patch' | 'replace'} */
318
+ let validationMethod = 'create'
319
+ if (isPartialValidation) validationMethod = 'patch'
320
+ else if (context.method === 'put') validationMethod = 'replace'
389
321
 
390
322
  const { validatedObject, errors } = await schema[validationMethod](attributesForValidation)
391
323
 
392
324
  if (Object.keys(errors).length > 0) {
393
325
  const violations = Object.entries(errors).map(([field, error]) => {
394
- let fieldPath = `data.attributes.${field}` // Default path for attributes
326
+ let fieldPath = `data.attributes.${field}`
395
327
 
396
328
  if (field === 'id' && hasLogicalIdField) {
397
329
  fieldPath = 'data.id'
398
330
  }
399
331
 
400
- // Check if this field is a foreign key that has an 'as' alias
401
332
  const fieldDef = schemaStructure[field]
402
333
  if (fieldDef && fieldDef.belongsTo && fieldDef.as) {
403
- // If it's a belongsTo field with an alias, rewrite the path to the relationship alias
404
334
  fieldPath = `data.relationships.${fieldDef.as}.data.id`
405
335
  }
406
- // For many-to-many relationships, the original `transformSimplifiedToJsonApi`
407
- // already puts them under `relationships.relName.data`, so `field` here
408
- // would already be the relationship name, not a foreign key.
409
- // However, if a validation error somehow slips through for a pivot table field
410
- // that doesn't have an 'as' alias but is a foreign key, you might need
411
- // more sophisticated mapping. For now, this covers belongsTo.
412
336
 
413
337
  return {
414
338
  field: fieldPath,
@@ -420,7 +344,7 @@ export const validateResourceAttributesBeforeWrite = async ({
420
344
  throw new RestApiValidationError(
421
345
  'Schema validation failed for resource attributes',
422
346
  {
423
- fields: violations.map(v => v.field), // Use the potentially rewritten fields
347
+ fields: violations.map(v => v.field),
424
348
  violations
425
349
  }
426
350
  )
@@ -431,21 +355,19 @@ export const validateResourceAttributesBeforeWrite = async ({
431
355
  }
432
356
  const { id: _validatedId, ...validatedAttributes } = validatedObject
433
357
 
434
- // Update attributes with validated values
435
- // Virtual fields have now been validated and cast properly
436
358
  context.inputRecord.data.attributes = validatedAttributes
437
359
 
438
- await runHooks(`afterSchemaValidate${methodSpecificHookSuffix}`)
360
+ await runHooks(`afterSchemaValidate${hookSuffix}`)
439
361
  await runHooks('afterSchemaValidate')
440
362
  }
441
363
 
442
364
  /**
443
365
  * Validates that the user has access to all resources referenced in relationships
444
366
  *
445
- * @param {object} context - The context object containing authentication info
446
- * @param {object} inputRecord - The input record containing relationships to validate
447
- * @param {object} helpers - Data helpers including dataGetMinimal
448
- * @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
449
371
  * @throws {Error} If user doesn't have access to any related resource
450
372
  */
451
373
  export const validateRelationshipAccess = async (context, inputRecord, helpers, api) => {
@@ -454,7 +376,7 @@ export const validateRelationshipAccess = async (context, inputRecord, helpers,
454
376
  for (const [relName, relData] of Object.entries(inputRecord.data.relationships)) {
455
377
  if (!relData?.data) continue
456
378
 
457
- const normalizedRelationshipData = normalizeRelationshipIdentifiers(relData.data, { api })
379
+ const normalizedRelationshipData = /** @type {Identifier | Identifier[]} */ (normalizeRelationshipIdentifiers(relData.data, { api }))
458
380
  inputRecord.data.relationships[relName] = {
459
381
  ...relData,
460
382
  data: normalizedRelationshipData
@@ -465,62 +387,67 @@ export const validateRelationshipAccess = async (context, inputRecord, helpers,
465
387
  ? normalizedRelationshipData
466
388
  : [normalizedRelationshipData]
467
389
 
468
- for (const item of relatedItems) {
469
- // Get the scope for the related resource
470
- const relatedScope = api.resources[item.type]
471
- if (!relatedScope) {
472
- throw new Error(`Unknown resource type: ${item.type}`)
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])
473
401
  }
474
-
475
- // Create context for dataGetMinimal
476
- const getContext = {
477
- ...context,
478
- id: item.id,
479
- schemaInfo: relatedScope.vars.schemaInfo,
480
- scopeName: item.type,
481
- queryParams: {},
482
- method: 'get', // We're checking read permission
483
- isUpdate: false
484
- }
485
-
486
- // Get the minimal record
487
- const record = await helpers.dataGetMinimal({
488
- scopeName: item.type,
489
- context: getContext,
490
- applyQueryFilters: relatedScope.applyQueryFilters
491
- ? (filterParams) => relatedScope.applyQueryFilters({
492
- ...filterParams,
493
- schemaInfo: getContext.schemaInfo
494
- }, getContext)
495
- : undefined,
496
- filters: {},
497
- queryPurpose: 'relationship-validation'
498
- })
499
-
500
- if (!record) {
501
- throw new RestApiResourceError(
502
- `Cannot create relationship to non-existent ${item.type} with id ${item.id}`,
503
- {
504
- subtype: 'not_found',
505
- resourceType: item.type,
506
- resourceId: item.id
507
- }
508
- )
509
- }
510
-
511
- // Check permissions using the related scope's checkPermissions
512
- await relatedScope.checkPermissions({
513
- method: 'get',
514
- 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 = {
515
407
  ...context,
516
- id: item.id,
517
- minimalRecord: record
408
+ id: undefined,
409
+ minimalRecord: undefined,
410
+ schemaInfo: relatedScope.vars.schemaInfo,
411
+ scopeName: type,
412
+ queryParams: {},
413
+ method: 'get',
414
+ isUpdate: false
518
415
  }
519
- })
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
+ }
520
446
  }
521
447
  }
522
448
  }
523
449
 
450
+ /** @param {Pick<LifecycleArguments, 'helpers' | 'scopeName' | 'runHooks'> & { context: IdentityContext }} options */
524
451
  export const getVisibleRelationshipParent = async ({
525
452
  context,
526
453
  helpers,
@@ -538,30 +465,29 @@ export const getVisibleRelationshipParent = async ({
538
465
  }
539
466
 
540
467
  /**
541
- * Applies field setters to transform attribute values before storage
542
- * Setters are applied in dependency order to handle interdependent fields
543
- *
544
- * @param {object} attributes - The validated attributes to transform
545
- * @param {object} schemaInfo - Schema information including fieldSetters
546
- * @param {object} context - The request context
547
- * @param {object} api - The API instance
548
- * @param {object} helpers - Helper functions
549
- * @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>}
550
474
  */
551
- 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
552
479
  const fieldSetters = schemaInfo.fieldSetters || {}
553
480
  const sortedSetterFields = schemaInfo.sortedSetterFields || []
554
481
 
555
- // No setters to apply
556
482
  if (sortedSetterFields.length === 0) {
557
- return attributes
483
+ return
558
484
  }
559
485
 
560
486
  const transformedAttributes = { ...attributes }
561
487
 
562
488
  for (const fieldName of sortedSetterFields) {
563
- const setterInfo = fieldSetters[fieldName]
564
- const fieldPresent = fieldName in transformedAttributes
489
+ const setterInfo = /** @type {NonNullable<LifecycleSchema['fieldSetters'][string]>} */ (fieldSetters[fieldName])
490
+ const fieldPresent = Object.hasOwn(transformedAttributes, fieldName)
565
491
  const hasDependencies = Array.isArray(setterInfo.runSetterAfter) && setterInfo.runSetterAfter.length > 0
566
492
 
567
493
  if (!fieldPresent && !hasDependencies) continue
@@ -570,7 +496,7 @@ export const applyFieldSetters = async (attributes, schemaInfo, context, api, he
570
496
  const setterContext = {
571
497
  attributes: transformedAttributes, // Current state with previous setters applied
572
498
  fieldName,
573
- originalValue: attributes[fieldName],
499
+ originalValue: Object.hasOwn(attributes, fieldName) ? attributes[fieldName] : undefined,
574
500
  originalAttributes: attributes,
575
501
  scopeName: context.scopeName,
576
502
  method: context.method,
@@ -579,144 +505,124 @@ export const applyFieldSetters = async (attributes, schemaInfo, context, api, he
579
505
  auth: context.auth
580
506
  }
581
507
  transformedAttributes[fieldName] = await setterInfo.setter(
582
- transformedAttributes[fieldName],
508
+ fieldPresent ? transformedAttributes[fieldName] : undefined,
583
509
  setterContext
584
510
  )
585
511
  } catch (error) {
586
- const message = error instanceof Error ? error.message : String(error)
587
- throw new RestApiValidationError(
588
- `Setter for field '${fieldName}' failed: ${message}`,
589
- {
590
- fields: [`data.attributes.${fieldName}`],
591
- violations: [{
592
- field: `data.attributes.${fieldName}`,
593
- rule: 'setter_failed',
594
- message
595
- }]
596
- }
597
- )
512
+ throw wrapUnexpectedError(error, {
513
+ message: `Setter for field '${fieldName}' failed`,
514
+ context: { scopeName: context.scopeName, fieldName, phase: 'setter' }
515
+ })
598
516
  }
599
517
  }
600
518
 
601
- return transformedAttributes
519
+ context.inputRecord.data.attributes = transformedAttributes
602
520
  }
603
521
 
604
- export async function handleRecordReturnAfterWrite ({
605
- context,
606
- scopeName,
607
- api,
608
- scopes,
609
- schemaStructure,
610
- schemaRelationships,
611
- scopeOptions,
612
- vars,
613
- runHooks,
614
- helpers,
615
- log
616
- }) {
617
- // Create enhanced logger for warnings
618
- const enhancedLog = getEnhancedLogger(log)
619
- const methodSpecificHookSuffix = getMethodHookSuffix(context.method)
620
-
621
- // Step 1: Set up record state for hooks
622
- // Handle originalMinimalRecord and minimalRecord based on method type
623
- if (context.method === 'DELETE') {
624
- // For DELETE, keep the deleted record reference
625
- if (context.minimalRecord) {
626
- context.originalMinimalRecord = context.minimalRecord
627
- }
628
- } else {
629
- // For POST, PUT, PATCH - save the original state if it exists
630
- if (context.minimalRecord) {
631
- context.originalMinimalRecord = context.minimalRecord
632
- }
633
-
634
- // Fetch the current state of the record after the write operation
635
- try {
636
- const currentRecord = await helpers.dataGetMinimal({
637
- scopeName,
638
- context,
639
- runHooks
640
- })
641
- context.minimalRecord = currentRecord
642
- } catch (error) {
643
- enhancedLog.warn(`Could not fetch minimal record after ${context.method} operation`, { error, id: context.id })
644
- }
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' }
537
+ })
645
538
  }
646
539
 
647
- // Step 2: Determine what to return based on configuration
648
- const returnMode = context.returnRecordSetting[context.method]
649
-
650
- // Case 1: Return nothing (204 No Content)
651
- if (returnMode === 'no') {
540
+ const returnMode = context.returning
541
+ if (returnMode === 'none') {
652
542
  context.responseRecord = undefined
653
- await runHooks('finish')
654
- await runHooks(`finish${methodSpecificHookSuffix}`)
655
- return undefined
656
- }
657
-
658
- // Case 2: Return minimal record (just type and id)
659
- if (returnMode === 'minimal') {
660
- if (context.simplified) {
661
- context.responseRecord = {
662
- id: String(context.id),
663
- type: scopeName
664
- }
665
- } else {
666
- context.responseRecord = {
667
- data: {
668
- type: scopeName,
669
- id: String(context.id)
670
- }
671
- }
672
- }
673
- await runHooks('finish')
674
- await runHooks(`finish${methodSpecificHookSuffix}`)
675
- return context.responseRecord
676
- }
677
-
678
- // Case 3: Return full record
679
- if (returnMode === 'full') {
680
- // Fetch the complete record using the GET method
681
- const fullRecord = await api.resources[scopeName].get({
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({
682
548
  id: context.id,
683
549
  queryParams: context.queryParams,
684
550
  transaction: context.transaction,
685
- simplified: context.simplified
686
- }, { ...context })
687
-
551
+ format: context.format
552
+ }, {
553
+ ...context,
554
+ inputRecord: {
555
+ ...context.inputRecord,
556
+ data: { ...context.inputRecord.data, id: String(context.id) }
557
+ }
558
+ })
688
559
  context.responseRecord = fullRecord || undefined
560
+ } else {
561
+ throw new Error(`Invalid returnMode: ${returnMode}`)
562
+ }
689
563
 
690
- // Run finish hooks
691
- await runHooks('finish')
692
- await runHooks(`finish${methodSpecificHookSuffix}`)
564
+ await runHooks('finish')
565
+ await runHooks(`finish${hookSuffix}`)
566
+ if (returnMode === 'none') return undefined
693
567
 
694
- context.responseRecord = normalizeRecordAttributes(context.responseRecord, scopes, {
695
- source: 'response'
696
- })
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
+ }
697
577
 
698
- // No transformation needed - GET already returns the correct format
699
- // based on context.simplified
700
- 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
701
599
  }
702
-
703
- // This should never be reached, but just in case
704
- throw new Error(`Invalid returnMode: ${returnMode}`)
705
600
  }
706
601
 
707
- export const findRelationshipDefinition = (schemaInfo, relationshipName) => {
708
- // First check schemaRelationships (for relationships defined in relationships object)
709
- const relDef = schemaInfo.schemaRelationships?.[relationshipName]
710
- if (relDef) {
711
- 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')
712
607
  }
713
-
714
- // Then check schema fields for belongsTo relationships with matching 'as' property
715
- for (const [, fieldDef] of Object.entries(schemaInfo.schemaStructure || {})) {
716
- if (fieldDef.as === relationshipName && fieldDef.belongsTo) {
717
- 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
718
626
  }
719
627
  }
720
-
721
- return null
722
628
  }