json-rest-api 1.0.28 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. package/LICENSE +10 -670
  2. package/LICENSE-GPL-3.0 +674 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +39 -30
  5. package/docs/API.md +746 -1273
  6. package/docs/GUIDE/01-getting-started.md +338 -0
  7. package/docs/GUIDE/02-resources-and-relationships.md +33 -0
  8. package/docs/GUIDE/03-running-example.md +87 -0
  9. package/docs/GUIDE/04-creating-and-querying.md +181 -0
  10. package/docs/GUIDE/05-belongs-to.md +177 -0
  11. package/docs/GUIDE/06-has-many.md +184 -0
  12. package/docs/GUIDE/07-polymorphic-relationships.md +191 -0
  13. package/docs/GUIDE/08-many-to-many.md +184 -0
  14. package/docs/GUIDE/09-pagination-and-sorting.md +149 -0
  15. package/docs/GUIDE/10-put-and-patch.md +174 -0
  16. package/docs/GUIDE/11-relationship-endpoints.md +174 -0
  17. package/docs/GUIDE/12-field-transformations.md +287 -0
  18. package/docs/GUIDE/13-hooks-and-lifecycle.md +479 -0
  19. package/docs/GUIDE/14-custom-resource-methods.md +125 -0
  20. package/docs/GUIDE/15-query-projections.md +266 -0
  21. package/docs/GUIDE/{GUIDE_X_Autofiltering.md → 16-autofiltering.md} +77 -15
  22. package/docs/GUIDE/{GUIDE_X_Row_Policies.md → 17-row-policies.md} +137 -44
  23. package/docs/GUIDE/18-labels.md +86 -0
  24. package/docs/GUIDE/19-managed-transactions.md +179 -0
  25. package/docs/GUIDE/20-transaction-outcomes.md +207 -0
  26. package/docs/GUIDE/21-schema-and-migrations.md +634 -0
  27. package/docs/GUIDE/22-version-field-migrations.md +208 -0
  28. package/docs/GUIDE/23-fastify.md +179 -0
  29. package/docs/GUIDE/24-cors.md +172 -0
  30. package/docs/GUIDE/{GUIDE_X_URL_Management.md → 25-url-management.md} +7 -1
  31. package/docs/GUIDE/26-file-uploads.md +278 -0
  32. package/docs/GUIDE/27-bulk-operations.md +277 -0
  33. package/docs/GUIDE/28-socketio.md +325 -0
  34. package/docs/GUIDE/29-writing-plugins.md +278 -0
  35. package/docs/GUIDE/30-backend-capabilities.md +100 -0
  36. package/docs/GUIDE/31-positioning.md +181 -0
  37. package/docs/GUIDE/{GUIDE_Y_Appendices.md → 32-date-and-time.md} +90 -31
  38. package/docs/GUIDE/33-migrating-to-v2.md +3827 -0
  39. package/docs/GUIDE/index.md +51 -59
  40. package/docs/QUICKSTART.md +120 -287
  41. package/docs/architecture.md +75 -0
  42. package/docs/contributing.md +178 -0
  43. package/index.d.ts +36 -0
  44. package/index.js +1 -22
  45. package/lib/enhanced-logger.js +91 -85
  46. package/lib/error-context.js +445 -5
  47. package/lib/error-formatter.js +219 -59
  48. package/lib/knex-transaction.js +64 -0
  49. package/lib/logger-types.d.ts +22 -0
  50. package/lib/missing-package.js +4 -0
  51. package/lib/rest-api-errors.js +103 -8
  52. package/lib/runtime/json-rest-api.js +172 -0
  53. package/lib/transaction-types.d.ts +59 -0
  54. package/package.json +97 -27
  55. package/plugins/core/bulk-operations-plugin.js +191 -184
  56. package/plugins/core/connectors/express-plugin.js +148 -219
  57. package/plugins/core/connectors/fastify-plugin.js +125 -82
  58. package/plugins/core/connectors/lib/busboy-detector.js +74 -156
  59. package/plugins/core/connectors/lib/connector-core.js +144 -20
  60. package/plugins/core/connectors/lib/formidable-detector.js +63 -120
  61. package/plugins/core/connectors/lib/http-validators.js +77 -0
  62. package/plugins/core/connectors/lib/multipart-helpers.js +18 -0
  63. package/plugins/core/connectors/lib/request-helpers.js +10 -1
  64. package/plugins/core/connectors/lib/transport-http-helpers.js +89 -8
  65. package/plugins/core/connectors/lib/transport-route-schemas.js +7 -4
  66. package/plugins/core/file-handling-plugin.js +87 -174
  67. package/plugins/core/lib/anyapi/anyapi-registry.js +213 -164
  68. package/plugins/core/lib/anyapi/canonical-link-store.js +507 -0
  69. package/plugins/core/lib/anyapi/canonical-relationship-reader.js +598 -0
  70. package/plugins/core/lib/anyapi/schema-utils.js +25 -16
  71. package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
  72. package/plugins/core/lib/dbIntrospection.js +228 -21
  73. package/plugins/core/lib/dbTablesOperations.js +409 -92
  74. package/plugins/core/lib/fractional-positioning.js +12 -48
  75. package/plugins/core/lib/querying/identifier-query.js +19 -0
  76. package/plugins/core/lib/querying/include-query-helpers.js +364 -0
  77. package/plugins/core/lib/querying/include-to-many.js +428 -0
  78. package/plugins/core/lib/querying/include-to-one.js +579 -0
  79. package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
  80. package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
  81. package/plugins/core/lib/querying/knex-pagination-helpers.js +67 -330
  82. package/plugins/core/lib/querying/knex-process-includes.js +5 -110
  83. package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
  84. package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
  85. package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
  86. package/plugins/core/lib/querying/knex-window-queries.js +62 -253
  87. package/plugins/core/lib/querying/query-builder-utils.js +44 -4
  88. package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
  89. package/plugins/core/lib/querying/query-constraint.js +27 -0
  90. package/plugins/core/lib/querying/query-field-sort-helpers.js +205 -30
  91. package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
  92. package/plugins/core/lib/querying/sort-helpers.js +20 -1
  93. package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
  94. package/plugins/core/lib/querying/url-helpers.js +11 -4
  95. package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
  96. package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
  97. package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
  98. package/plugins/core/lib/querying-writing/database-value-normalizers.js +238 -187
  99. package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
  100. package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
  101. package/plugins/core/lib/querying-writing/field-utils.js +113 -92
  102. package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
  103. package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
  104. package/plugins/core/lib/querying-writing/knex-field-helpers.js +111 -287
  105. package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
  106. package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
  107. package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
  108. package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
  109. package/plugins/core/lib/querying-writing/request-contracts.js +109 -44
  110. package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
  111. package/plugins/core/lib/querying-writing/response-options.js +35 -0
  112. package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
  113. package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
  114. package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
  115. package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
  116. package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
  117. package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
  118. package/plugins/core/lib/storage/storage-adapter.js +142 -62
  119. package/plugins/core/lib/storage/storage-mapping.js +78 -28
  120. package/plugins/core/lib/storage/storage-types.d.ts +502 -0
  121. package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
  122. package/plugins/core/lib/writing/relationship-processor.js +94 -158
  123. package/plugins/core/lib/writing/resource-version.js +172 -0
  124. package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
  125. package/plugins/core/rest-api-anyapi-knex-plugin.js +116 -2509
  126. package/plugins/core/rest-api-autofilter-plugin.js +17 -19
  127. package/plugins/core/rest-api-cors-plugin.js +52 -205
  128. package/plugins/core/rest-api-knex-plugin.js +45 -1069
  129. package/plugins/core/rest-api-label-plugin.js +25 -38
  130. package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
  131. package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
  132. package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
  133. package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
  134. package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
  135. package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
  136. package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
  137. package/plugins/core/rest-api-plugin-methods/common.js +291 -385
  138. package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
  139. package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
  140. package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -145
  141. package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
  142. package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
  143. package/plugins/core/rest-api-plugin-methods/get.js +38 -69
  144. package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
  145. package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
  146. package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
  147. package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
  148. package/plugins/core/rest-api-plugin-methods/post.js +51 -51
  149. package/plugins/core/rest-api-plugin-methods/put.js +69 -148
  150. package/plugins/core/rest-api-plugin-methods/query.js +39 -82
  151. package/plugins/core/rest-api-plugin-methods/release.js +2 -1
  152. package/plugins/core/rest-api-plugin.js +32 -46
  153. package/plugins/core/rest-api-positioning-plugin.js +183 -628
  154. package/plugins/core/rest-api-query-projections-plugin.js +19 -102
  155. package/plugins/core/rest-api-row-policy-plugin.js +3 -3
  156. package/plugins/core/socketio-plugin.js +364 -344
  157. package/plugins/storage/local-storage.js +40 -36
  158. package/types/autofilter.d.ts +57 -0
  159. package/types/bulk-methods.d.ts +63 -0
  160. package/types/errors.d.ts +118 -0
  161. package/types/fastify-options.d.ts +19 -0
  162. package/types/file-detectors.d.ts +33 -0
  163. package/types/file-storage.d.ts +48 -0
  164. package/types/hook-context.d.ts +180 -0
  165. package/types/plugin-options.d.ts +53 -0
  166. package/types/relationship-methods.d.ts +74 -0
  167. package/types/representations.d.ts +50 -0
  168. package/types/resource-methods.d.ts +95 -0
  169. package/types/resource-schema.d.ts +157 -0
  170. package/types/row-policy.d.ts +31 -0
  171. package/types/runtime.d.ts +132 -0
  172. package/types/transactions.d.ts +21 -0
  173. package/.claude/agents/library-guide-expert.md +0 -30
  174. package/.claude/agents/rest-api-internals-expert.md +0 -54
  175. package/.claude/settings.local.json +0 -70
  176. package/AGENTS.md +0 -34
  177. package/CLAUDE.md +0 -160
  178. package/CRUSH.md +0 -55
  179. package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
  180. package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
  181. package/TODO.todo +0 -13
  182. package/connectors_ideas/fastify.js +0 -217
  183. package/connectors_ideas/koa-plugin.js +0 -112
  184. package/docs/.bundle/config +0 -2
  185. package/docs/COMPARISON.md +0 -433
  186. package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
  187. package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
  188. package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
  189. package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
  190. package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
  191. package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
  192. package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
  193. package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1238
  194. package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
  195. package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
  196. package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
  197. package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
  198. package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
  199. package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
  200. package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
  201. package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
  202. package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
  203. package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
  204. package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
  205. package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
  206. package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
  207. package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
  208. package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
  209. package/docs/Gemfile +0 -6
  210. package/docs/Gemfile.lock +0 -308
  211. package/docs/ONBOARDING.md +0 -315
  212. package/docs/_config.yml +0 -36
  213. package/docs/_layouts/default.html +0 -123
  214. package/docs/apple-touch-icon.svg +0 -4
  215. package/docs/assets/css/style.scss +0 -392
  216. package/docs/favicon.svg +0 -4
  217. package/docs/index.md +0 -164
  218. package/docs/mini-guides/file-uploads-complete.md +0 -161
  219. package/eslint.config.js +0 -13
  220. package/examples/url-override-example.js +0 -137
  221. package/fixing_plan.md +0 -112
  222. package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
  223. package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
  224. package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
  225. package/plugins/storage/s3-storage.js +0 -93
  226. package/quickTest.js +0 -136
  227. package/scripts/debug-anyapi-review-filter.mjs +0 -63
  228. package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
  229. package/scripts/inspect-anyapi-descriptor.mjs +0 -19
  230. package/scripts/run-docs.js +0 -133
  231. package/test-debug.js +0 -59
  232. package/tests/README.md +0 -202
  233. package/tests/TEST_TEMPLATE.test.js +0 -178
  234. package/tests/anyapi-basic.test.js +0 -105
  235. package/tests/anyapi-cursor-pagination.test.js +0 -150
  236. package/tests/anyapi-custom-idproperty.test.js +0 -163
  237. package/tests/autofilter.test.js +0 -463
  238. package/tests/bulk-operations.test.js +0 -657
  239. package/tests/computed-fields.test.js +0 -586
  240. package/tests/cors.test.js +0 -834
  241. package/tests/curl.test.js +0 -467
  242. package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
  243. package/tests/custom-id-simplified-post.test.js +0 -75
  244. package/tests/custom-idproperty-relationships.test.js +0 -932
  245. package/tests/db-introspection.test.js +0 -521
  246. package/tests/db-table-operations.test.js +0 -798
  247. package/tests/fastify-plugin.test.js +0 -184
  248. package/tests/field-getters.test.js +0 -370
  249. package/tests/field-setters.test.js +0 -306
  250. package/tests/file-handling.test.js +0 -118
  251. package/tests/fixtures/api-configs.js +0 -2509
  252. package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
  253. package/tests/helpers/fake-fastify.js +0 -99
  254. package/tests/helpers/storage-mode.js +0 -65
  255. package/tests/helpers/test-utils.js +0 -203
  256. package/tests/http-connectors-parity.test.js +0 -395
  257. package/tests/id-normalization.test.js +0 -444
  258. package/tests/include-depth-validation.test.js +0 -331
  259. package/tests/includes.test.js +0 -427
  260. package/tests/local-storage.test.js +0 -96
  261. package/tests/logical-id-write-validation.test.js +0 -151
  262. package/tests/nested-includes.test.js +0 -617
  263. package/tests/pagination-cursor-multifield.test.js +0 -643
  264. package/tests/pagination-enhanced.test.js +0 -563
  265. package/tests/polymorphic-relationships.test.js +0 -347
  266. package/tests/polymorphic-simple.test.js +0 -148
  267. package/tests/positioning-advanced.test.js +0 -692
  268. package/tests/positioning-extended.test.js +0 -915
  269. package/tests/positioning-multifilter.test.js +0 -699
  270. package/tests/positioning.test.js +0 -560
  271. package/tests/post-commit-hooks.test.js +0 -120
  272. package/tests/projected-fields.test.js +0 -319
  273. package/tests/public-exports.test.js +0 -15
  274. package/tests/put-create-parity.test.js +0 -216
  275. package/tests/queries.test.js +0 -521
  276. package/tests/query-limits.test.js +0 -584
  277. package/tests/relationship-endpoints.test.js +0 -620
  278. package/tests/relationship-include-storage-adapter.test.js +0 -56
  279. package/tests/relationships.test.js +0 -681
  280. package/tests/rest-api.test.js +0 -337
  281. package/tests/return-record-settings.test.js +0 -184
  282. package/tests/row-policy.test.js +0 -489
  283. package/tests/s3-storage.test.js +0 -38
  284. package/tests/searchschema-knex-behavior.test.js +0 -187
  285. package/tests/searchschema-merge.test.js +0 -151
  286. package/tests/socketio.test.js +0 -860
  287. package/tests/storage-mapping.test.js +0 -362
  288. package/tests/test-development-guide.md +0 -370
  289. package/tests/virtual-fields.test.js +0 -404
@@ -1,4 +1,10 @@
1
+ import { createKnexTransaction } from '../../../../lib/knex-transaction.js'
2
+ import { applyInsertReturning } from '../querying-writing/database-capabilities.js'
1
3
  import { DEFAULT_CANONICAL_CONFIG, SLOT_LIMITS, SLOT_POOLS, TYPE_TO_POOL } from './schema-utils.js'
4
+ import { beginWriteTransaction, commitTransaction, endWriteOperation, getOperationDiagnosticContext, rollbackAfterError, wrapUnexpectedError, wrapWriteError } from '../../../../lib/error-context.js'
5
+ import { createEnhancedLogger } from '../../../../lib/enhanced-logger.js'
6
+ import { assertFieldName, assertFieldNameMap } from '../querying-writing/field-utils.js'
7
+ import { validateRelationshipFieldNames, validateSchemaFieldNames } from '../querying-writing/scope-validations.js'
2
8
 
3
9
  const clone = (value) => {
4
10
  if (typeof structuredClone === 'function') {
@@ -8,6 +14,7 @@ const clone = (value) => {
8
14
  }
9
15
 
10
16
  const SUPPORTED_TYPES = new Set([...TYPE_TO_POOL.keys(), 'id'])
17
+ const MAX_CACHED_DESCRIPTORS = 100
11
18
 
12
19
  const resolveIdProperty = (schema = {}, explicitIdProperty = null) => {
13
20
  if (explicitIdProperty) return explicitIdProperty
@@ -41,12 +48,14 @@ const SLOT_PATTERNS = {
41
48
  }
42
49
 
43
50
  const BELONGS_TO_ID_PATTERN = /^rel_(\d+)_id$/
44
- const BELONGS_TO_TYPE_PATTERN = /^rel_(\d+)_type$/
45
51
 
46
52
  const parseSimpleSlotColumn = (column) => {
47
53
  for (const [type, pattern] of Object.entries(SLOT_PATTERNS)) {
48
54
  const match = pattern.exec(column)
49
55
  if (match) {
56
+ if (!SLOT_POOLS[type].includes(column)) {
57
+ throw new Error(`Canonical slot '${column}' is out of range`)
58
+ }
50
59
  return { slotType: type, slotIndex: Number(match[1]) }
51
60
  }
52
61
  }
@@ -62,10 +71,12 @@ const parseBelongsToSlots = ({ idSlot, typeSlot }) => {
62
71
  throw new Error(`Invalid belongsTo id slot '${idSlot}'`)
63
72
  }
64
73
  const index = Number(idMatch[1])
74
+ if (index < 1 || index > SLOT_LIMITS.belongsTo || idSlot !== `rel_${index}_id`) {
75
+ throw new Error(`BelongsTo slot '${idSlot}' is out of range`)
76
+ }
65
77
  const expectedTypeSlot = `rel_${index}_type`
66
78
  const actualTypeSlot = typeSlot || expectedTypeSlot
67
- const typeMatch = BELONGS_TO_TYPE_PATTERN.exec(actualTypeSlot)
68
- if (!typeMatch || Number(typeMatch[1]) !== index) {
79
+ if (actualTypeSlot !== expectedTypeSlot) {
69
80
  throw new Error(`BelongsTo type slot '${actualTypeSlot}' must correspond to id slot '${idSlot}'`)
70
81
  }
71
82
  return { slotType: 'belongsTo', slotIndex: index, idColumn: idSlot, typeColumn: actualTypeSlot }
@@ -82,49 +93,56 @@ export class AnyapiRegistry {
82
93
  }
83
94
 
84
95
  #key (tenant, resource) {
85
- return `${tenant}::${resource}`
96
+ return JSON.stringify([tenant, resource])
86
97
  }
87
98
 
88
- async registerResource (definition, options = {}) {
89
- this.#validateDefinition(definition)
90
- const descriptor = await this.#register(definition, options.transaction)
99
+ #rememberDescriptor (descriptor) {
91
100
  const key = this.#key(descriptor.tenant, descriptor.resource)
101
+ this.cache.delete(key)
92
102
  this.cache.set(key, descriptor)
93
- return clone(descriptor)
103
+ if (this.cache.size > MAX_CACHED_DESCRIPTORS) this.cache.delete(this.cache.keys().next().value)
94
104
  }
95
105
 
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
- }
100
-
101
- const descriptor = await this.getDescriptor(tenant, resource, { bypassCache: true })
102
- if (!descriptor) {
103
- throw new Error(`Resource '${resource}' not registered for tenant '${tenant}'`)
106
+ async registerResource (definition, options = {}) {
107
+ const context = { method: 'registerResource', tenant: definition?.tenant, resource: definition?.resource }
108
+ try {
109
+ this.#validateDefinition(definition)
110
+ const descriptor = await this.#register(definition, options.transaction, context)
111
+ if (!options.transaction) this.#rememberDescriptor(descriptor)
112
+ return clone(descriptor)
113
+ } catch (error) {
114
+ throw wrapWriteError(error, context, context.transaction || options.transaction)
115
+ } finally {
116
+ endWriteOperation(context)
104
117
  }
118
+ }
105
119
 
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 || {}
111
-
112
- const usedSlots = new Set()
113
- Object.values(descriptor.fields || {}).forEach((info) => {
114
- if (info?.slot) {
115
- usedSlots.add(info.slot)
120
+ async allocateField (params, options = {}) {
121
+ const context = { method: 'allocateField' }
122
+ let trx; let managed = false
123
+ try {
124
+ const { tenant, resource, fieldName, definition, canonicalField } = params || {}
125
+ Object.assign(context, { tenant, resource })
126
+ if (!tenant || !resource || !fieldName || !definition) {
127
+ throw new Error('allocateField requires tenant, resource, fieldName, and definition')
116
128
  }
117
- })
118
- Object.values(descriptor.belongsTo || {}).forEach((info) => {
119
- if (info?.typeColumn) {
120
- usedSlots.add(info.typeColumn)
129
+ validateSchemaFieldNames({ [fieldName]: definition }, resource)
130
+
131
+ managed = !options.transaction
132
+ trx = await beginWriteTransaction(context, options.transaction, ownerContext => createKnexTransaction(this.knex, ownerContext))
133
+ const descriptor = await this.#loadDescriptor(tenant, resource, trx)
134
+ if (!descriptor) throw new Error(`Resource '${resource}' not registered for tenant '${tenant}'`)
135
+ if (fieldName === descriptor.idProperty || Object.hasOwn(descriptor.schema, fieldName)) {
136
+ throw new Error(`Field '${resource}.${fieldName}' already exists`)
137
+ }
138
+ if (canonicalField && (definition.belongsToPolymorphic || definition.computed || definition.virtual)) {
139
+ throw new Error(`canonicalFieldsMap should not include non-stored field '${fieldName}'`)
121
140
  }
122
- if (info?.idColumn) {
141
+ const usedSlots = new Set(Object.values(descriptor.fields).map(info => info.slot))
142
+ for (const info of Object.values(descriptor.belongsTo)) {
123
143
  usedSlots.add(info.idColumn)
144
+ usedSlots.add(info.typeColumn)
124
145
  }
125
- })
126
-
127
- try {
128
146
  const resourceRow = await trx('any_resource_configs')
129
147
  .where({ tenant_id: tenant, resource })
130
148
  .first()
@@ -152,144 +170,116 @@ export class AnyapiRegistry {
152
170
  alias,
153
171
  meta_json: JSON.stringify({ types }),
154
172
  })
155
-
156
- if (managed) {
157
- await trx.commit()
173
+ } else if (!definition.computed && !definition.virtual) {
174
+ let override = null
175
+ if (canonicalField) {
176
+ if (definition.belongsTo) {
177
+ const normalized = typeof canonicalField === 'string'
178
+ ? { idSlot: canonicalField }
179
+ : canonicalField
180
+ if (!normalized || !normalized.idSlot) {
181
+ throw new Error(`canonicalFieldsMap for belongsTo field '${fieldName}' requires idSlot`)
182
+ }
183
+ const parsed = parseBelongsToSlots(normalized)
184
+ if (usedSlots.has(parsed.idColumn) || usedSlots.has(parsed.typeColumn)) {
185
+ throw new Error(`Canonical slots '${parsed.idColumn}'/'${parsed.typeColumn}' already in use`)
186
+ }
187
+ usedSlots.add(parsed.idColumn)
188
+ usedSlots.add(parsed.typeColumn)
189
+ override = normalized
190
+ } else {
191
+ const slotColumn = typeof canonicalField === 'string'
192
+ ? canonicalField
193
+ : canonicalField?.slot || canonicalField?.slotColumn
194
+ if (!slotColumn) {
195
+ throw new Error(`canonicalFieldsMap for field '${fieldName}' must provide a slot column`)
196
+ }
197
+ if (usedSlots.has(slotColumn)) {
198
+ throw new Error(`Canonical slot '${slotColumn}' already in use`)
199
+ }
200
+ usedSlots.add(slotColumn)
201
+ override = { slotColumn }
202
+ }
158
203
  }
204
+ const fieldSlot = this.#assignFieldSlot({ ...definition, fieldName }, descriptor.slotState, override)
205
+ if (!fieldSlot) throw new Error(`Field '${resource}.${fieldName}' requires a supported type`)
206
+ const meta = definition.meta ? JSON.stringify(definition.meta) : null
159
207
 
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)
171
- }
208
+ await trx('any_field_configs').insert({
209
+ resource_config_id: resourceRow.id,
210
+ field_name: fieldName,
211
+ slot_type: fieldSlot.slotType,
212
+ slot_index: fieldSlot.slotIndex,
213
+ slot_column: fieldSlot.slotColumn,
214
+ nullable: definition.nullable === true,
215
+ required: definition.required === true,
216
+ target_resource: definition.belongsTo || null,
217
+ alias: definition.as || null,
218
+ meta_json: meta,
219
+ })
172
220
 
173
- let override = null
174
- if (canonicalField) {
175
- if (definition.belongsTo) {
176
- const normalized = typeof canonicalField === 'string'
177
- ? { idSlot: canonicalField }
178
- : canonicalField
179
- if (!normalized || !normalized.idSlot) {
180
- throw new Error(`canonicalFieldsMap for belongsTo field '${fieldName}' requires idSlot`)
181
- }
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)
188
- override = normalized
189
- } else {
190
- const slotColumn = typeof canonicalField === 'string'
191
- ? canonicalField
192
- : canonicalField?.slot || canonicalField?.slotColumn
193
- if (!slotColumn) {
194
- throw new Error(`canonicalFieldsMap for field '${fieldName}' must provide a slot column`)
195
- }
196
- if (usedSlots.has(slotColumn)) {
197
- throw new Error(`Canonical slot '${slotColumn}' already in use`)
221
+ if (fieldSlot.relationshipRow) {
222
+ const relationshipRow = {
223
+ resource_config_id: resourceRow.id,
224
+ relationship_name: fieldSlot.relationshipRow.name,
225
+ relationship_type: fieldSlot.relationshipRow.type,
226
+ target_resource: fieldSlot.relationshipRow.target,
227
+ slot_index: fieldSlot.relationshipRow.slotIndex,
228
+ id_column: fieldSlot.relationshipRow.idColumn,
229
+ type_column: fieldSlot.relationshipRow.typeColumn,
230
+ relationship_key: fieldSlot.relationshipRow.relationshipKey,
231
+ through: fieldSlot.relationshipRow.through,
232
+ foreign_key: fieldSlot.relationshipRow.foreignKey,
233
+ other_key: fieldSlot.relationshipRow.otherKey,
234
+ alias: fieldSlot.relationshipRow.alias,
235
+ meta_json: fieldSlot.relationshipRow.meta ? JSON.stringify(fieldSlot.relationshipRow.meta) : null,
198
236
  }
199
- usedSlots.add(slotColumn)
200
- override = { slotColumn }
237
+ await trx('any_relationship_configs').insert(relationshipRow)
201
238
  }
202
239
  }
203
-
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,
240
+ await trx('any_resource_configs').where({ id: resourceRow.id }).update({
241
+ schema_json: JSON.stringify({ ...descriptor.schema, [fieldName]: definition })
218
242
  })
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
- }
238
-
243
+ const updated = await this.#loadDescriptor(tenant, resource, trx)
239
244
  if (managed) {
240
- await trx.commit()
241
- }
242
-
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,
245
+ await commitTransaction(trx, context)
246
+ this.#rememberDescriptor(updated)
251
247
  }
252
-
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
- }
264
-
265
- usedSlots.add(fieldSlot.slotColumn)
266
- if (fieldSlot.relationshipRow?.typeColumn) {
267
- usedSlots.add(fieldSlot.relationshipRow.typeColumn)
268
- }
269
-
270
- descriptor.slotState = fieldSlot.updatedState
271
- this.cache.set(this.#key(tenant, resource), descriptor)
272
- return clone(descriptor)
248
+ return clone(updated)
273
249
  } catch (error) {
274
250
  if (managed) {
275
- await trx.rollback()
251
+ await this.#handleWriteFailure(error, trx || context.transaction, context)
276
252
  }
277
- throw error
253
+ throw wrapWriteError(error, context, context.transaction || options.transaction)
254
+ } finally {
255
+ endWriteOperation(context)
278
256
  }
279
257
  }
280
258
 
281
259
  async getDescriptor (tenant, resource, options = {}) {
282
260
  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
- }
261
+ try {
262
+ const key = this.#key(tenant, resource)
263
+ if (!options.transaction && !options.bypassCache && this.cache.has(key)) {
264
+ const descriptor = this.cache.get(key)
265
+ this.#rememberDescriptor(descriptor)
266
+ return clone(descriptor)
267
+ }
287
268
 
288
- const descriptor = await this.#loadDescriptor(tenant, resource, options.transaction)
289
- if (!descriptor) return null
269
+ const descriptor = await this.#loadDescriptor(tenant, resource, options.transaction)
270
+ if (!descriptor) {
271
+ if (!options.transaction) this.cache.delete(key)
272
+ return null
273
+ }
290
274
 
291
- this.cache.set(key, descriptor)
292
- return clone(descriptor)
275
+ if (!options.transaction) this.#rememberDescriptor(descriptor)
276
+ return clone(descriptor)
277
+ } catch (error) {
278
+ throw wrapUnexpectedError(error, {
279
+ message: `Failed to load descriptor for resource '${resource}'`,
280
+ context: { tenant, scopeName: resource, phase: 'descriptor' }
281
+ })
282
+ }
293
283
  }
294
284
 
295
285
  async listResources (tenant) {
@@ -305,6 +295,28 @@ export class AnyapiRegistry {
305
295
  this.cache.delete(this.#key(tenant, resource))
306
296
  }
307
297
 
298
+ async #handleWriteFailure (error, transaction, context) {
299
+ // A rejected commit can still have changed persisted metadata.
300
+ this.invalidateDescriptor(context.tenant, context.resource)
301
+ await rollbackAfterError(error, context, transaction)
302
+ if (context.cleanupErrors.length) {
303
+ try {
304
+ await createEnhancedLogger(this.log).error('AnyAPI registry rollback failed', {
305
+ ...getOperationDiagnosticContext(context, {
306
+ phase: 'registryRollback',
307
+ scopeName: context.resource,
308
+ backend: this.knex.client.config.client
309
+ }),
310
+ tenant: context.tenant,
311
+ error: context.error,
312
+ cleanupErrors: context.cleanupErrors
313
+ })
314
+ } catch (error) {
315
+ context.cleanupErrors.push({ phase: 'logging', error })
316
+ }
317
+ }
318
+ }
319
+
308
320
  #validateDefinition (definition) {
309
321
  const { tenant, resource, schema } = definition || {}
310
322
  if (!tenant || typeof tenant !== 'string') {
@@ -316,15 +328,19 @@ export class AnyapiRegistry {
316
328
  if (!schema || typeof schema !== 'object') {
317
329
  throw new Error('AnyapiRegistry.registerResource requires a schema object')
318
330
  }
331
+ validateSchemaFieldNames(schema, resource)
332
+ validateRelationshipFieldNames(definition.relationships, resource)
333
+ assertFieldNameMap(definition.canonicalFieldMap, `canonical fields in '${resource}'`)
319
334
  }
320
335
 
321
- async #register (definition, externalTrx) {
336
+ async #register (definition, externalTrx, context) {
322
337
  const { tenant, resource, schema, relationships = {}, canonicalFieldMap = null } = definition
323
338
  const idProperty = resolveIdProperty(schema, definition.idProperty)
324
- const trx = externalTrx || await this.knex.transaction()
339
+ let trx
325
340
  const managed = !externalTrx
326
341
 
327
342
  try {
343
+ trx = await beginWriteTransaction(context, externalTrx, ownerContext => createKnexTransaction(this.knex, ownerContext))
328
344
  const now = this.knex.fn.now()
329
345
  let resourceRow = await trx('any_resource_configs')
330
346
  .where({ tenant_id: tenant, resource })
@@ -332,8 +348,11 @@ export class AnyapiRegistry {
332
348
 
333
349
  const schemaJson = JSON.stringify(schema)
334
350
  const relationshipsJson = JSON.stringify(relationships || {})
351
+ let previousDescriptor
335
352
 
336
353
  if (resourceRow) {
354
+ // Validate persisted mappings before registration can replace migration evidence.
355
+ previousDescriptor = await this.#loadDescriptor(tenant, resource, trx, { idProperty })
337
356
  await trx('any_resource_configs')
338
357
  .where({ id: resourceRow.id })
339
358
  .update({
@@ -342,17 +361,17 @@ export class AnyapiRegistry {
342
361
  updated_at: now,
343
362
  })
344
363
  } else {
345
- const [insertedId] = await trx('any_resource_configs').insert({
364
+ const [inserted] = await applyInsertReturning(trx('any_resource_configs').insert({
346
365
  tenant_id: tenant,
347
366
  resource,
348
367
  schema_json: schemaJson,
349
368
  relationships_json: relationshipsJson,
350
369
  created_at: now,
351
370
  updated_at: now,
352
- })
371
+ }), 'id')
353
372
 
354
373
  resourceRow = {
355
- id: insertedId,
374
+ id: inserted && typeof inserted === 'object' ? inserted.id : inserted,
356
375
  tenant_id: tenant,
357
376
  resource,
358
377
  schema_json: schemaJson,
@@ -372,8 +391,12 @@ export class AnyapiRegistry {
372
391
  const fieldInserts = []
373
392
  const relationshipInserts = []
374
393
 
375
- for (const [fieldName, fieldDef] of Object.entries(schema)) {
376
- if (fieldName === 'id') {
394
+ // ID attributes previously had no slot; append them without moving existing slots.
395
+ const fields = Object.entries(schema).sort(([, left], [, right]) =>
396
+ Number(left?.type === 'id' && !left.belongsTo) - Number(right?.type === 'id' && !right.belongsTo)
397
+ )
398
+ for (const [fieldName, fieldDef] of fields) {
399
+ if (fieldName === 'id' || (fieldName === idProperty && fieldDef?.type === 'id')) {
377
400
  if (canonicalOverrides) {
378
401
  canonicalOverrides.delete(fieldName)
379
402
  }
@@ -537,15 +560,32 @@ export class AnyapiRegistry {
537
560
  }
538
561
 
539
562
  const descriptor = await this.#loadDescriptor(tenant, resource, trx, { idProperty })
563
+ const changedField = Object.keys(previousDescriptor?.fields || {}).find(fieldName => {
564
+ const previous = previousDescriptor.fields[fieldName]
565
+ const current = descriptor.fields[fieldName]
566
+ if (!current || previous.slot !== current.slot || previous.slotType !== current.slotType) return true
567
+ return previous.slotType === 'belongsTo' &&
568
+ previousDescriptor.belongsTo[previous.alias]?.typeColumn !== descriptor.belongsTo[current.alias]?.typeColumn
569
+ })
570
+ if (changedField) {
571
+ const { canonical } = previousDescriptor
572
+ const storedRecord = await trx(canonical.tableName)
573
+ .where(canonical.tenantColumn, tenant)
574
+ .where(canonical.resourceColumn, resource)
575
+ .first()
576
+ if (storedRecord) {
577
+ 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.`)
578
+ }
579
+ }
540
580
 
541
581
  if (managed) {
542
- await trx.commit()
582
+ await commitTransaction(trx, context)
543
583
  }
544
584
 
545
585
  return descriptor
546
586
  } catch (error) {
547
587
  if (managed) {
548
- await trx.rollback()
588
+ await this.#handleWriteFailure(error, trx || context.transaction, context)
549
589
  }
550
590
  throw error
551
591
  }
@@ -607,6 +647,8 @@ export class AnyapiRegistry {
607
647
  }
608
648
 
609
649
  #buildDescriptor ({ tenant, resource, schema, relationships, slotState, fieldRows, relationshipRows, idProperty }) {
650
+ validateSchemaFieldNames(schema, resource)
651
+ validateRelationshipFieldNames(relationships, resource)
610
652
  const fields = {}
611
653
  const reverseAttributes = {}
612
654
  const belongsTo = {}
@@ -614,6 +656,11 @@ export class AnyapiRegistry {
614
656
  const polymorphicBelongsTo = {}
615
657
 
616
658
  for (const row of fieldRows) {
659
+ assertFieldName(row.field_name, `stored field in '${resource}'`)
660
+ assertFieldName(row.alias, `stored relationship alias in '${resource}'`)
661
+ if (['date', 'time'].includes(schema?.[row.field_name]?.type) && row.slot_type !== 'string') {
662
+ 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.`)
663
+ }
617
664
  fields[row.field_name] = {
618
665
  slot: row.slot_column,
619
666
  slotType: row.slot_type,
@@ -629,6 +676,8 @@ export class AnyapiRegistry {
629
676
  }
630
677
 
631
678
  for (const row of relationshipRows) {
679
+ assertFieldName(row.relationship_name, `stored relationship in '${resource}'`)
680
+ assertFieldName(row.alias, `stored relationship alias in '${resource}'`)
632
681
  if (row.relationship_type === 'belongsTo') {
633
682
  const alias = row.alias || row.relationship_name
634
683
  belongsTo[alias] = {
@@ -718,14 +767,14 @@ export class AnyapiRegistry {
718
767
  }
719
768
 
720
769
  const { type } = fieldDef
721
- if (!type || type === 'id') {
770
+ if (!type) {
722
771
  return null
723
772
  }
724
773
  if (!SUPPORTED_TYPES.has(type)) {
725
774
  throw new Error(`Unsupported field type '${type}'`)
726
775
  }
727
776
 
728
- const pool = TYPE_TO_POOL.get(type)
777
+ const pool = type === 'id' ? 'string' : TYPE_TO_POOL.get(type)
729
778
  if (!pool) {
730
779
  throw new Error(`No slot pool defined for type '${type}'`)
731
780
  }