json-rest-api 1.0.29 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (294) hide show
  1. package/LICENSE +10 -670
  2. package/LICENSE-GPL-3.0 +674 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +39 -30
  5. package/docs/API.md +744 -1273
  6. package/docs/GUIDE/01-getting-started.md +338 -0
  7. package/docs/GUIDE/02-resources-and-relationships.md +33 -0
  8. package/docs/GUIDE/03-running-example.md +87 -0
  9. package/docs/GUIDE/04-creating-and-querying.md +181 -0
  10. package/docs/GUIDE/05-belongs-to.md +177 -0
  11. package/docs/GUIDE/06-has-many.md +184 -0
  12. package/docs/GUIDE/07-polymorphic-relationships.md +191 -0
  13. package/docs/GUIDE/08-many-to-many.md +184 -0
  14. package/docs/GUIDE/09-pagination-and-sorting.md +149 -0
  15. package/docs/GUIDE/10-put-and-patch.md +174 -0
  16. package/docs/GUIDE/11-relationship-endpoints.md +174 -0
  17. package/docs/GUIDE/12-field-transformations.md +287 -0
  18. package/docs/GUIDE/13-hooks-and-lifecycle.md +479 -0
  19. package/docs/GUIDE/14-custom-resource-methods.md +125 -0
  20. package/docs/GUIDE/15-query-projections.md +266 -0
  21. package/docs/GUIDE/{GUIDE_X_Autofiltering.md → 16-autofiltering.md} +77 -15
  22. package/docs/GUIDE/{GUIDE_X_Row_Policies.md → 17-row-policies.md} +137 -44
  23. package/docs/GUIDE/18-labels.md +86 -0
  24. package/docs/GUIDE/19-managed-transactions.md +179 -0
  25. package/docs/GUIDE/20-transaction-outcomes.md +207 -0
  26. package/docs/GUIDE/21-schema-and-migrations.md +733 -0
  27. package/docs/GUIDE/22-version-field-migrations.md +208 -0
  28. package/docs/GUIDE/23-fastify.md +179 -0
  29. package/docs/GUIDE/24-cors.md +172 -0
  30. package/docs/GUIDE/{GUIDE_X_URL_Management.md → 25-url-management.md} +7 -1
  31. package/docs/GUIDE/26-file-uploads.md +278 -0
  32. package/docs/GUIDE/27-bulk-operations.md +277 -0
  33. package/docs/GUIDE/28-socketio.md +325 -0
  34. package/docs/GUIDE/29-writing-plugins.md +278 -0
  35. package/docs/GUIDE/30-backend-capabilities.md +103 -0
  36. package/docs/GUIDE/31-positioning.md +181 -0
  37. package/docs/GUIDE/{GUIDE_Y_Appendices.md → 32-date-and-time.md} +74 -30
  38. package/docs/GUIDE/33-migrating-to-v2.md +3838 -0
  39. package/docs/GUIDE/index.md +51 -59
  40. package/docs/QUICKSTART.md +120 -287
  41. package/docs/architecture.md +75 -0
  42. package/docs/contributing.md +178 -0
  43. package/index.d.ts +37 -0
  44. package/index.js +3 -22
  45. package/lib/enhanced-logger.js +91 -85
  46. package/lib/error-context.js +445 -5
  47. package/lib/error-formatter.js +219 -59
  48. package/lib/knex-transaction.js +70 -0
  49. package/lib/logger-types.d.ts +22 -0
  50. package/lib/missing-package.js +4 -0
  51. package/lib/rest-api-errors.js +103 -8
  52. package/lib/runtime/json-rest-api.js +172 -0
  53. package/lib/transaction-types.d.ts +59 -0
  54. package/package.json +97 -27
  55. package/plugins/core/bulk-operations-plugin.js +191 -184
  56. package/plugins/core/connectors/express-plugin.js +148 -219
  57. package/plugins/core/connectors/fastify-plugin.js +125 -82
  58. package/plugins/core/connectors/lib/busboy-detector.js +74 -156
  59. package/plugins/core/connectors/lib/connector-core.js +144 -20
  60. package/plugins/core/connectors/lib/formidable-detector.js +63 -120
  61. package/plugins/core/connectors/lib/http-validators.js +77 -0
  62. package/plugins/core/connectors/lib/multipart-helpers.js +18 -0
  63. package/plugins/core/connectors/lib/request-helpers.js +10 -1
  64. package/plugins/core/connectors/lib/transport-http-helpers.js +89 -8
  65. package/plugins/core/connectors/lib/transport-route-schemas.js +7 -4
  66. package/plugins/core/file-handling-plugin.js +87 -177
  67. package/plugins/core/lib/anyapi/anyapi-registry.js +586 -418
  68. package/plugins/core/lib/anyapi/canonical-link-store.js +507 -0
  69. package/plugins/core/lib/anyapi/canonical-relationship-reader.js +598 -0
  70. package/plugins/core/lib/anyapi/schema-utils.js +120 -56
  71. package/plugins/core/lib/anyapi/stored-definition.js +109 -0
  72. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
  73. package/plugins/core/lib/dbIntrospection.js +228 -21
  74. package/plugins/core/lib/dbTablesOperations.js +409 -92
  75. package/plugins/core/lib/fractional-positioning.js +12 -48
  76. package/plugins/core/lib/querying/identifier-query.js +19 -0
  77. package/plugins/core/lib/querying/include-query-helpers.js +364 -0
  78. package/plugins/core/lib/querying/include-to-many.js +428 -0
  79. package/plugins/core/lib/querying/include-to-one.js +579 -0
  80. package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
  81. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
  82. package/plugins/core/lib/querying/knex-pagination-helpers.js +48 -317
  83. package/plugins/core/lib/querying/knex-process-includes.js +5 -110
  84. package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
  85. package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
  86. package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
  87. package/plugins/core/lib/querying/knex-window-queries.js +62 -253
  88. package/plugins/core/lib/querying/query-builder-utils.js +44 -4
  89. package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
  90. package/plugins/core/lib/querying/query-constraint.js +27 -0
  91. package/plugins/core/lib/querying/query-field-sort-helpers.js +134 -37
  92. package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
  93. package/plugins/core/lib/querying/sort-helpers.js +13 -1
  94. package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
  95. package/plugins/core/lib/querying/url-helpers.js +11 -4
  96. package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
  97. package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
  98. package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
  99. package/plugins/core/lib/querying-writing/database-value-normalizers.js +179 -194
  100. package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
  101. package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
  102. package/plugins/core/lib/querying-writing/field-utils.js +113 -92
  103. package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
  104. package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
  105. package/plugins/core/lib/querying-writing/knex-field-helpers.js +100 -290
  106. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
  107. package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
  108. package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
  109. package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
  110. package/plugins/core/lib/querying-writing/request-contracts.js +93 -38
  111. package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
  112. package/plugins/core/lib/querying-writing/response-options.js +35 -0
  113. package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
  114. package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
  115. package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
  116. package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
  117. package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
  118. package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
  119. package/plugins/core/lib/storage/storage-adapter.js +128 -62
  120. package/plugins/core/lib/storage/storage-mapping.js +78 -28
  121. package/plugins/core/lib/storage/storage-types.d.ts +502 -0
  122. package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
  123. package/plugins/core/lib/writing/relationship-processor.js +94 -158
  124. package/plugins/core/lib/writing/resource-version.js +172 -0
  125. package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
  126. package/plugins/core/rest-api-anyapi-knex-plugin.js +290 -2475
  127. package/plugins/core/rest-api-autofilter-plugin.js +17 -19
  128. package/plugins/core/rest-api-cors-plugin.js +52 -205
  129. package/plugins/core/rest-api-knex-plugin.js +45 -1077
  130. package/plugins/core/rest-api-label-plugin.js +25 -38
  131. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
  132. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
  133. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
  134. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
  135. package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
  136. package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
  137. package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
  138. package/plugins/core/rest-api-plugin-methods/common.js +291 -395
  139. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
  140. package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
  141. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -148
  142. package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
  143. package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
  144. package/plugins/core/rest-api-plugin-methods/get.js +36 -68
  145. package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
  146. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
  147. package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
  148. package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
  149. package/plugins/core/rest-api-plugin-methods/post.js +51 -51
  150. package/plugins/core/rest-api-plugin-methods/put.js +69 -148
  151. package/plugins/core/rest-api-plugin-methods/query.js +37 -81
  152. package/plugins/core/rest-api-plugin-methods/release.js +2 -1
  153. package/plugins/core/rest-api-plugin.js +32 -46
  154. package/plugins/core/rest-api-positioning-plugin.js +183 -628
  155. package/plugins/core/rest-api-query-projections-plugin.js +19 -102
  156. package/plugins/core/rest-api-row-policy-plugin.js +3 -3
  157. package/plugins/core/socketio-plugin.js +364 -344
  158. package/plugins/storage/local-storage.js +40 -36
  159. package/types/anyapi.d.ts +30 -0
  160. package/types/autofilter.d.ts +57 -0
  161. package/types/bulk-methods.d.ts +63 -0
  162. package/types/errors.d.ts +118 -0
  163. package/types/fastify-options.d.ts +19 -0
  164. package/types/file-detectors.d.ts +33 -0
  165. package/types/file-storage.d.ts +48 -0
  166. package/types/hook-context.d.ts +180 -0
  167. package/types/plugin-options.d.ts +65 -0
  168. package/types/relationship-methods.d.ts +74 -0
  169. package/types/representations.d.ts +50 -0
  170. package/types/resource-methods.d.ts +95 -0
  171. package/types/resource-schema.d.ts +157 -0
  172. package/types/row-policy.d.ts +31 -0
  173. package/types/runtime.d.ts +132 -0
  174. package/types/transactions.d.ts +21 -0
  175. package/.claude/agents/library-guide-expert.md +0 -30
  176. package/.claude/agents/rest-api-internals-expert.md +0 -54
  177. package/.claude/settings.local.json +0 -70
  178. package/AGENTS.md +0 -34
  179. package/CLAUDE.md +0 -160
  180. package/CRUSH.md +0 -55
  181. package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
  182. package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
  183. package/TODO.todo +0 -13
  184. package/connectors_ideas/fastify.js +0 -217
  185. package/connectors_ideas/koa-plugin.js +0 -112
  186. package/docs/.bundle/config +0 -2
  187. package/docs/COMPARISON.md +0 -433
  188. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
  189. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
  190. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
  191. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
  192. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
  193. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
  194. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
  195. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1247
  196. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
  197. package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
  198. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
  199. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
  200. package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
  201. package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
  202. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
  203. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
  204. package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
  205. package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
  206. package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
  207. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
  208. package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
  209. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
  210. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
  211. package/docs/Gemfile +0 -6
  212. package/docs/Gemfile.lock +0 -308
  213. package/docs/ONBOARDING.md +0 -315
  214. package/docs/_config.yml +0 -36
  215. package/docs/_layouts/default.html +0 -123
  216. package/docs/apple-touch-icon.svg +0 -4
  217. package/docs/assets/css/style.scss +0 -392
  218. package/docs/favicon.svg +0 -4
  219. package/docs/index.md +0 -164
  220. package/docs/mini-guides/file-uploads-complete.md +0 -161
  221. package/eslint.config.js +0 -13
  222. package/examples/url-override-example.js +0 -137
  223. package/fixing_plan.md +0 -112
  224. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
  225. package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
  226. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
  227. package/plugins/storage/s3-storage.js +0 -93
  228. package/quickTest.js +0 -136
  229. package/scripts/debug-anyapi-review-filter.mjs +0 -63
  230. package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
  231. package/scripts/inspect-anyapi-descriptor.mjs +0 -19
  232. package/scripts/run-docs.js +0 -133
  233. package/test-debug.js +0 -59
  234. package/tests/README.md +0 -202
  235. package/tests/TEST_TEMPLATE.test.js +0 -178
  236. package/tests/anyapi-basic.test.js +0 -105
  237. package/tests/anyapi-cursor-pagination.test.js +0 -148
  238. package/tests/anyapi-custom-idproperty.test.js +0 -163
  239. package/tests/autofilter.test.js +0 -463
  240. package/tests/bulk-operations.test.js +0 -657
  241. package/tests/computed-fields.test.js +0 -586
  242. package/tests/cors.test.js +0 -834
  243. package/tests/curl.test.js +0 -467
  244. package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
  245. package/tests/custom-id-simplified-post.test.js +0 -75
  246. package/tests/custom-idproperty-relationships.test.js +0 -932
  247. package/tests/db-introspection.test.js +0 -521
  248. package/tests/db-table-operations.test.js +0 -798
  249. package/tests/fastify-plugin.test.js +0 -184
  250. package/tests/field-getters.test.js +0 -370
  251. package/tests/field-setters.test.js +0 -306
  252. package/tests/file-handling.test.js +0 -155
  253. package/tests/fixtures/api-configs.js +0 -2566
  254. package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
  255. package/tests/helpers/fake-fastify.js +0 -99
  256. package/tests/helpers/storage-mode.js +0 -65
  257. package/tests/helpers/test-utils.js +0 -203
  258. package/tests/http-connectors-parity.test.js +0 -395
  259. package/tests/id-normalization.test.js +0 -444
  260. package/tests/include-depth-validation.test.js +0 -331
  261. package/tests/includes.test.js +0 -427
  262. package/tests/local-storage.test.js +0 -96
  263. package/tests/logical-id-write-validation.test.js +0 -151
  264. package/tests/nested-includes.test.js +0 -617
  265. package/tests/pagination-boundaries.test.js +0 -154
  266. package/tests/pagination-contract.test.js +0 -122
  267. package/tests/pagination-cursor-multifield.test.js +0 -641
  268. package/tests/pagination-enhanced.test.js +0 -563
  269. package/tests/polymorphic-relationships.test.js +0 -347
  270. package/tests/polymorphic-simple.test.js +0 -148
  271. package/tests/positioning-advanced.test.js +0 -692
  272. package/tests/positioning-extended.test.js +0 -915
  273. package/tests/positioning-multifilter.test.js +0 -699
  274. package/tests/positioning.test.js +0 -560
  275. package/tests/post-commit-hooks.test.js +0 -120
  276. package/tests/projected-fields.test.js +0 -319
  277. package/tests/public-exports.test.js +0 -15
  278. package/tests/put-create-parity.test.js +0 -216
  279. package/tests/queries.test.js +0 -521
  280. package/tests/query-limits.test.js +0 -584
  281. package/tests/relationship-endpoints.test.js +0 -620
  282. package/tests/relationship-include-storage-adapter.test.js +0 -56
  283. package/tests/relationships.test.js +0 -681
  284. package/tests/rest-api.test.js +0 -337
  285. package/tests/return-record-settings.test.js +0 -184
  286. package/tests/row-policy.test.js +0 -489
  287. package/tests/s3-storage.test.js +0 -38
  288. package/tests/searchschema-knex-behavior.test.js +0 -187
  289. package/tests/searchschema-merge.test.js +0 -151
  290. package/tests/socketio.test.js +0 -860
  291. package/tests/storage-mapping.test.js +0 -362
  292. package/tests/temporal-boundaries.test.js +0 -208
  293. package/tests/test-development-guide.md +0 -370
  294. package/tests/virtual-fields.test.js +0 -404
@@ -1,4 +1,12 @@
1
- import { DEFAULT_CANONICAL_CONFIG, SLOT_LIMITS, SLOT_POOLS, TYPE_TO_POOL } from './schema-utils.js'
1
+ import { createKnexTransaction, readCommittedTransaction } from '../../../../lib/knex-transaction.js'
2
+ import { applyInsertReturning, getDatabaseCapabilities } from '../querying-writing/database-capabilities.js'
3
+ import { DEFAULT_CANONICAL_CONFIG, TYPE_TO_POOL, ensureAnyApiSchema, readSlotPools } from './schema-utils.js'
4
+ import { toStoredJson } from './stored-definition.js'
5
+ import { beginWriteTransaction, commitTransaction, endWriteOperation, getOperationDiagnosticContext, rollbackAfterError, wrapUnexpectedError, wrapWriteError } from '../../../../lib/error-context.js'
6
+ import { createEnhancedLogger } from '../../../../lib/enhanced-logger.js'
7
+ import { RestApiResourceError } from '../../../../lib/rest-api-errors.js'
8
+ import { assertFieldName, assertFieldNameMap } from '../querying-writing/field-utils.js'
9
+ import { validateRelationshipFieldNames, validateSchemaFieldNames } from '../querying-writing/scope-validations.js'
2
10
 
3
11
  const clone = (value) => {
4
12
  if (typeof structuredClone === 'function') {
@@ -8,6 +16,12 @@ const clone = (value) => {
8
16
  }
9
17
 
10
18
  const SUPPORTED_TYPES = new Set([...TYPE_TO_POOL.keys(), 'id'])
19
+ const DEFAULT_CACHE_SIZE = 100
20
+ const EMPTY_RUNTIME = Object.freeze({ schema: {}, searchSchema: {}, callbacks: [] })
21
+
22
+ // Errors from two servers inserting the same new resource at once.
23
+ const WRITE_CONFLICT_CODES = new Set(['23505', '40001', '40P01', 'ER_DUP_ENTRY', 'ER_LOCK_DEADLOCK', 'SQLITE_BUSY', 'SQLITE_CONSTRAINT_UNIQUE'])
24
+ const creationRaces = new WeakSet()
11
25
 
12
26
  const resolveIdProperty = (schema = {}, explicitIdProperty = null) => {
13
27
  if (explicitIdProperty) return explicitIdProperty
@@ -41,19 +55,30 @@ const SLOT_PATTERNS = {
41
55
  }
42
56
 
43
57
  const BELONGS_TO_ID_PATTERN = /^rel_(\d+)_id$/
44
- const BELONGS_TO_TYPE_PATTERN = /^rel_(\d+)_type$/
45
58
 
46
- const parseSimpleSlotColumn = (column) => {
59
+ const slotsExhausted = pool => Object.assign(new Error(`No available ${pool} slots remaining`), { slotsExhausted: true })
60
+
61
+ const slotPoolFor = (fieldDef = {}) => {
62
+ if (fieldDef.belongsTo) return 'belongsTo'
63
+ return fieldDef.type === 'id' ? 'string' : TYPE_TO_POOL.get(fieldDef.type)
64
+ }
65
+
66
+ const belongsToColumns = index => [`rel_${index}_id`, `rel_${index}_type`]
67
+
68
+ const parseSimpleSlotColumn = (column, pools) => {
47
69
  for (const [type, pattern] of Object.entries(SLOT_PATTERNS)) {
48
70
  const match = pattern.exec(column)
49
71
  if (match) {
72
+ if (!pools[type].includes(column)) {
73
+ throw new Error(`Canonical slot '${column}' is out of range`)
74
+ }
50
75
  return { slotType: type, slotIndex: Number(match[1]) }
51
76
  }
52
77
  }
53
78
  throw new Error(`Invalid canonical slot column '${column}'`)
54
79
  }
55
80
 
56
- const parseBelongsToSlots = ({ idSlot, typeSlot }) => {
81
+ const parseBelongsToSlots = ({ idSlot, typeSlot }, pools) => {
57
82
  if (!idSlot || typeof idSlot !== 'string') {
58
83
  throw new Error('BelongsTo canonical mapping requires idSlot')
59
84
  }
@@ -62,83 +87,244 @@ const parseBelongsToSlots = ({ idSlot, typeSlot }) => {
62
87
  throw new Error(`Invalid belongsTo id slot '${idSlot}'`)
63
88
  }
64
89
  const index = Number(idMatch[1])
90
+ if (!pools.belongsTo.includes(index) || idSlot !== `rel_${index}_id`) {
91
+ throw new Error(`BelongsTo slot '${idSlot}' is out of range`)
92
+ }
65
93
  const expectedTypeSlot = `rel_${index}_type`
66
94
  const actualTypeSlot = typeSlot || expectedTypeSlot
67
- const typeMatch = BELONGS_TO_TYPE_PATTERN.exec(actualTypeSlot)
68
- if (!typeMatch || Number(typeMatch[1]) !== index) {
95
+ if (actualTypeSlot !== expectedTypeSlot) {
69
96
  throw new Error(`BelongsTo type slot '${actualTypeSlot}' must correspond to id slot '${idSlot}'`)
70
97
  }
71
98
  return { slotType: 'belongsTo', slotIndex: index, idColumn: idSlot, typeColumn: actualTypeSlot }
72
99
  }
73
100
 
101
+ const reserveSlots = (used, columns) => {
102
+ if (columns.some(column => used.has(column))) {
103
+ throw new Error(columns.length > 1 ? `Canonical slots '${columns[0]}'/'${columns[1]}' already in use` : `Canonical slot '${columns[0]}' already in use`)
104
+ }
105
+ columns.forEach(column => used.add(column))
106
+ }
107
+
108
+ const FIELD_ROW_COLUMNS = ['field_name', 'slot_type', 'slot_index', 'slot_column', 'nullable', 'required', 'target_resource', 'alias', 'meta_json']
109
+ const RELATIONSHIP_ROW_COLUMNS = ['relationship_name', 'relationship_type', 'target_resource', 'slot_index', 'id_column', 'type_column', 'relationship_key', 'through', 'foreign_key', 'other_key', 'alias', 'meta_json']
110
+
111
+ // Drivers return booleans and integers in different shapes; compare meaning only.
112
+ const rowsSignature = (rows, columns) => JSON.stringify(rows.map(row => columns.map(column => {
113
+ const value = row[column]
114
+ if (column === 'nullable' || column === 'required') return Boolean(value)
115
+ if (column === 'slot_index') return value == null ? null : Number(value)
116
+ return value ?? null
117
+ })).sort((left, right) => String(left[0]).localeCompare(String(right[0]))))
118
+
119
+ const isWriteConflict = error => WRITE_CONFLICT_CODES.has(error?.code) || [1062, 1213].includes(error?.errno)
120
+
121
+ /**
122
+ * Canonical resource metadata. One registry can serve every tenant API that
123
+ * shares a Knex instance: start-up checks, capabilities and the slot layout are
124
+ * read once, and descriptors are cached per tenant and resource.
125
+ */
74
126
  export class AnyapiRegistry {
75
- constructor ({ knex, log } = {}) {
127
+ #shared = new Map()
128
+
129
+ constructor ({ knex, log, cacheSize = DEFAULT_CACHE_SIZE } = {}) {
76
130
  if (!knex) {
77
131
  throw new Error('AnyapiRegistry requires a knex instance')
78
132
  }
133
+ if (!Number.isInteger(cacheSize) || cacheSize < 1) {
134
+ throw new Error('AnyapiRegistry cacheSize must be a positive integer')
135
+ }
79
136
  this.knex = knex
80
137
  this.log = log || console
138
+ this.cacheSize = cacheSize
81
139
  this.cache = new Map()
82
140
  }
83
141
 
142
+ #once (key, run) {
143
+ if (!this.#shared.has(key)) {
144
+ const result = run()
145
+ this.#shared.set(key, result)
146
+ result.catch(() => { if (this.#shared.get(key) === result) this.#shared.delete(key) })
147
+ }
148
+ return this.#shared.get(key)
149
+ }
150
+
151
+ ensureSchema ({ slots } = {}) {
152
+ return this.#once(`schema:${JSON.stringify(slots ?? null)}`, async () => {
153
+ await ensureAnyApiSchema(this.knex, { slots })
154
+ this.#shared.delete('slots')
155
+ })
156
+ }
157
+
158
+ getCapabilities (log = this.log) {
159
+ return this.#once('capabilities', () => getDatabaseCapabilities(this.knex, log))
160
+ }
161
+
162
+ getSlotPools ({ db = this.knex, refresh = false } = {}) {
163
+ if (refresh) this.#shared.delete('slots')
164
+ return this.#once('slots', () => readSlotPools(db))
165
+ }
166
+
167
+ async #withSlotPools (db, plan) {
168
+ try {
169
+ return plan(await this.getSlotPools({ db }))
170
+ } catch (error) {
171
+ // A migration may have added slot columns since the layout was read.
172
+ if (!error?.slotsExhausted) throw error
173
+ return plan(await this.getSlotPools({ db, refresh: true }))
174
+ }
175
+ }
176
+
84
177
  #key (tenant, resource) {
85
- return `${tenant}::${resource}`
178
+ return JSON.stringify([tenant, resource])
86
179
  }
87
180
 
88
- async registerResource (definition, options = {}) {
89
- this.#validateDefinition(definition)
90
- const descriptor = await this.#register(definition, options.transaction)
181
+ #rememberDescriptor (descriptor) {
91
182
  const key = this.#key(descriptor.tenant, descriptor.resource)
183
+ this.cache.delete(key)
92
184
  this.cache.set(key, descriptor)
93
- return clone(descriptor)
185
+ if (this.cache.size > this.cacheSize) this.cache.delete(this.cache.keys().next().value)
94
186
  }
95
187
 
96
- async allocateField ({ tenant, resource, fieldName, definition, canonicalField }, options = {}) {
97
- if (!tenant || !resource || !fieldName || !definition) {
98
- throw new Error('allocateField requires tenant, resource, fieldName, and definition')
99
- }
188
+ async registerResource (definition, options = {}) {
189
+ return (await this.register(definition, options)).descriptor
190
+ }
100
191
 
101
- const descriptor = await this.getDescriptor(tenant, resource, { bypassCache: true })
102
- if (!descriptor) {
103
- throw new Error(`Resource '${resource}' not registered for tenant '${tenant}'`)
192
+ /**
193
+ * Register a declaration and report whether stored metadata changed.
194
+ * @returns {Promise<{ descriptor: Record<string, any>, changed: boolean }>}
195
+ */
196
+ async register (definition, options = {}) {
197
+ try {
198
+ return await this.#registerOnce(definition, options)
199
+ } catch (error) {
200
+ if (options.transaction || !creationRaces.has(error?.cause)) throw error
201
+ return this.#registerOnce(definition, options)
104
202
  }
203
+ }
105
204
 
106
- const trx = options.transaction || await this.knex.transaction()
107
- const managed = !options.transaction
108
-
109
- descriptor.belongsTo = descriptor.belongsTo || {}
110
- descriptor.canonicalFieldMap = descriptor.canonicalFieldMap || {}
205
+ async #registerOnce (definition, options) {
206
+ const context = { method: 'registerResource', tenant: definition?.tenant, resource: definition?.resource, [readCommittedTransaction]: true }
207
+ try {
208
+ this.#validateDefinition(definition)
209
+ const { descriptor, changed } = await this.#register(definition, options.transaction, context)
210
+ if (!options.transaction) this.#rememberDescriptor(descriptor)
211
+ return { descriptor: clone(descriptor), changed }
212
+ } catch (error) {
213
+ throw wrapWriteError(error, context, context.transaction || options.transaction)
214
+ } finally {
215
+ endWriteOperation(context)
216
+ }
217
+ }
111
218
 
112
- const usedSlots = new Set()
113
- Object.values(descriptor.fields || {}).forEach((info) => {
114
- if (info?.slot) {
115
- usedSlots.add(info.slot)
219
+ async allocateField (params, options = {}) {
220
+ return this.#allocate(options, 'allocateField', () => {
221
+ const { tenant, resource, fieldName, definition, declaration, canonicalField } = params || {}
222
+ if (!tenant || !resource || !fieldName || !definition) {
223
+ throw new Error('allocateField requires tenant, resource, fieldName, and definition')
116
224
  }
225
+ return { tenant, resource, fields: { [fieldName]: { definition, declaration, canonicalField } } }
117
226
  })
118
- Object.values(descriptor.belongsTo || {}).forEach((info) => {
119
- if (info?.typeColumn) {
120
- usedSlots.add(info.typeColumn)
227
+ }
228
+
229
+ /**
230
+ * Allocate several fields under one lock and one schema version.
231
+ * `declaration` is the uncompiled definition stored for other servers.
232
+ */
233
+ async allocateFields (params, options = {}) {
234
+ return this.#allocate(options, 'allocateFields', () => {
235
+ const { tenant, resource, fields, searchSchema } = params || {}
236
+ if (!tenant || !resource || !fields || typeof fields !== 'object' || !Object.keys(fields).length) {
237
+ throw new Error('allocateFields requires tenant, resource, and fields')
121
238
  }
122
- if (info?.idColumn) {
123
- usedSlots.add(info.idColumn)
239
+ for (const field of Object.values(fields)) {
240
+ if (!field?.definition) throw new Error('allocateFields requires a definition for each field')
124
241
  }
242
+ return { tenant, resource, fields, searchSchema }
125
243
  })
244
+ }
126
245
 
246
+ async #allocate (options, method, readParams) {
247
+ const context = { method, [readCommittedTransaction]: true }
248
+ let trx; let managed = false
127
249
  try {
128
- const resourceRow = await trx('any_resource_configs')
129
- .where({ tenant_id: tenant, resource })
130
- .first()
250
+ const { tenant, resource, fields, searchSchema } = readParams()
251
+ Object.assign(context, { tenant, resource })
252
+ for (const [fieldName, { definition }] of Object.entries(fields)) {
253
+ validateSchemaFieldNames({ [fieldName]: definition }, resource)
254
+ }
255
+ assertFieldNameMap(searchSchema, `added search schema in '${resource}'`)
256
+
257
+ managed = !options.transaction
258
+ trx = await beginWriteTransaction(context, options.transaction, ownerContext => createKnexTransaction(this.knex, ownerContext))
259
+ const resourceRow = await this.#lockResourceRow(trx, tenant, resource)
260
+ const descriptor = resourceRow && await this.#loadDescriptor(tenant, resource, trx)
261
+ if (!descriptor) throw new Error(`Resource '${resource}' not registered for tenant '${tenant}'`)
262
+ const plan = await this.#withSlotPools(trx, pools => this.#planAllocation(descriptor, fields, pools))
131
263
 
132
- if (!resourceRow) {
133
- throw new Error(`Resource '${resource}' not found in metadata`)
264
+ for (const row of plan.fieldRows) {
265
+ await trx('any_field_configs').insert({ resource_config_id: resourceRow.id, ...row })
266
+ }
267
+ for (const row of plan.relationshipRows) {
268
+ await trx('any_relationship_configs').insert({ resource_config_id: resourceRow.id, ...row })
134
269
  }
135
270
 
271
+ const stored = descriptor.definition || { options: null, callbacks: [] }
272
+ const runtime = clone(stored.runtime || EMPTY_RUNTIME)
273
+ for (const [kind, entries] of [
274
+ ['schema', Object.entries(fields).map(([name, field]) => [name, field.declaration ?? field.definition])],
275
+ ['searchSchema', Object.entries(searchSchema || {})]
276
+ ]) {
277
+ for (const [name, value] of entries) {
278
+ const { json, callbacks } = toStoredJson(value)
279
+ runtime[kind][name] = json
280
+ runtime.callbacks.push(...callbacks.map(path => [kind, name, ...path]))
281
+ }
282
+ }
283
+ await this.#bumpVersion(trx, resourceRow, {
284
+ schema_json: JSON.stringify({ ...descriptor.schema, ...plan.definitions }),
285
+ definition_json: JSON.stringify({ ...stored, runtime }),
286
+ updated_at: this.knex.fn.now()
287
+ })
288
+ const updated = await this.#loadDescriptor(tenant, resource, trx)
289
+ if (managed) {
290
+ await commitTransaction(trx, context)
291
+ this.#rememberDescriptor(updated)
292
+ }
293
+ return clone(updated)
294
+ } catch (error) {
295
+ if (managed) {
296
+ await this.#handleWriteFailure(error, trx || context.transaction, context)
297
+ }
298
+ throw wrapWriteError(error, context, context.transaction || options.transaction)
299
+ } finally {
300
+ endWriteOperation(context)
301
+ }
302
+ }
303
+
304
+ #planAllocation (descriptor, fields, pools) {
305
+ const { resource } = descriptor
306
+ const used = new Set(Object.values(descriptor.fields).map(info => info.slot))
307
+ for (const info of Object.values(descriptor.belongsTo)) {
308
+ used.add(info.idColumn)
309
+ used.add(info.typeColumn)
310
+ }
311
+ const fieldRows = []
312
+ const relationshipRows = []
313
+ const definitions = {}
314
+
315
+ for (const [fieldName, { definition, canonicalField }] of Object.entries(fields)) {
316
+ if (fieldName === descriptor.idProperty || Object.hasOwn(descriptor.schema, fieldName) || Object.hasOwn(definitions, fieldName)) {
317
+ throw new Error(`Field '${resource}.${fieldName}' already exists`)
318
+ }
319
+ if (canonicalField && (definition.belongsToPolymorphic || definition.computed || definition.virtual)) {
320
+ throw new Error(`canonicalFieldsMap should not include non-stored field '${fieldName}'`)
321
+ }
322
+ definitions[fieldName] = definition
323
+
136
324
  if (definition?.belongsToPolymorphic) {
137
325
  const alias = definition.as || fieldName
138
326
  const { typeField, idField, types = [] } = definition.belongsToPolymorphic
139
-
140
- await trx('any_relationship_configs').insert({
141
- resource_config_id: resourceRow.id,
327
+ relationshipRows.push({
142
328
  relationship_name: alias,
143
329
  relationship_type: 'belongsToPolymorphic',
144
330
  target_resource: null,
@@ -152,23 +338,9 @@ export class AnyapiRegistry {
152
338
  alias,
153
339
  meta_json: JSON.stringify({ types }),
154
340
  })
155
-
156
- if (managed) {
157
- await trx.commit()
158
- }
159
-
160
- descriptor.polymorphicBelongsTo = descriptor.polymorphicBelongsTo || {}
161
- descriptor.polymorphicBelongsTo[alias] = {
162
- alias,
163
- typeField,
164
- idField,
165
- types,
166
- typeColumn: descriptor.fields?.[typeField]?.slot || null,
167
- idColumn: descriptor.fields?.[idField]?.slot || null,
168
- }
169
- this.cache.set(this.#key(tenant, resource), descriptor)
170
- return clone(descriptor)
341
+ continue
171
342
  }
343
+ if (definition.computed || definition.virtual) continue
172
344
 
173
345
  let override = null
174
346
  if (canonicalField) {
@@ -179,12 +351,8 @@ export class AnyapiRegistry {
179
351
  if (!normalized || !normalized.idSlot) {
180
352
  throw new Error(`canonicalFieldsMap for belongsTo field '${fieldName}' requires idSlot`)
181
353
  }
182
- const parsed = parseBelongsToSlots(normalized)
183
- if (usedSlots.has(parsed.idColumn) || usedSlots.has(parsed.typeColumn)) {
184
- throw new Error(`Canonical slots '${parsed.idColumn}'/'${parsed.typeColumn}' already in use`)
185
- }
186
- usedSlots.add(parsed.idColumn)
187
- usedSlots.add(parsed.typeColumn)
354
+ const parsed = parseBelongsToSlots(normalized, pools)
355
+ reserveSlots(used, [parsed.idColumn, parsed.typeColumn])
188
356
  override = normalized
189
357
  } else {
190
358
  const slotColumn = typeof canonicalField === 'string'
@@ -193,103 +361,106 @@ export class AnyapiRegistry {
193
361
  if (!slotColumn) {
194
362
  throw new Error(`canonicalFieldsMap for field '${fieldName}' must provide a slot column`)
195
363
  }
196
- if (usedSlots.has(slotColumn)) {
197
- throw new Error(`Canonical slot '${slotColumn}' already in use`)
198
- }
199
- usedSlots.add(slotColumn)
364
+ reserveSlots(used, [slotColumn])
200
365
  override = { slotColumn }
201
366
  }
202
367
  }
368
+ const fieldSlot = this.#assignFieldSlot({ ...definition, fieldName }, used, override, pools)
369
+ if (!fieldSlot) throw new Error(`Field '${resource}.${fieldName}' requires a supported type`)
370
+ fieldRows.push(this.#fieldRow(fieldName, definition, fieldSlot))
371
+ if (fieldSlot.relationshipRow) relationshipRows.push(this.#relationshipRow(fieldSlot.relationshipRow))
372
+ }
373
+ return { fieldRows, relationshipRows, definitions }
374
+ }
203
375
 
204
- const fieldSlot = this.#assignFieldSlot({ ...definition, fieldName }, descriptor.slotState, override)
205
- const meta = definition.meta ? JSON.stringify(definition.meta) : null
206
-
207
- await trx('any_field_configs').insert({
208
- resource_config_id: resourceRow.id,
209
- field_name: fieldName,
210
- slot_type: fieldSlot.slotType,
211
- slot_index: fieldSlot.slotIndex,
212
- slot_column: fieldSlot.slotColumn,
213
- nullable: definition.nullable === true,
214
- required: definition.required === true,
215
- target_resource: definition.belongsTo || null,
216
- alias: definition.as || null,
217
- meta_json: meta,
218
- })
219
-
220
- if (fieldSlot.relationshipRow) {
221
- const relationshipRow = {
222
- resource_config_id: resourceRow.id,
223
- relationship_name: fieldSlot.relationshipRow.name,
224
- relationship_type: fieldSlot.relationshipRow.type,
225
- target_resource: fieldSlot.relationshipRow.target,
226
- slot_index: fieldSlot.relationshipRow.slotIndex,
227
- id_column: fieldSlot.relationshipRow.idColumn,
228
- type_column: fieldSlot.relationshipRow.typeColumn,
229
- relationship_key: fieldSlot.relationshipRow.relationshipKey,
230
- through: fieldSlot.relationshipRow.through,
231
- foreign_key: fieldSlot.relationshipRow.foreignKey,
232
- other_key: fieldSlot.relationshipRow.otherKey,
233
- alias: fieldSlot.relationshipRow.alias,
234
- meta_json: fieldSlot.relationshipRow.meta ? JSON.stringify(fieldSlot.relationshipRow.meta) : null,
235
- }
236
- await trx('any_relationship_configs').insert(relationshipRow)
237
- }
376
+ #fieldRow (fieldName, fieldDef, allocation) {
377
+ return {
378
+ field_name: fieldName,
379
+ slot_type: allocation.slotType,
380
+ slot_index: allocation.slotIndex,
381
+ slot_column: allocation.slotColumn,
382
+ nullable: fieldDef.nullable === true,
383
+ required: fieldDef.required === true,
384
+ target_resource: fieldDef.belongsTo || null,
385
+ alias: fieldDef.as || null,
386
+ meta_json: fieldDef.meta ? JSON.stringify(fieldDef.meta) : null,
387
+ }
388
+ }
238
389
 
239
- if (managed) {
240
- await trx.commit()
241
- }
390
+ #relationshipRow (relationship) {
391
+ return {
392
+ relationship_name: relationship.name,
393
+ relationship_type: relationship.type,
394
+ target_resource: relationship.target,
395
+ slot_index: relationship.slotIndex,
396
+ id_column: relationship.idColumn,
397
+ type_column: relationship.typeColumn,
398
+ relationship_key: relationship.relationshipKey,
399
+ through: relationship.through,
400
+ foreign_key: relationship.foreignKey,
401
+ other_key: relationship.otherKey,
402
+ alias: relationship.alias,
403
+ meta_json: relationship.meta ? JSON.stringify(relationship.meta) : null,
404
+ }
405
+ }
242
406
 
243
- descriptor.fields[fieldName] = {
244
- slot: fieldSlot.slotColumn,
245
- slotType: fieldSlot.slotType,
246
- slotIndex: fieldSlot.slotIndex,
247
- nullable: definition.nullable === true,
248
- required: definition.required === true,
249
- target: definition.belongsTo || null,
250
- alias: definition.as || null,
251
- }
407
+ // Concurrent schema changes to one resource queue on its metadata row.
408
+ #lockResourceRow (trx, tenant, resource) {
409
+ return trx('any_resource_configs').where({ tenant_id: tenant, resource }).forUpdate().first()
410
+ }
252
411
 
253
- if (fieldSlot.belongsToInfo) {
254
- descriptor.belongsTo[fieldSlot.belongsToInfo.alias] = fieldSlot.belongsToInfo
255
- descriptor.canonicalFieldMap = descriptor.canonicalFieldMap || {}
256
- descriptor.canonicalFieldMap[fieldName] = {
257
- idSlot: fieldSlot.slotColumn,
258
- typeSlot: fieldSlot.belongsToInfo.typeColumn,
259
- }
260
- } else {
261
- descriptor.canonicalFieldMap = descriptor.canonicalFieldMap || {}
262
- descriptor.canonicalFieldMap[fieldName] = fieldSlot.slotColumn
263
- }
412
+ async #bumpVersion (trx, resourceRow, values) {
413
+ const version = Number(resourceRow.schema_version ?? 1)
414
+ const updated = await trx('any_resource_configs')
415
+ .where({ id: resourceRow.id, schema_version: version })
416
+ .update({ ...values, schema_version: version + 1 })
417
+ if (Number(updated) !== 1) {
418
+ throw new RestApiResourceError(`AnyAPI resource '${resourceRow.resource}' changed concurrently; retry the schema change`, {
419
+ subtype: 'conflict', resourceType: 'anyapi_resource', resourceId: resourceRow.resource
420
+ })
421
+ }
422
+ return version + 1
423
+ }
264
424
 
265
- usedSlots.add(fieldSlot.slotColumn)
266
- if (fieldSlot.relationshipRow?.typeColumn) {
267
- usedSlots.add(fieldSlot.relationshipRow.typeColumn)
268
- }
425
+ async #hasRecords (trx, descriptor) {
426
+ const { canonical } = descriptor
427
+ return Boolean(await trx(canonical.tableName)
428
+ .where(canonical.tenantColumn, descriptor.tenant)
429
+ .where(canonical.resourceColumn, descriptor.resource)
430
+ .first('id'))
431
+ }
269
432
 
270
- descriptor.slotState = fieldSlot.updatedState
271
- this.cache.set(this.#key(tenant, resource), descriptor)
272
- return clone(descriptor)
273
- } catch (error) {
274
- if (managed) {
275
- await trx.rollback()
276
- }
277
- throw error
278
- }
433
+ async getSchemaVersion (tenant, resource, { transaction } = {}) {
434
+ const row = await (transaction || this.knex)('any_resource_configs')
435
+ .where({ tenant_id: tenant, resource })
436
+ .first('schema_version')
437
+ return row ? Number(row.schema_version) : null
279
438
  }
280
439
 
281
440
  async getDescriptor (tenant, resource, options = {}) {
282
441
  if (!tenant || !resource) return null
283
- const key = this.#key(tenant, resource)
284
- if (!options.bypassCache && this.cache.has(key)) {
285
- return clone(this.cache.get(key))
286
- }
442
+ try {
443
+ const key = this.#key(tenant, resource)
444
+ if (!options.transaction && !options.bypassCache && this.cache.has(key)) {
445
+ const descriptor = this.cache.get(key)
446
+ this.#rememberDescriptor(descriptor)
447
+ return clone(descriptor)
448
+ }
287
449
 
288
- const descriptor = await this.#loadDescriptor(tenant, resource, options.transaction)
289
- if (!descriptor) return null
450
+ const descriptor = await this.#loadDescriptor(tenant, resource, options.transaction)
451
+ if (!descriptor) {
452
+ if (!options.transaction) this.cache.delete(key)
453
+ return null
454
+ }
290
455
 
291
- this.cache.set(key, descriptor)
292
- return clone(descriptor)
456
+ if (!options.transaction) this.#rememberDescriptor(descriptor)
457
+ return clone(descriptor)
458
+ } catch (error) {
459
+ throw wrapUnexpectedError(error, {
460
+ message: `Failed to load descriptor for resource '${resource}'`,
461
+ context: { tenant, scopeName: resource, phase: 'descriptor' }
462
+ })
463
+ }
293
464
  }
294
465
 
295
466
  async listResources (tenant) {
@@ -305,6 +476,28 @@ export class AnyapiRegistry {
305
476
  this.cache.delete(this.#key(tenant, resource))
306
477
  }
307
478
 
479
+ async #handleWriteFailure (error, transaction, context) {
480
+ // A rejected commit can still have changed persisted metadata.
481
+ this.invalidateDescriptor(context.tenant, context.resource)
482
+ await rollbackAfterError(error, context, transaction)
483
+ if (context.cleanupErrors.length) {
484
+ try {
485
+ await createEnhancedLogger(this.log).error('AnyAPI registry rollback failed', {
486
+ ...getOperationDiagnosticContext(context, {
487
+ phase: 'registryRollback',
488
+ scopeName: context.resource,
489
+ backend: this.knex.client.config.client
490
+ }),
491
+ tenant: context.tenant,
492
+ error: context.error,
493
+ cleanupErrors: context.cleanupErrors
494
+ })
495
+ } catch (error) {
496
+ context.cleanupErrors.push({ phase: 'logging', error })
497
+ }
498
+ }
499
+ }
500
+
308
501
  #validateDefinition (definition) {
309
502
  const { tenant, resource, schema } = definition || {}
310
503
  if (!tenant || typeof tenant !== 'string') {
@@ -316,247 +509,254 @@ export class AnyapiRegistry {
316
509
  if (!schema || typeof schema !== 'object') {
317
510
  throw new Error('AnyapiRegistry.registerResource requires a schema object')
318
511
  }
512
+ validateSchemaFieldNames(schema, resource)
513
+ validateRelationshipFieldNames(definition.relationships, resource)
514
+ assertFieldNameMap(definition.canonicalFieldMap, `canonical fields in '${resource}'`)
319
515
  }
320
516
 
321
- async #register (definition, externalTrx) {
517
+ async #register (definition, externalTrx, context) {
322
518
  const { tenant, resource, schema, relationships = {}, canonicalFieldMap = null } = definition
323
519
  const idProperty = resolveIdProperty(schema, definition.idProperty)
324
- const trx = externalTrx || await this.knex.transaction()
520
+ let trx
325
521
  const managed = !externalTrx
326
522
 
327
523
  try {
524
+ trx = await beginWriteTransaction(context, externalTrx, ownerContext => createKnexTransaction(this.knex, ownerContext))
328
525
  const now = this.knex.fn.now()
329
- let resourceRow = await trx('any_resource_configs')
330
- .where({ tenant_id: tenant, resource })
331
- .first()
332
-
333
- const schemaJson = JSON.stringify(schema)
334
- const relationshipsJson = JSON.stringify(relationships || {})
526
+ const resourceRow = await this.#lockResourceRow(trx, tenant, resource)
527
+ // Validate persisted mappings before registration can replace migration evidence.
528
+ const previous = resourceRow ? await this.#loadState(tenant, resource, trx, { idProperty }) : null
529
+ const previousDescriptor = previous?.descriptor
530
+ const stored = previousDescriptor?.definition || null
531
+ const runtime = clone(stored?.runtime || EMPTY_RUNTIME)
532
+ let populated
533
+ const isPopulated = () => (populated ??= this.#hasRecords(trx, previousDescriptor))
534
+
535
+ // Registration never drops a mapping other servers may still use. Runtime
536
+ // additions stay published; a populated field the declaration omits keeps
537
+ // its slot but is no longer published. Metadata written before
538
+ // definitions were stored cannot tell the two apart, so it keeps both.
539
+ const retained = {}
540
+ for (const [fieldName, fieldDef] of Object.entries(previousDescriptor?.schema || {})) {
541
+ if (Object.hasOwn(schema, fieldName)) continue
542
+ if (!stored?.options && !Object.hasOwn(runtime.schema, fieldName)) runtime.schema[fieldName] = fieldDef
543
+ if (Object.hasOwn(runtime.schema, fieldName) || await isPopulated()) retained[fieldName] = fieldDef
544
+ }
545
+ const storageSchema = { ...schema, ...retained }
546
+
547
+ const plan = await this.#withSlotPools(trx, pools => this.#planRegistration({
548
+ tenant, resource, schema, storageSchema, relationships, canonicalFieldMap, idProperty, previousDescriptor, pools
549
+ }))
550
+ const declaration = toStoredJson(definition.declaration ?? { schema, relationships })
551
+ const row = {
552
+ schema_json: JSON.stringify(storageSchema),
553
+ relationships_json: JSON.stringify(relationships || {}),
554
+ definition_json: JSON.stringify({ options: declaration.json, callbacks: declaration.callbacks, runtime }),
555
+ }
556
+ const unchanged = previous &&
557
+ Object.entries(row).every(([column, value]) => previous.resourceRow[column] === value) &&
558
+ rowsSignature(previous.fieldRows, FIELD_ROW_COLUMNS) === rowsSignature(plan.fieldRows, FIELD_ROW_COLUMNS) &&
559
+ rowsSignature(previous.relationshipRows, RELATIONSHIP_ROW_COLUMNS) === rowsSignature(plan.relationshipRows, RELATIONSHIP_ROW_COLUMNS)
560
+ if (unchanged) {
561
+ if (managed) await commitTransaction(trx, context)
562
+ return { descriptor: previousDescriptor, changed: false }
563
+ }
335
564
 
565
+ let resourceConfigId = resourceRow?.id
336
566
  if (resourceRow) {
337
- await trx('any_resource_configs')
338
- .where({ id: resourceRow.id })
339
- .update({
340
- schema_json: schemaJson,
341
- relationships_json: relationshipsJson,
342
- updated_at: now,
343
- })
567
+ await trx('any_field_configs').where({ resource_config_id: resourceRow.id }).delete()
568
+ await trx('any_relationship_configs').where({ resource_config_id: resourceRow.id }).delete()
569
+ await this.#bumpVersion(trx, resourceRow, { ...row, updated_at: now })
344
570
  } else {
345
- const [insertedId] = await trx('any_resource_configs').insert({
346
- tenant_id: tenant,
347
- resource,
348
- schema_json: schemaJson,
349
- relationships_json: relationshipsJson,
350
- created_at: now,
351
- updated_at: now,
352
- })
353
-
354
- resourceRow = {
355
- id: insertedId,
356
- tenant_id: tenant,
357
- resource,
358
- schema_json: schemaJson,
359
- relationships_json: relationshipsJson,
571
+ try {
572
+ const [inserted] = await applyInsertReturning(trx('any_resource_configs').insert({
573
+ tenant_id: tenant,
574
+ resource,
575
+ ...row,
576
+ schema_version: 1,
577
+ created_at: now,
578
+ updated_at: now,
579
+ }), 'id')
580
+ resourceConfigId = inserted && typeof inserted === 'object' ? inserted.id : inserted
581
+ } catch (error) {
582
+ if (isWriteConflict(error)) creationRaces.add(error)
583
+ throw error
360
584
  }
361
585
  }
362
586
 
363
- await trx('any_field_configs').where({ resource_config_id: resourceRow.id }).delete()
364
- await trx('any_relationship_configs').where({ resource_config_id: resourceRow.id }).delete()
365
-
366
- const slotState = this.#initializeSlotState()
367
- const usedSlotColumns = new Set()
368
- let canonicalOverrides = null
369
- if (canonicalFieldMap) {
370
- canonicalOverrides = new Map(Object.entries(canonicalFieldMap))
587
+ const stamp = { resource_config_id: resourceConfigId, created_at: now, updated_at: now }
588
+ if (plan.fieldRows.length > 0) {
589
+ await trx('any_field_configs').insert(plan.fieldRows.map(fieldRow => ({ ...fieldRow, ...stamp })))
590
+ }
591
+ if (plan.relationshipRows.length > 0) {
592
+ await trx('any_relationship_configs').insert(plan.relationshipRows.map(relationshipRow => ({ ...relationshipRow, ...stamp })))
371
593
  }
372
- const fieldInserts = []
373
- const relationshipInserts = []
374
594
 
375
- for (const [fieldName, fieldDef] of Object.entries(schema)) {
376
- if (fieldName === 'id') {
377
- if (canonicalOverrides) {
378
- canonicalOverrides.delete(fieldName)
379
- }
380
- continue
381
- }
595
+ const descriptor = await this.#loadDescriptor(tenant, resource, trx, { idProperty })
596
+ const changedField = Object.keys(previousDescriptor?.fields || {}).find(fieldName => {
597
+ const previousField = previousDescriptor.fields[fieldName]
598
+ const current = descriptor.fields[fieldName]
599
+ if (!current || previousField.slot !== current.slot || previousField.slotType !== current.slotType) return true
600
+ return previousField.slotType === 'belongsTo' &&
601
+ previousDescriptor.belongsTo[previousField.alias]?.typeColumn !== descriptor.belongsTo[current.alias]?.typeColumn
602
+ })
603
+ if (changedField && await isPopulated()) {
604
+ throw new Error(`Canonical storage migration required for '${resource}.${changedField}': registration cannot remove or remap stored fields while records exist. Preserve canonicalFieldsMap or migrate the stored data before changing the schema.`)
605
+ }
382
606
 
383
- if (fieldDef?.belongsToPolymorphic) {
384
- if (canonicalOverrides?.has(fieldName)) {
385
- throw new Error(`canonicalFieldsMap should not include polymorphic field '${fieldName}'`)
386
- }
387
- const alias = fieldDef.as || fieldName
388
- const { typeField, idField, types = [] } = fieldDef.belongsToPolymorphic
389
-
390
- relationshipInserts.push({
391
- resource_config_id: resourceRow.id,
392
- relationship_name: alias,
393
- relationship_type: 'belongsToPolymorphic',
394
- target_resource: null,
395
- slot_index: null,
396
- id_column: idField,
397
- type_column: typeField,
398
- relationship_key: null,
399
- through: null,
400
- foreign_key: null,
401
- other_key: null,
402
- alias,
403
- meta_json: JSON.stringify({ types }),
404
- created_at: this.knex.fn.now(),
405
- updated_at: this.knex.fn.now(),
406
- })
407
- continue
408
- }
607
+ if (managed) {
608
+ await commitTransaction(trx, context)
609
+ }
409
610
 
410
- if (fieldDef?.computed) {
411
- if (canonicalOverrides?.has(fieldName)) {
412
- throw new Error(`canonicalFieldsMap should not include computed field '${fieldName}'`)
413
- }
414
- continue
415
- }
611
+ return { descriptor, changed: true }
612
+ } catch (error) {
613
+ if (managed) {
614
+ await this.#handleWriteFailure(error, trx || context.transaction, context)
615
+ }
616
+ throw error
617
+ }
618
+ }
416
619
 
417
- if (fieldDef?.virtual === true) {
418
- if (canonicalOverrides?.has(fieldName)) {
419
- throw new Error(`canonicalFieldsMap should not include virtual field '${fieldName}'`)
420
- }
421
- continue
422
- }
620
+ #planRegistration ({ tenant, resource, schema, storageSchema, relationships, canonicalFieldMap, idProperty, previousDescriptor, pools }) {
621
+ const used = new Set()
622
+ const canonicalOverrides = canonicalFieldMap ? new Map(Object.entries(canonicalFieldMap)) : null
623
+ const entries = []
624
+ const fieldRows = []
625
+ const relationshipRows = []
626
+
627
+ // ID attributes previously had no slot; append them without moving existing slots.
628
+ const fields = Object.entries(storageSchema).sort(([, left], [, right]) =>
629
+ Number(left?.type === 'id' && !left.belongsTo) - Number(right?.type === 'id' && !right.belongsTo)
630
+ )
631
+ for (const [fieldName, fieldDef] of fields) {
632
+ if (fieldName === 'id' || (fieldName === idProperty && fieldDef?.type === 'id')) {
633
+ canonicalOverrides?.delete(fieldName)
634
+ continue
635
+ }
423
636
 
424
- let override = null
425
- if (canonicalOverrides) {
426
- if (!canonicalOverrides.has(fieldName)) {
427
- throw new Error(`canonicalFieldsMap missing entry for field '${fieldName}'`)
428
- }
429
- const rawOverride = canonicalOverrides.get(fieldName)
430
- canonicalOverrides.delete(fieldName)
431
-
432
- if (fieldDef.belongsTo) {
433
- const normalized = typeof rawOverride === 'string'
434
- ? { idSlot: rawOverride }
435
- : rawOverride
436
- if (!normalized || !normalized.idSlot) {
437
- throw new Error(`canonicalFieldsMap for belongsTo field '${fieldName}' requires idSlot`)
438
- }
439
- const parsed = parseBelongsToSlots(normalized)
440
- if (usedSlotColumns.has(parsed.idColumn) || usedSlotColumns.has(parsed.typeColumn)) {
441
- throw new Error(`Canonical slots '${parsed.idColumn}'/'${parsed.typeColumn}' already in use`)
442
- }
443
- usedSlotColumns.add(parsed.idColumn)
444
- usedSlotColumns.add(parsed.typeColumn)
445
- override = normalized
446
- } else {
447
- const slotColumn = typeof rawOverride === 'string'
448
- ? rawOverride
449
- : rawOverride?.slot || rawOverride?.slotColumn
450
- if (!slotColumn) {
451
- throw new Error(`canonicalFieldsMap for field '${fieldName}' must provide a slot column`)
452
- }
453
- const parsed = parseSimpleSlotColumn(slotColumn)
454
- if (usedSlotColumns.has(slotColumn)) {
455
- throw new Error(`Canonical slot '${slotColumn}' already in use`)
456
- }
457
- usedSlotColumns.add(slotColumn)
458
- override = { slotColumn, slotType: parsed.slotType, slotIndex: parsed.slotIndex }
459
- }
637
+ if (fieldDef?.belongsToPolymorphic) {
638
+ if (canonicalOverrides?.has(fieldName)) {
639
+ throw new Error(`canonicalFieldsMap should not include polymorphic field '${fieldName}'`)
460
640
  }
461
-
462
- const allocation = this.#assignFieldSlot({ ...fieldDef, fieldName }, slotState, override)
463
- if (!allocation) continue
464
-
465
- const meta = fieldDef.meta ? JSON.stringify(fieldDef.meta) : null
466
- fieldInserts.push({
467
- resource_config_id: resourceRow.id,
468
- field_name: fieldName,
469
- slot_type: allocation.slotType,
470
- slot_index: allocation.slotIndex,
471
- slot_column: allocation.slotColumn,
472
- nullable: fieldDef.nullable === true,
473
- required: fieldDef.required === true,
474
- target_resource: fieldDef.belongsTo || null,
475
- alias: fieldDef.as || null,
476
- meta_json: meta,
477
- created_at: this.knex.fn.now(),
478
- updated_at: this.knex.fn.now(),
641
+ const alias = fieldDef.as || fieldName
642
+ const { typeField, idField, types = [] } = fieldDef.belongsToPolymorphic
643
+ relationshipRows.push({
644
+ relationship_name: alias,
645
+ relationship_type: 'belongsToPolymorphic',
646
+ target_resource: null,
647
+ slot_index: null,
648
+ id_column: idField,
649
+ type_column: typeField,
650
+ relationship_key: null,
651
+ through: null,
652
+ foreign_key: null,
653
+ other_key: null,
654
+ alias,
655
+ meta_json: JSON.stringify({ types }),
479
656
  })
657
+ continue
658
+ }
480
659
 
481
- if (allocation.relationshipRow) {
482
- const relationshipRow = {
483
- resource_config_id: resourceRow.id,
484
- relationship_name: allocation.relationshipRow.name,
485
- relationship_type: allocation.relationshipRow.type,
486
- target_resource: allocation.relationshipRow.target,
487
- slot_index: allocation.relationshipRow.slotIndex,
488
- id_column: allocation.relationshipRow.idColumn,
489
- type_column: allocation.relationshipRow.typeColumn,
490
- relationship_key: allocation.relationshipRow.relationshipKey,
491
- through: allocation.relationshipRow.through,
492
- foreign_key: allocation.relationshipRow.foreignKey,
493
- other_key: allocation.relationshipRow.otherKey,
494
- alias: allocation.relationshipRow.alias,
495
- meta_json: allocation.relationshipRow.meta ? JSON.stringify(allocation.relationshipRow.meta) : null,
496
- created_at: this.knex.fn.now(),
497
- updated_at: this.knex.fn.now(),
498
- }
499
- relationshipInserts.push(relationshipRow)
660
+ if (fieldDef?.computed) {
661
+ if (canonicalOverrides?.has(fieldName)) {
662
+ throw new Error(`canonicalFieldsMap should not include computed field '${fieldName}'`)
500
663
  }
664
+ continue
665
+ }
501
666
 
502
- usedSlotColumns.add(allocation.slotColumn)
503
- if (allocation.relationshipRow?.typeColumn) {
504
- usedSlotColumns.add(allocation.relationshipRow.typeColumn)
667
+ if (fieldDef?.virtual === true) {
668
+ if (canonicalOverrides?.has(fieldName)) {
669
+ throw new Error(`canonicalFieldsMap should not include virtual field '${fieldName}'`)
505
670
  }
671
+ continue
506
672
  }
507
673
 
508
- if (canonicalOverrides && canonicalOverrides.size > 0) {
509
- const unknownFields = [...canonicalOverrides.keys()].join(', ')
510
- throw new Error(`canonicalFieldsMap contains unknown fields: ${unknownFields}`)
511
- }
512
-
513
- for (const [relName, relDef] of Object.entries(relationships || {})) {
514
- if (relDef?.type === 'manyToMany') {
515
- relationshipInserts.push({
516
- resource_config_id: resourceRow.id,
517
- relationship_name: relName,
518
- relationship_type: 'manyToMany',
519
- target_resource: relDef.target || relName,
520
- relationship_key: relDef.relationship || `${tenant}:${resource}:${relName}`,
521
- through: relDef.through,
522
- foreign_key: relDef.foreignKey,
523
- other_key: relDef.otherKey,
524
- alias: relDef.as || relName,
525
- meta_json: JSON.stringify(relDef.meta || {}),
526
- created_at: this.knex.fn.now(),
527
- updated_at: this.knex.fn.now(),
528
- })
674
+ let override = null
675
+ // Retained fields are not part of the declaration, so its map does not cover them.
676
+ if (canonicalOverrides && Object.hasOwn(schema, fieldName)) {
677
+ if (!canonicalOverrides.has(fieldName)) {
678
+ throw new Error(`canonicalFieldsMap missing entry for field '${fieldName}'`)
529
679
  }
530
- }
680
+ const rawOverride = canonicalOverrides.get(fieldName)
681
+ canonicalOverrides.delete(fieldName)
531
682
 
532
- if (fieldInserts.length > 0) {
533
- await trx('any_field_configs').insert(fieldInserts)
534
- }
535
- if (relationshipInserts.length > 0) {
536
- await trx('any_relationship_configs').insert(relationshipInserts)
683
+ if (fieldDef.belongsTo) {
684
+ const normalized = typeof rawOverride === 'string'
685
+ ? { idSlot: rawOverride }
686
+ : rawOverride
687
+ if (!normalized || !normalized.idSlot) {
688
+ throw new Error(`canonicalFieldsMap for belongsTo field '${fieldName}' requires idSlot`)
689
+ }
690
+ const parsed = parseBelongsToSlots(normalized, pools)
691
+ reserveSlots(used, [parsed.idColumn, parsed.typeColumn])
692
+ override = normalized
693
+ } else {
694
+ const slotColumn = typeof rawOverride === 'string'
695
+ ? rawOverride
696
+ : rawOverride?.slot || rawOverride?.slotColumn
697
+ if (!slotColumn) {
698
+ throw new Error(`canonicalFieldsMap for field '${fieldName}' must provide a slot column`)
699
+ }
700
+ parseSimpleSlotColumn(slotColumn, pools)
701
+ reserveSlots(used, [slotColumn])
702
+ override = { slotColumn }
703
+ }
537
704
  }
705
+ entries.push({ fieldName, fieldDef, override })
706
+ }
538
707
 
539
- const descriptor = await this.#loadDescriptor(tenant, resource, trx, { idProperty })
708
+ if (canonicalOverrides && canonicalOverrides.size > 0) {
709
+ const unknownFields = [...canonicalOverrides.keys()].join(', ')
710
+ throw new Error(`canonicalFieldsMap contains unknown fields: ${unknownFields}`)
711
+ }
540
712
 
541
- if (managed) {
542
- await trx.commit()
543
- }
713
+ // Stored fields keep their slots, so declaration order never moves data.
714
+ for (const entry of entries) {
715
+ const previous = previousDescriptor?.fields?.[entry.fieldName]
716
+ if (entry.override || !previous || previous.slotType !== slotPoolFor(entry.fieldDef)) continue
717
+ const columns = previous.slotType === 'belongsTo' ? belongsToColumns(previous.slotIndex) : [previous.slot]
718
+ const available = previous.slotType === 'belongsTo'
719
+ ? pools.belongsTo.includes(previous.slotIndex)
720
+ : pools[previous.slotType]?.includes(previous.slot)
721
+ if (!available || columns.some(column => used.has(column))) continue
722
+ columns.forEach(column => used.add(column))
723
+ entry.override = previous.slotType === 'belongsTo' ? { idSlot: columns[0], typeSlot: columns[1] } : { slotColumn: previous.slot }
724
+ }
544
725
 
545
- return descriptor
546
- } catch (error) {
547
- if (managed) {
548
- await trx.rollback()
726
+ for (const { fieldName, fieldDef, override } of entries) {
727
+ const allocation = this.#assignFieldSlot({ ...fieldDef, fieldName }, used, override, pools)
728
+ if (!allocation) continue
729
+ fieldRows.push(this.#fieldRow(fieldName, fieldDef, allocation))
730
+ if (allocation.relationshipRow) relationshipRows.push(this.#relationshipRow(allocation.relationshipRow))
731
+ }
732
+
733
+ for (const [relName, relDef] of Object.entries(relationships || {})) {
734
+ if (relDef?.type === 'manyToMany') {
735
+ relationshipRows.push(this.#relationshipRow({
736
+ name: relName,
737
+ type: 'manyToMany',
738
+ target: relDef.target || relName,
739
+ relationshipKey: relDef.relationship || `${tenant}:${resource}:${relName}`,
740
+ through: relDef.through,
741
+ foreignKey: relDef.foreignKey,
742
+ otherKey: relDef.otherKey,
743
+ alias: relDef.as || relName,
744
+ meta: relDef.meta || {},
745
+ }))
549
746
  }
550
- throw error
551
747
  }
748
+
749
+ return { fieldRows, relationshipRows }
552
750
  }
553
751
 
554
752
  async #loadDescriptor (tenant, resource, trx, options = {}) {
555
- const query = (trx || this.knex)('any_resource_configs')
753
+ return (await this.#loadState(tenant, resource, trx, options))?.descriptor || null
754
+ }
755
+
756
+ async #loadState (tenant, resource, trx, options = {}) {
757
+ const resourceRow = await (trx || this.knex)('any_resource_configs')
556
758
  .where({ tenant_id: tenant, resource })
557
759
  .first()
558
-
559
- const resourceRow = await query
560
760
  if (!resourceRow) return null
561
761
 
562
762
  const fieldRows = await (trx || this.knex)('any_field_configs')
@@ -602,11 +802,15 @@ export class AnyapiRegistry {
602
802
  }
603
803
 
604
804
  descriptor.canonicalFieldMap = canonicalFieldMap
805
+ descriptor.schemaVersion = Number(resourceRow.schema_version ?? 1)
806
+ descriptor.definition = resourceRow.definition_json ? JSON.parse(resourceRow.definition_json) : null
605
807
 
606
- return descriptor
808
+ return { resourceRow, fieldRows, relationshipRows, descriptor }
607
809
  }
608
810
 
609
811
  #buildDescriptor ({ tenant, resource, schema, relationships, slotState, fieldRows, relationshipRows, idProperty }) {
812
+ validateSchemaFieldNames(schema, resource)
813
+ validateRelationshipFieldNames(relationships, resource)
610
814
  const fields = {}
611
815
  const reverseAttributes = {}
612
816
  const belongsTo = {}
@@ -614,6 +818,11 @@ export class AnyapiRegistry {
614
818
  const polymorphicBelongsTo = {}
615
819
 
616
820
  for (const row of fieldRows) {
821
+ assertFieldName(row.field_name, `stored field in '${resource}'`)
822
+ assertFieldName(row.alias, `stored relationship alias in '${resource}'`)
823
+ if (['date', 'time'].includes(schema?.[row.field_name]?.type) && row.slot_type !== 'string') {
824
+ throw new Error(`AnyAPI temporal storage migration required for '${resource}.${row.field_name}': calendar dates and times must use string slots. See 33-migrating-to-v2.md.`)
825
+ }
617
826
  fields[row.field_name] = {
618
827
  slot: row.slot_column,
619
828
  slotType: row.slot_type,
@@ -629,6 +838,8 @@ export class AnyapiRegistry {
629
838
  }
630
839
 
631
840
  for (const row of relationshipRows) {
841
+ assertFieldName(row.relationship_name, `stored relationship in '${resource}'`)
842
+ assertFieldName(row.alias, `stored relationship alias in '${resource}'`)
632
843
  if (row.relationship_type === 'belongsTo') {
633
844
  const alias = row.alias || row.relationship_name
634
845
  belongsTo[alias] = {
@@ -712,20 +923,22 @@ export class AnyapiRegistry {
712
923
  }
713
924
  }
714
925
 
715
- #assignFieldSlot (fieldDef, slotState, canonicalOverride = null) {
926
+ // Callers reserve explicit and preserved slots in `used` before assigning;
927
+ // other fields take the first free slot of their pool.
928
+ #assignFieldSlot (fieldDef, used, canonicalOverride, pools) {
716
929
  if (fieldDef.belongsTo) {
717
- return this.#assignBelongsTo(fieldDef, slotState, canonicalOverride)
930
+ return this.#assignBelongsTo(fieldDef, used, canonicalOverride, pools)
718
931
  }
719
932
 
720
933
  const { type } = fieldDef
721
- if (!type || type === 'id') {
934
+ if (!type) {
722
935
  return null
723
936
  }
724
937
  if (!SUPPORTED_TYPES.has(type)) {
725
938
  throw new Error(`Unsupported field type '${type}'`)
726
939
  }
727
940
 
728
- const pool = TYPE_TO_POOL.get(type)
941
+ const pool = slotPoolFor(fieldDef)
729
942
  if (!pool) {
730
943
  throw new Error(`No slot pool defined for type '${type}'`)
731
944
  }
@@ -737,103 +950,58 @@ export class AnyapiRegistry {
737
950
  if (!slotColumn) {
738
951
  throw new Error(`Canonical mapping for field '${fieldDef.fieldName}' must provide a slot column`)
739
952
  }
740
- const { slotType, slotIndex } = parseSimpleSlotColumn(slotColumn)
953
+ const { slotType, slotIndex } = parseSimpleSlotColumn(slotColumn, pools)
741
954
  if (slotType !== pool) {
742
955
  throw new Error(
743
956
  `Canonical slot '${slotColumn}' does not match expected type '${pool}' for field '${fieldDef.fieldName}'`
744
957
  )
745
958
  }
746
- slotState[pool] = Math.max(slotState[pool], slotIndex)
747
- return {
748
- slotType: pool,
749
- slotIndex,
750
- slotColumn,
751
- updatedState: slotState,
752
- }
753
- }
754
-
755
- const nextIndex = slotState[pool] + 1
756
- if (nextIndex > SLOT_LIMITS[pool]) {
757
- throw new Error(`No available ${pool} slots remaining`)
959
+ used.add(slotColumn)
960
+ return { slotType: pool, slotIndex, slotColumn }
758
961
  }
759
962
 
760
- slotState[pool] = nextIndex
761
- const slotColumn = SLOT_POOLS[pool][nextIndex - 1]
762
-
763
- return {
764
- slotType: pool,
765
- slotIndex: nextIndex,
766
- slotColumn,
767
- updatedState: slotState,
963
+ const slotColumn = pools[pool].find(column => !used.has(column))
964
+ if (!slotColumn) {
965
+ throw slotsExhausted(pool)
768
966
  }
967
+ used.add(slotColumn)
968
+ return { slotType: pool, slotIndex: pools[pool].indexOf(slotColumn) + 1, slotColumn }
769
969
  }
770
970
 
771
- #assignBelongsTo (fieldDef, slotState, canonicalOverride = null) {
971
+ #assignBelongsTo (fieldDef, used, canonicalOverride, pools) {
772
972
  const alias = BELONGS_TO_ALIAS(fieldDef.fieldName || '', fieldDef)
773
973
  const relationshipKey = fieldDef.relationshipKey || null
774
974
 
975
+ let slotIndex
775
976
  if (canonicalOverride) {
776
977
  const normalized = typeof canonicalOverride === 'string'
777
978
  ? { idSlot: canonicalOverride }
778
979
  : canonicalOverride
779
- const { slotIndex, idColumn, typeColumn } = parseBelongsToSlots(normalized || {})
780
- slotState.belongsTo = Math.max(slotState.belongsTo, slotIndex)
781
- return {
782
- slotType: 'belongsTo',
783
- slotIndex,
784
- slotColumn: idColumn,
785
- relationshipRow: {
786
- name: alias,
787
- type: 'belongsTo',
788
- target: fieldDef.belongsTo,
789
- slotIndex,
790
- idColumn,
791
- typeColumn,
792
- relationshipKey,
793
- alias,
794
- },
795
- belongsToInfo: {
796
- alias,
797
- target: fieldDef.belongsTo,
798
- slotIndex,
799
- idColumn,
800
- typeColumn,
801
- nullable: fieldDef.nullable === true,
802
- },
803
- updatedState: slotState,
980
+ slotIndex = parseBelongsToSlots(normalized || {}, pools).slotIndex
981
+ } else {
982
+ slotIndex = pools.belongsTo.find(index => belongsToColumns(index).every(column => !used.has(column)))
983
+ if (slotIndex === undefined) {
984
+ throw slotsExhausted('belongsTo')
804
985
  }
805
986
  }
806
-
807
- const nextIndex = slotState.belongsTo + 1
808
- if (nextIndex > SLOT_LIMITS.belongsTo) {
809
- throw new Error('No available belongsTo slots remaining')
810
- }
811
-
812
- slotState.belongsTo = nextIndex
987
+ const [idColumn, typeColumn] = belongsToColumns(slotIndex)
988
+ used.add(idColumn)
989
+ used.add(typeColumn)
813
990
 
814
991
  return {
815
992
  slotType: 'belongsTo',
816
- slotIndex: nextIndex,
817
- slotColumn: `rel_${nextIndex}_id`,
993
+ slotIndex,
994
+ slotColumn: idColumn,
818
995
  relationshipRow: {
819
996
  name: alias,
820
997
  type: 'belongsTo',
821
998
  target: fieldDef.belongsTo,
822
- slotIndex: nextIndex,
823
- idColumn: `rel_${nextIndex}_id`,
824
- typeColumn: `rel_${nextIndex}_type`,
999
+ slotIndex,
1000
+ idColumn,
1001
+ typeColumn,
825
1002
  relationshipKey,
826
1003
  alias,
827
1004
  },
828
- belongsToInfo: {
829
- alias,
830
- target: fieldDef.belongsTo,
831
- slotIndex: nextIndex,
832
- idColumn: `rel_${nextIndex}_id`,
833
- typeColumn: `rel_${nextIndex}_type`,
834
- nullable: fieldDef.nullable === true,
835
- },
836
- updatedState: slotState,
837
1005
  }
838
1006
  }
839
1007
  }