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
@@ -0,0 +1,3827 @@
1
+ ---
2
+ title: "Migrating to v2"
3
+ chapter: 33
4
+ chapter_label: "33"
5
+ ---
6
+
7
+ # 33. Migrating to v2
8
+
9
+ Version 2 changes setup, resource calls and transaction ownership. Upgrade each
10
+ application's source and dependency versions together. Old option spellings are
11
+ rejected; there is no compatibility mode. This guide describes the required port;
12
+ it does not imply that any consuming application or seed generator is migrated.
13
+ Before v2 is published, install the intended tarball or pin an exact reviewed Git
14
+ commit in place of the registry package. Verify the installed artifact; changing
15
+ the version declaration alone does not migrate an application.
16
+
17
+ ## Replace the API host
18
+
19
+ Remove the direct `hooked-api` dependency from the application:
20
+
21
+ ```sh
22
+ npm uninstall hooked-api
23
+ ```
24
+
25
+ Import the constructor from this package:
26
+
27
+ ```js
28
+ import { JsonRestApi, RestApiPlugin, RestApiKnexPlugin } from 'json-rest-api'
29
+
30
+ const api = new JsonRestApi({ name: 'app', logger: applicationLogger })
31
+ await api.use(RestApiPlugin, { format: 'jsonapi' })
32
+ await api.use(RestApiKnexPlugin, { knex })
33
+ await api.addResource('books', bookOptions)
34
+ ```
35
+
36
+ `api.resources.books.get(...)`, the other resource methods and `api.transaction(...)`
37
+ keep their documented call shapes. Update the setup and extension code:
38
+
39
+ | Previous API | Replacement |
40
+ | --- | --- |
41
+ | `Api` imported from `hooked-api` | `JsonRestApi` imported from `json-rest-api` |
42
+ | `api.scopes` | `api.resources` |
43
+ | `api.addScope(...)` | `api.addResource(...)` |
44
+ | Injected `addScopeMethod` | Injected `addResourceMethod` |
45
+ | Setup hook `scope:added` | `resource:added` |
46
+ | Resource option `scopeMethods` | Resource option `methods` |
47
+ | `customize({ scopeMethods })` | `customize({ methods })` |
48
+ | Dependency alternative `'a\|b'` | `dependencies: [['a', 'b']]` |
49
+ | Constructor `log` / `logging` options | Injected `logger` object |
50
+ | `HookedApiError` base class | `RestApiError` from `json-rest-api` |
51
+
52
+ Logging is silent by default. Supply the logger's level methods directly; configure
53
+ formatting and filtering in that logger. Plugin installation preserves the original
54
+ error, so stop matching hooked-api's wrapping message. There is no compatibility mode.
55
+ Generic scope aliases, `customize({ apiMethods })` and method-registration lifecycle
56
+ hooks have been removed. Install API methods through a plugin's `addApiMethod` instead.
57
+
58
+ Hooks support named `beforeFunction` or `afterFunction` placement, with one placement
59
+ per handler. Numeric ordering and per-plugin placement are unsupported. Returning
60
+ `false` stops that hook list; throw an appropriate error to reject a request.
61
+
62
+ Resources are ordinary objects. Call shared methods with their resource receiver;
63
+ use `api.resources.books.get.bind(api.resources.books)` if you need a detached callback.
64
+ Resource vars and helpers inherit live API defaults. Local writes override them;
65
+ deleting an override reveals the default again. `Object.keys(resource.vars)` lists
66
+ local values only, rather than combining inherited defaults.
67
+
68
+ Await plugin and resource setup sequentially. Discard an instance after a setup failure;
69
+ plugin installation is not transactional. Finish setup before serving requests.
70
+
71
+ TypeScript applications can keep an explicit `JsonRestApi` resource map using the
72
+ existing resource, relationship and bulk method interfaces. Ordinary literal
73
+ schemas also infer attribute types on the handle returned by `addResource()`;
74
+ see [resource and hook typing](../API.md#typescript-resources-and-hooks).
75
+
76
+ ## Remove the S3 demo adapter
77
+
78
+ `S3Storage` and `S3StorageOptions` have been removed, including the direct
79
+ `plugins/storage/s3-storage.js` import. The old adapter generated mock URLs;
80
+ it did not upload to S3. There is no replacement export or compatibility mode.
81
+
82
+ Use the application's existing storage integration through the `FileStorage`
83
+ `upload`/`delete` contract, or use `LocalStorage` for local files. Remote-provider
84
+ credentials, clients and storage policy belong to the application. See
85
+ [file handling](26-file-uploads.md) for the adapter contract.
86
+
87
+ ## Start with the common changes
88
+
89
+ For an ordinary repository/app migration, work through these first:
90
+
91
+ 1. [Replace the API host](#replace-the-api-host), then [update Node](#runtime-requirement) and [rename the two response options](#rename-the-two-response-options).
92
+ 2. [Replace `inputRecord` with `data` or `document`](#replace-inputrecord-with-data-or-document) and
93
+ [select input and output independently](#port-calls-that-mixed-jsonapi-input-with-plain-output).
94
+ 3. [Update configuration and consumers together](#update-configuration-and-consumers-together),
95
+ then check [result shapes](#result-shapes) and [request context](#arguments-and-request-context).
96
+ 4. Review [relationship writes](#review-relationship-writes) and
97
+ [transaction/error handling](#transactions-and-errors) where your app uses them.
98
+ 5. [Verify the port](#verify-the-port) against the intended package set.
99
+
100
+ The remaining sections are feature-specific references. In particular, check
101
+ [storage naming](#storage-naming-configuration), [schema migration helpers](#schema-migration-helpers)
102
+ and [temporal storage](#temporal-values-and-storage-serializers) before changing
103
+ stored data. Connector/plugin sections apply only to the integrations your app uses.
104
+
105
+ | If your app customizes… | Review |
106
+ | --- | --- |
107
+ | Stored values or table names | [Structured attributes](#object-and-array-attributes), [temporal values](#temporal-values-and-storage-serializers), [schema helpers](#schema-migration-helpers), [storage naming](#storage-naming-configuration) |
108
+ | Queries and output | [Fieldsets](#sparse-fieldsets-include-relationships), [sort fields](#sort-fields-and-defaults), [pagination](#pagination-modes-and-counts), [includes](#full-includes-and-projected-fields) |
109
+ | Hooks and plugins | [Read failures](#setter-getter-and-computed-field-failures), [schema enrichment](#schema-enrichment-and-canonical-registration), [write responses](#write-hooks-and-the-returned-response), [query builders](#native-query-builders-and-explicit-custom-filter-translation) |
110
+ | Transports and files | [CORS](#cors-and-http-response-hooks), [uploads](#multipart-uploads), [Socket.IO](#socketio-startup-and-shutdown), [bulk writes](#bulk-writes) |
111
+ | Logging | [Logger enhancement](#enhancing-an-existing-logger), [error causes](#error-causes-in-diagnostic-output), [field redaction](#field-aware-write-diagnostics) |
112
+ | Conditional writes | [Stored revisions](#opt-in-stored-revision-migration), [HTTP conditions](#opt-in-http-representation-conditions) |
113
+
114
+ ## Runtime requirement
115
+
116
+ Use Node 24 or newer and reinstall dependencies after upgrading Node so native
117
+ database modules match its ABI. This library's development and CI checks run on
118
+ Node 24 only; `.nvmrc` pins 24.6.0. Update application runtime configuration when
119
+ performing its migration.
120
+
121
+ ## Declare searchable relationship fields explicitly
122
+
123
+ The library no longer guesses that a resource is a pivot table from its number
124
+ of belongs-to fields. That heuristic used to enable search automatically, even
125
+ overriding `search: false`.
126
+
127
+ If an application filters a pivot resource by a relationship, declare the filter
128
+ on the field or in `searchSchema`, just as for any other resource:
129
+
130
+ ```js
131
+ schema: {
132
+ groupId: { type: 'id', belongsTo: 'groups', as: 'group', search: true },
133
+ itemId: { type: 'id', belongsTo: 'items', as: 'item' }
134
+ }
135
+ ```
136
+
137
+ Here `filters: { group: groupId }` is supported. Filtering by `item` is rejected
138
+ unless separately declared. Omitting `search` or setting it to `false` does not
139
+ generate a filter; an explicit `searchSchema` entry or schema enrichment hook
140
+ can still declare one. Relationship add, replace, remove, related-resource reads
141
+ and includes work without enabling public search on pivot fields.
142
+
143
+ No database migration is needed. Review application calls that directly filter
144
+ pivot resources and add only the search declarations those calls require.
145
+
146
+ ## What stays familiar
147
+
148
+ Keep resource declarations and calls through `api.resources.books`. The familiar
149
+ `get`, `query`, `post`, `put`, `patch`, and `delete` operations remain. JSON:API
150
+ documents and plain records both remain useful formats. This migration does not
151
+ require replacing ordinary repository code with a new ORM.
152
+
153
+ ## Rename the two response options
154
+
155
+ | Old option | Replacement |
156
+ | --- | --- |
157
+ | `simplified: true` | `format: 'plain'` |
158
+ | `simplified: false` | `format: 'jsonapi'` |
159
+ | `returnFullRecord: 'full'` or `true` | `returning: 'full'` |
160
+ | `returnFullRecord: 'minimal'` | `returning: 'minimal'` |
161
+ | `returnFullRecord: 'no'` or `false` | `returning: 'none'` |
162
+
163
+ `format` chooses output representation. `returning` chooses how much a write returns.
164
+ Boolean aliases and the old option names are removed, not deprecated aliases.
165
+
166
+ ```js
167
+ // Before
168
+ const book = await api.resources.books.get({
169
+ id: '42',
170
+ simplified: true
171
+ })
172
+
173
+ // After
174
+ const book = await api.resources.books.get({
175
+ id: '42',
176
+ format: 'plain'
177
+ })
178
+ ```
179
+
180
+ For a JSON:API response, use `format: 'jsonapi'`. Keep the existing `queryParams`
181
+ container for filters, sort, sparse fields, includes, and pagination unless a
182
+ separately documented query-contract change is made.
183
+
184
+ ```js
185
+ const document = await api.resources.books.query({
186
+ format: 'jsonapi',
187
+ queryParams: { fields: { books: 'title' }, page: { size: 20 } }
188
+ })
189
+ ```
190
+
191
+ ## Replace inputRecord with data or document
192
+
193
+ Resource POST/PUT/PATCH accept exactly one input key: `data` for plain resource
194
+ values, or `document` for a JSON:API document. The old `inputRecord` parameter is
195
+ removed. Both keys together are rejected, including a key set to `undefined`.
196
+ Fields inside `data` are resource values; options outside it control the operation.
197
+
198
+ ```js
199
+ // Before: plain resource values were inside inputRecord
200
+ await api.resources.books.post({
201
+ inputRecord: { title: 'Dune' },
202
+ format: 'plain',
203
+ returning: 'full'
204
+ })
205
+
206
+ // After: plain input remains convenient and has a clear boundary
207
+ await api.resources.books.post({
208
+ data: { title: 'Dune' },
209
+ format: 'plain',
210
+ returning: 'full'
211
+ })
212
+ ```
213
+
214
+ Fields named `format`, `returning`, `queryParams`, `document`, or `data` belong
215
+ inside `data`; they are ordinary data there. Plain input is never autodetected as
216
+ a JSON:API document. `data.id` is the record identity: on PATCH/PUT it must
217
+ match the target `id`, and cannot redirect the operation to another record. Keep the target ID outside the
218
+ patch data:
219
+
220
+ ```js
221
+ await api.resources.books.patch({
222
+ id: '42',
223
+ data: { title: 'Updated title' },
224
+ format: 'plain',
225
+ returning: 'none'
226
+ })
227
+ ```
228
+
229
+ When using JSON:API input, rename the parameter to `document` and retain its
230
+ document structure. `format` independently selects the response:
231
+
232
+ ```js
233
+ await api.resources.books.patch({
234
+ id: '42',
235
+ document: {
236
+ data: {
237
+ type: 'books',
238
+ id: '42',
239
+ attributes: { title: 'Updated title' }
240
+ }
241
+ },
242
+ format: 'jsonapi',
243
+ returning: 'full'
244
+ })
245
+ ```
246
+
247
+ ## Port calls that mixed JSON:API input with plain output
248
+
249
+ Older versions detected JSON:API documents inside `inputRecord`; the intermediate
250
+ v2 contract instead coupled input to `format`. Both are replaced by explicit
251
+ input keys. `format` now selects only output, so a JSON:API `document` can return
252
+ plain output and plain `data` can return a JSON:API document.
253
+
254
+ Ordinary repositories can remove JSON:API builders entirely:
255
+
256
+ ```js
257
+ // Before: JSON:API input was autodetected under the plain default.
258
+ const profile = await profiles.patch({
259
+ inputRecord: createJsonApiInputRecord('userProfiles', { displayName }, { id: userId }),
260
+ transaction: trx
261
+ }, requestContext)
262
+
263
+ // After: record data, target ID, and transaction are explicit.
264
+ const profile = await profiles.patch({
265
+ id: userId,
266
+ data: { displayName },
267
+ format: 'plain',
268
+ transaction: trx
269
+ }, requestContext)
270
+ ```
271
+
272
+ For a belongs-to relationship, plain input uses the relationship name and ID,
273
+ for example `data: { name: 'Team', owner: userId }`. Keep existing domain
274
+ normalization. A repository intentionally consuming JSON:API documents can keep
275
+ its application-owned document builder instead. The `createJsonApiInputRecord`
276
+ name below represents that application's function; the old JSKIT export with
277
+ that name has been removed.
278
+
279
+ ```js
280
+ const profile = await profiles.patch({
281
+ id: userId,
282
+ document: createJsonApiInputRecord('userProfiles', { displayName }, { id: userId }),
283
+ format: 'plain',
284
+ transaction: trx
285
+ }, requestContext)
286
+ ```
287
+
288
+ There is one resource method implementation, not a separate `api.jsonapi` API.
289
+ HTTP connectors submit their request body through `document` and request JSON:API
290
+ output. Validation, permissions, hooks and transaction behavior are shared.
291
+ Full-response preparation still happens before commit.
292
+
293
+ Write documents accept optional object-valued top-level `meta`, `links` and
294
+ `jsonapi` members. They remain available to hooks on `context.inputRecord` and
295
+ are not stored attributes. `errors` and `included` are rejected: accepting
296
+ documents does not enable compound writes or JSON:API extensions.
297
+
298
+ The hook field `context.inputRecord` remains the normalized operation document
299
+ for both input kinds. Do not mechanically rename that field when migrating
300
+ public call parameters. See the [hook context contract](13-hooks-and-lifecycle.md).
301
+
302
+ ## Update configuration and consumers together
303
+
304
+ Use the same scalar option names for plugin defaults, resource overrides, and
305
+ individual calls. Precedence is **call → resource → plugin → built-in default**.
306
+ The built-in defaults are `format: 'plain'` and `returning: 'full'`.
307
+
308
+ ```js
309
+ // Before
310
+ await api.use(RestApiPlugin, {
311
+ simplifiedApi: true,
312
+ simplifiedTransport: false,
313
+ returnRecordApi: { post: 'full', put: 'full', patch: 'full' }
314
+ })
315
+
316
+ // After
317
+ await api.use(RestApiPlugin, { format: 'plain', returning: 'full' })
318
+
319
+ // A resource can override these defaults.
320
+ await api.addResource('books', {
321
+ schema: bookSchema,
322
+ format: 'jsonapi',
323
+ returning: 'minimal'
324
+ })
325
+ ```
326
+
327
+ Per-method return maps are removed. Choose one resource default and set
328
+ `returning` on calls needing a different result. For example, port a former
329
+ `{ post: 'full', put: 'none', patch: 'none' }` policy by keeping the full default
330
+ and passing `returning: 'none'` on PUT/PATCH. Invalid strings, booleans, maps,
331
+ and the removed option names raise `REST_API_VALIDATION`; they never silently
332
+ select a fallback.
333
+
334
+ The library's Express/Fastify resource routes explicitly use `format: 'jsonapi'`
335
+ and `returning: 'full'`, regardless of programmatic defaults. Remove
336
+ `simplifiedTransport`, `returnRecordTransport`, and simulated `isTransport`
337
+ parameters. Standard resource POST responses contain a document with status
338
+ 201; updates return their document with status 200. DELETE and relationship
339
+ mutations return no content. An application owning its HTTP handlers should
340
+ select its response contract explicitly.
341
+
342
+ For jskit-ai, start with `packages/json-rest-api-core/src/server/jsonRestApiHost.js`.
343
+ Then update the shared JSON:API CRUD repository and the plain-record repositories
344
+ in `users-core` and `workspaces-core`. Update generated-code templates before
345
+ regenerating app code. Each app with custom calls or hooks needs its own review.
346
+
347
+ For applications using the library's Fastify connector, register resources
348
+ before starting the server. Connector routes and JSON parsers now occupy their
349
+ own Fastify scope, leaving host parsers intact. The route validator uses the
350
+ existing request contract without Ajv stripping fields or adding coercion rules.
351
+ Both connectors return 422 for invalid resource documents, 400 for malformed
352
+ JSON, and 413 for an oversized body. Bodyless resource DELETE works with either
353
+ accepted JSON content type.
354
+
355
+ Check HTTP clients and tests for these boundary changes:
356
+
357
+ - Send `Content-Type: application/vnd.api+json` without `charset`. Ordinary
358
+ `application/json` requests remain accepted. Unsupported types and JSON:API
359
+ extensions receive 415; unknown profiles are ignored.
360
+ - Allow `application/vnd.api+json` in `Accept`, or use an appropriate wildcard.
361
+ Incompatible headers receive 406 before writing. Responses use the exact
362
+ JSON:API media type and preserve `Vary: Accept` alongside other vary values.
363
+ - Remove retired options from query strings as well as resource calls. They
364
+ now receive 422. Fractional page sizes/numbers also receive 422 instead of
365
+ being truncated; opaque cursor strings retain their spelling.
366
+ - Correct unknown resource types in sparse fieldsets. They now receive the
367
+ typed `REST_API_FIELDSET_INVALID` error (HTTP 400), including prototype-like
368
+ names, rather than being silently ignored.
369
+
370
+ The [Fastify guide](23-fastify.md) documents body limits, prefixed 404s and
371
+ the host's `onBadUrl` setting for malformed URLs. Fastify's default malformed-URL
372
+ response occurs before connector hooks and retains its native 400 shape unless
373
+ the host configures that setting. Express maps malformed route parameters to
374
+ JSON:API errors. These transport changes require no stored-data migration.
375
+
376
+ ## Result shapes
377
+
378
+ | Operation | `format: 'plain'` | `format: 'jsonapi'` |
379
+ | --- | --- | --- |
380
+ | `get` | Plain record | `{ data: resource, ... }` |
381
+ | `query` | `{ data: [record, ...], meta?, links? }` | JSON:API collection document |
382
+ | Resource write, `returning: 'full'` | Plain record | Full resource document |
383
+ | Resource write, `returning: 'minimal'` | `{ type, id }` | `{ data: { type, id } }` |
384
+ | Resource write, `returning: 'none'` | `undefined` | `undefined` |
385
+ | `getRelated`, to one | Plain record or `null` | Document with resource or `data: null` |
386
+ | `getRelated`, to many | Same envelope as `query` | JSON:API collection document |
387
+
388
+ An empty collection has `data: []`. Plain collections retain their envelope so
389
+ pagination links and metadata remain available. Plain includes become nested
390
+ records; JSON:API includes stay in the document's `included` array with
391
+ relationship identifiers in `data.relationships`. Missing primary resources
392
+ raise the existing typed `not_found` error.
393
+
394
+ To-one related reads (`belongsTo`, polymorphic belongs-to, and `hasOne`) now
395
+ run the target resource's GET lifecycle whether or not fields or includes are
396
+ requested. Previously, a read without those options could bypass target
397
+ `checkPermissions`, `checkDataPermissions`, `checkDataPermissionsGet`, and
398
+ GET finish hooks by returning an included record directly. Check permission
399
+ hooks that previously only ran when callers supplied a fieldset. Target GET
400
+ errors now propagate consistently; SQL-hidden or absent linkage still returns
401
+ `null`. Parent GET permission checks retain the parent's full attributes.
402
+ The caller's transaction is used for both reads and remains caller-owned.
403
+
404
+ `getRelationship` also retains parent attributes for its internal GET permission
405
+ checks, instead of imposing an ID-only parent fieldset. Its response still
406
+ contains only linkage and links. A parent data-permission hook that denies a
407
+ normal GET can now deny the linkage endpoint using the same attributes.
408
+
409
+ `getRelated` and `getRelationship` set the current resource's `scopeName` before
410
+ permission hooks run. Reusing a context from another resource cannot leave those
411
+ hooks checking its stale resource name.
412
+
413
+ Included resources and relationship identifiers now require the target resource's
414
+ `query` permission, including nested includes and default linkage without
415
+ `include`. The library applies this check at the shared query-filtering boundary
416
+ before loading related rows. A permission hook receives `method: 'query'`, the
417
+ target scope/schema, no parent `id` or parent filters, and the caller's auth and
418
+ transaction. Explicit permission errors reject the read; row policies continue
419
+ to filter individual rows or null hidden to-one linkage. GET-only data hooks
420
+ remain part of the single-resource GET lifecycle, not collection/include policy.
421
+
422
+ Review applications that grant a primary read while denying query permission on
423
+ its related resources. Their includes/linkage previously bypassed that denial.
424
+ To-one `getRelated` also resolves authorized linkage before running target GET.
425
+ A full write response that fails this permission check rolls back its owned
426
+ write; `returning: 'minimal'` or `'none'` does not fetch that full response.
427
+
428
+ Regular-storage many-to-many includes and linkage now enforce the declared pivot
429
+ resource's query permission and row filters, before include limits. This applies
430
+ to standard and windowed includes with mapped pivot keys. AnyAPI continues to
431
+ store these associations as tenant-scoped canonical links rather than rows in a
432
+ declared pivot resource; do not expect a pivot-row policy to govern those links.
433
+
434
+ Canonical many-to-many linkage now applies target query permission and row filters
435
+ in SQL before loading link rows. Its `relationship-identifiers` filter query is
436
+ built once per relationship read, including an empty relationship, rather than
437
+ once per 100 already-loaded target IDs. The returned builder may be used inside
438
+ several paged SQL statements. Keep these hooks as query transformations; do not
439
+ depend on their call count for per-record work. Parent GETs may still filter
440
+ other declared relationships independently. Target auth, transaction and scoped
441
+ metadata remain available, and parent filters are not applied to the target.
442
+ Public resource arguments and response shapes need no change for this fix.
443
+
444
+ Plain responses now expand all available included levels. Previously the
445
+ converter discarded the remaining included document after the first level and
446
+ looked up the wrong nested schema property, losing nested belongs-to identifiers.
447
+ For example, `include: ['authors.publisher.country']` now produces
448
+ `book.authors[0].publisher.country`. Cycles end with `{ id }` (and `_type` for
449
+ polymorphic references), so the result remains JSON-serializable. Repeated sibling
450
+ references expand independently; relationships missing from `included` keep their
451
+ identifier instead of being dropped. This changes response shape only; stored
452
+ records and relationships do not require a data migration.
453
+
454
+ Plain conversion also shares one type/ID lookup across the included document.
455
+ This removes repeated searches for large responses without changing call
456
+ arguments or the expansion rules above. It does not impose a new response limit;
457
+ large or repeatedly expanded graphs still need memory for their complete output.
458
+
459
+ For a hasMany related-resource read, the parent relationship no longer needs
460
+ `search: true` on the child foreign key or polymorphic discriminator fields.
461
+ Parent membership is applied internally alongside your permitted child filters,
462
+ before pagination and counts. A caller filter on a searchable parent relationship
463
+ is intersected with membership instead of being overwritten. Non-searchable
464
+ fields remain unavailable as public filters. Follow the returned pagination
465
+ links: they now retain the related-resource route, requested fields, filters,
466
+ and pagination parameters.
467
+
468
+ Many-to-many related reads now use the target resource's collection query too.
469
+ Pass target filters, fields, includes, sort and pagination exactly as you would
470
+ to `api.resources.books.query(...)`; the library intersects that query with
471
+ the parent's membership. Results and counts describe distinct target records,
472
+ including when storage contains duplicate links. Default limits and sort now
473
+ come from the target resource. Follow returned links to traverse the collection.
474
+
475
+ Target query permissions and query-filter hooks run on these reads. Regular
476
+ storage also checks the declared pivot resource's query permission and filters
477
+ its membership subquery. AnyAPI stores membership in tenant-scoped canonical
478
+ links. The former per-member GET calls and formatted pivot queries are removed;
479
+ move any custom behavior that relied on their GET/finish hooks to the target
480
+ query hooks or the appropriate row policy. No scoped consumer source currently
481
+ uses `getRelated` or those read hooks.
482
+
483
+ Resource GET/query and related reads now reject undeclared include paths with
484
+ `REST_API_INCLUDE_INVALID` (HTTP 400, `source.parameter: 'include'`), including
485
+ empty collections and absent to-one targets. Use declared relationship names,
486
+ not attribute names, and remove obsolete paths instead of relying on them being
487
+ ignored. Finite cycles remain valid; a polymorphic nested path may apply to a
488
+ subset of its declared target types. This follows the
489
+ [JSON:API include contract](https://jsonapi.org/format/#fetching-includes).
490
+
491
+ Two filter corrections can change results: `!= null` selects non-null values,
492
+ and `oneOf` with `splitBy` and `matchAll: false` matches any split term as
493
+ documented. A `between` filter now requires an array of exactly two non-null
494
+ bounds. Use an equality filter for one value; omit an optional range filter
495
+ when no bounds are supplied. Empty or oversized arrays no longer silently
496
+ remove the range condition. `enablePaginationCounts: false` at plugin level
497
+ now disables offset count queries. These changes require no stored-data migration.
498
+ Offset totals also count distinct primary resources when a search joins multiple
499
+ matching children, so duplicate join rows no longer inflate page counts.
500
+
501
+ To-one relationship identifiers now obey the target resource's row policy and
502
+ autofilters even when you do not request an include. This applies to belongsTo
503
+ and polymorphic linkage on primary and included resources, and to full write
504
+ responses. A hidden target produces JSON:API `data: null`; plain output omits
505
+ that relationship field, following the existing null-belongsTo convention.
506
+ Stored foreign keys remain unchanged. Do not use a previously exposed hidden
507
+ target ID as an authorization shortcut. A borrowed transaction also governs
508
+ these visibility reads. Lookups are grouped by target resource type rather
509
+ than performed separately for each record.
510
+
511
+ AnyAPI now omits belongs-to foreign keys and polymorphic type/ID backing fields
512
+ from response attributes, matching regular storage. This also applies to sparse
513
+ responses, included resources and plain output. Read the declared relationship:
514
+ for example, use `data.relationships.subject.data` in JSON:API or `record.subject`
515
+ in plain output instead of `attributes.subject_id` and `attributes.subject_type`.
516
+ Hidden targets retain the null/omitted behavior above. Storage columns and write
517
+ linkage are unchanged; ordinary ID attributes that do not back a relationship
518
+ remain attributes.
519
+
520
+ Cross-resource and polymorphic search paths now check each joined resource's
521
+ `query` permission and apply its row policy/autofilters with
522
+ `queryPurpose: 'search-join'`. Move mandatory predicates out of branches that only
523
+ handle `collection`. Related-only matches against hidden attributes disappear
524
+ from results and counts; a primary attribute can still match an OR search.
525
+ Explicit target permission failures reject the query, even if no primary rows
526
+ match. No per-target GET hooks run. Combined direct/nested polymorphic filters
527
+ now work in either argument order, including mapped tables and columns.
528
+
529
+ PostgreSQL text searches (`like`, `contains`, `startsWith`, `endsWith`) now work
530
+ on numeric relationship IDs by comparing their text representation. A pattern
531
+ such as `"01"` does not become the ID `1`, and `"1e0"` does not become `"1"`.
532
+ Existing string validation still applies, including trimming. Equality/range
533
+ comparisons retain the field's type; text matching retains the database's case
534
+ behavior. No call-site or stored-data migration is required for this correction.
535
+
536
+ The same visibility rule now applies to filters on relationship backing keys:
537
+ for example, `filters: { author_id: id }` and the declared `author` alias only
538
+ match a visible author. Polymorphic ID/type filters check the declared target
539
+ types together; identical IDs in different types remain distinct references.
540
+ Hidden and missing references both behave as null, including for `!=`, lists,
541
+ ranges and `oneOf` searches. Independent primary-attribute OR matches remain
542
+ eligible. These checks use the caller's transaction and run before counts and
543
+ pagination. They do not change stored relationships.
544
+
545
+ Sorting by a relationship key or its declared alias now uses the same visibility
546
+ rule. This includes polymorphic ID/type fields and resource `defaultSort`.
547
+ Hidden and absent references sort together as null, with later sort fields and
548
+ the primary ID breaking ties. Cursor boundaries and generated links use those
549
+ same values: a hidden `author_id` appears as `author_id:~null` in a cursor,
550
+ never as the hidden author's ID. Visible linkage and includes still work.
551
+ The target's `query` permission and `search-join` policy apply to these lookups,
552
+ including offset pagination and queries without explicit pagination.
553
+ Start a new pagination traversal after upgrading, and follow returned links.
554
+
555
+ Null and array filters now survive generated pagination links. For manual HTTP
556
+ requests, the explicit typed form is:
557
+
558
+ ```text
559
+ filter[author][json]=null
560
+ filter[authorIds][json]=["1","2"]
561
+ filter[authorIds][json]=[]
562
+ ```
563
+
564
+ URL-encode these parameters normally. `filter[name]=null` still searches for the
565
+ literal string `null`; only the `[json]` suffix requests JSON parsing. Malformed
566
+ JSON returns HTTP 400. Generated links use this form for nulls, arrays and object
567
+ filter values, preserving embedded commas and empty lists. Programmatic calls
568
+ continue to pass JavaScript values in `queryParams.filters`. Use the returned
569
+ links directly instead of reconstructing or dropping these typed parameters.
570
+
571
+ Collection includes now honor the same limits and ordering on both backends.
572
+ AnyAPI previously ignored these settings. `strategy: 'window'` limits visible
573
+ children per parent, including reverse polymorphic collections; `standard` (the
574
+ default) limits the global target set. Under a global many-to-many limit, a shared
575
+ target counts once and remains linked to each of its selected parents. Duplicate
576
+ links do not consume slots, and selecting query projections no longer switches
577
+ away from per-parent limits. `orderBy` uses mapped target fields, relationship
578
+ aliases or SQL query projections, with nulls last and target IDs breaking ties.
579
+
580
+ Review callers that assumed an include returned every related record: the
581
+ included relationship linkage now describes its selected subset on AnyAPI too.
582
+ Use the related or relationship endpoint for full visible membership and its
583
+ pagination. An omitted limit uses the target query default; `limit: 0` selects no
584
+ children and `limit: null` explicitly disables the include limit. Window includes
585
+ require database window-function support. The resource method signatures are
586
+ unchanged by this fix.
587
+
588
+ The unused deep helpers `buildWindowedIncludeSubquery`, `buildOrderByClause` and
589
+ `applyStandardIncludeConfig` were removed from
590
+ `plugins/core/lib/querying/knex-window-queries.js`. Configure relationships through
591
+ their `include` settings instead of importing storage query builders. No forwarding
592
+ exports or compatibility implementation remain. Check application extensions
593
+ for imports of the removed helpers.
594
+
595
+ Other unused deep exports have also been removed: `createRequiredIndexes` and
596
+ `analyzeRequiredIndexes` from `knex-cross-table-search.js`, `isNonDatabaseField`
597
+ from `knex-field-helpers.js`, `translateSelectFieldsForAdapter` from
598
+ `storage-adapter.js`, and `getLogicalFieldName` from `storage-mapping.js`.
599
+ Remove imports of these internal utilities. Use resource configuration and the
600
+ existing storage adapter when writing custom queries; create indexes through
601
+ reviewed schema changes. The automatic cross-table index advice was unused by
602
+ query execution and did not reliably identify the required indexes.
603
+
604
+ The standalone AnyAPI `api.helpers.dataQueryCount` helper has been removed.
605
+ Use `resource.query({ queryParams: { page: { number: 1, size: 1 } } })` and read
606
+ `result.meta.pagination.total` when a pagination total is needed. This uses the
607
+ same filters and access rules as the resource's returned records.
608
+
609
+ AnyAPI also allocates string slots for non-primary scalar fields declared
610
+ `type: 'id'`, including polymorphic backing IDs. Such fields previously lacked
611
+ storage. Writes and filters bind the normalized ID as text, avoiding SQLite's
612
+ numeric-to-TEXT conversion of `1` to `"1.0"`. Newly supported ID fields are
613
+ allocated after the existing ordinary fields so those fields retain their slots
614
+ when an unchanged declaration is registered again. If you use an explicit
615
+ `canonicalFieldsMap`, assign each such field a free string slot. Back up and
616
+ inspect existing descriptors before migration; values never stored by the older
617
+ implementation cannot be recovered automatically. This fix does not provide
618
+ general schema-reordering or schema-evolution migration.
619
+
620
+ `returning` governs POST/PUT/PATCH resource writes. DELETE and relationship
621
+ mutations return `undefined`. Linkage methods retain one explicit representation:
622
+ `getRelationship` returns `{ data, links }`, with resource identifiers, an array,
623
+ or null; relationship writes take `relationshipData` in JSON:API identifier
624
+ form. Their purpose is to manage linkage, so plain formatting does not flatten
625
+ that linkage document.
626
+
627
+ ## Arguments and request context
628
+
629
+ Keep controls in the first argument and request context in the second. This
630
+ convention already matches jskit-ai and avoids moving every context field into
631
+ every method's parameter parser. A transaction belongs in the first argument:
632
+
633
+ ```js
634
+ await api.resources.books.patch({
635
+ id: bookId,
636
+ data: { title: 'Updated' },
637
+ format: 'plain',
638
+ returning: 'full',
639
+ transaction: trx,
640
+ queryParams: { include: ['author'] }
641
+ }, requestContext)
642
+ ```
643
+
644
+ Forward both arguments when wrapping resource calls. HTTP relationship routes
645
+ now forward the connector context too, and linkage reads forward a caller's
646
+ transaction to their nested resource read. No stored-data migration is required
647
+ by these option and argument changes.
648
+
649
+ ## Review relationship writes
650
+
651
+ Reverse relationships (`hasMany`, `hasOne`, and polymorphic `via`) now update the
652
+ existing child records through their resource PATCH operation. Previous hasMany
653
+ endpoint calls could either fail on raw foreign-key validation or report success
654
+ without changing membership. Check for application workarounds that manually
655
+ patched children after one of those calls.
656
+
657
+ For a to-many relationship, POST adds members, PATCH replaces the complete
658
+ membership, and DELETE removes only the specified members. Repeated identifiers
659
+ and repeated successful add/remove calls do not create extra membership or
660
+ delete child records. These semantics follow the
661
+ [JSON:API relationship update contract](https://jsonapi.org/format/#crud-updating-to-many-relationships).
662
+
663
+ ```js
664
+ const relationship = { id: publisherId, relationshipName: 'books' }
665
+ const book = { type: 'books', id: bookId }
666
+
667
+ await api.resources.publishers.postRelationship({
668
+ ...relationship, relationshipData: [book]
669
+ }, requestContext)
670
+
671
+ // Keep exactly this book; unlink other members.
672
+ await api.resources.publishers.patchRelationship({
673
+ ...relationship, relationshipData: [book]
674
+ }, requestContext)
675
+
676
+ await api.resources.publishers.deleteRelationship({
677
+ ...relationship, relationshipData: [book]
678
+ }, requestContext)
679
+ ```
680
+
681
+ Use `relationshipData: []` to clear a to-many relationship with PATCH. A to-one
682
+ PATCH takes one `{ type, id }` identifier or `null`. Unlinking requires the child
683
+ foreign key to allow null explicitly (`nullable: true`); an optional field is
684
+ not automatically nullable. Target types and cardinality are validated on the
685
+ relationship routes as well as normal resource writes.
686
+
687
+ Normal resource POST/PATCH/PUT payloads can also supply reverse relationships.
688
+ PATCH changes only supplied relationships. For PUT, omitting the JSON:API
689
+ `relationships` object preserves reverse and many-to-many membership; supplying
690
+ that object clears any omitted reverse or many-to-many relationships. Stored
691
+ attributes and belongs-to foreign keys still follow PUT's complete replacement
692
+ validation. Include every relationship you intend to retain when supplying a
693
+ PUT relationships object.
694
+
695
+ Child PATCH hooks and permissions now run for actual reverse-membership changes,
696
+ with the caller context and transaction. A replacement that cannot unlink an
697
+ inaccessible existing child fails rather than leaving a partial relationship.
698
+ A transaction owned by the operation rolls back earlier changes on failure.
699
+ The library leaves completion of a caller-supplied transaction to its owner,
700
+ which must now be `api.transaction` for library writes. A rejected participating
701
+ write aborts that unit even when caught; a native database error may already have
702
+ aborted its SQL transaction. Concurrent replacements now serialize on the parent, and
703
+ many-to-many additions lock related records too, including additions through
704
+ opposite endpoints. This prevents merged replacements, duplicate pivot rows,
705
+ and multiple children left behind by competing hasOne replacements. Belongs-to
706
+ and polymorphic writes also lock and recheck their targets after validation;
707
+ a target deleted in that interval causes the write to fail.
708
+
709
+ Reverse additions and replacements now use the same target-identity locking
710
+ helper. Equivalent ID spellings under the database's equality rules identify
711
+ one child: retaining it does not invoke PATCH again, and adding it through
712
+ multiple equivalent spellings writes it once. A removal still ignores missing
713
+ or unrelated children. No method arguments change.
714
+
715
+ Reverse membership reads now use batches of at most 100 requested IDs. A
716
+ replacement walks removed children in database ID order, reading at most 101
717
+ identifiers at a time and unlinking them before any additions. Keep child hooks
718
+ independent of a database's former incidental row order. Hooks and permissions
719
+ still run for each changed child, and the enclosing transaction owns rollback;
720
+ this does not turn child writes into a bulk SQL update.
721
+
722
+ Canonical many-to-many attachment also pages existing physical links: at most
723
+ 100 requested target IDs and 101 returned edge rows per query. Pre-existing
724
+ duplicate edges and their payloads remain intact, and inverse metadata is still
725
+ repaired on the first retained match. A duplicate-heavy batch can issue more
726
+ SQL statements while keeping read memory bounded. Arguments, resource hooks,
727
+ target/edge locks and transaction ownership are unchanged; no data migration is
728
+ required for this paging change.
729
+
730
+ Relationship mutations can issue an UPDATE that preserves the parent's ID even
731
+ when no attribute changed. That row version change makes stale PostgreSQL
732
+ repeatable-read transactions fail instead of applying an old membership snapshot.
733
+ Database UPDATE triggers on the parent may therefore run for relationship-only
734
+ writes. Resource hooks still follow their existing operation lifecycle; these
735
+ internal locks do not invoke a second resource PATCH hook.
736
+ If you use per-table database grants, account for parent UPDATEs and target
737
+ `SELECT ... FOR UPDATE` statements when configuring the application's database
738
+ role.
739
+
740
+ Treat native deadlock, lock-timeout and serialization errors as failed transaction
741
+ attempts. Let `api.transaction` roll back, then retry the complete callback in a
742
+ new unit only when its side effects can safely repeat. SQLite permits one active
743
+ writer and can reject a stale read snapshot's write upgrade. The library does
744
+ not automatically retry or change the caller's isolation level. The core managed
745
+ helper owns completion; migrate application transaction wrappers accordingly.
746
+
747
+ Existing duplicate pivot/link rows are not automatically removed. Inspect any
748
+ previously concurrent relationship workloads and reconcile duplicates while
749
+ preserving their pivot metadata. For a regular pivot, group by its two mapped
750
+ foreign-key columns; for canonical `any_links`, group by `tenant_id`,
751
+ `relationship`, `left_resource`, `left_id`, `right_resource` and `right_id`.
752
+ Groups with more than one row need reconciliation. This fix requires no column
753
+ migration and does not replace database constraints used by direct SQL writers.
754
+
755
+ Canonical many-to-many removal and replacement now delete the selected links in
756
+ scoped SQL batches instead of reading the complete old membership and deleting
757
+ each link individually. Both stored directions are recognized, including links
758
+ created before an inverse was declared. Retained links keep their row IDs and
759
+ payloads; attachment repairs missing inverse metadata in place. Replacement
760
+ with `relationshipData: []` also clears matching links whose inverse metadata
761
+ is missing. Tenant, relationship and target resource type all constrain the
762
+ write, so another resource that reuses a target ID is left intact. No call-site
763
+ or schema migration is needed for this batching change. If you instrument SQL,
764
+ expect fewer DELETE statements and additional target locks for retained links
765
+ during replacement; transaction ownership and the resource hook lifecycle stay
766
+ the same.
767
+
768
+ ## Transactions and errors
769
+
770
+ A failed call can represent a committed write. When commit acknowledgement is
771
+ lost, `unknown` does not tell you whether the database stored the change. Keep
772
+ an application operation identity and reconcile persisted state before replaying
773
+ the write or deleting tracked uploads. No commit/rollback side-effect hooks run
774
+ for an unknown outcome. See the [commit uncertainty contract](20-transaction-outcomes.md#when-commit-acknowledgement-is-lost)
775
+ for the evidence used and the limits of failure-injection coverage.
776
+
777
+ Migrate transaction wrappers to `api.transaction(callback, context?)`. Its callback
778
+ receives a real Knex transaction for resource calls and raw SQL; the library owns
779
+ completion and deferred hooks. Pass that handle through related operations,
780
+ using a separate context for each enlisted operation. Remove overlapping
781
+ transaction ownership from application wrappers.
782
+
783
+ Unmanaged raw transactions and child savepoints cannot own library writes.
784
+ Raw Knex transactions still support direct SQL, low-level storage helpers and
785
+ read-only API operations. See the [managed transaction contract](19-managed-transactions.md).
786
+
787
+ ```js
788
+ // Before: a Knex callback owned library writes.
789
+ await knex.transaction(transaction => changeBooks(transaction))
790
+
791
+ // After: the library owns writes, raw SQL and deferred completion hooks.
792
+ await api.transaction(async transaction => {
793
+ const book = await api.resources.books.patch({
794
+ id: '42', data: { title: 'Updated title' }, format: 'plain', transaction
795
+ })
796
+ await transaction('audit_entries').insert({ book_id: book.id })
797
+ return book
798
+ })
799
+ ```
800
+
801
+ Await every operation and let the callback return normally to commit. Throw to
802
+ roll back. A caught participating write or observed SQL failure still aborts
803
+ the unit. Do not call `commit()` or `rollback()` yourself inside the callback.
804
+ Pass the received handle through helper functions to compose work; calling
805
+ `api.transaction` again starts a separate top-level unit.
806
+
807
+ Completion ownership is private to the library. Remove any hook code that sets
808
+ `context.shouldCommit` to take over or suppress completion; that diagnostic flag
809
+ does not control it. Each operation's completion hooks receive its own context.
810
+ Every accepted resource/relationship operation runs its completion chain once,
811
+ including nested PATCH calls made by relationship replacement and reverse-child
812
+ updates. Update observers that assumed one completion hook meant one database
813
+ commit. Transaction and bulk owners do not add a synthetic resource hook chain.
814
+ On rollback, failed operations retain their original error, while successful
815
+ operations receive the owner's failure. Inspect secondary completion errors on
816
+ the outer helper's context.
817
+
818
+ Reusing a context after a successful write no longer carries the previous
819
+ commit flag into a later operation. A rejected write rolls back its own new
820
+ transaction, while an operation borrowing a caller's transaction leaves its
821
+ completion to the caller. This also fixes connection exhaustion after denied
822
+ entries in non-atomic bulk calls that reuse authenticated context.
823
+ PUT validation also uses the current payload and record rather than attributes
824
+ retained from a previous call. Omitting populated attributes still fails a
825
+ replacement; creating a new ID does not inherit the old record's requirements.
826
+
827
+ Write failures now use the root export `RestApiWriteError`. Read failures retain
828
+ their existing contract. The wrapper's `cause` is the original thrown value and
829
+ its read-only `transactionOutcome` is `none`, `pending`, `committed`, `rolledBack`
830
+ or `unknown`. Existing `code`, `subtype` and other ordinary diagnostic fields
831
+ remain readable directly. Code that compares original error identity or checks
832
+ the original class with `instanceof` must inspect the cause instead.
833
+
834
+ ```js
835
+ import { RestApiWriteError } from 'json-rest-api'
836
+
837
+ try {
838
+ await api.resources.books.post({ document, format: 'jsonapi' })
839
+ } catch (error) {
840
+ if (!(error instanceof RestApiWriteError)) throw error
841
+ console.error(error.code, error.transactionOutcome, error.cause)
842
+ throw error
843
+ }
844
+ ```
845
+
846
+ A nested write can be another `RestApiWriteError` in the cause chain. For example,
847
+ an atomic batch preserves its child's `pending` snapshot while reporting the
848
+ outer transaction as `rolledBack`. HTTP JSON:API errors add
849
+ `meta.transactionOutcome`; non-atomic bulk errors add
850
+ `error.transactionOutcome` per entry. Status codes alone do not establish whether
851
+ a write committed. A `committed` or `unknown` failure must not trigger blind
852
+ replay, and `pending` leaves the decision to the transaction owner. Even confirmed
853
+ database rollback says nothing about external effects already performed.
854
+
855
+ For example, a POST using a generated ID can commit a new book, then reject
856
+ because an `afterCommit` notification fails. Repeating that POST creates another
857
+ book. The first error reports `committed`; it is a failed call with a stored
858
+ write. A lost commit acknowledgement instead reports `unknown`, which also
859
+ cannot authorize a blind retry. Handle the failed side effect or reconcile the
860
+ write using application identifiers rather than replaying it automatically.
861
+
862
+ Errors before transaction setup—including malformed bodies, oversized requests
863
+ and explicit request rejection—report `none`. A response-hook failure after a
864
+ successful write reports `committed`; adding that failure does not erase an
865
+ earlier write's confirmed outcome. Read-only HTTP errors have no write-outcome
866
+ metadata.
867
+
868
+ Owned transaction completion now also checks Knex's `executionPromise`. Knex can
869
+ resolve `commit()` or `rollback()` while reporting a failed control query through
870
+ that promise. Such failures now reject the write or become rollback diagnostics;
871
+ they no longer run the corresponding completion hooks or publish committed
872
+ registry metadata. Existing payloads and stored data need no migration. A rejected
873
+ commit still does not prove rollback, including when the driver reports itself
874
+ completed. The [outcome contract](20-transaction-outcomes.md) explains
875
+ the evidence and remaining verification boundaries.
876
+
877
+ PostgreSQL can also acknowledge COMMIT by reporting ROLLBACK after an earlier
878
+ SQL error was caught. Those writes now reject instead of reporting success.
879
+ The library recognizes the confirmed rollback, runs existing resource rollback
880
+ cleanup, and avoids commit hooks or cache publication. It does not issue a
881
+ second rollback. This correction requires no payload or stored-data migration;
882
+ an application hook that catches a database error can now expose a failure
883
+ that was previously mistaken for a successful write.
884
+
885
+ Custom transaction factories must return top-level transactions. Returning a
886
+ savepoint previously allowed `afterCommit` or registry cache publication before
887
+ the parent transaction committed. Owned completion now rejects that case and
888
+ rolls back the savepoint; it leaves unrelated parent work and parent completion
889
+ with the caller. The failure reports rolledBack only when savepoint rollback
890
+ is confirmed. Managed acquisition now rejects that factory result before
891
+ resource hooks or uploads run. Explicit raw savepoints passed in `transaction`
892
+ also reject before library writes; their raw SQL and completion remain the
893
+ caller's responsibility. No schema or stored-data migration is needed.
894
+
895
+ Resource and relationship write failures now retain the original error when
896
+ rollback, an `afterRollback` hook, or error logging also fails. The supplied
897
+ operation context exposes the original `error` and `cleanupErrors`, an ordered array of
898
+ `{ phase, error }` entries. Phases include `rollback`, `afterRollback` and `logging`.
899
+ File cleanup adds the phases and field information described under multipart
900
+ uploads; diagnostics gathered before rollback are retained.
901
+ Managed completion records rejected hook chains with `operationIndex`,
902
+ `scopeName` and `method`, using `afterCommit` or `afterRollback`. Internal
903
+ diagnostic-collection failures use `finalization` and `finalizerIndex`.
904
+ Later completion work is still attempted after an earlier failure. On confirmed
905
+ commit the first completion failure is primary; on rollback the original write
906
+ or callback failure remains primary. See the
907
+ [managed completion contract](19-managed-transactions.md#completion-work).
908
+ These diagnostic fields are cleared at the start of the next write, including
909
+ when its context is reused. The original error object is not mutated; frozen
910
+ errors retain their identity as the wrapper's cause. A failure rejected before
911
+ transaction setup has outcome `none` and its own original `context.error`.
912
+
913
+ `afterRollback` receives the original failure as `context.error` and runs only
914
+ after rollback is confirmed, including PostgreSQL reporting ROLLBACK for COMMIT.
915
+ If rollback rejects without confirmation, the library retains
916
+ that secondary failure and does not claim rollback succeeded or run rollback
917
+ cleanup hooks. A participating write leaves completion to its managed owner. Asynchronous
918
+ error logging is awaited, and its rejection is recorded without replacing the
919
+ write error. These fields are diagnostics separate from the wrapper's immutable
920
+ transaction outcome. Atomic bulk rollback uses the same diagnostic shape, as described above.
921
+ Remaining cleanup and extension-error boundaries are still under review; the
922
+ rollback-hook and logging guarantees above describe resource and relationship
923
+ methods.
924
+
925
+ The shared failure handler also checks the driver's completion state before
926
+ attempting rollback. A commit wrapper that rejects after actual completion
927
+ cannot trigger rollback hooks that delete committed uploads. Completion alone
928
+ does not prove which outcome occurred; without confirmation the error reports
929
+ `unknown`.
930
+
931
+ Query-projection and include errors retain their original causes, including
932
+ `null`, `undefined` and other non-Error throws. Typed API errors propagate
933
+ unchanged through reads and become the write wrapper's cause when raised during
934
+ a write. Unexpected projection errors have a wrapper with resource/field
935
+ context and `cause`. Include errors retain their context through the cause
936
+ chain. Redundant logging in nested include catches has been removed so it cannot
937
+ replace the failure. GET/query reject an affected response; full write-response
938
+ failures occur before owned commit, and borrowed transactions remain active for
939
+ their owner. Callers should classify typed errors and inspect causes rather
940
+ than match the wording of unexpected-error messages. Field callbacks follow
941
+ the policy below; arbitrary hook-dispatch failures remain under review.
942
+
943
+ Direct canonical registry reads and explicit descriptor refreshes retain unexpected causes with
944
+ `context: { tenant, scopeName, phase: 'descriptor' }`. Typed API errors keep
945
+ their identity. The direct registry lookup returns null for absent persisted
946
+ metadata; a required refresh rejects without replacing the published resource.
947
+
948
+ Canonical resource operations use published descriptors, so a cold or failing
949
+ registry read no longer blocks an otherwise valid CRUD/relationship request.
950
+ Required published metadata that is missing still rejects, as do invalid stored
951
+ relationship targets and data-query failures. Data reads/writes continue using
952
+ the active transaction. Borrowed transactions remain active for their owner to
953
+ complete. CRUD arguments and response shapes are unchanged; direct registry
954
+ changes take effect only through the publication steps above.
955
+
956
+ POST, PUT and PATCH also reject an unexpected failure while refreshing
957
+ `context.minimalRecord` after writing. Previously this failure could be logged
958
+ and ignored, allowing a successful response and completion hooks with stale
959
+ state. The cause chain retains the failure and resource context with phase
960
+ `postWriteRead`; typed errors retain their identity within that chain. This refresh runs for
961
+ `returning: 'full'`, `'minimal'` and `'none'`. It happens before finish hooks and
962
+ owned commit, so a failure rolls back owned changes, including relationship
963
+ updates. Borrowed transactions remain active for the caller to complete.
964
+
965
+ Callers need no new option. Handle the rejected operation and correct the
966
+ metadata/storage failure instead of relying on partial success. Custom
967
+ `dataGetMinimal` helpers must propagate read failures; they cannot rely on the
968
+ library ignoring them. Express and Fastify send an error response for failures
969
+ in request data reads and writes (500 for unexpected failures, the original
970
+ status for typed errors). Configuration-read failures outside a request do not
971
+ invalidate an already published resource. These rules do not change post-commit
972
+ outcome uncertainty.
973
+
974
+ ## Verify the port
975
+
976
+ 1. Confirm the library resolves to the intended v2 artifact, and each app resolves
977
+ to the matching versions of its shared repositories and generated code.
978
+ 2. Run library checks and affected application tests; an old installed
979
+ dependency passing tests does not verify the port.
980
+ 3. Exercise a create/read/update/delete flow, scoped authorization, and a complete
981
+ pagination traversal in each relevant app. Check a multi-operation rollback
982
+ wherever transaction ownership changes.
983
+ 4. Regenerate affected source and confirm it emits the new calls directly.
984
+ 5. Recheck call sites added during the migration, and remove obsolete wrappers
985
+ and imports once their replacements pass.
986
+
987
+ Record the verification commands and artifact versions with each application
988
+ upgrade. Apply these steps to seed generators and generated templates as well
989
+ as checked-in application code.
990
+
991
+ ## TypeScript imports
992
+
993
+ The package exposes declarations through `json-rest-api` itself:
994
+
995
+ ```ts
996
+ import { RestApiPlugin, RestApiWriteError } from 'json-rest-api'
997
+ import type { RestApiPluginOptions, ResourceCoreMethods, ResourceVersionOptions } from 'json-rest-api'
998
+
999
+ const restOptions = {
1000
+ format: 'plain',
1001
+ returning: 'minimal'
1002
+ } satisfies RestApiPluginOptions
1003
+
1004
+ // The resource schema must also declare revision as a stored string attribute.
1005
+ const versionOptions = { versionField: 'revision' } satisfies ResourceVersionOptions
1006
+
1007
+ interface Book { title: string }
1008
+ async function readTitle(books: ResourceCoreMethods<Book>, id: string) {
1009
+ const book = await books.get({ id })
1010
+ return book.title // string | undefined: sparse fields can omit title
1011
+ }
1012
+ ```
1013
+
1014
+ Use normal value imports for plugins, storage classes and errors; use type-only
1015
+ imports for option and resource interfaces. No runtime files live under the
1016
+ declaration `types/` directory. TypeScript consumers need Node and Knex types for
1017
+ the current declaration surface. Framework peers are still optional at runtime.
1018
+
1019
+ For ordinary literal schemas, retain the handle returned by `addResource()` to
1020
+ infer attribute names and value types. `ResourceSchema`, `InferInput` and
1021
+ `InferOutput` also support reusable definitions. Existing JavaScript setup and
1022
+ call syntax stay the same. The library's `npm run test:public-types` checks these
1023
+ declarations through a packed consumer.
1024
+
1025
+ An explicit `JsonRestApi` resource map remains the choice for typed central
1026
+ registries, relationships, dynamic enrichment, method replacements and custom
1027
+ API-level defaults. Match its format/returning generics to the actual resource
1028
+ configuration. Plugin installation itself does not infer options or methods;
1029
+ use `satisfies` with the plugin's option interface. Literal resource registration
1030
+ checks built-in field and resource options. Custom plugin configuration uses an
1031
+ explicit intersection with its own interface. See the
1032
+ [typing contract and limits](../API.md#typescript-resources-and-hooks).
1033
+
1034
+ Known hooks now describe phase-specific input and library-owned fields. This
1035
+ does not change mutable context at runtime. If a hook receives an ownership type
1036
+ error, inspect the documented mutation phase rather than casting it away.
1037
+ Use `HookHandler<Event, AppContext>` for known hooks and
1038
+ `RuntimeHook<PluginContext>` for arbitrary plugin events; see the
1039
+ [hook guide](13-hooks-and-lifecycle.md).
1040
+
1041
+ ## Object and array attributes
1042
+
1043
+ Declare structured fields using their public JSON type:
1044
+
1045
+ ```js
1046
+ schema: {
1047
+ metadata: { type: 'object', nullable: true },
1048
+ tags: { type: 'array', nullable: true }
1049
+ }
1050
+ ```
1051
+
1052
+ Built-in storage writes JSON text and reads it back as objects or arrays, in
1053
+ both plain and JSON:API responses. Nested JSON values, empty objects/arrays and
1054
+ SQL null are preserved. Canonical storage now accepts arrays in its existing
1055
+ JSON slots; adding this support does not alter existing tables.
1056
+
1057
+ Remove setters whose only purpose was `JSON.stringify`. A setter for an object
1058
+ or array must return that logical type; it can still prepare or transform its
1059
+ contents. Reads now return structured values, so remove application-level
1060
+ `JSON.parse` calls that expected these fields to be strings.
1061
+
1062
+ ```js
1063
+ // Before: storage conversion was done in a setter.
1064
+ metadata: { type: 'object', setter: value => JSON.stringify(value) }
1065
+
1066
+ // After: built-in storage owns JSON conversion.
1067
+ metadata: { type: 'object' }
1068
+ ```
1069
+
1070
+ If a custom storage representation is necessary, keep its conversion in
1071
+ `storage.serialize` and decode it with a getter. The getter receives the driver's
1072
+ stored value: native JSON columns may return an object, while text columns
1073
+ return a string. For example:
1074
+
1075
+ ```js
1076
+ metadata: {
1077
+ type: 'object',
1078
+ nullable: true,
1079
+ storage: {
1080
+ serialize: value => value == null ? null : JSON.stringify({ wrapped: value })
1081
+ },
1082
+ getter: value => value == null
1083
+ ? null
1084
+ : (typeof value === 'string' ? JSON.parse(value) : value).wrapped
1085
+ }
1086
+ ```
1087
+
1088
+ Getters decode custom representations before the final declared shape check.
1089
+ Built-in structured values are decoded before getters on dependent fields and
1090
+ computed callbacks run. A stored value with the wrong declared shape fails the
1091
+ read. Built-in writes reject wrong-shaped setter results, circular references
1092
+ and BigInt values before SQL, including with `returning: 'none'`. Supply JSON
1093
+ compatible contents; the default serializer uses JavaScript `JSON.stringify`.
1094
+
1095
+ ### Querying structured attributes
1096
+
1097
+ Whole-object/array equality and ordering are not generic library operations.
1098
+ Remove `search: true`, generic search aliases/`oneOf` references, explicit
1099
+ `sortableFields`, and default sorts targeting those fields. A structured
1100
+ projection cannot declare `sortable: true`. Local invalid declarations reject
1101
+ schema compilation; relationship paths reject when resolved, allowing forward
1102
+ resource declarations. A request to sort a structured field returns a validation
1103
+ error (HTTP 422) before SQL. Direct adapter comparison conversion also rejects
1104
+ structured fields, including null and empty arrays, before invoking serializers.
1105
+ Array operands for `in` and `between` on scalar stored fields remain supported.
1106
+
1107
+ Expose the JSON key that gives the application a meaningful order as a scalar
1108
+ projection. For example, this selects an integer `metadata.value`:
1109
+
1110
+ ```js
1111
+ queryFields: {
1112
+ metadataValue: {
1113
+ type: 'integer',
1114
+ sortable: true,
1115
+ select: ({ knex, column }) => {
1116
+ const field = column('metadata')
1117
+ if (knex.client.config.client === 'pg') {
1118
+ return knex.raw('CAST(CAST(?? AS json) #>> ?::text[] AS integer)', [field, '{value}'])
1119
+ }
1120
+ if (knex.client.config.client === 'mysql2') {
1121
+ return knex.raw('CAST(JSON_UNQUOTE(JSON_EXTRACT(??, ?)) AS SIGNED)', [field, '$.value'])
1122
+ }
1123
+ return knex.raw('CAST(json_extract(??, ?) AS INTEGER)', [field, '$.value'])
1124
+ }
1125
+ }
1126
+ }
1127
+ ```
1128
+
1129
+ Then use `queryParams: { sort: ['metadataValue'], page: { size: 20 } }`.
1130
+ Scalar projections support sparse output and forward/backward cursor traversal.
1131
+ Sorting and cursor comparisons operate on the selected SQL value; output getters
1132
+ format that value afterwards. Custom storage serializers must produce values
1133
+ compatible with the physical column, and custom scalar cursors must satisfy the
1134
+ declared scalar type. A getter does not make an incompatible stored cursor valid.
1135
+
1136
+ For application-specific JSON filters, use `searchSchema.applyFilter` with an
1137
+ explicit SQL predicate and bound values. This callback owns the predicate's
1138
+ meaning and value encoding. For example, a null-presence filter for built-in JSON
1139
+ storage can resolve the physical column without hard-coding canonical slots:
1140
+
1141
+ ```js
1142
+ searchSchema: {
1143
+ hasMetadata: {
1144
+ type: 'boolean',
1145
+ applyFilter: (query, present) => {
1146
+ const column = api.knex.helpers.getStorageAdapter('books').translateColumn('metadata')
1147
+ return present ? query.whereNotNull(column) : query.whereNull(column)
1148
+ }
1149
+ }
1150
+ }
1151
+ ```
1152
+
1153
+ The example belongs to the `books` resource. Use the query hook's adapter and
1154
+ alias when a custom filter introduces joins. Whole-document custom comparisons
1155
+ must account for the chosen database's JSON semantics.
1156
+
1157
+ PostgreSQL JSON selections now use `to_jsonb` so relationship filters can remove
1158
+ duplicate parents with `DISTINCT`. This applies to stored structured fields and
1159
+ structured projections. Public object/array values are preserved, and existing
1160
+ tables are not altered. Custom structured storage should use JSON-compatible SQL
1161
+ representations, such as JSON columns or JSON-encoded text.
1162
+
1163
+ ## Temporal values and storage serializers
1164
+
1165
+ Use JSON values in both plain and JSON:API write records:
1166
+
1167
+ | Field type | Input example | Public read value |
1168
+ | --- | --- | --- |
1169
+ | `date` | `'2024-02-29'` | `'2024-02-29'` |
1170
+ | `dateTime` | `'2024-03-01T07:59:59.987+08:00'` | `'2024-02-29T23:59:59.987Z'` |
1171
+ | `time` | `'09:30'` or `'09:30:00.123'` | `'09:30:00'` or `'09:30:00.123'` without a declared precision |
1172
+ | `epochMilliseconds` | `1709251199987` or `'1709251199987'` | `1709251199987` |
1173
+ | `epochSeconds` | `1709251199` or `'1709251199'` | `1709251199` |
1174
+
1175
+ Client date/dateTime fields take strings, not JavaScript Date objects or SQL
1176
+ datetime text. Use `null` to clear a nullable field. Epochs must be integers:
1177
+ zero and negative epochs are valid; the absolute limits are 8640000000000000
1178
+ milliseconds and 8640000000000 seconds. Calendar dates must exist. DateTime
1179
+ offsets must normalize to a UTC year between 0000 and 9999; values outside that
1180
+ range now fail validation before built-in storage, including writes requesting no record.
1181
+
1182
+ `temporalPrecision` limits fractional digits. Built-in dateTime conversion uses
1183
+ JavaScript Date and supports milliseconds. It rejects nonzero submillisecond
1184
+ digits; declaring precision 6 alone does not enable six-digit writes. A value
1185
+ ending in `.123000Z` can be stored as `.123Z`. Database strings can retain finer
1186
+ precision when read. Time fields remain strings and do not require a Date
1187
+ conversion. Getters, computed fields and response hooks may return native Date
1188
+ or BigInt values; the declared temporal field type normalizes the final response
1189
+ and rejects invalid values.
1190
+
1191
+ Keep `storage.serialize(value, details)` functions on stored attributes.
1192
+ Both storage modes honor them for writes and filters.
1193
+ The function receives the logical `fieldName`, actual `columnName`, `definition`,
1194
+ `schemaInfo`, write `context`, and `operation` (`post`, `put`, `patch` or `filter`).
1195
+ Comparisons use `operation: 'filter'` and a null context. Return the same storage
1196
+ representation for equivalent values on writes and comparisons. The callback
1197
+ must be synchronous; returning a Promise now fails before the SQL operation,
1198
+ including when no write response is requested. Use field setters for asynchronous preparation. Custom temporal
1199
+ serializers must preserve the required precision, normalize offsets consistently,
1200
+ and return values supported by the chosen SQL column/driver and read normalizer.
1201
+
1202
+ ### Temporal formatting and native databases
1203
+
1204
+ Time reads now always include seconds. With `temporalPrecision: 3`, both
1205
+ `'09:30'` and `'09:30:00.000'` read as `'09:30:00.000'` and compare equally.
1206
+ Without a declared precision, redundant fractional zeros are removed:
1207
+ `'09:30:00.100000'` reads as `'09:30:00.1'`. Update assertions that depended on
1208
+ the submitted spelling. DateTime reads use UTC, retain meaningful microseconds,
1209
+ and keep at least millisecond digits unless a smaller precision is declared;
1210
+ `.100000Z` reads as `.100Z`, while `.123456Z` remains unchanged.
1211
+
1212
+ Library reads request temporal strings from PostgreSQL/MySQL so driver Date
1213
+ conversion cannot discard microseconds or shift calendar dates. These options
1214
+ apply to library queries; the supplied Knex instance's connection configuration
1215
+ and ordinary caller queries are unchanged. PostgreSQL uses its normal ISO date
1216
+ output. Built-in PostgreSQL writes/comparisons bind explicit UTC values and map
1217
+ public year 0000 to PostgreSQL's 1 BC. MySQL native DATE/DATETIME writes reject
1218
+ years below 1000 with `storage_range`, following its documented supported range.
1219
+ Use a custom text column/serializer if the application needs earlier MySQL dates.
1220
+ See [node-postgres temporal parsing](https://node-postgres.com/features/types)
1221
+ and [MySQL date ranges](https://dev.mysql.com/doc/refman/8.0/en/datetime.html).
1222
+
1223
+ New table creation and generated migrations use fractional precision 6 for
1224
+ dateTime/time columns when no precision is declared. An explicit precision
1225
+ still takes precedence. This avoids MySQL's default precision 0 rounding a
1226
+ timestamp into the next second or day. Existing regular tables need an ordinary
1227
+ schema migration to the intended precision; table creation does not alter them.
1228
+ Preserve each column's nullability, defaults and other modifiers in that migration.
1229
+ The regular-table diff now detects temporal precision changes. PostgreSQL time
1230
+ columns created through older Knex helpers may report precision 6 even when the
1231
+ resource declared precision 3: the previous time builder ignored that argument.
1232
+ New table creation and migration code declare `time(n)` explicitly. Inspect live
1233
+ precision before changing existing columns; reducing it can round stored values.
1234
+
1235
+ Native `setValues` columns require an explicit MySQL target when generating a
1236
+ migration, for example `{ dialect: 'mysql2' }`. Runtime create/add/alter helpers
1237
+ reject SET columns on other clients before SQL. A cross-dialect migration diff
1238
+ continues to warn and omit unsupported SET changes; review those warnings rather
1239
+ than treating the generated supported subset as a complete schema migration.
1240
+
1241
+ Schema helpers now reject invalid `temporalPrecision` before executing SQL.
1242
+ Native PostgreSQL/MySQL time/dateTime columns require an integer from 0 through 6.
1243
+ For finer precision, use a text column and an appropriate storage mapping. When
1244
+ intentionally generating a higher-precision SQLite definition, pass an explicit
1245
+ `dialect: 'better-sqlite3'`; an unspecified migration target is rejected for
1246
+ precision above 6. Migration diffs use their explicit or introspected dialect.
1247
+ Runtime create/add/alter helpers use the actual database client, regardless of a
1248
+ supplied migration-dialect option. SQLite column declarations remain distinct
1249
+ from the built-in dateTime converter's millisecond write limit.
1250
+
1251
+ Increasing precision cannot restore digits already discarded by older writes.
1252
+ Native PostgreSQL/MySQL TIME columns reject input with more than six meaningful
1253
+ fractional digits before writing; SQLite and AnyAPI text time slots can retain
1254
+ finer values. [MySQL fractional-second behavior](https://dev.mysql.com/doc/refman/8.0/en/fractional-seconds.html)
1255
+ also explains why directly injecting extra digits into a lower-precision column
1256
+ can round them before the library reads them.
1257
+
1258
+ For custom PostgreSQL serializers, include an offset on timestamp-with-time-zone
1259
+ values, such as `'2024-02-29T23:59:59.123456Z'`. Offset-free timestamp input uses
1260
+ the server session's time zone. A custom serializer must make that choice
1261
+ explicit and use the same representation for comparisons.
1262
+
1263
+ Query projections compare the SQL expression's stored value directly, retaining
1264
+ microsecond precision without a write serializer. Remove `storage` from
1265
+ `queryFields` declarations; it is now rejected during compilation. Keep the
1266
+ serializer on the underlying stored attribute.
1267
+
1268
+ ### Custom serializer cursors and projection getters
1269
+
1270
+ Cursors for serialized attributes and SQL projections now retain the database
1271
+ value before getters run. Following a cursor binds that value directly instead
1272
+ of serializing it again. For example, a serializer that stores `"alpha"` as
1273
+ `"stored:alpha"` previously produced a next-page comparison against
1274
+ `"stored:stored:alpha"`. Numeric scaling had the same problem.
1275
+
1276
+ Restart pagination after upgrading and use returned cursors or links unchanged.
1277
+ Do not build a cursor from response attributes: getters can change the value,
1278
+ and database temporal spelling can differ from the JSON representation. Scalar
1279
+ cursor validation still checks the declared type; temporal validation accepts
1280
+ the supported database spelling without changing the bound value. Sort order
1281
+ is the SQL value's order. A getter does not change that order.
1282
+
1283
+ Projection declarations no longer take `storage`, `setter`, or `runSetterAfter`.
1284
+ For example, this precise timestamp projection needs no serializer:
1285
+
1286
+ ```javascript
1287
+ queryFields: {
1288
+ preciseTimestamp: {
1289
+ type: 'dateTime',
1290
+ temporalPrecision: 6,
1291
+ sortable: true,
1292
+ select: ({ knex, column }) => knex.raw('??', [column('recordedAt')])
1293
+ }
1294
+ }
1295
+ ```
1296
+
1297
+ Projection `getter` and `runGetterAfter` declarations now execute through the
1298
+ same compiled dependency order as stored-field getters. They were previously
1299
+ ignored. Getters may be async; computed fields receive their completed values.
1300
+ Remove a previously ignored getter if its behavior was not intended. Invalid
1301
+ callbacks/dependencies reject compilation, and getter failures propagate as
1302
+ read errors before computed output runs.
1303
+
1304
+ Resource ID fields and relationship backing ID/type fields cannot declare
1305
+ `storage.serialize`, including declarations added by enrichment or canonical
1306
+ field additions. The previous behavior could change an ordinary foreign key
1307
+ while canonical storage bypassed the write callback and only serialized filters.
1308
+ Remove these serializers; use resource `normalizeId` for ID canonicalization.
1309
+ Already transformed stored IDs require an explicit application data migration;
1310
+ removing a callback does not repair existing data automatically.
1311
+
1312
+ ### Existing AnyAPI temporal data
1313
+
1314
+ New AnyAPI resources allocate `date` and `time` fields to string slots. Calendar
1315
+ dates and times of day do not need a timestamp column. `dateTime` fields retain
1316
+ the `date_N` slots, now with fractional precision 6. Existing metadata assigning
1317
+ date/time fields to timestamp slots, or native canonical timestamp columns with
1318
+ lower precision, causes an explicit migration-required error.
1319
+ Resource re-registration checks the persisted mapping before replacing it, so
1320
+ starting the new API cannot silently rebind unmigrated temporal fields to empty
1321
+ slots. Complete the data migration before registering those resources again.
1322
+
1323
+ For disposable development data, recreate the AnyAPI database and run the seeds
1324
+ against the new schema. For data that must be preserved:
1325
+
1326
+ 1. Back up the database and stop application writers for the migration.
1327
+ 2. Inventory date/time fields from `any_resource_configs.schema_json` and their
1328
+ mappings in `any_field_configs`. Reserve unused `string_N` slots per resource;
1329
+ the existing limit is ten string slots. Update any explicit
1330
+ `canonicalFieldsMap` declarations to those allocations.
1331
+ 3. Copy each old field into its reserved string slot on `any_records`, selecting
1332
+ only that metadata row's tenant and resource. Use `YYYY-MM-DD` for dates and
1333
+ the time format above for time-only values. Inspect the raw stored values
1334
+ and the original write time zone: an old API read may already have shifted a
1335
+ date or discarded precision. Keep the old slots until verification is complete.
1336
+ 4. Update that field's `slot_type` to `string`, `slot_index` to the reserved
1337
+ index, and `slot_column` to its `string_N` name. Keep logical IDs, tenant/resource
1338
+ identifiers and relationship rows unchanged. Do not recreate metadata for
1339
+ unrelated fields or renumber their slots.
1340
+ 5. On PostgreSQL/MySQL, migrate `any_records.date_1` through `date_5` to timestamp/
1341
+ datetime precision 6, preserving the PostgreSQL time-zone type and column
1342
+ modifiers. Even an unused canonical timestamp slot must have precision 6.
1343
+ 6. Restart the library to reload descriptors. Verify row counts, date/time
1344
+ values, nulls, relationships, exact filters and complete cursor traversals
1345
+ against the backup before resuming writers.
1346
+
1347
+ The repository includes an executable example at
1348
+ `examples/migrations/anyapi-temporal-v2.js`. Its named
1349
+ `migrateAnyApiTemporalFields(transaction, moves)` function implements steps 3–4
1350
+ in batches of 100 records. It requires an explicit source/target mapping and a
1351
+ conversion callback for each field. It checks the persisted field type and source
1352
+ mapping, rejects occupied targets (including unassigned slots containing data),
1353
+ validates converted strings against the declared temporal precision, and leaves
1354
+ old slots and unrelated metadata intact. Nulls are copied without invoking the
1355
+ callback. Soft-deleted records are included.
1356
+
1357
+ For example, **only if the original application stored dates at UTC midnight
1358
+ and times as UTC-anchored timestamps or bare time strings**, the conversion can be:
1359
+
1360
+ ```js
1361
+ import { migrateAnyApiTemporalFields } from 'json-rest-api/examples/migrations/anyapi-temporal-v2.js'
1362
+ import { normalizeDateValue } from 'json-rest-api/plugins/core/lib/querying-writing/database-value-normalizers.js'
1363
+
1364
+ const moves = [
1365
+ {
1366
+ tenant: 'your-tenant', resource: 'events', field: 'day',
1367
+ from: 'date_1', to: 'string_8',
1368
+ convert: value => normalizeDateValue(value, 'date')
1369
+ },
1370
+ {
1371
+ tenant: 'your-tenant', resource: 'events', field: 'atTime',
1372
+ from: 'date_2', to: 'string_10',
1373
+ convert: value => typeof value === 'string' && /^\d{2}:/.test(value)
1374
+ ? value
1375
+ : normalizeDateValue(value, 'dateTime').slice(11, -1)
1376
+ }
1377
+ ]
1378
+ const report = await knex.transaction(trx => migrateAnyApiTemporalFields(trx, moves))
1379
+ ```
1380
+
1381
+ Replace every name and slot with the inventory from your database. Converters
1382
+ receive the raw value and `{ tenant, resource, field, id }`, may be asynchronous,
1383
+ and must return a valid string for every non-null value. PostgreSQL/MySQL reads
1384
+ retain raw temporal strings so the driver cannot discard microseconds before
1385
+ conversion. Use a different converter for a local calendar/time convention or
1386
+ custom serializer; the example does not infer the original time zone. Update
1387
+ any serializer that still expects the old timestamp representation.
1388
+
1389
+ Let a conversion error escape the transaction callback so Knex rolls back all
1390
+ copied records and mapping changes. The example neither commits nor rolls back
1391
+ the caller's transaction. Rerunning completed moves fails the source-mapping
1392
+ check; inspect metadata instead of treating that error as success.
1393
+
1394
+ For step 5, the following is the tested precision change for **standard,
1395
+ nullable canonical columns with no defaults and Knex's default timezone type**:
1396
+
1397
+ ```js
1398
+ // PostgreSQL/MySQL only; run separately from the data transaction.
1399
+ await knex.schema.alterTable('any_records', table => {
1400
+ for (let index = 1; index <= 5; index++) {
1401
+ table.dateTime(`date_${index}`, { precision: 6 }).nullable().alter()
1402
+ }
1403
+ })
1404
+ ```
1405
+
1406
+ Inspect column definitions first and adapt this DDL if their type, nullability,
1407
+ default or other modifiers differ. MySQL DDL can commit implicitly; the copy
1408
+ transaction and this precision change are separate operations. Keep writers
1409
+ stopped if either fails. Increasing precision preserves existing values but
1410
+ cannot recover fractional digits lost in earlier writes.
1411
+
1412
+ In the restarted resource declarations, preserve **all** existing allocations in
1413
+ `canonicalFieldsMap`, changing only the migrated fields. A supplied map must
1414
+ include every stored non-ID field. For the test resource, the complete map is:
1415
+
1416
+ ```js
1417
+ canonicalFieldsMap: {
1418
+ name: 'string_1', day: 'string_8', atTime: 'string_10',
1419
+ occurredAt: 'date_3', personId: 'rel_1_id'
1420
+ }
1421
+ ```
1422
+
1423
+ `tests/anyapi-temporal-migration.test.js` executes this example and the standard
1424
+ precision DDL on SQLite, PostgreSQL and MySQL, with fresh API initialization,
1425
+ overlapping logical IDs in two tenants, relationships, nulls, exact filters,
1426
+ forward/backward cursors, failed conversions and caller rollback. These are
1427
+ isolated synthetic migrations. Verify the conversion against a copy of each
1428
+ application database and update its seed generators before deployment.
1429
+
1430
+ AnyAPI's duplicate private filter pass and its unused deep helper exports
1431
+ `coerceValueForDefinition`, `normalizeFilterValues` and `ensureFilterableField`
1432
+ were removed. Declare filters in the resource/search schema and use ordinary
1433
+ resource queries. Remove direct imports of these private helpers if your
1434
+ application has any.
1435
+
1436
+ ## Schema migration helpers
1437
+
1438
+ `generateKnexMigrationDiff()` now preserves undeclared indexes and foreign keys
1439
+ by default, as it already did for columns. A resource declaration need not
1440
+ describe every constraint in an application's database. Check `warnings` for
1441
+ preserved metadata. For an intentional removal, pass the corresponding boolean
1442
+ in `options`: `allowDropColumns`, `allowDropIndexes` or `allowDropForeignKeys`.
1443
+ These options reject non-boolean values. Explicit changes to a named index or
1444
+ foreign key can still produce a replacement, with a warning.
1445
+
1446
+ Column drops reject when their plan retains a dependent index or foreign key.
1447
+ Full snapshots also reject generated columns and SQLite partial/expression
1448
+ indexes, instead of reporting an incomplete comparison of those features.
1449
+ Use an authored migration for database features outside the snapshot model.
1450
+ Review warnings about required-column backfills and narrowing integer ranges.
1451
+ Generation does not execute DDL; keep migration execution/history in the app's
1452
+ Knex workflow and write recovery steps deliberately. See
1453
+ [diff migrations](21-schema-and-migrations.md#diff-migrations).
1454
+
1455
+ Regular `addKnexFields` and `alterKnexFields` now honor resource-level
1456
+ `storage.naming`. Under `naming: 'exact'`, `loginCount` addresses that exact
1457
+ column. Inspect tables previously changed through these helpers for unintended
1458
+ snake-case columns; this fix does not rename or move existing data. Retain
1459
+ explicit `storage.column` mappings in supplied field definitions.
1460
+
1461
+ AnyAPI `addKnexFields({ fields, canonicalFieldsMap?, searchSchema? })` now persists
1462
+ added definitions and refreshes the compiled schema, including defaults,
1463
+ transforms and cached request contracts. A multi-field call uses one metadata
1464
+ transaction. Existing records remain unchanged; backfill old rows separately.
1465
+ Missing `fields`, duplicate fields and invalid maps now throw instead of silently
1466
+ succeeding or leaving unusable metadata. See
1467
+ [AnyAPI field additions](21-schema-and-migrations.md#anyapi-field-additions).
1468
+
1469
+ Explicit maps take effect during registration, before `createKnexTable()`.
1470
+ Repeating that method no longer re-registers the initial schema and discards
1471
+ additions. Keep the expanded declaration and complete map in app source for
1472
+ restart, including functions that JSON metadata cannot reconstruct.
1473
+
1474
+ Deep `AnyapiRegistry` writes must receive a transaction from `api.transaction`
1475
+ when joining an existing unit. `addKnexFields()` uses this owner for its metadata
1476
+ batch internally. Direct registry callers must invalidate the registry descriptor
1477
+ cache after confirmed commit. Transaction-local reads use that
1478
+ transaction and bypass the global cache, preventing rolled-back metadata from
1479
+ leaking through cached descriptors. This does not publish new resource metadata.
1480
+ Resource operations use `resource.vars.schemaInfo.descriptor`, the same published
1481
+ mapping as their storage adapter. CRUD, counts, includes and relationship calls
1482
+ do not query configuration tables or apply direct registry changes implicitly.
1483
+ Register related resources on the API before querying them; persisted registry
1484
+ entries alone do not supply runtime resource declarations.
1485
+
1486
+ Use `addKnexFields()` for supported field additions; it commits metadata before
1487
+ publishing the new compiled schema and adapter. After a direct, committed mapping
1488
+ migration with the same declarations, invalidate the registry cache and call
1489
+ `createKnexTable()` to refresh the descriptor. A failed refresh retains the
1490
+ previous published resource. Structural schema changes also need updated source
1491
+ declarations and compilation; invalidation alone cannot reconstruct callbacks.
1492
+ Perform configuration/mapping changes while application operations are stopped.
1493
+ Arbitrary in-place metadata edits or concurrent reconfiguration are not a
1494
+ supported publication mechanism. No automatic data migration is performed.
1495
+
1496
+ The internal `preloadRelatedDescriptors` helper now accepts
1497
+ `{ descriptor, scopes }` and returns its map synchronously; `scopes` is the
1498
+ resource collection. It no longer accepts registry/transaction arguments.
1499
+ `AnyapiQueryAdapter` no longer stores its unused registry argument. Deep callers
1500
+ must use the published resource metadata when building related query maps.
1501
+
1502
+ Owned registry registration/allocation now preserves the original failure when
1503
+ rollback also rejects; secondary diagnostics go to the registry error logger.
1504
+ Failed owned writes invalidate cached descriptors, including when a commit
1505
+ call rejects after database completion. A completed transaction is not rolled
1506
+ back again, and its next descriptor read reloads persisted metadata. Rejection
1507
+ does not by itself establish that a write was rolled back or can safely be
1508
+ retried. The descriptor cache also isolates tenant/resource pairs containing
1509
+ `::`; this cache-key change does not alter persisted data or method arguments.
1510
+
1511
+ The registry now retains at most 100 recently used descriptors. This is a cache
1512
+ bound, not a resource limit: an evicted descriptor reloads on the next explicit
1513
+ registry lookup, while normal resource operations continue using their published
1514
+ metadata. Direct registry callers must pass `{ transaction }` when borrowing
1515
+ one; do not rely on a warm cache to avoid acquiring another database connection.
1516
+ An explicit `{ bypassCache: true }` read that confirms removal now discards its
1517
+ stale cache entry. It does not remove a running resource or publish a new schema.
1518
+ No data migration or resource declaration change is needed for cache eviction.
1519
+
1520
+ Request-contract reuse now includes the resource name in its key. Deep callers
1521
+ sharing compiled field definitions across resource names receive the correct
1522
+ JSON:API type validation. Temporal normalization still accepts its supported
1523
+ precision declarations; unusual precision simply builds an uncached validator.
1524
+ Neither correction adds a caller option or requires a payload migration.
1525
+
1526
+ Schema inspection now supports PostgreSQL's current schema. Generated migrations
1527
+ preserve BigInt defaults as BigInt literals, and unchanged effective defaults or
1528
+ implicit size metadata no longer cause repeated alterations. Diff plans now
1529
+ include `dropCheckConstraints` for PostgreSQL inline-enum replacements. Generated
1530
+ enum diffs execute on PostgreSQL/MySQL; SQLite diffs report a required table rebuild
1531
+ and retain the existing enum definition. This is distinct from changing only an
1532
+ enum default, which is supported on all three databases. See the
1533
+ [schema and migration guide](21-schema-and-migrations.md) for execution
1534
+ and transaction limits. These helpers do not automatically migrate existing
1535
+ application databases.
1536
+
1537
+ Direct `alterKnexFields` now replaces PostgreSQL inline enum checks correctly,
1538
+ with rollback on failure. SQLite changes that require replacing an enum check
1539
+ throw before the field batch starts; changing only its default still works.
1540
+ For deep-helper callers, PostgreSQL transactions retain ownership through a
1541
+ savepoint. MySQL caller transactions are rejected before their implicit DDL
1542
+ commit, and SQLite borrowed rebuilds require foreign-key enforcement disabled
1543
+ before the transaction starts, with validation/restoration owned by the caller.
1544
+
1545
+ Schema DDL now preserves apostrophes, backslashes and question marks in enum
1546
+ values and static text/JSON/array defaults. Inspect previously generated or
1547
+ applied definitions containing those characters; regenerate faulty migration
1548
+ code and migrate stored data explicitly where necessary. Standalone generators
1549
+ must specify `options.dialect` for values needing dialect-specific SQL escaping.
1550
+ Resource generators infer the dialect from their Knex client.
1551
+
1552
+ MySQL diffs replacing a foreign-key-supporting index now include the dependent
1553
+ key in `dropForeignKeys` and `addForeignKeys`, even if its definition is unchanged.
1554
+ Review the whole plan and pause application writes for these multiple-statement
1555
+ DDL migrations; MySQL does not roll them back as one transaction.
1556
+
1557
+ ## Sparse fieldsets include relationships
1558
+
1559
+ Fieldsets now restrict attributes **and relationships** in both formats. Earlier
1560
+ versions rejected valid relationship names yet returned unrequested relationships.
1561
+ Update requests that use both `include` and `fields` to select the relationships
1562
+ their callers read:
1563
+
1564
+ ```js
1565
+ // Before: relied on an unrequested author relationship being returned.
1566
+ queryParams: { include: ['author'], fields: { books: 'title' } }
1567
+
1568
+ // After: asks for both the attribute and the relationship.
1569
+ queryParams: {
1570
+ include: ['author'],
1571
+ fields: { books: 'title,author', authors: 'name' }
1572
+ }
1573
+ ```
1574
+
1575
+ Use the declared relationship name (`author`), not its storage field (`author_id`).
1576
+ An empty fieldset (`books: ''`) returns no attributes or relationships; omit
1577
+ `books` from `fields` for the normal visible response. IDs remain present.
1578
+ Included resource types obey their own fieldsets, and unrequested computed
1579
+ fields are not evaluated. Full write responses follow the same rules without
1580
+ changing the fields stored by the write.
1581
+
1582
+ JSON:API still permits requested `included` resources when the primary fieldset
1583
+ omits their relationship linkage. Plain output reaches related resources through
1584
+ selected relationship properties, so select each relationship along a nested
1585
+ path. No stored-data migration is required. See the
1586
+ [JSON:API sparse-fieldset rules](https://jsonapi.org/format/#fetching-sparse-fieldsets).
1587
+
1588
+ The internal `findRelationshipDefinition` helper moved from
1589
+ `rest-api-plugin-methods/common.js` to
1590
+ `lib/querying-writing/relationship-contracts.js` under `plugins/core`, so fieldset
1591
+ validation and relationship methods use the same resolver. Update any deep
1592
+ imports; the former module does not forward that export.
1593
+
1594
+ ## Generated labels and computed visibility
1595
+
1596
+ `LabelPlugin` now preserves an explicitly declared stored or computed `label`.
1597
+ Automatic labels use public stored values, resolve a global-search alias through
1598
+ its local `actualField`, retain null fallbacks in sparse reads, and fall back to
1599
+ the current resource's logical ID. Hidden, normally hidden and virtual fields
1600
+ are excluded as automatic sources. Applications that need a custom display rule
1601
+ can declare their own computed `label`; see the [label guide](18-labels.md).
1602
+
1603
+ Computed callbacks now receive `{ id, attributes, ... }`, with the record's public
1604
+ logical ID supplied on GET, query and included-resource reads. Existing callbacks
1605
+ can keep their current arguments. Use `id` instead of looking for a custom ID
1606
+ column in `attributes` or using the parent request's ID.
1607
+
1608
+ Declare a computed field's inputs in `dependencies`, especially normally hidden
1609
+ stored fields. Sparse reads no longer fetch every normally hidden field just
1610
+ because one computed field was requested. That fallback could select nonexistent
1611
+ virtual columns and made sparse/full behavior disagree. Declared virtual inputs
1612
+ are not selected as SQL columns.
1613
+
1614
+ Hidden computed fields are no longer returned. Normally hidden computed fields
1615
+ require an explicit sparse-fieldset request. Visibility also applies after
1616
+ virtual input values are added to a full write response. Review callers that
1617
+ accidentally relied on these values being exposed.
1618
+
1619
+ Automatic labels require no data migration. If an older LabelPlugin installation
1620
+ removed an authored stored `label` from the compiled schema, check whether its
1621
+ column/canonical mapping exists and use the normal schema migration helpers to
1622
+ add it if needed. Values that were never persisted cannot be recovered by this
1623
+ code change.
1624
+
1625
+ ## Cursor validation and registered schema handlers
1626
+
1627
+ Cursor validation now uses the type handlers and validators captured by the
1628
+ resource's compiled attribute schema. Previously each cursor field created a
1629
+ schema from the current global registry, so changing a registered handler could
1630
+ make cursor validation disagree with the already-compiled resource.
1631
+
1632
+ Register schema extensions before adding resources. Existing compiled resources
1633
+ retain those handler functions; a new resource initialization or supported
1634
+ recompilation captures the current registrations. This does not introduce general
1635
+ live schema editing. Normal cursor spelling, numeric conversion, opaque IDs,
1636
+ null ordering, temporal precision and HTTP validation errors are unchanged.
1637
+ Temporal output conversion keeps its existing separate contracts and precision
1638
+ behavior; cursor validation does not truncate an invalid over-precise token.
1639
+
1640
+ Applications using resource methods need no argument changes. Direct importers
1641
+ of the internal `validateCursorValues` helper must pass the owning schema as its
1642
+ fourth argument:
1643
+
1644
+ ```js
1645
+ validateCursorValues(descriptors, values, 'after', scope.vars.schemaInfo.schemaInstance)
1646
+ ```
1647
+
1648
+ The helper has no fallback to the global registry. Contract reuse is scoped to
1649
+ that schema instance, and values/errors remain local to each call.
1650
+
1651
+ ## Field dependencies and projection metadata
1652
+
1653
+ Getter/setter declarations keep their callback signatures and use
1654
+ `runGetterAfter`/`runSetterAfter`. Computed fields keep `dependencies` and
1655
+ `compute({ attributes, id, ... })`. Dependencies now support transitive chains
1656
+ and fields without their own callback. Missing names, cycles, non-array
1657
+ declarations and supplied non-function getters/setters fail registration.
1658
+ Remove placeholder `null`/`false` callbacks; omit the callback and its ordering
1659
+ property when the field needs no transformation.
1660
+
1661
+ List every local input a callback reads. Sparse reads fetch the entire dependency
1662
+ chain, run getters before computations, and compute prerequisites once. A getter
1663
+ cannot depend on a computed field; move that calculation to a computed field.
1664
+ Setters see available validated input and earlier setter results; they do not
1665
+ load omitted PATCH fields from the database. Read logical IDs from callback
1666
+ `id`. Remove read dependencies on raw relationship backing fields or relationship
1667
+ aliases: these are not attribute values at callback time. The
1668
+ [dependency contract](12-field-transformations.md#dependency-contract)
1669
+ includes a chained example and visibility rules.
1670
+
1671
+ Full write responses now apply virtual getters before computations. Virtual
1672
+ input belongs to the written type/ID and is no longer copied onto included or
1673
+ unrelated records. Update calculations that expected raw virtual input after a
1674
+ getter, and remove any reliance on a parent's input appearing on its includes.
1675
+
1676
+ For custom plugins, replace writes to `scope.vars.queryFields` in `resource:added`
1677
+ with declarations in `context.queryFields` during `schema:enrich`. Read the final
1678
+ map from `scope.vars.schemaInfo.queryFields`. There is no compatibility copy at
1679
+ the former path. Core normalizes the declarations and checks collisions against
1680
+ the final attribute/computed/relationship/ID namespace before publication.
1681
+ Canonical additions rebuild this map together with dependencies.
1682
+ See the [plugin example](29-writing-plugins.md#the-query-field-seam).
1683
+ Ordinary applications using `QueryProjectionsPlugin` and resource `queryFields`
1684
+ options keep that setup unchanged.
1685
+
1686
+ The unused internal `sortFieldsByDependencies` wrapper was removed. Internal
1687
+ code uses the existing `topologicalSort` through `compileFieldDependencies`;
1688
+ applications should declare dependencies in their resource schemas.
1689
+
1690
+ ### Response dependency metadata
1691
+
1692
+ JSON:API resource objects no longer expose the internal
1693
+ `__$jsonrestapi_computed_deps$__` member. Update snapshots or application code
1694
+ that consumed it. Computed fields still receive their dependencies during
1695
+ enrichment; cleanup happens at the final response boundary, including full and
1696
+ minimal write responses after finish hooks.
1697
+
1698
+ Use declared `dependencies` and callback arguments for computations. Put
1699
+ application metadata in `meta` or declared attributes. Cleanup removes only the
1700
+ reserved resource-level member; keys inside user attributes and metadata retain
1701
+ their values. Method arguments and stored records need no migration for this fix.
1702
+
1703
+ ## Resource IDs and links
1704
+
1705
+ Write documents and relationship identifiers accept strings or finite numbers,
1706
+ then apply the resource's ID normalizer and schema. Plain inputs now preserve
1707
+ those values for the same validation as JSON:API inputs. They no longer silently
1708
+ drop zero, turn false into an unlinked relationship, or stringify objects into
1709
+ IDs. Use `null` to clear a to-one relationship. Convert a BigInt to a string
1710
+ before placing it in a write document or relationship identifier.
1711
+
1712
+ The default normalizer trims strings and accepts numeric path IDs (including
1713
+ BigInt); it no longer accepts booleans, arrays or objects through implicit
1714
+ `toString()`. A configured `normalizeId` remains the explicit customization
1715
+ point. Resource-specific normalizers still override the plugin default.
1716
+
1717
+ PATCH and PUT use the path ID only when the body ID is absent or undefined.
1718
+ An explicit zero must match the path after normalization. Explicit null, empty
1719
+ or invalid body IDs fail validation instead of being replaced by the path ID.
1720
+ For example, this now rejects without changing record `42`:
1721
+
1722
+ ```js
1723
+ await api.resources.books.patch({
1724
+ id: '42',
1725
+ format: 'plain',
1726
+ data: { id: 0, title: 'Wrong target' }
1727
+ })
1728
+ ```
1729
+
1730
+ The schema still controls valid keys: `type: 'id'` requires positive values;
1731
+ an integer or string key can permit zero. Public IDs remain strings. Leading
1732
+ zeroes, case, punctuation and large numeric strings remain intact for opaque
1733
+ string keys. Use a string to avoid JavaScript numeric precision loss.
1734
+
1735
+ SQL bigint primary and relationship IDs now retain their exact decimal values
1736
+ on library reads and writes. For example, `9223372036854775806` and
1737
+ `9223372036854775807` remain distinct through includes, relationship changes
1738
+ and cursor pages. For explicitly supplied keys this large, use a string ID
1739
+ declaration and pass decimal strings; keep SQL primary and reference column
1740
+ types compatible. Existing text keys retain leading zeroes.
1741
+
1742
+ Storage hooks can now receive an exact string where SQLite or MySQL previously
1743
+ returned a rounded Number. Keep identifiers as strings instead of converting
1744
+ them with `Number()` or `parseInt()`. This correction needs no new API option or
1745
+ data migration. It applies to library queries without changing the caller's
1746
+ Knex connection configuration. A Number already rounded in application code
1747
+ cannot be repaired by the library.
1748
+
1749
+ PostgreSQL seed/import jobs that write explicit IDs into sequence-backed keys
1750
+ must synchronize the sequence before later generated-ID inserts. Explicit IDs
1751
+ do not advance that sequence. Keep synchronization in the seed/import workflow.
1752
+ Both ordinary and custom ID columns are exercised with actual
1753
+ PostgreSQL/MySQL generated inserts and all return modes.
1754
+
1755
+ Resource and relationship URLs, including POST's Location header, now encode
1756
+ the ID as one path segment. Pass the original ID to resource methods; follow
1757
+ returned URLs as supplied, without encoding them again. For example, ID
1758
+ `part/one?x` produces `/books/part%2Fone%3Fx`.
1759
+
1760
+ Table creation and migration generation now map `type: 'integer'` to integer
1761
+ columns; previously it fell through to string columns. Existing tables are not
1762
+ altered automatically. If you used these helpers with integer declarations,
1763
+ inspect the stored values and review the generated migration before applying
1764
+ it. Determine whether existing application tables need conversion before
1765
+ applying the changed schema helpers.
1766
+
1767
+ ## Storage naming configuration
1768
+
1769
+ Use a string for `storage.naming`: `snake_case` (also `snake` or `snakeCase`) or
1770
+ `exact` (also `field` or `verbatim`). Omit it for `snake_case`. Coercible objects
1771
+ and inherited property names such as `constructor` are invalid naming modes
1772
+ and now reject at the normalization boundary. Replace an object with its
1773
+ intended string value; applications using the documented strings need no change.
1774
+
1775
+ Canonical row conversion also preserves a field literally named `undefined`
1776
+ when a relationship lacks a reverse attribute mapping. An absent mapping no
1777
+ longer causes that unrelated attribute to be removed.
1778
+
1779
+ ### Public field names and physical columns
1780
+
1781
+ Declared fields named `constructor`, `prototype`, `toString`, `valueOf`,
1782
+ `hasOwnProperty`, `isPrototypeOf`, `propertyIsEnumerable` and `toLocaleString`
1783
+ are handled as own fields. Stored values, generated filters, sparse responses,
1784
+ cursor sorting and field callbacks must not resolve to inherited JavaScript
1785
+ properties. This applies to plain records and JSON:API attributes.
1786
+
1787
+ Ordinary Knex storage rejects physical columns literally named `constructor`,
1788
+ `prototype` or `__proto__`. Knex's update compiler drops these names; a write
1789
+ could previously leave an old value unchanged. Validation now rejects these
1790
+ mappings during resource registration, adapter creation and table/migration
1791
+ generation. Unsupported `idProperty` names are also rejected. Use an explicit
1792
+ physical mapping while keeping the public name:
1793
+
1794
+ ```js
1795
+ schema: {
1796
+ constructor: {
1797
+ type: 'string',
1798
+ storage: { column: 'constructor_value' }
1799
+ }
1800
+ }
1801
+ ```
1802
+
1803
+ If the old physical column already contains data, first rename it in your normal
1804
+ database migration, then change the declaration. For example:
1805
+
1806
+ ```js
1807
+ await knex.schema.alterTable('books', table => {
1808
+ table.renameColumn('constructor', 'constructor_value')
1809
+ })
1810
+ ```
1811
+
1812
+ Review dependent indexes, constraints and raw SQL, and verify row counts and
1813
+ values on the application's database before enabling the new declaration.
1814
+ Registration and `createKnexTable()` do not rename existing columns for you.
1815
+ Canonical storage uses allocated slots and does not need this physical-column
1816
+ mapping for public `constructor` or `prototype` fields.
1817
+
1818
+ Minimal reads used by write authorization and PUT completeness checks now
1819
+ translate physical columns to logical fields once. If a physical column name
1820
+ also names another logical field, hooks now receive the correct original
1821
+ attributes. Update any hook that compensated for the previous double mapping.
1822
+
1823
+ ### Declaration dictionaries and reserved names
1824
+
1825
+ Use plain objects or null-prototype dictionaries with **own** declarations for
1826
+ schemas, search fields, projections and relationships. Definitions inherited
1827
+ from a custom prototype now reject instead of disappearing during compilation.
1828
+ This includes an object literal such as `{ __proto__: definition }`, which
1829
+ changes an object's prototype rather than declaring an ordinary property.
1830
+
1831
+ `__proto__` is not a supported field, filter, projection, relationship alias or
1832
+ relationship backing-field name. These declarations reject before compilation
1833
+ can lose the key. The same checks apply after enrichment hooks, during field
1834
+ additions, in direct storage/table helpers and at the canonical registry's write
1835
+ and descriptor-loading boundaries. Rename such a declaration. Preserve the
1836
+ existing physical column or canonical slot through an explicit mapping when it
1837
+ contains data; do not re-register over old metadata to erase migration evidence.
1838
+
1839
+ These checks do not recursively strip user data. An object-valued attribute can
1840
+ contain JSON keys named `__proto__`, `constructor` or `prototype`; strings such
1841
+ as `__proto__` also remain valid resource IDs. Their existing validation rules
1842
+ still apply.
1843
+
1844
+ Declare a belongs-to relationship alias as a string, for example
1845
+ `ownerId: { type: 'id', belongsTo: 'users', as: 'owner' }`. Numbers, booleans,
1846
+ arrays and objects in `as` now reject during schema validation, including values
1847
+ introduced by `schema:enrich`. Replace `as: ['owner']` with `as: 'owner'`; do not
1848
+ depend on JavaScript coercing a configuration value into a property name.
1849
+
1850
+ The relationship mapping options `target`, `through` and `via` also require
1851
+ strings when supplied. For example, change `target: ['users']` to
1852
+ `target: 'users'`, and `through: ['memberships']` to `through: 'memberships'`.
1853
+ They check the declaration's type; they do not require a forward-referenced
1854
+ target or pivot resource to be registered earlier.
1855
+
1856
+ Use strings for schema `belongsTo` targets and relationship `foreignKey` and
1857
+ `otherKey` names too. In particular, enrichment hooks cannot bypass the
1858
+ backing-name type checks: change `foreignKey: ['ownerId']` to
1859
+ `foreignKey: 'ownerId'`. A schema field uses `belongsTo: 'users'`, not
1860
+ `belongsTo: ['users']`.
1861
+
1862
+ Polymorphic relationship declarations require `types` to be a nonempty array of
1863
+ nonempty strings, and `typeField` and `idField` must be nonempty strings.
1864
+ For example, use `{ types: ['users'], typeField: 'subjectType', idField:
1865
+ 'subjectId' }` as the `belongsToPolymorphic` definition. The authored
1866
+ polymorphic registration check still requires its target resources to be
1867
+ registered; these shape checks do not establish backing-column existence.
1868
+
1869
+ For reverse relationships, `hasOne` requires `target` and `foreignKey`; `hasMany`
1870
+ requires `target` and either `foreignKey` or its reverse-polymorphic `via`;
1871
+ `manyToMany` requires `through`, `foreignKey` and `otherKey`.
1872
+ Many-to-many `target` remains optional where the
1873
+ existing relationship-name default applies.
1874
+
1875
+ Declare these relationships in the resource's `relationships` option.
1876
+ `computedSchema:enrich.schemaRelationships` remains an inspection input, as
1877
+ described in [computed enrichment](#computed-field-enrichment). Defensive
1878
+ validation now catches malformed relationship mappings if a hook mutates that
1879
+ input; this does not make mutation or replacement of the inspection map a
1880
+ supported relationship declaration API. Move such declarations into the
1881
+ resource configuration when migrating.
1882
+
1883
+ Resource **names** must be JavaScript
1884
+ identifiers starting with a letter, `_` or `$`, excluding `__proto__`,
1885
+ `constructor` and `prototype`. Other inherited names such as `toString`,
1886
+ `valueOf` and `hasOwnProperty` now work with omitted or explicit fieldsets.
1887
+ This resource-name restriction does not prohibit `constructor` or `prototype`
1888
+ as attribute or relationship names. Valid relationship aliases also work in
1889
+ include trees and polymorphic include grouping.
1890
+
1891
+ Null relationship representation is unchanged: JSON:API contains `data: null`;
1892
+ plain conversion omits that relationship property. Use `Object.hasOwn(record,
1893
+ name)` when checking for presence of a potentially inherited name.
1894
+
1895
+ This establishes the own-property and reserved-name boundary. Full JSON:API
1896
+ member-name validation and other namespace/capability conflicts remain part of
1897
+ the wider compiled-metadata work; arbitrary punctuation is not guaranteed to
1898
+ work in every query syntax.
1899
+
1900
+ ## Sort fields and defaults
1901
+
1902
+ Use a string or an array of strings for resource `defaultSort`:
1903
+
1904
+ ```javascript
1905
+ // Replace this inconsistent configuration:
1906
+ defaultSort: { field: 'name', direction: 'desc' }
1907
+
1908
+ // With:
1909
+ defaultSort: ['-name']
1910
+ ```
1911
+
1912
+ The object form previously passed registration but failed through the public
1913
+ query method. Invalid non-string entries now fail during schema compilation.
1914
+ `null` and `[]` leave the default as ascending resource ID; explicit query sorting
1915
+ overrides the resource default. Keep programmatic `queryParams.sort` as an array.
1916
+ The resource ID remains the stable tie-breaker unless already included.
1917
+
1918
+ Sorting by `id` always means the resource ID. Sorting by a stored attribute or
1919
+ query projection uses that field, even if `searchSchema` defines a filter with
1920
+ the same name and a different `actualField`. For example:
1921
+
1922
+ ```javascript
1923
+ searchSchema: {
1924
+ name: { type: 'string', actualField: 'shortName' },
1925
+ byShortName: { type: 'string', actualField: 'shortName' }
1926
+ },
1927
+ sortableFields: ['id', 'name', 'byShortName']
1928
+ ```
1929
+
1930
+ Here `filters: { name: 'Ada' }` still filters `shortName`; `sort: ['name']`
1931
+ orders the stored `name`. Use `sort: ['byShortName']` to order by the distinct
1932
+ alias. An explicit sortable alias must resolve to a scalar stored field;
1933
+ relationship aliases retain their visibility checks. Whole-document JSON and
1934
+ cross-resource scalar ordering are not introduced by this change.
1935
+
1936
+ The same resolution applies to collection include `orderBy`. Cursor comparisons
1937
+ use the stored field's type and selected value, independently of the filter's
1938
+ input type. Sort fields needed internally are selected even when sparse output
1939
+ omits them. Ordinary and canonical storage both follow these rules.
1940
+ Restart existing cursor traversals after upgrading if a filter alias overlapped
1941
+ a sort field; the old cursor may describe a different ordering.
1942
+
1943
+ ### Hidden sort fields
1944
+
1945
+ Fields marked `hidden: true` cannot be sort keys. This includes stored fields,
1946
+ their search/relationship aliases, query projections, resource `defaultSort`,
1947
+ explicit `sortableFields`, and collection include `orderBy`. Previously, an
1948
+ explicit hidden stored sort could produce an incomplete cursor; a hidden
1949
+ projection marked sortable could expose its SQL value in cursors and links.
1950
+
1951
+ Remove hidden fields and their aliases from sort configuration and choose a
1952
+ public scalar sort field instead:
1953
+
1954
+ ```javascript
1955
+ schema: {
1956
+ secret: { type: 'string', hidden: true },
1957
+ displayOrder: { type: 'integer', normallyHidden: true }
1958
+ },
1959
+ sortableFields: ['id', 'displayOrder'],
1960
+ defaultSort: ['displayOrder']
1961
+ ```
1962
+
1963
+ Use `normallyHidden` only for publicly readable values that should be omitted
1964
+ by default. They remain sortable and their values can appear in cursors even
1965
+ when omitted by a sparse fieldset. Keep sensitive values `hidden`.
1966
+
1967
+ Invalid local declarations fail during schema compilation with
1968
+ `REST_API_VALIDATION` and violation rule `hidden_sort`. Include ordering is
1969
+ checked when the target resource is resolved, preserving forward declarations.
1970
+ Request validation excludes hidden sort keys, and query hooks cannot reintroduce
1971
+ them. Hidden fields/projections still work as declared getter/computed
1972
+ dependencies; their raw values remain omitted from resource output.
1973
+
1974
+ ## Pagination modes and counts
1975
+
1976
+ SQLite ordering now uses native `NULLS FIRST/LAST`, preserving the public null
1977
+ placement and cursor results while allowing existing ordering indexes to help.
1978
+ The verified engine is SQLite 3.49.2; SQLite engines older than 3.30 are no longer
1979
+ supported by these ordering helpers. PostgreSQL/MySQL ordering is unchanged.
1980
+ No payload or cursor migration is needed. Optional index choices and the existing
1981
+ migration paths belong in reviewed [schema migrations](21-schema-and-migrations.md).
1982
+
1983
+ The existing page options keep their meanings in both storage modes:
1984
+
1985
+ | Query page | Behavior |
1986
+ | --- | --- |
1987
+ | Omitted or `{}` | Apply the resource's capped default limit; omit pagination metadata |
1988
+ | `{ number: 2, size: 25 }` | Offset page 2, using the capped size for the offset and links |
1989
+ | `{ number: 2 }` | Offset page 2 with the resource's default size |
1990
+ | `{ size: 25 }` | First cursor page |
1991
+ | `{ after: cursor }` or `{ before: cursor }` | Adjacent forward/backward cursor page with the resource's default size |
1992
+
1993
+ Add `size` to a cursor boundary to choose a different page size. Page sizes are
1994
+ capped by `queryMaxLimit`. Use one of `number`, `after` or `before`; combining
1995
+ them, supplying non-positive/fractional page numbers/sizes, or supplying an
1996
+ empty cursor rejects before SQL. Follow generated links to retain filters,
1997
+ fieldsets and the effective size.
1998
+
1999
+ `enablePaginationCounts` controls totals for offset pages. With counts disabled,
2000
+ offset metadata contains `page` and `pageSize`; it does not promise totals or
2001
+ `hasMore`. Cursor pages use one extra row for `hasMore` and never issue a count
2002
+ query, regardless of this setting. Ordinary storage previously issued a count
2003
+ on the first cursor page and discarded its result; that extra query and its
2004
+ count-filter hook invocation have been removed. Collection predicates still
2005
+ run for every cursor page. Keep mandatory filters in the collection path.
2006
+
2007
+ Malformed cursor syntax now has the same error message in either storage mode,
2008
+ with code `REST_API_VALIDATION`, a `page.after` or `page.before` field, and rule
2009
+ `invalid_cursor`. Prefer the typed code/details when handling errors instead of
2010
+ matching a former backend-specific message. No call-site migration is needed
2011
+ for applications already using the page options above.
2012
+
2013
+ ## CORS and HTTP response hooks
2014
+
2015
+ Install `CorsPlugin` after Express or Fastify. Installation without a connector
2016
+ now fails immediately. Pass options directly to `api.use(CorsPlugin, options)`.
2017
+ Origin predicates may be async: their boolean result is now awaited, including
2018
+ inside origin arrays. A rejected predicate becomes a 500 response without CORS
2019
+ permission. Do not rely on the old behavior that treated a Promise as permission.
2020
+ `origin: false` disables origin permission, and `maxAge: 0` disables preflight
2021
+ caching. These values are no longer replaced by defaults.
2022
+
2023
+ Denied OPTIONS responses now use the JSON:API error envelope (`errors[0]`,
2024
+ status 403) instead of `{ error: 'CORS origin not allowed' }`. Other requests
2025
+ retain their actual resource/status behavior; CORS does not authorize writes.
2026
+ Existing credential/origin defaults remain permissive, so use explicit allowed
2027
+ origins for credentialed applications. See the [CORS guide](24-cors.md).
2028
+
2029
+ `transport:response` now runs for early parser/media/Accept errors and explicit
2030
+ `context.reject(...)` responses. Its request body can therefore be undefined;
2031
+ avoid assuming authentication, parsing or a resource method has completed.
2032
+ The hook chain is attempted once. If it throws, the connector maps the error
2033
+ without rerunning response hooks. A response-stage failure cannot roll back an
2034
+ already committed write.
2035
+
2036
+ Vary headers from the host, custom route, CORS and response hooks are merged
2037
+ case-insensitively, including on rejected requests; compare fields rather than
2038
+ their order. Fastify's native malformed-URL boundary still precedes connector
2039
+ hooks and has no connector CORS headers. Raw responses sent by host middleware
2040
+ or a hook that takes over the response need their own headers. No stored-data
2041
+ migration is required.
2042
+
2043
+ ## Multipart uploads
2044
+
2045
+ Unexpected file detector/parser/storage errors now retain their cause and
2046
+ operation context. Typed errors still propagate unchanged. Unexpected storage
2047
+ failures return HTTP 500 rather than being mislabeled as validation errors;
2048
+ declared MIME/size/schema violations remain 422 and typed access denials remain
2049
+ 403. Update callers that classified every upload failure as invalid input.
2050
+
2051
+ File cleanup keeps attempting later files after a cleanup or warning-log failure.
2052
+ Inspect `context.cleanupErrors` for `temporaryFileCleanup`, `uploadedFileCleanup`
2053
+ and associated `logging` entries. Failed deletions retain their tracking entries
2054
+ for reconciliation. Tracking belongs to the creating transaction, preventing a
2055
+ reused context from deleting files committed by an earlier transaction. Managed
2056
+ rollback invokes enlisted cleanup hooks after confirmed completion.
2057
+
2058
+ After commit, uploaded objects belong to the application. Replacing or deleting
2059
+ a record does not delete its previously committed files, including when no
2060
+ references remain in that resource. URLs may be shared across records and
2061
+ external consumers. If your application needs garbage collection, establish
2062
+ ownership and absence of references before calling `storage.delete(url)`; do
2063
+ not attach unconditional file deletion to a record's DELETE or replacement.
2064
+ Rollback cleans only newly uploaded objects belonging to the failed transaction.
2065
+ See the [file ownership contract](26-file-uploads.md#ownership-after-commit).
2066
+
2067
+ `LocalStorage` now reserves filenames exclusively, so concurrent original/custom
2068
+ names cannot overwrite another upload or make rollback delete its bytes.
2069
+ Existing files and symlinks are occupied names. Filename inspection now rejects
2070
+ permission and other I/O failures instead of returning an available name.
2071
+ Failed uploads remove only their reserved destination. If removal or closing
2072
+ also fails, inspect the returned `AggregateError`'s `cause` and `errors` for the
2073
+ original and cleanup failures. Its destination may need reconciliation. Buffer
2074
+ and temporary-file inputs retain their existing signatures; see
2075
+ [storage behavior](26-file-uploads.md#storage-adapters).
2076
+
2077
+ Install `FileHandlingPlugin` before `ExpressPlugin` and install the selected
2078
+ optional peer (`busboy` or `formidable`). Missing parsers and invalid factories
2079
+ now fail setup. The Express connector accepts multipart only when a detector is
2080
+ registered and uploads are enabled; Fastify continues to reject multipart.
2081
+
2082
+ The corrected parsers use Busboy 1.x and Formidable 3.x. Both return buffered
2083
+ files with `filename`, `mimetype`, `size` and `data`. If a custom storage adapter
2084
+ used Formidable's old `file.filepath` or `file.cleanup`, use `file.data` instead:
2085
+ the detector now removes its private request directory before returning.
2086
+ Custom detectors' cleanup callbacks are still supported.
2087
+
2088
+ Repeated file fields now produce 400. Upload one file for each distinct declared
2089
+ file field. Repeated text fields become arrays consistently across parsers;
2090
+ bracketed array suffixes group values in arrival order. Unknown text/file fields
2091
+ are validated, including prototype-like names that could previously disappear.
2092
+ Parser limits produce 413, and declared file MIME/size violations produce 422.
2093
+ Review the documented [bounded defaults](26-file-uploads.md#protocol-configuration)
2094
+ and set parser limits explicitly when larger uploads are needed.
2095
+
2096
+ Multipart POST/PUT/PATCH use the route's resource type; PUT takes its ID from
2097
+ the URL. Required file fields follow the normal schema rules, so PATCH can omit
2098
+ an existing file. Numeric `maxSize` now works, including zero for empty files.
2099
+ Plain programmatic calls with custom detectors update the canonical input record
2100
+ correctly. Detector errors propagate instead of falling through to another
2101
+ parser or silently continuing the write.
2102
+
2103
+ Owned and managed rollback cleanup, multiple uploads, replacement and deletion
2104
+ are verified with real local storage. Previously committed files remain
2105
+ application-owned: replacing a URL or deleting its resource does not delete
2106
+ the old object, which may have other references. Confirmed rollback cleans new
2107
+ uploads; confirmed commit releases their tracking. Review
2108
+ [file ownership after commit](26-file-uploads.md#ownership-after-commit)
2109
+ when migrating application cleanup code. No stored-data migration is required
2110
+ by this parser correction.
2111
+
2112
+ ## Socket.IO startup and shutdown
2113
+
2114
+ Keep the existing `await api.startSocketServer(server, options)` call, and await
2115
+ it before accepting subscription traffic. Calling it while another start is
2116
+ pending or a Socket.IO server is active now rejects; remove duplicate bootstrap
2117
+ calls instead of relying on replacement of a live server.
2118
+
2119
+ Redis connection failure no longer leaves Socket.IO attached or published in
2120
+ `api.io`. Partial connections are closed, the HTTP server and authentication
2121
+ configuration remain intact, and the application can retry. The supplied Redis
2122
+ reconnection policy still controls retries. Set
2123
+ `redis.socket.reconnectStrategy: false` if your bootstrap should reject on the
2124
+ first connection failure.
2125
+
2126
+ Closing Socket.IO also closes the HTTP server supplied to it. The plugin now
2127
+ owns shutdown of its two Redis clients when that HTTP server closes and clears
2128
+ `api.io` and its server/client references. Remove any subsequent manual `quit()`
2129
+ calls on `api.vars.socketIORedisClients`. Close your application database
2130
+ separately. No new shutdown method, compatibility wrapper or data migration is
2131
+ required. The [Socket.IO guide](28-socketio.md) lists the verified paths and
2132
+ the remaining network/failover limits.
2133
+
2134
+ ## Socket.IO subscriptions and notifications
2135
+
2136
+ Subscription messages now accept only `resource`, `filters` and an optional
2137
+ nonempty string `subscriptionId`. Remove `include` and `fields` from subscribe
2138
+ and restore messages; those options never changed the notification data. Keep
2139
+ them in the subsequent resource GET/query instead. They are also absent from
2140
+ the acknowledgement's `data`. Other unknown options receive `UNSUPPORTED_OPTION`.
2141
+
2142
+ Null, scalar and array filters now receive `INVALID_FILTERS`. A duplicate active
2143
+ subscription ID receives `SUBSCRIPTION_EXISTS`; unsubscribe before replacing it.
2144
+ Concurrent admissions respect the per-socket limit. A subscription hook receives
2145
+ `{ context }`, with `context.subscription` and `context.auth`; it may modify
2146
+ filters but not identity. Its final filters are validated.
2147
+
2148
+ Custom SQL search fields use their existing `applyFilter(query, value)` callback
2149
+ for subscription matching too. Remove Socket.IO-only `filterRecord` callbacks;
2150
+ the earlier draft's requirement for a parallel JavaScript predicate is withdrawn.
2151
+ Notification eligibility now runs ordinary resource queries before/after writes
2152
+ in the same transaction, with subscriber identity and an internal changed-ID
2153
+ constraint. Query hooks must support these calls.
2154
+
2155
+ If row policies or autofilters need context beyond `auth`, supply it from trusted
2156
+ server data in the existing hook:
2157
+
2158
+ ```js
2159
+ subscriptionFilters: {
2160
+ functionName: 'subscriber-context',
2161
+ handler: ({ context: { subscription, auth } }) => {
2162
+ subscription.context = {
2163
+ scopeValues: { workspaceId: auth.workspaceId },
2164
+ visibility: { groups: auth.groups }
2165
+ }
2166
+ }
2167
+ }
2168
+ ```
2169
+
2170
+ The property defaults to `{}`. Keep it JSON-serializable when using Redis.
2171
+ Client messages cannot set it. Populate whatever context your own policies
2172
+ require; the property names above are application examples. Resource query
2173
+ permissions receive this context on admission and notification as well.
2174
+
2175
+ Relationship POST/DELETE now run `beforeDataCall` and their respective
2176
+ `beforeDataCallPostRelationship` / `beforeDataCallDeleteRelationship` hooks
2177
+ before mutation. Hooks shared across methods must handle these operations.
2178
+
2179
+ Update consumers of `change.type`:
2180
+
2181
+ | Previous value | New value |
2182
+ | --- | --- |
2183
+ | `resource.postd` | `resource.created` |
2184
+ | `resource.putd` | `resource.created` for PUT-create, otherwise `resource.updated` |
2185
+ | `resource.patchd` | `resource.updated` |
2186
+ | `resource.deleted` | Unchanged |
2187
+
2188
+ The `action` field retains resource method names (`post`, `put`, `patch`,
2189
+ `delete`). IDs, including `deletedRecord.id`, are strings. Relationship changes
2190
+ produce parent updates with action `patch`, and changed reverse children also
2191
+ produce their resource updates. Each matching subscription gets its own event;
2192
+ use `subscriptionId` when one connection holds multiple views. Both entering and
2193
+ leaving a filtered or authorized result trigger invalidation when the corresponding
2194
+ query succeeds. Deletion uses the before-write query.
2195
+
2196
+ Query permissions are checked again before notification. `socket.data.subscriptions`
2197
+ is now a serializable array for adapter transport; applications that inspected
2198
+ the old internal Map must update that code. No stored-data migration is needed.
2199
+ The [Socket.IO guide](28-socketio.md) separates verified local behavior from
2200
+ remaining Redis failover/network uncertainty. Atomic bulk enlists each child's
2201
+ completion hooks with its owner. A failed deferred notice now leaves later
2202
+ queued notices eligible for delivery. The draining operation's context records
2203
+ `socketioBroadcast` cleanup diagnostics with a zero-based `broadcastIndex`;
2204
+ the call retains its committed outcome and first failure. Failed notices are
2205
+ not automatically replayed. See the Socket.IO guide for partial-delivery limits.
2206
+ Managed callbacks defer repeated changes to the same resource until completion.
2207
+ In-memory notices do not guarantee exactly-once delivery or recovery after
2208
+ process failure. Verify subscription and notification handling in each application
2209
+ that uses this plugin.
2210
+
2211
+ ## Bulk writes
2212
+
2213
+ Remove `batchSize` and `enableOptimizations` from plugin configuration, and pass
2214
+ the remaining options directly:
2215
+
2216
+ ```javascript
2217
+ await api.use(BulkOperationsPlugin, {
2218
+ maxBulkOperations: 100,
2219
+ defaultAtomic: true
2220
+ })
2221
+ ```
2222
+
2223
+ `maxBulkOperations` must be a positive safe integer and `defaultAtomic` a boolean.
2224
+ Unknown keys now fail installation, including a nested `'bulk-operations'`
2225
+ wrapper. The removed settings did not batch SQL: `batchSize` only divided a
2226
+ sequential loop, and the optimization hook never ran. Removing the loop setting
2227
+ also removes its invalid-value hang/skip paths. Bulk operations still execute
2228
+ the normal resource methods, including validation, permission checks and hooks.
2229
+ Atomic requests use one transaction; non-atomic requests complete each record's
2230
+ transaction separately. No stored-data migration is needed.
2231
+
2232
+ Bulk POST replaces `inputRecords` with either `data: plainRecords` or
2233
+ `document: { data: jsonApiResources }`. Bulk PATCH entries use either
2234
+ `{ id, data: plainValues }` or `{ id, document: { data: jsonApiResource } }`.
2235
+ Do not put a JSON:API resource directly inside an operation's `data` field.
2236
+ Each entry explicitly selects its input; neither bulk method infers it from
2237
+ `format`. The documented bulk HTTP bodies keep their wire shapes: POST uses
2238
+ `{ data: [resourceObject, ...] }` and PATCH uses `{ operations: [...] }`.
2239
+ The connector translates them into these method arguments. Remove raw-array
2240
+ bulk POST shorthand and arrays of individually wrapped documents; send one
2241
+ document containing the resource array instead.
2242
+
2243
+ Bulk POST/PATCH honor output-only `format` and `returning`, including resource
2244
+ defaults. Bulk results retain their `meta`/`errors` envelope:
2245
+ `returning: 'full'` supplies complete records in `data`, `minimal` supplies
2246
+ identifiers, and `none` omits `data`. Bulk PATCH no longer performs an extra GET
2247
+ to override a minimal/none return choice.
2248
+
2249
+ ```js
2250
+ await api.resources.books.bulkPatch({
2251
+ operations: [{ id: '42', data: { title: 'Updated title' } }],
2252
+ format: 'plain',
2253
+ returning: 'none',
2254
+ atomic: true
2255
+ }, requestContext)
2256
+ // { meta: { total: 1, succeeded: 1, failed: 0, atomic: true }, ... }
2257
+ ```
2258
+
2259
+ `atomic` must be a boolean in direct calls. HTTP accepts `atomic=true` or
2260
+ `atomic=false`; misspellings now fail with 422 before writing. Removed response
2261
+ options also fail clearly. Numeric zero is a valid bulk PATCH ID when the
2262
+ resource's declared ID schema accepts zero.
2263
+
2264
+ Install the connector and bulk plugin before declaring resources. Bulk routes
2265
+ now register through the active scope hook and retain authenticated request
2266
+ context. They explicitly select full JSON:API records, regardless of
2267
+ programmatic defaults. POST responds with 201; PATCH and DELETE respond with
2268
+ 200 and the batch summary. The optional plugin reserves the `bulk` path segment
2269
+ for these methods; do not use it as an opaque resource ID with this plugin.
2270
+ These endpoints are the library's bulk protocol, not the JSON:API Atomic
2271
+ Operations extension. An atomic denial rolls back the batch; a non-atomic batch
2272
+ retains permitted operations and reports denied entries by index.
2273
+
2274
+ An atomic batch now keeps its original child, validation or commit error if
2275
+ rollback also fails. The outer batch attempts rollback once for an unfinished
2276
+ transaction it owns. Its supplied context exposes the original `error` and any secondary
2277
+ rollback failure in `cleanupErrors`, using `{ phase: 'rollback', error }` entries.
2278
+ Both fields are cleared when the context starts another bulk call. A transaction
2279
+ already completed by the driver is not rolled back; a rejected commit still
2280
+ does not establish a safe retry. The error wrapper reports the available outcome.
2281
+ This change does
2282
+ not add bulk completion hooks or change non-atomic result envelopes.
2283
+
2284
+ Child cleanup warnings and failures now survive on the supplied batch context.
2285
+ `cleanupErrors` entries add a zero-based `bulkIndex` to their existing phase,
2286
+ original error and file information. This includes warnings from successful
2287
+ entries, rollback/cleanup-hook failures from rejected entries, and diagnostics
2288
+ collected before an atomic batch rethrows. Managed completion-chain failures
2289
+ also identify `operationIndex`, `scopeName` and `method`. Secondary errors remain on the
2290
+ context rather than being added to the batch response.
2291
+
2292
+ Remaining child `fileHandlingUploads` entries also reach the batch context with
2293
+ `bulkIndex`, preserving storage and transaction references. Context reuse clears
2294
+ old error diagnostics, retains unresolved upload tracking and starts each new
2295
+ child with separate tracking. Do not use retained tracking as a deletion or retry
2296
+ instruction: files can belong to committed data when an earlier `afterCommit`
2297
+ hook prevents the tracking-release hook from running, or to pending outer work.
2298
+
2299
+ For non-atomic results, `meta.failed` counts rejected calls. A post-commit hook
2300
+ failure can therefore appear as an indexed error even though the write is
2301
+ committed. Inspect the underlying failure and transaction evidence before
2302
+ retrying. Each error entry now has `error.transactionOutcome`; remaining managed
2303
+ integration is tracked separately.
2304
+ No payload or stored-data migration is required for this diagnostic correction.
2305
+
2306
+ Stored file handles also now read as strings when the ordinary SQL driver returns
2307
+ their UTF-8 bytes as a Buffer. The shared normalizer decodes declared `file`
2308
+ attributes, including byte views, without changing blob fields or database
2309
+ columns. Invalid encoding fails with its original cause and field context.
2310
+ Custom getters retain their raw database input. No schema migration or consumer
2311
+ Buffer-to-string wrapper is needed; use the resulting URL string directly.
2312
+
2313
+ Bulk POST/PATCH/DELETE now honor `transaction` in the first argument; previously
2314
+ they ignored it and could commit separately from the caller's transaction.
2315
+ Select `atomic: true` when supplying it, including when the plugin's
2316
+ `defaultAtomic` is false. Each child and its relationship changes use the same
2317
+ transaction. The in-progress managed contract requires `api.transaction` to
2318
+ own the unit. Earlier entries and the failing entry's pending writes roll back
2319
+ together, including when the callback catches a participating write failure.
2320
+ Catching the error does not restore those rows or create a savepoint.
2321
+
2322
+ ```javascript
2323
+ await api.transaction(async transaction => {
2324
+ return api.resources.books.bulkPatch({
2325
+ operations: [{ id: '42', data: { title: 'Updated title' } }],
2326
+ format: 'plain',
2327
+ returning: 'minimal',
2328
+ atomic: true,
2329
+ transaction
2330
+ })
2331
+ })
2332
+ ```
2333
+
2334
+ `atomic: false` with a supplied transaction now fails with
2335
+ `REST_API_VALIDATION` on `transaction`, before any child or SQL runs. Independent
2336
+ per-entry completion requires calls without an outer transaction. A supplied
2337
+ completed transaction also rejects instead of being silently replaced. Calls
2338
+ without `transaction` keep their atomic/non-atomic ownership. Completion hooks,
2339
+ file cleanup and notifications use the managed owner. No stored-data migration
2340
+ is needed for this transaction-ownership change. Check any application wrapper
2341
+ that supplies raw transactions to bulk methods.
2342
+
2343
+ ## Application-supplied primary IDs in table helpers
2344
+
2345
+ Ordinary table creation, generated migrations and migration diffs now recognize
2346
+ the declared primary ID by its mapped column, including `integer` and `string`
2347
+ definitions. Previously only a field with type `id` was recognized; a declared
2348
+ integer/string primary key could produce a duplicate generated ID column.
2349
+
2350
+ For a caller-supplied opaque key, declare the primary key explicitly:
2351
+
2352
+ ```js
2353
+ await api.addResource('records', {
2354
+ idProperty: 'record_key',
2355
+ schema: {
2356
+ id: {
2357
+ type: 'string', primary: true, required: true,
2358
+ storage: { column: 'record_key' }
2359
+ },
2360
+ name: { type: 'string' }
2361
+ }
2362
+ })
2363
+ ```
2364
+
2365
+ The same pattern works with `type: 'integer'`. `primary: true` keeps the
2366
+ authored column instead of adding an implicit auto-incrementing ID. Supply IDs
2367
+ when writing to a table without database allocation or a default.
2368
+ An opaque ID without that primary declaration cannot use implicit numeric
2369
+ allocation: the helper now rejects that configuration before producing DDL.
2370
+ For lower-level table definitions, `autoIncrement: false` remains available.
2371
+
2372
+ Table snapshots now accept these non-null, single-column integer/string primary
2373
+ keys without requiring auto-increment or a database default. Existing tables are
2374
+ not altered by this change. Regenerate migrations that failed with duplicate ID
2375
+ columns and review the resulting DDL; an unchanged valid table should produce
2376
+ an empty migration diff. Missing, nullable, floating-point and composite resource
2377
+ IDs remain rejected by snapshot validation.
2378
+
2379
+ ## Collection filtering hooks
2380
+
2381
+ Collection reads now honor a query builder assigned to
2382
+ `context.knexQuery.query` by a `knexQueryFiltering` hook. Previously, both storage
2383
+ modes could ignore that replacement and omit restrictions added to it; ordinary
2384
+ pagination totals could disagree with the returned page, and canonical totals
2385
+ could omit the restriction too. Related-resource collections use the corrected
2386
+ path as well.
2387
+
2388
+ When a hook needs a separate builder, clone the supplied query and apply further
2389
+ conditions to that clone. Keep its tenant/resource constraints, relationship
2390
+ membership and existing visibility predicates. The selected builder governs the
2391
+ page. Ordinary storage also runs filtering hooks for its separate count query;
2392
+ canonical storage clones the filtered collection query for that count. Apply the
2393
+ complete predicate on every filtering call, including count calls. Surrounding
2394
+ `context.knexQuery` metadata is restored after filtering succeeds or fails.
2395
+ Row-policy functions retain their existing
2396
+ `true`/`false` return contract. This correction adds no public option and requires
2397
+ no change to hooks that already mutate or replace the supplied builder correctly.
2398
+
2399
+ ## Full includes and projected fields
2400
+
2401
+ Ordinary-storage belongsTo, hasOne and polymorphic belongsTo includes now select
2402
+ visible SQL projections when no target fieldset is supplied. Previously these
2403
+ paths could omit the projections and pass missing dependencies to computed
2404
+ fields. Full and sparse includes now use the same field-selection rules as
2405
+ collection includes and canonical storage.
2406
+
2407
+ No call changes are required. Remove any fieldset used solely to work around
2408
+ missing projections; keep intentional sparse fieldsets to control response
2409
+ size. For example, `books.get({ id: '1', queryParams: { include: ['author'] } })`
2410
+ now returns the author's visible projections without also specifying
2411
+ `fields.authors`. A projection used only as a computed-field dependency remains
2412
+ absent from a sparse response unless requested. Projection failures reject the
2413
+ operation; full write-response failures happen before an owned commit.
2414
+
2415
+ ### Cyclic includes and resource identity
2416
+
2417
+ A resource appears once across a JSON:API document's `data` and `included`.
2418
+ For example, `items.get({ id: '1', format: 'jsonapi', queryParams: { include: ['groups.items'] } })`
2419
+ keeps the item in `data` and its group in `included`. The group's relationship
2420
+ can point back to `items/1` without adding another copy of that item. Relationships
2421
+ discovered through the cycle are retained on the primary resource, and computed
2422
+ fields run once per represented resource. Explicit collection include limits
2423
+ remain in effect when another path revisits the same resource.
2424
+
2425
+ If your response resolver searches only `included`, index both primary `data`
2426
+ and `included` by type and ID instead. Update snapshots that expected a primary
2427
+ resource to be repeated in `included`. Relationship identifiers and resource URLs
2428
+ keep their existing shape; this requires no stored-data migration.
2429
+
2430
+ Plain responses resolve references against both primary and included resources.
2431
+ Peers in a primary collection can therefore expand fully. Actual cycles end in
2432
+ an `{ id }` reference, with `_type` retained for polymorphic relationships;
2433
+ independent branches still receive separate objects. Keep custom computed
2434
+ callbacks free of assumptions that a cyclic path will invoke them twice.
2435
+
2436
+ ### Empty includes and refresh links
2437
+
2438
+ JSON:API resource reads and full write responses now retain `included: []` when
2439
+ an explicit include returns no resources. This also applies to empty collections
2440
+ and related endpoints with `data: null`. An explicitly empty include is supported:
2441
+
2442
+ ```js
2443
+ await api.resources.groups.get({
2444
+ id: '1',
2445
+ format: 'jsonapi',
2446
+ queryParams: { include: [] }
2447
+ })
2448
+ // { data: { ... }, included: [], links: { self: '.../groups/1?include=' } }
2449
+ ```
2450
+
2451
+ Omitting `include` keeps it absent from normalized query parameters and omits
2452
+ `included` when there are no included resources. HTTP `?include=` parses as an
2453
+ explicit empty array and survives serialization into refresh/pagination links.
2454
+ Hooks and direct parser callers that iterate includes should use
2455
+ `context.queryParams?.include ?? []`; do not assume an omitted include has been
2456
+ populated with an empty array. Update response snapshots for explicitly requested
2457
+ includes that used to omit `included`.
2458
+
2459
+ Top-level `links.self` on single-resource and related responses now preserves
2460
+ includes and fieldsets, as collection links do. Follow that link to refresh the
2461
+ same representation. Resource-level `data.links.self` remains the resource URL.
2462
+ Update assertions that assumed those two links were always identical.
2463
+
2464
+ Plain results retain their existing shapes. Programmatic `returning: 'minimal'`
2465
+ and `'none'` retain their selected return contracts; HTTP resource writes select
2466
+ full JSON:API responses. No method arguments or stored records need migration.
2467
+
2468
+ ### Relationship visibility hooks
2469
+
2470
+ Row policies and `knexQueryFiltering` hooks with
2471
+ `queryPurpose: 'relationship-identifiers'` now run once per batch of at most
2472
+ 100 unique target IDs for each resource type. Remove assumptions that this is
2473
+ one callback per request, and remove flags that skip a visibility predicate
2474
+ after the first query. Add the full predicate to each supplied builder; cloning
2475
+ that builder remains supported. Keep ordering, pagination and include limits
2476
+ in the operation/resource settings, and retain the selected ID and mandatory
2477
+ scope constraints when customizing a visibility query.
2478
+
2479
+ Identifier-visibility selections no longer put every ID into one `WHERE IN`. These reads
2480
+ select distinct IDs, preserve the input linkage order and duplicate occurrences,
2481
+ and retain the caller's transaction. A later batch failure rejects the whole
2482
+ read. See the [row-policy contract](17-row-policies.md#query-purposes-and-lifecycle-coverage)
2483
+ for the context supplied to these hooks.
2484
+
2485
+ ### Nested belongs-to include batches
2486
+
2487
+ Regular and polymorphic belongs-to includes now load at most 100 unique target
2488
+ IDs per resource type in each query. Large nested includes no longer place all
2489
+ of those IDs in one SQL statement. Target permission/filter hooks with
2490
+ `queryPurpose: 'include'` and SQL projection `select` callbacks run for every
2491
+ batch. Apply the complete predicate or projection each time; remove assumptions
2492
+ that they run once per include path. This does not change computed-field
2493
+ callbacks into query callbacks.
2494
+
2495
+ All target batches are gathered before traversing nested collections. Existing
2496
+ global and per-parent collection limits still apply across that complete parent
2497
+ set. A later batch failure rejects the operation; a failed full write response
2498
+ rolls back an owned transaction, while a borrowed transaction remains the
2499
+ caller's responsibility. Canonical storage also batches parent-link prefetches.
2500
+ This change adds no method options and does not impose a total response-memory
2501
+ bound or batch every collection include query.
2502
+
2503
+ ### Large collection identifier queries
2504
+
2505
+ Collection includes and relationship-metadata reads now deduplicate their ID
2506
+ lists. Above 100 distinct IDs, PostgreSQL receives one array parameter and
2507
+ SQLite receives one JSON array parameter through JSON1. Sorting, global limits,
2508
+ per-parent limits and policy predicates still run in the original SQL query.
2509
+ SQL projection and filtering callbacks retain their collection-query frequency.
2510
+ The separately batched belongs-to loaders retain the behavior described above.
2511
+
2512
+ Code that inspects raw Knex statements must allow array predicates and ID
2513
+ subqueries; use the documented query-purpose/context fields when applying
2514
+ policies. Small lists keep ordinary `IN` predicates. MySQL retains its ordinary
2515
+ `IN` query through Knex's text protocol, preserving its existing numeric and
2516
+ collation comparisons. These changes add no public options. They avoid database
2517
+ parameter-count overflow, but complete ID lists and responses still consume
2518
+ memory proportional to their size.
2519
+
2520
+ ### Explicit includes and linkage reads
2521
+
2522
+ An explicitly included relationship is now loaded directly by its include
2523
+ loader. The library skips the preliminary identifier read that the loader used
2524
+ to replace afterward. For standard many-to-many includes, authorized target
2525
+ records are selected and limited in SQL before the library reads their parent
2526
+ mappings. Window includes keep their per-parent limit behavior. Result ordering,
2527
+ duplicate-edge handling, sparse fields and transaction ownership remain the same.
2528
+
2529
+ Hooks observing `relationship-identifiers` queries will see fewer calls for
2530
+ explicitly included relationships. Target and ordinary pivot include permission
2531
+ checks still run. Mapping reads now occur after the target query, on the same
2532
+ transaction; a mapping-read failure still fails the operation. Complete unlimited
2533
+ includes can require additional mapping pages. No API option or data migration
2534
+ is needed.
2535
+
2536
+ ### Sparse responses and relationship hooks
2537
+
2538
+ Sparse fieldsets now avoid database reads for omitted relationship linkage in
2539
+ both storage modes. For example, `queryParams: { fields: { books: 'title' } }`
2540
+ does not load the book's collection identifiers or check visibility of to-one
2541
+ linkage that will be removed from the response. Selected linkage retains its
2542
+ visibility checks. Requests without a fieldset for that resource keep their
2543
+ existing linkage behavior.
2544
+
2545
+ An explicit `include` still loads its resources even when the corresponding
2546
+ relationship field is omitted. Nested includes, target permissions, computed
2547
+ attribute dependencies and transaction ownership retain their existing behavior.
2548
+ No new option is needed.
2549
+
2550
+ If a hook reads collection linkage from `context.record`, request that
2551
+ relationship in the resource's fieldset or load the data explicitly for the
2552
+ hook's purpose. Do not use the number of `relationship-identifiers` filtering
2553
+ hooks as a request notification: omitted linkage no longer triggers those reads.
2554
+ Authorization for requested primary/included resources still runs; authorization
2555
+ must not depend on an incidental query for an omitted relationship.
2556
+
2557
+ ### Canonical parent-link reads
2558
+
2559
+ Canonical many-to-many linkage now reads at most 101 physical link rows per
2560
+ query, retaining its existing 100-parent batch size. It still returns complete
2561
+ visible linkage. Raw linkage is assembled in physical link-ID order for each
2562
+ parent; use declared include ordering or related-resource query sorting when
2563
+ application order matters. Configured include limits and ordering still apply
2564
+ to the complete requested parent set.
2565
+
2566
+ The prefetch now also enforces the relationship's declared target resource type,
2567
+ matching the existing related-resource query. A same-key physical link to a
2568
+ different resource no longer appears in this relationship. No method option or
2569
+ data migration is needed. Later page failures reject the operation and preserve
2570
+ the existing owned/borrowed transaction behavior. Raw SQL observers should expect
2571
+ additional reads for large link collections.
2572
+
2573
+ ### Ordinary pivot membership writes
2574
+
2575
+ Many-to-many additions and replacements now read existing pivot rows for at
2576
+ most 100 requested target IDs at a time, paging duplicate physical edges in
2577
+ bounded results. New inserts still use batches of 100 rows. Replacement deletes
2578
+ unwanted rows using one predicate for the complete desired set, including large
2579
+ SQLite/PostgreSQL ID lists. Retained matching pivot rows keep their IDs and
2580
+ metadata. Empty replacement removes every link for that owner.
2581
+
2582
+ Equivalent requested target IDs, as resolved by the database's target ID column,
2583
+ are written once using the first submitted spelling. For example, `BETA` and
2584
+ `beta` referring to the same target no longer create two new pivot rows in one
2585
+ request. Existing duplicate rows are not merged automatically. Keep target ID
2586
+ and pivot reference column types/collations consistent; this does not migrate
2587
+ existing application schemas.
2588
+
2589
+ Method arguments, permission hooks and transaction ownership are unchanged.
2590
+ Code that observes raw SQL must allow paged pivot reads and a single exclusion
2591
+ DELETE for replacement. The complete input and keep-list still require memory
2592
+ proportional to their size; the batching does not set a new relationship limit.
2593
+
2594
+ ## Relationship target validation
2595
+
2596
+ POST, PUT and PATCH payloads, including relationship endpoints, now use one
2597
+ target-validation contract. Readable target rows are selected in batches of at
2598
+ most 100 distinct identifiers; `checkPermissions` then runs with `method: 'get'`
2599
+ for each distinct target within each relationship. Its `originalContext`
2600
+ contains that target's `id`, `scopeName`, `schemaInfo` and `minimalRecord`, plus
2601
+ the caller's auth and transaction. Repeated identifiers do not repeat permission
2602
+ checks within one relationship. A target referenced through two relationships
2603
+ is checked for each relationship.
2604
+
2605
+ Many-to-many writes no longer run a full target GET after this check. Move any
2606
+ link-authorization logic from `checkDataPermissionsGet`, `beforeDataGet`, getters,
2607
+ computed fields, enrichment or `finishGet` into the existing permission hook:
2608
+
2609
+ ```js
2610
+ checkPermissions: {
2611
+ functionName: 'authorize-readable-target',
2612
+ handler: ({ context }) => {
2613
+ if (context.method !== 'get') return
2614
+ const request = context.originalContext
2615
+ assertCanRead(request.auth, request.minimalRecord)
2616
+ }
2617
+ }
2618
+ ```
2619
+
2620
+ The minimal record contains stored attributes and to-one linkage; it has no
2621
+ response getters/computed fields or includes. Response hooks still run for real
2622
+ GETs and requested full write responses. Use write/transaction hooks for side
2623
+ effects of linking. Remove assumptions about one target GET per supplied ID.
2624
+
2625
+ Row policies and `knexQueryFiltering` run with
2626
+ `queryPurpose: 'relationship-validation'`, empty client filters and no individual
2627
+ `context.id`. Add the complete row predicate to every supplied builder. Retain
2628
+ its selected ID and mandatory scope constraints; the filtered ID subquery
2629
+ selects the full minimal rows without multiplying them through policy joins.
2630
+ An alternate-spelling lookup can run an additional scoped query to retain the
2631
+ database's ID collation semantics. Per-target decisions belong in the permission
2632
+ hook, where the individual ID and record are available.
2633
+
2634
+ Target locks and storage existence checks remain. A failure rejects before
2635
+ link writes; an owned operation rolls back, while a borrowed transaction stays
2636
+ with its caller. Internal pivot/link storage helpers now assume the resource
2637
+ operation has authorized its payload; applications should use resource methods.
2638
+ No option retains the old target GET lifecycle. Port application hooks that
2639
+ relied on those implicit reads.
2640
+
2641
+ ## Setter, getter and computed-field failures
2642
+
2643
+ Field callbacks now propagate failures in both formats and storage modes,
2644
+ including primary resources, included resources and nested includes. There is
2645
+ no additional policy option. The changes are:
2646
+
2647
+ | Callback failure | Previous behavior | Current behavior |
2648
+ | --- | --- | --- |
2649
+ | Unexpected setter error | Converted to validation error (422), losing its cause | Operation rejects with the original cause; HTTP 500 |
2650
+ | Unexpected getter error | Logged; response kept the current field value | Operation rejects; HTTP 500 |
2651
+ | Unexpected computed-field error | Logged; response substituted null | Operation rejects; HTTP 500 |
2652
+ | Typed API error | Propagated at field callbacks | Unchanged on reads; retained as the write wrapper's cause, with code/details still readable |
2653
+ | Relationship target permission failure | Could be replaced with not-found (404) | Original classification/cause retained by target validation; a missing target still produces 404 |
2654
+
2655
+ In the cause chain, unexpected callback errors have `cause` and
2656
+ `context: { scopeName, fieldName, phase }`, where `phase` is `setter`, `getter`
2657
+ or `computed`. Include processing can add outer wrappers; follow the cause
2658
+ chain to the field error. Synchronous throws and asynchronous rejections follow
2659
+ the same rule, including non-Error values such as null or undefined.
2660
+
2661
+ If a setter intentionally rejects user input, change its generic throw to the
2662
+ existing validation error class. For example, inside a resource schema:
2663
+
2664
+ ```js
2665
+ import { RestApiValidationError } from 'json-rest-api'
2666
+
2667
+ const quantityField = {
2668
+ type: 'number',
2669
+ setter: value => {
2670
+ if (!Number.isInteger(value)) {
2671
+ // Previously: throw new Error('Quantity must be a whole number')
2672
+ throw new RestApiValidationError('Quantity must be a whole number', {
2673
+ fields: ['data.attributes.quantity'],
2674
+ violations: [{
2675
+ field: 'data.attributes.quantity',
2676
+ rule: 'integer',
2677
+ message: 'Quantity must be a whole number'
2678
+ }]
2679
+ })
2680
+ }
2681
+ return value
2682
+ }
2683
+ }
2684
+ ```
2685
+
2686
+ This keeps intentional input rejection at 422. A typed forbidden resource
2687
+ error remains 403. Unexpected programming or service failures should propagate.
2688
+ When a value is intentionally unavailable, return that domain value explicitly:
2689
+
2690
+ ```js
2691
+ const helpfulnessField = {
2692
+ type: 'number',
2693
+ computed: true,
2694
+ dependencies: ['helpful_votes', 'total_votes'],
2695
+ compute: ({ attributes }) => attributes.total_votes === 0
2696
+ ? null
2697
+ : 100 * attributes.helpful_votes / attributes.total_votes
2698
+ }
2699
+ ```
2700
+
2701
+ A callback's own intentional fallback still works. Review existing generic
2702
+ validation throws and code that depended on the library substituting null or
2703
+ returning an untransformed value. Handle rejected reads and writes instead of
2704
+ treating an incomplete response as success.
2705
+
2706
+ GET/query return no successful partial response after an executed callback
2707
+ fails. POST/PUT/PATCH full-response enrichment runs before owned commit, so its
2708
+ failure rolls back owned record and relationship changes. Borrowed transactions
2709
+ remain active for their owner to complete. Minimal/none responses do not run
2710
+ callbacks solely to enrich an unused full response; callbacks needed for other
2711
+ work, such as validating related resources, still run and can fail the write.
2712
+ Sparse fieldsets likewise avoid callbacks that are neither selected nor needed
2713
+ as dependencies. These rules do not undo a write whose commit was already
2714
+ acknowledged; post-commit side effects and cleanup retain their own semantics.
2715
+
2716
+ ## Stored relationship metadata
2717
+
2718
+ Canonical reverse relationship linkage now selects only child identity and
2719
+ parent-reference columns. Large target attributes are no longer fetched merely
2720
+ to construct linkage. Returned IDs, filters and transaction ownership are
2721
+ unchanged. Query hooks for `queryPurpose: 'relationship-identifiers'` should
2722
+ preserve these selections when replacing the builder; add predicates against
2723
+ the underlying columns as usual. No payload or data migration is needed.
2724
+
2725
+ Polymorphic linkage read from storage must use a target type declared by its
2726
+ relationship. For example, with
2727
+ `subject: { belongsToPolymorphic: { types: ['groups'], ... } }`, a stored
2728
+ `subjectType: 'items'` is invalid even if an `items` resource exists. Previously
2729
+ ordinary includes could silently omit that target, while canonical storage
2730
+ could include it. Both modes now reject with HTTP 500 and programmatic error
2731
+ context `{ scopeName, relationshipName, phase: 'relationshipData' }`.
2732
+ This also applies to linkage returned without an include. A disallowed target
2733
+ submitted through the write API still produces a validation error (422).
2734
+
2735
+ Repair inconsistent stored type/ID pairs or update the declared relationship
2736
+ when the target type is intentional. Nullable, intentionally empty
2737
+ relationships retain their existing representation. Sparse fieldsets do not
2738
+ guarantee bypassing stored-linkage checks: preliminary reads and relationship
2739
+ selection can still require those columns.
2740
+
2741
+ Required reverse-relationship metadata must also exist: a `via` relationship
2742
+ must refer to a polymorphic relationship, and canonical foreign-key mappings
2743
+ must resolve to a stored field. Missing required metadata now rejects instead
2744
+ of returning empty linkage. Correct the resource declaration or stored
2745
+ canonical metadata, then retry. Schema-read failures during field selection
2746
+ and record conversion retain their original cause and resource context with
2747
+ phase `relationshipMetadata`; they are no longer ignored as if the resource
2748
+ had no relationships.
2749
+
2750
+ Nested polymorphic include paths can still apply to only some declared target
2751
+ types. If `subject` allows `writers` and `books`, and only writers have a
2752
+ `profile`, `include=subject.profile` remains valid; books simply have no profile
2753
+ branch. A path supported by none of the declared target types is rejected by
2754
+ request validation.
2755
+
2756
+ These read checks also apply while preparing write responses. A failure before
2757
+ owned commit rolls back the owned write; a borrowed transaction remains its
2758
+ owner's responsibility. An acknowledged commit cannot be undone by a later
2759
+ response or side-effect error.
2760
+
2761
+ ## Schema enrichment and canonical registration
2762
+
2763
+ Use `schema:enrich` to change attribute definitions. Use `searchSchema:enrich`
2764
+ to change filter definitions. Previously the search hook received the attribute
2765
+ map in `context.fields`, so edits could affect writes while failing to change
2766
+ filter validation. It now receives the generated search map, including explicit
2767
+ search aliases. Adding, editing, deleting or replacing `context.fields` affects
2768
+ the search contract. The hook also runs when the initial search map is empty.
2769
+
2770
+ ```js
2771
+ await api.customize({
2772
+ hooks: {
2773
+ 'schema:enrich': {
2774
+ functionName: 'declare-product-stock',
2775
+ handler: ({ context }) => {
2776
+ if (context.scopeName !== 'products') return
2777
+ context.fields.stock = { type: 'integer', defaultTo: 0 }
2778
+ }
2779
+ },
2780
+ 'searchSchema:enrich': {
2781
+ functionName: 'declare-product-filters',
2782
+ handler: ({ context }) => {
2783
+ if (context.scopeName !== 'products') return
2784
+ context.fields.nameContains = {
2785
+ type: 'string', actualField: 'name', filterOperator: 'contains'
2786
+ }
2787
+ }
2788
+ }
2789
+ }
2790
+ })
2791
+ // Register resources after installing their schema hooks.
2792
+ ```
2793
+
2794
+ Treat `originalFields` as inspection input; edit `fields`. Definitions are
2795
+ shallow copies, so this is not a guarantee of deep immutability. Search index
2796
+ hints are applied to the hook's final result. A search-only alias is not a
2797
+ writable attribute.
2798
+
2799
+ Both storage modes now use the enriched compiled field definitions. Canonical
2800
+ registration previously persisted raw authored definitions and then overwrote
2801
+ some enriched metadata. It now allocates storage from compiled definitions and
2802
+ attaches its descriptor to the same compiled schema. This preserves types,
2803
+ removed/added fields, search overrides and callbacks without a second schema
2804
+ compilation. Canonical metadata no longer invents an attribute declaration for
2805
+ an implicit resource ID; metadata consumers should use `schemaInfo.idProperty`
2806
+ to identify the resource ID configuration.
2807
+
2808
+ Enrichment runs once at resource registration, and once for each canonical
2809
+ `addKnexFields` compilation. Repeated canonical `createKnexTable()` refreshes
2810
+ storage metadata without rerunning enrichment or restoring removed search
2811
+ fields. Deep registry allocation does not update a running resource's compiled
2812
+ schema; use the resource helper and retain its expanded declaration in source.
2813
+ Declare callbacks in source again after restart: persisted JSON cannot restore
2814
+ functions. Ordinary `addKnexFields` retains its DDL-only behavior.
2815
+
2816
+ Fastify now resolves the current cached request contract when validating a
2817
+ payload. A belongs-to alias added through canonical `addKnexFields` can be used
2818
+ in resource POST/PUT/PATCH documents after server startup, as with Express and
2819
+ programmatic calls. The JSON Schema published at route registration remains an
2820
+ initialization snapshot; finalize declarations before generating route-based
2821
+ API documentation.
2822
+
2823
+ ### Computed-field enrichment
2824
+
2825
+ Plugins that derive computed fields from schema metadata should use
2826
+ `computedSchema:enrich` instead of adding fields to `vars.schemaInfo.computed`
2827
+ in `resource:added`. The compiler awaits this hook after attribute and search
2828
+ enrichment, including during canonical field additions. Edit or replace
2829
+ `context.fields`; each definition must have `computed: true` and a `type`,
2830
+ and any supplied `compute` callback must be a function. Computed names cannot
2831
+ collide with stored attributes.
2832
+
2833
+ This validation also applies to authored computed fields. `compute: false`,
2834
+ `0`, `null` and `''` previously registered successfully but silently omitted
2835
+ the field from output. They now fail registration with `invalid compute
2836
+ function`. Supply a function, or omit `compute` when an `enrichAttributes`
2837
+ hook intentionally supplies the value.
2838
+
2839
+ ```js
2840
+ await api.customize({
2841
+ hooks: {
2842
+ 'computedSchema:enrich': {
2843
+ functionName: 'derive-product-summary',
2844
+ handler: ({ context }) => {
2845
+ if (context.scopeName !== 'products') return
2846
+ context.fields.summary = {
2847
+ type: 'string',
2848
+ computed: true,
2849
+ dependencies: ['name'],
2850
+ compute: ({ attributes }) => `Product: ${attributes.name}`
2851
+ }
2852
+ }
2853
+ }
2854
+ }
2855
+ })
2856
+ ```
2857
+
2858
+ The context also exposes `originalFields` (authored computed declarations),
2859
+ `schemaStructure`, `searchSchemaStructure`, `schemaRelationships`, `idProperty`, `scopeName` and
2860
+ `scopeOptions` for inspection. Change stored fields in `schema:enrich` and
2861
+ search fields in `searchSchema:enrich`; the computed hook's other structures
2862
+ are inspection inputs, not an alternative configuration mutation API.
2863
+ Definitions are shallow copies, not deeply immutable objects.
2864
+
2865
+ LabelPlugin now uses this stage. Automatic labels survive canonical field
2866
+ additions and use the newly compiled search/preference candidates. Explicit
2867
+ stored or computed labels remain authoritative. If computed enrichment or
2868
+ validation fails during canonical `addKnexFields`, the candidate is rejected
2869
+ before field allocation; existing compiled metadata and records remain usable.
2870
+
2871
+ ### Existing canonical data
2872
+
2873
+ Registration now rejects removal or remapping of an existing stored field when
2874
+ the resource has records. The error starts with `Canonical storage migration
2875
+ required` and identifies the field. An owned registration rolls back metadata
2876
+ changes; a borrowed registry transaction remains the caller's responsibility.
2877
+ This guard prevents a changed schema order or newly effective enrichment hook
2878
+ from silently reading one field's existing slot as another field.
2879
+
2880
+ Before upgrading an app with existing canonical data:
2881
+
2882
+ 1. Inspect its schema hooks and compare their final field types with persisted
2883
+ field definitions and `canonicalFieldMap`.
2884
+ 2. Keep every existing stored field in the declaration. For changes that retain
2885
+ the same storage types, preserve existing slot assignments with an explicit
2886
+ complete `canonicalFieldsMap`; allocate unused slots for new fields.
2887
+ 3. If a field needs a different slot pool, a new value encoding or removal,
2888
+ migrate and verify its data and metadata explicitly before registering that
2889
+ changed layout. A slot map cannot convert values between storage types.
2890
+ 4. Restart with the expanded schema, callbacks and slot map, then verify old
2891
+ records, new writes, filters and relationship linkage.
2892
+
2893
+ Registration is not a data migration. The layout guard does not verify semantic
2894
+ changes to callbacks or values that retain the same physical slot. Empty
2895
+ resources can change layout without this guard rejecting registration.
2896
+
2897
+ Older canonical registrations could also allocate an undeclared numeric field
2898
+ named after a custom `idProperty`. That synthetic field is no longer allocated.
2899
+ If its removal triggers the guard, inspect the old slot and metadata in your
2900
+ data migration. Remove its field configuration/schema entry only after proving
2901
+ the slot has no values to preserve, and retain every real field's slot map.
2902
+ Do not add a fake attribute merely to retain the unused allocation.
2903
+
2904
+ ## Write hooks and the returned response
2905
+
2906
+ POST, PUT and PATCH prepare their response before committing an owned
2907
+ transaction. `finish`, then `finishPost`/`finishPut`/`finishPatch`, are the last
2908
+ hooks that can customize `context.responseRecord`. The library then normalizes
2909
+ schema-declared values and reapplies requested sparse fieldsets to full output,
2910
+ including related records. A finish hook cannot expose an excluded field by
2911
+ adding it after the nested GET. Minimal responses retain their resource type
2912
+ and ID; `returning: 'none'` still returns undefined.
2913
+
2914
+ The returned minimal/full response is now a detached snapshot. `afterCommit`
2915
+ is awaited for external side effects, but mutations to its context or response
2916
+ do not rewrite the prepared result. Move response changes from `afterCommit`
2917
+ into the appropriate finish hook. For example, JSON:API response metadata can be
2918
+ added with:
2919
+
2920
+ ```js
2921
+ await api.customize({
2922
+ hooks: {
2923
+ finishPost: {
2924
+ functionName: 'add-write-metadata',
2925
+ handler: ({ context }) => {
2926
+ if (context.format !== 'jsonapi' || context.returning !== 'full') return
2927
+ context.responseRecord.meta = { requestId: context.requestId }
2928
+ }
2929
+ }
2930
+ }
2931
+ })
2932
+ ```
2933
+
2934
+ Use data in response extensions and metadata, not functions or live application
2935
+ objects. The snapshot uses `structuredClone`; a non-cloneable extension rejects
2936
+ before commit, with owned transactions rolled back and borrowed transactions
2937
+ left to their owner. This does not add validation of arbitrary JSON attributes
2938
+ or metadata. Schema-declared temporal fields still use their existing conversion
2939
+ rules. A failing `afterCommit` hook still rejects after the database has committed;
2940
+ the error reports `committed`.
2941
+
2942
+ POST, PUT and PATCH clear any ID left by a previous operation before processing
2943
+ hooks run. POST's generated ID becomes available as `context.id` at
2944
+ `afterDataCallPost`, and remains available to later hooks and the nested response
2945
+ GET. Before insertion, a caller-supplied ID is available in
2946
+ `context.inputRecord.data.id` after input conversion. PUT and PATCH assign their
2947
+ validated target ID before `beforeSchemaValidate`. Early processing hooks can
2948
+ inspect the current request in `context.params`; they must not use a previous
2949
+ operation's ID.
2950
+
2951
+ ## Direct imports of the setter helper
2952
+
2953
+ Resource declarations and setter callback arguments keep their existing shape.
2954
+ If application code directly imported `applyFieldSetters` from the internal
2955
+ resource-method `common.js`, update its call:
2956
+
2957
+ ```js
2958
+ // Before: assign the helper's returned attributes.
2959
+ context.inputRecord.data.attributes = await applyFieldSetters(
2960
+ context.inputRecord.data.attributes, context.schemaInfo, context, api, helpers
2961
+ )
2962
+
2963
+ // Now: the helper assigns the transformed attributes to this write context.
2964
+ await applyFieldSetters(context, api, helpers)
2965
+ ```
2966
+
2967
+ The helper returns no value. It runs setters at the same point, after validation
2968
+ and before storage, without completing the transaction. Applications using
2969
+ public resource calls need no change for this internal simplification.
2970
+ The [lifecycle guide](13-hooks-and-lifecycle.md)
2971
+ documents the retained hooks and their ordering.
2972
+
2973
+ ## Application rollout and experimental features
2974
+
2975
+ Coordinate dependency versions, consumer repositories, generated code and seeds
2976
+ before upgrading applications. Run their actual workflows against the intended
2977
+ package set. Library-side verification does not establish consumer compatibility.
2978
+
2979
+ ### JSKIT repositories
2980
+
2981
+ JSKIT's `Document` / `Documents` repository methods keep their existing public
2982
+ names and JSON:API responses. Repository callers continue to pass plain payloads
2983
+ and `{ trx, context }` options. The shared implementation selects `data` for
2984
+ writes, `format: 'jsonapi'` for documents, and `returning: 'full'` for returned
2985
+ write attributes. DELETE explicitly selects `returning: 'none'`.
2986
+
2987
+ Custom repositories calling the library directly need the same changes. Plain
2988
+ collection reads return an envelope with a `data` array; plain single-resource
2989
+ reads and full writes return the record itself. When a foreign-key field has a
2990
+ relationship alias, plain library input uses that alias. For example,
2991
+ `authorId: { belongsTo: 'authors', as: 'author' }` accepts `data: { author: id }`.
2992
+ JSKIT's shared repository maps its domain payload's `authorId` to `author` and
2993
+ rejects payloads that supply both names.
2994
+
2995
+ Copied repositories can reuse the shared CRUD implementation and retain their
2996
+ application-specific methods. Here `lockDocumentById` is the application's
2997
+ existing scoped lock method, kept in the same module:
2998
+
2999
+ ```js
3000
+ import { createCrudJsonApiRepository } from '@jskit-ai/crud-core/server/jsonApiRepository'
3001
+
3002
+ const repository = {
3003
+ ...createCrudJsonApiRepository({ api, resource, resourceScopeName }),
3004
+ lockDocumentById
3005
+ }
3006
+ ```
3007
+
3008
+ Preserve custom scoping, locks and write normalization when removing copied
3009
+ code. Standard document writes return full documents; the shared repository
3010
+ does not forward a `returning` option. A custom minimal-response path still
3011
+ needs an explicit resource call with `data`, `format: 'jsonapi'` and
3012
+ `returning: 'minimal'`.
3013
+
3014
+ Use the repository's transaction owner for operations involving JSON REST:
3015
+
3016
+ ```js
3017
+ // Before: a raw Knex owner surrounded JSON REST repository writes.
3018
+ await knex.transaction(async trx => {
3019
+ await repository.createDocument(payload, { trx, context })
3020
+ })
3021
+
3022
+ // After: the repository delegates ownership to api.transaction.
3023
+ await repository.withTransaction(async trx => {
3024
+ const document = await repository.createDocument(payload, { trx, context })
3025
+ await trx('audit_entries').insert({ record_id: document.data.id })
3026
+ })
3027
+ ```
3028
+
3029
+ Pass `trx` explicitly to every participating repository call. The transaction
3030
+ remains callable for raw SQL; return normally to commit and throw to roll back.
3031
+ Keep independent raw Knex transaction helpers for integrations that perform
3032
+ only SQL. They do not need to become JSON REST consumers.
3033
+
3034
+ Put an allocation retry around the complete transaction. For example, the
3035
+ application-owned `allocateCode(trx)` below computes a candidate using only SQL;
3036
+ `isCodeAllocationConflict` identifies its exact unique constraint from the
3037
+ underlying driver error. Other uniqueness failures must escape the loop.
3038
+
3039
+ ```js
3040
+ import { findDuplicateEntryError } from '@jskit-ai/database-runtime/shared'
3041
+
3042
+ async function createWithAllocatedCode(payload, context) {
3043
+ for (let attempt = 1; attempt <= 3; attempt++) {
3044
+ try {
3045
+ return await repository.withTransaction(async trx => {
3046
+ const code = await allocateCode(trx)
3047
+ return repository.createDocument({ ...payload, code }, { trx, context })
3048
+ })
3049
+ } catch (error) {
3050
+ const duplicate = findDuplicateEntryError(error)
3051
+ if (attempt === 3 || error?.transactionOutcome !== 'rolledBack' ||
3052
+ !duplicate || !isCodeAllocationConflict(duplicate)) {
3053
+ throw error
3054
+ }
3055
+ }
3056
+ }
3057
+ }
3058
+ ```
3059
+
3060
+ Each attempt gets a new transaction and recomputes the candidate. Do not catch
3061
+ and retry `standard()` inside a failed CRUD lifecycle transaction, or restart a
3062
+ transaction supplied by another owner. Keep external effects outside this
3063
+ retrying unit unless the application can safely repeat or reconcile them.
3064
+
3065
+ Application-owned uploads need the same outcome check before compensation.
3066
+ This example creates one new object used only by this operation. `uploads.save`
3067
+ and `uploads.remove` are the application's storage integration; the caller
3068
+ supplies its own mutable `diagnostics = { cleanupErrors: [] }`:
3069
+
3070
+ ```js
3071
+ async function createWithUpload(payload, file, context, diagnostics) {
3072
+ const uploaded = await uploads.save(file)
3073
+ diagnostics.uploaded = uploaded
3074
+ try {
3075
+ return await repository.withTransaction(trx =>
3076
+ repository.createDocument(
3077
+ { ...payload, fileUrl: uploaded.url },
3078
+ { trx, context }
3079
+ )
3080
+ )
3081
+ } catch (error) {
3082
+ if (error?.transactionOutcome === 'rolledBack') {
3083
+ try {
3084
+ await uploads.remove(uploaded.key)
3085
+ } catch (cleanupError) {
3086
+ diagnostics.cleanupErrors.push({ key: uploaded.key, error: cleanupError })
3087
+ }
3088
+ }
3089
+ throw error
3090
+ }
3091
+ }
3092
+ ```
3093
+
3094
+ The original write error and its outcome still reach the caller; secondary
3095
+ cleanup failures and the uploaded object's identity remain in the application's
3096
+ diagnostics. Other outcomes retain the object for application reconciliation.
3097
+ In particular, deleting it after a committed or unknown failure could break a
3098
+ stored record. Library-managed file
3099
+ uploads already register rollback cleanup; do not add this second cleanup path
3100
+ for those files. See [file ownership](26-file-uploads.md#ownership-after-commit).
3101
+
3102
+ JSKIT copies the outer context for each library operation; hooks inside that
3103
+ operation share the resulting object. Its `visibilityContext` and `scopeValues`
3104
+ maps are copied, while other nested application objects retain their references.
3105
+ Use those maps for user/workspace scoping. A nested operation needs its own
3106
+ working context and explicit transaction participation. Remove hooks that repair
3107
+ dates or query results already normalized by v2; keep application-specific
3108
+ serializers and policies.
3109
+
3110
+ Custom HTTP adapters must translate the library's typed error codes and resource
3111
+ subtypes into status codes, in addition to preserving `transactionOutcome`.
3112
+ Library errors need not carry an HTTP `statusCode`. Validation is 422, missing
3113
+ resources 404, forbidden resources 403, and conflicts 409. Client retry policies
3114
+ must inspect the outcome: `pending`, `committed` and `unknown` are not permission
3115
+ to replay a write, even if another copied error field resembles a retryable CSRF
3116
+ failure. An application-defined retry callback owns its own policy.
3117
+
3118
+ The JSKIT migration uses Node 24 for verification and requires Node 24 or newer
3119
+ for packages that load v2. Upgrade the framework packages and lockfile together,
3120
+ rebuild the catalog and distributed agent docs from authored source patterns, and check
3121
+ custom app repositories and hooks separately. Passing JSKIT tests does not port
3122
+ Vibe64, its seeds or other applications. Those remain separate migration steps.
3123
+
3124
+ The local dispatcher preserves null/undefined throws; see [transaction outcomes](20-transaction-outcomes.md).
3125
+ [Positioning](31-positioning.md) now coordinates writes through database locks;
3126
+ its chapter describes tested concurrency and remaining storage requirements.
3127
+
3128
+ ### Positioning coordination and retired placeholders
3129
+
3130
+ Existing resource calls using `beforeId`, `null` for append and `'FIRST'` continue
3131
+ to work. PostgreSQL and MySQL writers serialize through a transaction-owned
3132
+ coordinator row, including empty groups and moves between groups. SQLite can
3133
+ report busy conflicts; stale transaction snapshots can reject. Applications must
3134
+ retry a failed move in a fresh operation/transaction when appropriate.
3135
+
3136
+ Installation creates `json_rest_api_positioning_locks` if absent. Provision the
3137
+ table before deployment when the runtime account lacks DDL permission, and keep
3138
+ all cooperating writers on the same table. The [positioning guide](31-positioning.md)
3139
+ contains its schema and collation requirements.
3140
+
3141
+ - Changing a group without `beforeId` now places the record at the configured
3142
+ beginning/end of its destination group instead of retaining a potentially
3143
+ duplicate key. Same-group updates preserve their position.
3144
+ - PUT creates receive positions, and PUT replacements preserve or recalculate
3145
+ their managed position. Other persisted fields still follow complete PUT
3146
+ replacement rules.
3147
+ - Missing/out-of-group targets append; a self-targeted move stays in place.
3148
+ Explicit targets now use normal GET permissions and visibility.
3149
+ - Default query ordering uses the supported query hook. Bytewise collation on
3150
+ position columns is required for ordinary sorting and cursor pagination;
3151
+ the allocator's binary comparisons do not change those query semantics.
3152
+ - Remove `rebalanceThreshold`; it never caused automatic rebalancing and now
3153
+ rejects. The no-op `api.positioning.reorder()` method has been removed. Use
3154
+ ordinary PATCH calls or atomic bulk PATCH for actual changes.
3155
+ - Existing keys must be valid fractional keys. Key exhaustion rejects before
3156
+ exceeding declared storage length; migrate/rebalance the data deliberately.
3157
+ - Deep imports of `calculatePosition()` now take `(previousPosition, nextPosition)`
3158
+ string bounds. The former array/ID arguments are no longer supported.
3159
+
3160
+ ## Native query builders and explicit custom-filter translation
3161
+
3162
+ Both storage modes now give query hooks a native Knex builder. Canonical storage
3163
+ no longer intercepts Knex methods to guess logical field/table names. This removes
3164
+ the old query proxy, its alias/descriptor traversal and its partial emulation of
3165
+ Knex methods. Keep the supplied scoped builder (or clone it) and use explicit
3166
+ column/value translation. Native Knex joins, callbacks, aliases, aggregates, raw
3167
+ bindings and cloning retain Knex's own behavior.
3168
+
3169
+ Custom search filters now receive a third argument:
3170
+
3171
+ ```js
3172
+ customRank: {
3173
+ type: 'number',
3174
+ applyFilter(query, input, { column, value }) {
3175
+ query.where(column('rank'), '>=', value('rank', input))
3176
+ }
3177
+ }
3178
+ ```
3179
+
3180
+ Replace `query.where('rank', '>=', input)` with this form when `rank` is a logical
3181
+ resource field. `column(field)` returns a qualified physical column, including
3182
+ canonical slots or an explicitly mapped ordinary column. `value(field, input)`
3183
+ applies that field's storage conversion/custom serializer. The validated public
3184
+ filter input remains the second argument; it is not automatically reinterpreted
3185
+ as another field's stored value. The details object also supplies `context` and
3186
+ `scopeName`. The callback's `this` remains the same builder as its first argument.
3187
+
3188
+ For a declared joined filter such as `actualField: 'groups.code'`, use
3189
+ `column('groups.code')` and `value('groups.code', input)`. These helpers resolve
3190
+ the declared path's prepared alias; they do not create arbitrary additional
3191
+ joins. Existing related-query permission checks still run. Custom SQL continues
3192
+ to own any extra relationships or visibility semantics it introduces. Callbacks
3193
+ run synchronously inside the current predicate group and must mutate that
3194
+ builder; returned builders/promises are not awaited or selected.
3195
+
3196
+ For `knexQueryFiltering` hooks, the existing
3197
+ `createStorageAdapterUtilities({ context })` helper supplies
3198
+ `translateColumn(scopeName, field)` and `translateFilterValue(scopeName, field,
3199
+ input)`. Keep using `context.knexQuery.tableName` as the active SQL alias and
3200
+ `context.knexQuery.storageAdapter` for resource storage operations. The legacy
3201
+ `context.knexQuery.adapter` now refers to that same storage adapter in both
3202
+ modes; it is no longer a query-method proxy. Removed private proxy state includes
3203
+ `__adapter__`, its related-descriptor/alias maps, and
3204
+ `context.resourceToTableName`. Importing the removed internal
3205
+ `anyapi-query-adapter.js` module is no longer supported.
3206
+
3207
+ A filtering hook can translate a local field without importing another helper:
3208
+
3209
+ ```js
3210
+ handler({ context }) {
3211
+ const { query, tableName, storageAdapter } = context.knexQuery
3212
+ const rankColumn = `${tableName}.${storageAdapter.translateColumn('rank')}`
3213
+ query.where(rankColumn, '>=', storageAdapter.translateFilterValue('rank', 10))
3214
+ }
3215
+ ```
3216
+
3217
+ Apply this predicate to the supplied query, including count invocations. Keep
3218
+ using declared cross-resource search paths where possible; writing a native join
3219
+ does not automatically add the target resource's permission or visibility checks.
3220
+
3221
+ The storage adapter's `buildBaseQuery({ transaction, tableAlias })` constructs a
3222
+ native builder with the optional alias and retains canonical tenant/resource
3223
+ predicates qualified by that alias. It borrows the database handle and does not
3224
+ complete transactions. Translate every logical identifier used in custom SQL;
3225
+ raw SQL text and binding strings are not rewritten. Consumer repositories have
3226
+ not been migrated by this library-only change.
3227
+
3228
+ ## Storage helpers consume prepared write attributes
3229
+
3230
+ Ordinary PUT/PATCH no longer re-extract belongs-to values from the request's
3231
+ relationship object. Core relationship processing and setters already prepare
3232
+ `context.inputRecord.data.attributes` before calling storage, as they do for
3233
+ POST and canonical writes. Resource API calls need no migration for this change.
3234
+ Custom storage implementations should consume those prepared attributes before
3235
+ applying physical storage mapping, preserving setter results.
3236
+
3237
+ A custom `dataPost` helper must return a nonempty string, a finite number, or a
3238
+ bigint ID. The lifecycle converts bigint IDs to strings without losing precision.
3239
+ Invalid helper results reject before `afterDataCall` hooks and roll back the
3240
+ write, including with `returning: 'none'` or `'minimal'`. Resource `normalizeId`
3241
+ is not invoked again on the helper's result.
3242
+
3243
+ The unexported internal `plugins/core/lib/writing/knex-json-api-transformers-writing.js`
3244
+ module and its `processBelongsToRelationships` helper have been removed. If you
3245
+ imported that file directly, remove the second relationship conversion and use
3246
+ the prepared attributes supplied to your storage callback. Public relationship
3247
+ validation and authorization remain in the resource lifecycle.
3248
+
3249
+ ## Pass explicit field maps to foreign-key inspection
3250
+
3251
+ The internal `getForeignKeyFields(fields, relationships)` helper accepts a field
3252
+ map. If an extension passes a schema instance, change the call to
3253
+ `getForeignKeyFields(schema.structure, relationships)`. Library callers already
3254
+ pass `schemaInfo.schemaStructure` or an enrichment field map.
3255
+
3256
+ The helper no longer guesses whether a property named `structure` is a wrapper.
3257
+ That guess misread a legitimate attribute named `structure`, hid relationship
3258
+ backing fields and could bypass the prohibition on identity serializers. Public
3259
+ resource declarations can continue to use `structure` as an attribute name.
3260
+
3261
+ ## Table helper schemas use an explicit structure wrapper
3262
+
3263
+ Direct calls to `createKnexTable`, `addKnexFields`, `alterKnexFields`,
3264
+ `generateKnexMigration` and `generateKnexMigrationDiff` now require a schema
3265
+ object whose `structure` property contains field definitions. A
3266
+ `json-rest-schema` schema instance already supplies that property and remains
3267
+ accepted. Field definitions must be objects.
3268
+
3269
+ Change bare field maps at the schema argument:
3270
+
3271
+ ```js
3272
+ // Before
3273
+ await alterKnexFields(knex, 'books', { title: { type: 'string' } })
3274
+
3275
+ // After
3276
+ await alterKnexFields(knex, 'books', {
3277
+ structure: { title: { type: 'string' } }
3278
+ })
3279
+ ```
3280
+
3281
+ For helpers that accept table metadata, keep `storage`, `indexes`, `foreignKeys`
3282
+ and `checkConstraints` beside `structure`. Fields with those names belong inside
3283
+ `structure`, like any other field. Add/alter helpers still reject top-level table
3284
+ constraints they cannot apply; use a migration diff for those changes.
3285
+
3286
+ Public resource calls such as `books.addKnexFields({ fields })`,
3287
+ `books.alterKnexFields({ fields })` and `books.createKnexTable()` keep their current
3288
+ arguments. The plugin supplies the explicit helper schema internally.
3289
+
3290
+ Bare maps are rejected before SQL or migration output is produced. This removes
3291
+ the ambiguity that could interpret a field named `structure` as a wrapper and
3292
+ generate columns from its definition's `type` and `nullable` properties.
3293
+
3294
+ ## Read helpers use compiled field maps
3295
+
3296
+ Internal response, include and field-selection helpers now read
3297
+ `scope.vars.schemaInfo.schemaStructure` directly. Extensions supplying synthetic
3298
+ resource metadata must provide that field map alongside `schemaRelationships`;
3299
+ `schemaInstance` is the validation object, not an alternate field-map argument.
3300
+ Normal registered resources already provide both compiled values.
3301
+
3302
+ The internal `getSchemaStructure` export from `knex-constants.js` is removed.
3303
+ Use the resource's compiled `schemaStructure`, or explicitly access `.structure`
3304
+ on a schema instance you own. No wrapper guessing or compatibility adapter is
3305
+ needed.
3306
+
3307
+
3308
+ ## Schema compilation snapshots declarations
3309
+
3310
+ Resource schema compilation now copies declaration data on entry and again when
3311
+ publishing its result. Changing an original field's nested `storage`, validation,
3312
+ relationship or dependency declaration after registration no longer changes the
3313
+ currently compiled schema. Enrichment hooks may mutate or replace their supplied
3314
+ fields during compilation; retaining that object and editing it after the hook
3315
+ pipeline finishes no longer changes published metadata.
3316
+
3317
+ Declare changes before registration, or use the supported schema operation such
3318
+ as canonical `addKnexFields()` while application operations are stopped. Do not
3319
+ use mutations of `resource.vars.schemaInfo` as a configuration API. Each explicit
3320
+ compilation uses its supplied input; this does not add automatic invalidation,
3321
+ concurrent reconfiguration, or a general resource-customization API.
3322
+
3323
+ Callbacks retain their identity and may still access application state through
3324
+ closures. Plain objects, arrays, dates, regular expressions and nested schema
3325
+ instances are copied; nested schemas retain their registered type/validator
3326
+ handlers and operation definitions. Other class instances remain application-owned
3327
+ references. The library does not freeze caller-owned objects or attempt to clone
3328
+ external services. This boundary applies to compiled declarations; it does not
3329
+ turn all runtime options, hook variables or application state into immutable data.
3330
+
3331
+
3332
+ Canonical registration also keeps an owned copy of declarations for later field
3333
+ additions. Changing the original registration object, or a previous
3334
+ `addKnexFields({ fields })` input, does not silently change existing fields during
3335
+ a subsequent addition. Pass new declarations to the supported operation instead.
3336
+
3337
+ Initial `sortableFields` and `defaultSort` arrays are copied into resource
3338
+ variables. Editing the original arrays no longer changes query behavior. An
3339
+ explicit assignment such as `resource.vars.defaultSort = ['-name']` still affects
3340
+ subsequent requests; this is a runtime variable override, not a schema change.
3341
+ It does not recompile fields or change their query eligibility.
3342
+
3343
+
3344
+ ## Plugin registries require explicit entries
3345
+
3346
+ Named row policies, autofilter resolvers and autofilter presets must be registered
3347
+ as own entries of their configuration maps. Inherited names such as `constructor`
3348
+ or `toString` no longer count as registered entries; resource registration rejects
3349
+ them as unknown. Autofilters also require an actual declared schema field rather
3350
+ than accepting inherited object properties as fields.
3351
+
3352
+ Explicitly registered policy, resolver and preset names remain valid, including
3353
+ prototype-like names. Use computed properties or `Object.fromEntries()` if a map
3354
+ key is `__proto__`, so it is an own entry rather than JavaScript object-literal
3355
+ prototype syntax. Explicit resolver names are also retained in plugin
3356
+ introspection. Policy and resolver callbacks still run per request; their return
3357
+ values and authorization results are not cached by this change.
3358
+
3359
+
3360
+ ## When configuration changes take effect
3361
+
3362
+ | Configuration | Effective boundary | How to change it |
3363
+ | --- | --- | --- |
3364
+ | Named policy/resolver maps and autofilter presets | Plugin installation captures entries and preset declarations; resource registration selects them | Supply the intended registry before installing the plugin. Callback closures may still read current application state. |
3365
+ | Fields, validation, search, relationships, projections and computed declarations | Awaited resource registration compiles and publishes an owned snapshot after enrichment | Declare them before registration. Finish registering forward-referenced resources before serving operations. |
3366
+ | Enrichment hook fields | Mutable during the corresponding compilation stage | Edit `context.fields`; `context.originalFields` remains a separate view of that stage's original nested declaration data. Install enrichment hooks before registering resources. |
3367
+ | Runtime hook/helper/variable customization | A subsequent method invocation observes the current customization | Use `api.customize()` for runtime hooks/helpers/global variables. Resource variable overrides remain explicit; they do not recompile schema facts. |
3368
+ | Response defaults, query default/max limits, include depth and allowed sort fields | Read for each operation; method parameters override the defaults where that method supports an override | Change runtime variables deliberately. Include-depth and allowed-sort changes replace the retained request contracts; format and page-default changes do not require a new schema or adapter. |
3369
+ | Canonical field additions | Successful metadata commit followed by publication of the new compiled resource | Use `addKnexFields()` while application operations are stopped. Existing source declarations and previous addition inputs are owned copies. Failed additions retain the prior published resource. |
3370
+ | Descriptor-only mapping refresh | Explicit refresh after the committed mapping migration | Follow the earlier canonical migration instructions; registry eviction alone does not republish an active resource. |
3371
+
3372
+ `api.customize()` is not a schema replacement API. Arbitrary writes to
3373
+ `scopeOptions`, compiled `schemaInfo`, descriptors or canonical maps are not
3374
+ supported configuration operations. Structural changes outside the supported
3375
+ addition path need updated source declarations, any required data/schema migration,
3376
+ and resource initialization. Concurrent structural reconfiguration is unsupported.
3377
+ There is no automatic fingerprinting of mutable metadata or cache of caller
3378
+ permissions, resolver values or query results.
3379
+
3380
+
3381
+ ## Compiled output definition indexes
3382
+
3383
+ Response normalization now reads `resource.vars.schemaInfo.outputFields` and
3384
+ `outputRelationships`. These are compiled lookup indexes over the existing field,
3385
+ computed, projection and relationship definitions. They are refreshed with the
3386
+ compiled resource, including after supported canonical field additions.
3387
+
3388
+ Ordinary resource calls need no changes. Code that calls the deep
3389
+ `normalizeRecordAttributes()` helper with handcrafted scope metadata must supply
3390
+ these indexes; the helper no longer rebuilds them from `schemaStructure`,
3391
+ `computed`, `queryFields` and `schemaRelationships` for every record. Prefer passing
3392
+ real `api.resources`. For a single attributes object with an explicit definition
3393
+ map, `normalizeAttributes(attributes, definitions)` remains available unchanged.
3394
+ Direct mutation of compiled indexes is not a configuration API.
3395
+
3396
+ ## Compiled foreign-key membership
3397
+
3398
+ Code constructing synthetic `schemaInfo` for deep query/conversion helpers must
3399
+ provide `foreignKeyFields`, a `Set` of logical backing-field names for ordinary
3400
+ and polymorphic relationships. Obtain this metadata from the registered resource
3401
+ where possible. Compilation publishes the set with the resource; do not mutate
3402
+ it or add resource IDs to it. Make a local copy when extending identity fields.
3403
+ Supported canonical field additions publish a replacement set automatically.
3404
+ The public resource call syntax is unchanged by this optimization.
3405
+
3406
+ Conversion no longer reads relationship declarations to reconstruct polymorphic
3407
+ backing-field membership. Supply both type and ID backing fields in the compiled
3408
+ set when constructing synthetic metadata. The conversion error boundary wraps
3409
+ failures while reading that set, retaining typed errors and original causes.
3410
+
3411
+ ## Relationship aliases must be unique
3412
+
3413
+ Each relationship name must have exactly one declaration. Two `belongsTo` fields
3414
+ cannot share an `as` name, and a field's `as` cannot also appear in the resource's
3415
+ `relationships` map. Previously these configurations were accepted but different
3416
+ operations could choose different definitions or cardinalities.
3417
+
3418
+ Give distinct relationships distinct names, for example `authorId` with
3419
+ `as: 'author'` and `reviewerId` with `as: 'reviewer'`. Declare a belongs-to
3420
+ relationship on its backing field; remove a duplicate declaration from the
3421
+ `relationships` map. Update payload and include names if you rename an alias.
3422
+ The compiler checks enriched declarations too. A rejected canonical field
3423
+ addition retains the previously published resource and its stored data.
3424
+
3425
+ ## Derived attributes cannot declare relationships
3426
+
3427
+ Computed fields and SQL query projections cannot declare `belongsTo` or
3428
+ `belongsToPolymorphic`. These fields produce attribute values and do not provide
3429
+ the stored backing fields required by relationship operations. Previously the
3430
+ compiler accepted these options and could produce inconsistent relationship
3431
+ lookup and output normalization. The same rejection applies after enrichment.
3432
+
3433
+ Remove relationship options from derived attributes. Declare an ordinary
3434
+ relationship on its stored foreign-key field using `belongsTo` and `as`; declare
3435
+ a polymorphic relationship in the resource's `relationships` map, identifying its
3436
+ stored type and ID fields. Give a derived display value its own attribute name.
3437
+ A rejected canonical addition keeps the previously published schema intact.
3438
+
3439
+ ## Compiled relationship lookup and polymorphic declaration location
3440
+
3441
+ `findRelationshipDefinition()` now reads the registered resource's
3442
+ `schemaInfo.outputRelationships` index. Deep-helper callers constructing synthetic
3443
+ metadata must supply that index, including both ordinary belongs-to aliases and
3444
+ declared relationships. Entries reference the existing definitions. Prefer the
3445
+ compiled metadata on `api.resources` and do not mutate its lookup index. The
3446
+ helper returns null for absent names, including inherited object names.
3447
+
3448
+ Declare `belongsToPolymorphic` in the resource's `relationships` map, with its
3449
+ `typeField` and `idField` pointing to stored schema fields. Placing it inside an
3450
+ individual stored field is now rejected, including after schema enrichment and
3451
+ when adding canonical fields. Previously that placement was accepted but lookup
3452
+ could not find the relationship and its backing fields were not recognized.
3453
+ Move the relationship definition into the map; keep its backing fields in
3454
+ `schema`. No stored data conversion is needed merely to move the declaration.
3455
+
3456
+ ## Request contracts reuse compiled relationship metadata
3457
+
3458
+ Deep callers of `getRequestContracts()` constructing synthetic `schemaInfo` must
3459
+ provide `foreignKeyFields` and `outputRelationships` along with their stored and
3460
+ search schema metadata. Normal registered resources already provide these facts.
3461
+ The foreign-key set excludes relationship backing fields from writable attribute
3462
+ contracts; the relationship index supplies linkage cardinalities and target types.
3463
+ There is no fallback that reconstructs these indexes from raw declarations.
3464
+
3465
+ For an ordinary belongs-to relationship, its `belongsTo` value determines the
3466
+ allowed resource type, consistent with relationship operations. An additional
3467
+ `target` property does not override it. Use `target` for has-one/has-many and
3468
+ many-to-many declarations. Existing public call syntax and the one-retained-variant
3469
+ request-contract cache are unchanged by this refactor.
3470
+
3471
+ ## File upload rules follow compiled resource configuration
3472
+
3473
+ File handling now reads the compiled stored fields, including MIME/size rules
3474
+ produced by `schema:enrich`. Mutating the original declaration after registration
3475
+ no longer changes those rules. Installing the file plugin after resource
3476
+ registration also discovers existing compiled file fields. Supported canonical
3477
+ field additions refresh the file-field list when the compiled resource changes;
3478
+ a rejected addition keeps the previous rules, including an empty file-field list.
3479
+
3480
+ File storage backends are external handles. A file field's `storage` object with
3481
+ an `upload()` method retains its identity and method receiver during declaration
3482
+ snapshotting, so backend state and intentional method replacement continue to
3483
+ work. MIME and size declarations are still owned snapshots. Ordinary column
3484
+ mapping objects without `upload()` remain copied declarations. Directly mutating
3485
+ published compiled metadata is unsupported.
3486
+
3487
+ ## New compiler hook: `schema:compiled`
3488
+
3489
+ Plugins can derive resource metadata in `schema:compiled`. It receives
3490
+ `context.scopeName`, `context.scopeOptions` and `context.schemaInfo` after core
3491
+ field, relationship, storage, query and dependency compilation. Read the compiled
3492
+ facts and attach plugin-owned metadata under a distinct key on `schemaInfo`.
3493
+ Change stored, search, computed or projected declarations in their existing
3494
+ enrichment hooks; core compiled facts are read-only at this stage.
3495
+
3496
+ The candidate is published only after all handlers succeed, using an owned
3497
+ snapshot. Retained candidate objects cannot mutate the published metadata.
3498
+ Throwing rejects registration or a canonical field-addition candidate before it
3499
+ replaces the resource. This hook runs on compilation, not on ordinary requests
3500
+ or a descriptor-only refresh.
3501
+
3502
+ Autofilter uses this hook to validate and derive its configuration on every
3503
+ compilation. Resolver metadata and write stamping follow enriched relationship
3504
+ aliases after canonical additions; removing a required autofilter field rejects
3505
+ the candidate. `api.autofilter.getScopeConfig(name)` is unchanged. Deep readers of
3506
+ `resource.vars.autofilter` must move to `resource.vars.schemaInfo.autofilter` or
3507
+ prefer the public inspection helper. The root registry summary remains available
3508
+ through `api.autofilter.getConfig()`. No forwarding metadata copy is kept.
3509
+
3510
+ ## Include configuration is validated before publication
3511
+
3512
+ Include configuration is now checked from the compiled relationship definitions,
3513
+ including enrichment, before a resource is published or a canonical field
3514
+ addition commits. An explicit resource `queryMaxLimit` takes precedence over the
3515
+ inherited value for this check. Previously the registration stages could reject
3516
+ valid higher overrides or miss enriched limits above a lower resource maximum.
3517
+
3518
+ `include.limit` accepts a non-negative integer, `null` or `false`; omission keeps
3519
+ the existing default. Zero is valid, and `null`/`false` retain their existing
3520
+ unbounded-include meaning. Negative, fractional, NaN and nonnumeric values now
3521
+ fail compilation. `include.orderBy` retains its array form and validates entry
3522
+ syntax during compilation. Target-field resolution and database window-function
3523
+ support remain query-time checks so forward resource references still work.
3524
+ Query-time target caps and selection behavior are unchanged.
3525
+
3526
+ The deep `validateIncludeConfigurations` helper now has a named export and takes
3527
+ the `schema:compiled` candidate context (`scopeName`, `scopeOptions`, `schemaInfo`),
3528
+ not the former scope-added context. Normal plugin users need no call-site change.
3529
+
3530
+ ## Enhancing an existing logger
3531
+
3532
+ The internal `enhanceLogger(logger, options)` utility now safely replaces methods
3533
+ on the supplied logger. Its wrappers capture each original writer and receiver
3534
+ at enhancement time; previously they looked up the replaced method again and
3535
+ could recurse until the stack overflowed. The utility still returns the same
3536
+ logger, preserves writer return values and propagates writer failures. Existing
3537
+ `createEnhancedLogger` wrappers likewise retain the writers captured at creation.
3538
+ Install the desired base writers before creating an enhanced logger.
3539
+
3540
+ ## Error causes in diagnostic output
3541
+
3542
+ Operation failure reports now include these stable fields:
3543
+
3544
+ | Field | Meaning |
3545
+ | --- | --- |
3546
+ | `method` | Resource method, HTTP verb, or named setup/socket operation; null when unavailable |
3547
+ | `scopeName` | Resolved resource name; null before resource resolution or for server-wide operations |
3548
+ | `phase` | Reporting boundary, such as `writeFailure`, `httpError`, `include`, `admission`, or `redisShutdown` |
3549
+ | `backend` | Knex client name, `socketio`, or `redis`; null when unavailable |
3550
+ | `transactionOutcome` | Existing transaction state at reporting time: `none`, `pending`, `committed`, `rolledBack`, or `unknown` |
3551
+
3552
+ `phase` describes where an error is reported. It does not identify every hook
3553
+ that ran before the failure. Existing nested getter, setter, include and
3554
+ relationship-metadata errors retain their more specific context and causes.
3555
+ Cleanup warnings may report `pending` even when the operation later commits.
3556
+ Socket notification permission checks run outside a transaction and report
3557
+ `none`; subscription-matching reads inside a write report its borrowed transaction
3558
+ state. Setup/debug traces are informational and need not contain this envelope.
3559
+
3560
+ Formatted diagnostics now include native `Error.cause` and `AggregateError.errors`
3561
+ properties, which JavaScript normally makes non-enumerable. Nested errors retain
3562
+ their names, messages and selected stacks, including errors inside diagnostic
3563
+ `details`. Primitive causes are retained. Existing depth and cycle limits apply
3564
+ to these nested values, and the original errors are not modified. Consumers that
3565
+ parse diagnostic objects should allow these additional fields. This does not
3566
+ change HTTP error responses or the errors thrown by resource methods.
3567
+
3568
+ ## Shared filter traces omit payloads
3569
+
3570
+ The shared basic-filter trace messages no longer print filter values, entire
3571
+ filter objects or field definitions. They retain scope, table, field and operator
3572
+ information. Filtering behavior and SQL bindings are unchanged. This is a
3573
+ specific trace-output change. See [field-aware diagnostics](#field-aware-write-diagnostics)
3574
+ for structured redaction and its limits.
3575
+
3576
+
3577
+ Ordinary storage operation messages also omit query parameters, POST bodies and
3578
+ PATCH attributes. Upload-completion messages omit stored URLs, and Socket.IO
3579
+ subscription messages omit filters. These changes affect diagnostics only;
3580
+ records, upload results and subscription acknowledgements retain their values.
3581
+
3582
+
3583
+ Include-loader diagnostics now report ID counts rather than identifier arrays.
3584
+ They also omit complete relationship/include definitions and selection lists
3585
+ from the affected load messages. Query construction and returned includes are
3586
+ unchanged.
3587
+
3588
+
3589
+ ## Connector error logs use route templates
3590
+
3591
+ Express and Fastify request-error diagnostics now use the registered route
3592
+ pattern, such as `/countries/:id`, as `path`. They no longer include a raw `url`
3593
+ field or put query strings and concrete resource IDs in that path metadata.
3594
+ Express may omit `path` for errors that occur before a route is matched.
3595
+ HTTP routing and error responses are unchanged. Error-object details are a
3596
+ separate diagnostic payload and may still carry resource identifiers.
3597
+
3598
+ Both connectors await error diagnostics. A failing writer leaves the original
3599
+ HTTP response intact and adds the logging failure to `context.cleanupErrors`
3600
+ with `phase: 'logging'` and `during: 'httpError'`. Express route-registration
3601
+ diagnostics also preserve the original setup rejection if logging fails.
3602
+
3603
+ HTTP diagnostic loggers omit structured `body` and `headers` containers,
3604
+ including on errors raised before a route supplies resource metadata. Known JSON
3605
+ parser failures (`type: 'entity.parse.failed'`) produce a generic diagnostic
3606
+ preview with name, message, type and status. Their raw message, stack, body and
3607
+ cause are omitted because parser messages can quote the request body. Express
3608
+ uses this parser identifier already; the Fastify JSON parser supplies the same
3609
+ identifier. The original error still reaches hooks and HTTP error mapping.
3610
+
3611
+ A read hook that throws `null` or `undefined` produces a generic JSON:API 500
3612
+ response. Conditional PUT and positioning target reads recognize typed
3613
+ not-found errors; other hook failures propagate through the transaction's
3614
+ normal failure handling.
3615
+
3616
+ ## Formatted errors are bounded previews
3617
+
3618
+ `formatError` and `formatErrorString` now produce bounded diagnostic previews:
3619
+ 50 entries per container, 256 serialized-value visits, 2,048 characters per
3620
+ string, and a shared 8,192-character budget for copied text. Existing depth and
3621
+ cycle handling still applies. `[Truncated]` markers identify omitted content;
3622
+ these limits are not a promise of an exact byte length after JSON escaping or
3623
+ pretty printing. The original error and its attached values are unchanged.
3624
+
3625
+ Non-object `toJSON()` results appear under `toJSONResult` rather than being
3626
+ spread into character-index properties. Big integers and symbols in diagnostic
3627
+ values become text, functions become markers, and object-valued error names or
3628
+ messages become a non-string-text marker. These are diagnostic representations,
3629
+ not changes to the errors thrown by the API or the HTTP error contract.
3630
+
3631
+ One-line summaries share the bounded formatter and are capped at 2,048
3632
+ characters, including any truncation marker.
3633
+ Short summary spelling is unchanged. Summaries inspect only message, code,
3634
+ violations and fields; they do not invoke custom `toJSON()` methods.
3635
+
3636
+ ## Enhanced logger events share a preview budget
3637
+
3638
+ Standard enhanced log methods now bound the complete argument list, including
3639
+ message strings and additional diagnostic data. They format at most 50 supplied
3640
+ arguments and use the existing shared depth/traversal/text limits for the emitted
3641
+ event. Truncation is explicit; source arguments are not mutated. The internal
3642
+ `formatDiagnosticValue` helper exposes that same bounded value conversion.
3643
+
3644
+ Validation errors now produce one structured event containing their details.
3645
+ The former extra `Validation error details:` event has been removed. Logger
3646
+ receiver binding, return values and exceptions still come from the original
3647
+ writer. These bounds do not apply to direct calls that bypass the enhanced
3648
+ logger, and bounding is not sensitive-field redaction.
3649
+
3650
+ The storage, positioning and file plugins use this formatter for their own
3651
+ diagnostics too. Include messages report counts instead of complete path
3652
+ collections. These changes preserve logger binding and query/file behavior;
3653
+ they do not add a resource-field policy to every logger.
3654
+
3655
+ ## Field-aware write diagnostics
3656
+
3657
+ The resource write-error logger now derives redacted field names from compiled
3658
+ `outputFields`: both `hidden: true` and `normallyHidden: true` are redacted when
3659
+ that metadata is available. Explicitly requesting a normally-hidden field in a
3660
+ response does not make its diagnostic value visible. Caller input and thrown
3661
+ errors are unchanged.
3662
+
3663
+ The internal enhanced logger and formatter accept `redactFields` for structured
3664
+ payload keys. Matching values become `[Redacted]` before they are read. Validation
3665
+ objects whose `field` is a listed name or `data.attributes.<name>` retain field
3666
+ and rule metadata with a redacted message; their other payload properties are
3667
+ omitted. Summaries use the same policy. Enhanced loggers snapshot the configured
3668
+ field-name list at creation.
3669
+
3670
+ When field redaction is configured or `includeStack: false` is selected,
3671
+ `formatError` inspects ordinary error properties without invoking `toJSON()`.
3672
+ Custom conversion could otherwise read an excluded stack or copy a hidden value
3673
+ under another key before filtering. Unrestricted diagnostics still use custom
3674
+ `toJSON()` results. Nested error-envelope accessors are inspected once through the
3675
+ bounded value formatter; their returned errors use ordinary property inspection.
3676
+
3677
+ This is structured-field redaction, not a scanner for arbitrary secret text in
3678
+ messages. Early failures without compiled metadata cannot infer which application
3679
+ attributes are sensitive. Custom search validators should return general
3680
+ validation messages: their text can become the query error's top-level message,
3681
+ where field-key redaction cannot recognize a value embedded in a sentence.
3682
+
3683
+ The resource-field policy applies to write reports, matched HTTP resource routes,
3684
+ file cleanup, subscription admission/removal, subscription matching and
3685
+ notification permission reports. It uses the resolved resource's compiled fields;
3686
+ it does not guess a resource from a URL, an authentication error or a client-supplied
3687
+ subscription name. Authentication and server-wide reports omit raw handshake,
3688
+ credential and subscription-filter objects. Application properties deliberately
3689
+ attached to an authentication error still appear as bounded diagnostics: a field
3690
+ named `accessKey` there does not inherit an unrelated resource's field policy.
3691
+
3692
+ Buffers and other binary values are represented by type and byte length,
3693
+ including uploaded-file metadata and nested errors. Identifiers, filenames,
3694
+ paths, arbitrary application/driver error text and values copied under unrelated
3695
+ keys are not automatically secret-free. Use general error messages, omit secrets
3696
+ from custom error properties, and apply any broader retention/redaction policy in
3697
+ the application-owned logger. Direct application logger calls do not pass through
3698
+ the library's formatter. The built-in runtime no longer emits upstream method
3699
+ arguments or plugin-option dumps.
3700
+
3701
+ Socket connection/disconnection diagnostics and Redis setup/shutdown diagnostics
3702
+ contain throwing and rejecting writers. They cannot prevent handler installation,
3703
+ subscription acknowledgement or later Redis cleanup attempts. A Redis setup
3704
+ failure retains cleanup failures as secondary `AggregateError` members. The Redis
3705
+ adapter is configured before attaching Socket.IO to the caller's HTTP server, so
3706
+ an adapter setup failure leaves that server's listeners intact. If destruction
3707
+ itself fails, startup reports the failure without waiting indefinitely for the
3708
+ connection attempt that could not be cancelled.
3709
+
3710
+
3711
+ ## Opt-in stored revision migration
3712
+
3713
+ Resources that select `versionField` need an explicit stored string field and a
3714
+ backfill of existing rows before conditional writers are enabled. Follow the
3715
+ [version-field migration guide](22-version-field-migrations.md) for
3716
+ ordinary and canonical storage, a tested backfill example, retry/rollback and
3717
+ readiness checks. Clients submit `expectedVersion` outside record attributes;
3718
+ bulk PATCH/DELETE use aligned `expectedVersions` arrays. These are revision
3719
+ conditions. HTTP representation conditions use the separate contract below.
3720
+
3721
+ ## Opt-in HTTP representation conditions
3722
+
3723
+ The executable client example is `examples/conditional-http-client.js` in the
3724
+ repository. Import `readForConditionalUpdate`, pass the complete resource URL
3725
+ (including its query), edit the returned `document`, then call `save` with a
3726
+ JSON:API update document. Supply application headers and `credentials: 'include'`
3727
+ when your cross-origin session requires them. Each returned object retains the
3728
+ original read's ETag: after saving, read again before another edit. The example
3729
+ returns the write's `Response` so the caller can handle 412 and other errors.
3730
+ Run `node --test tests/http-validator-client-example.test.js` on Node 24 to
3731
+ exercise it against real Express and Fastify HTTP servers.
3732
+
3733
+ HTTP representation conditions require no version column. They compare the
3734
+ selected JSON response, including requested includes,
3735
+ fieldsets, computed values and response-hook changes.
3736
+
3737
+ Enable it when installing either connector, before registering resources:
3738
+
3739
+ ```js
3740
+ await api.use(ExpressPlugin, { mountPath: '/api', httpValidators: true })
3741
+ // Or use FastifyPlugin with the same httpValidators option.
3742
+ ```
3743
+
3744
+ The default is `false`. Successful GET/HEAD responses with a body receive a
3745
+ strong ETag. Preserve the complete quoted header value and send it unchanged in
3746
+ `If-Match` when updating or deleting the same resource. Use the same query string
3747
+ and representation-selecting request headers for the read and write:
3748
+
3749
+ ```js
3750
+ const url = '/api/articles/42?include=author'
3751
+ const selectedHeaders = { Accept: 'application/vnd.api+json' }
3752
+ const current = await fetch(url, { headers: selectedHeaders })
3753
+ if (!current.ok) throw new Error(`Read failed: ${current.status}`)
3754
+ const etag = current.headers.get('ETag')
3755
+ if (!etag) throw new Error('The selected response has no ETag')
3756
+
3757
+ const updated = await fetch(url, {
3758
+ method: 'PATCH',
3759
+ headers: {
3760
+ ...selectedHeaders,
3761
+ 'Content-Type': 'application/vnd.api+json',
3762
+ 'If-Match': etag
3763
+ },
3764
+ body: JSON.stringify({
3765
+ data: { type: 'articles', id: '42', attributes: { title: 'Revised title' } }
3766
+ })
3767
+ })
3768
+ if (updated.status === 412) {
3769
+ // Read again and resolve the competing change before submitting another edit.
3770
+ } else if (!updated.ok) {
3771
+ throw new Error(`Update failed: ${updated.status}`)
3772
+ }
3773
+ ```
3774
+
3775
+ A matching strong tag permits registered resource PUT/PATCH/DELETE. A stale tag
3776
+ returns 412 with code `REST_API_PRECONDITION_FAILED`. Weak tags cannot match;
3777
+ malformed headers return 422. `If-Match: *` checks visible existence only and
3778
+ does not detect intervening edits. Omitting the header leaves writes
3779
+ unconditional. Conditional PUT cannot create a missing resource. Collection
3780
+ POST, relationship mutations and bulk routes currently reject `If-Match` with
3781
+ 422; use their documented direct revision conditions where supported. Normal
3782
+ validation and authorization errors take precedence over comparison. A hidden
3783
+ PUT target rejected by existing resource checks retains 404; a missing target
3784
+ that otherwise permits PUT-create returns 412. These HTTP PUT outcomes are not
3785
+ an identical hidden/missing response contract. This
3786
+ option does not implement `If-None-Match` caching. Stored `expectedVersion`
3787
+ conflicts remain a distinct 409 error.
3788
+
3789
+ For cross-origin browser clients, install `CorsPlugin` after the connector and
3790
+ allow `If-Match` requests and expose `ETag` responses. These arrays replace the
3791
+ defaults, so retain other headers your application needs:
3792
+
3793
+ ```js
3794
+ await api.use(CorsPlugin, {
3795
+ origin: 'https://app.example.com',
3796
+ credentials: true,
3797
+ methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
3798
+ allowedHeaders: [
3799
+ 'Content-Type', 'Authorization', 'X-Requested-With',
3800
+ 'X-HTTP-Method-Override', 'Accept', 'Origin', 'If-Match'
3801
+ ],
3802
+ exposedHeaders: ['X-Total-Count', 'X-Page-Count', 'Link', 'Location', 'ETag']
3803
+ })
3804
+ ```
3805
+
3806
+ Enabling this option makes the connector serialize successful GET/HEAD bodies
3807
+ once with `JSON.stringify` after response hooks and send those exact bytes.
3808
+ Express JSON spacing, replacers and escaping settings, and Fastify custom JSON
3809
+ serializers, do not process those pre-serialized bodies. Move required body
3810
+ transformations into the resource output or transport response hook before
3811
+ enabling it. Middleware that subsequently changes bytes or content encoding
3812
+ must also maintain the corresponding validator. Successful PUT responses emit
3813
+ no validator in this mode; fetch the selected representation again for its tag.
3814
+
3815
+ A strong conditional write renders a speculative GET inside its managed
3816
+ serializable transaction before changing the resource. Response hooks see
3817
+ `context.precondition === true` and normalized GET request metadata during that
3818
+ render, then run separately for the actual write response. Keep rendering
3819
+ deterministic and free of external side effects. Computed database reads must
3820
+ borrow `context.transaction` and preserve the caller's authorization context.
3821
+ Normalized request headers are copied; native request/reply objects and
3822
+ application callbacks are still owned by the actual request. Do not use native
3823
+ response methods while rendering the speculative view. Transaction conflicts
3824
+ currently retain native error handling (HTTP 500), with no automatic retry.
3825
+
3826
+ See the [API reference](../API.md) for conditional request options and
3827
+ [version-field migrations](22-version-field-migrations.md) for database preparation.