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.
- package/LICENSE +10 -670
- package/LICENSE-GPL-3.0 +674 -0
- package/LICENSE-MIT +21 -0
- package/README.md +39 -30
- package/docs/API.md +744 -1273
- package/docs/GUIDE/01-getting-started.md +338 -0
- package/docs/GUIDE/02-resources-and-relationships.md +33 -0
- package/docs/GUIDE/03-running-example.md +87 -0
- package/docs/GUIDE/04-creating-and-querying.md +181 -0
- package/docs/GUIDE/05-belongs-to.md +177 -0
- package/docs/GUIDE/06-has-many.md +184 -0
- package/docs/GUIDE/07-polymorphic-relationships.md +191 -0
- package/docs/GUIDE/08-many-to-many.md +184 -0
- package/docs/GUIDE/09-pagination-and-sorting.md +149 -0
- package/docs/GUIDE/10-put-and-patch.md +174 -0
- package/docs/GUIDE/11-relationship-endpoints.md +174 -0
- package/docs/GUIDE/12-field-transformations.md +287 -0
- package/docs/GUIDE/13-hooks-and-lifecycle.md +479 -0
- package/docs/GUIDE/14-custom-resource-methods.md +125 -0
- package/docs/GUIDE/15-query-projections.md +266 -0
- package/docs/GUIDE/{GUIDE_X_Autofiltering.md → 16-autofiltering.md} +77 -15
- package/docs/GUIDE/{GUIDE_X_Row_Policies.md → 17-row-policies.md} +137 -44
- package/docs/GUIDE/18-labels.md +86 -0
- package/docs/GUIDE/19-managed-transactions.md +179 -0
- package/docs/GUIDE/20-transaction-outcomes.md +207 -0
- package/docs/GUIDE/21-schema-and-migrations.md +733 -0
- package/docs/GUIDE/22-version-field-migrations.md +208 -0
- package/docs/GUIDE/23-fastify.md +179 -0
- package/docs/GUIDE/24-cors.md +172 -0
- package/docs/GUIDE/{GUIDE_X_URL_Management.md → 25-url-management.md} +7 -1
- package/docs/GUIDE/26-file-uploads.md +278 -0
- package/docs/GUIDE/27-bulk-operations.md +277 -0
- package/docs/GUIDE/28-socketio.md +325 -0
- package/docs/GUIDE/29-writing-plugins.md +278 -0
- package/docs/GUIDE/30-backend-capabilities.md +103 -0
- package/docs/GUIDE/31-positioning.md +181 -0
- package/docs/GUIDE/{GUIDE_Y_Appendices.md → 32-date-and-time.md} +74 -30
- package/docs/GUIDE/33-migrating-to-v2.md +3838 -0
- package/docs/GUIDE/index.md +51 -59
- package/docs/QUICKSTART.md +120 -287
- package/docs/architecture.md +75 -0
- package/docs/contributing.md +178 -0
- package/index.d.ts +37 -0
- package/index.js +3 -22
- package/lib/enhanced-logger.js +91 -85
- package/lib/error-context.js +445 -5
- package/lib/error-formatter.js +219 -59
- package/lib/knex-transaction.js +70 -0
- package/lib/logger-types.d.ts +22 -0
- package/lib/missing-package.js +4 -0
- package/lib/rest-api-errors.js +103 -8
- package/lib/runtime/json-rest-api.js +172 -0
- package/lib/transaction-types.d.ts +59 -0
- package/package.json +97 -27
- package/plugins/core/bulk-operations-plugin.js +191 -184
- package/plugins/core/connectors/express-plugin.js +148 -219
- package/plugins/core/connectors/fastify-plugin.js +125 -82
- package/plugins/core/connectors/lib/busboy-detector.js +74 -156
- package/plugins/core/connectors/lib/connector-core.js +144 -20
- package/plugins/core/connectors/lib/formidable-detector.js +63 -120
- package/plugins/core/connectors/lib/http-validators.js +77 -0
- package/plugins/core/connectors/lib/multipart-helpers.js +18 -0
- package/plugins/core/connectors/lib/request-helpers.js +10 -1
- package/plugins/core/connectors/lib/transport-http-helpers.js +89 -8
- package/plugins/core/connectors/lib/transport-route-schemas.js +7 -4
- package/plugins/core/file-handling-plugin.js +87 -177
- package/plugins/core/lib/anyapi/anyapi-registry.js +586 -418
- package/plugins/core/lib/anyapi/canonical-link-store.js +507 -0
- package/plugins/core/lib/anyapi/canonical-relationship-reader.js +598 -0
- package/plugins/core/lib/anyapi/schema-utils.js +120 -56
- package/plugins/core/lib/anyapi/stored-definition.js +109 -0
- package/plugins/core/lib/anyapi/utils/descriptor-helpers.js +0 -64
- package/plugins/core/lib/dbIntrospection.js +228 -21
- package/plugins/core/lib/dbTablesOperations.js +409 -92
- package/plugins/core/lib/fractional-positioning.js +12 -48
- package/plugins/core/lib/querying/identifier-query.js +19 -0
- package/plugins/core/lib/querying/include-query-helpers.js +364 -0
- package/plugins/core/lib/querying/include-to-many.js +428 -0
- package/plugins/core/lib/querying/include-to-one.js +579 -0
- package/plugins/core/lib/querying/knex-cross-table-search.js +31 -307
- package/plugins/core/lib/querying/knex-json-api-transformers-querying.js +99 -401
- package/plugins/core/lib/querying/knex-pagination-helpers.js +48 -317
- package/plugins/core/lib/querying/knex-process-includes.js +5 -110
- package/plugins/core/lib/querying/knex-query-helpers-base.js +21 -56
- package/plugins/core/lib/querying/knex-query-helpers.js +298 -448
- package/plugins/core/lib/querying/knex-relationship-includes.js +120 -2138
- package/plugins/core/lib/querying/knex-window-queries.js +62 -253
- package/plugins/core/lib/querying/query-builder-utils.js +44 -4
- package/plugins/core/lib/querying/query-constraint.d.ts +12 -0
- package/plugins/core/lib/querying/query-constraint.js +27 -0
- package/plugins/core/lib/querying/query-field-sort-helpers.js +134 -37
- package/plugins/core/lib/querying/relationship-identifiers.js +208 -0
- package/plugins/core/lib/querying/sort-helpers.js +13 -1
- package/plugins/core/lib/querying/storage-adapter-utils.js +13 -5
- package/plugins/core/lib/querying/url-helpers.js +11 -4
- package/plugins/core/lib/querying-writing/compile-schemas.js +148 -249
- package/plugins/core/lib/querying-writing/connectors-query-parser.js +33 -118
- package/plugins/core/lib/querying-writing/database-capabilities.js +149 -223
- package/plugins/core/lib/querying-writing/database-value-normalizers.js +179 -194
- package/plugins/core/lib/querying-writing/default-data-helpers.js +15 -45
- package/plugins/core/lib/querying-writing/field-selection-types.d.ts +43 -0
- package/plugins/core/lib/querying-writing/field-utils.js +113 -92
- package/plugins/core/lib/querying-writing/knex-constants.d.ts +18 -0
- package/plugins/core/lib/querying-writing/knex-constants.js +2 -26
- package/plugins/core/lib/querying-writing/knex-field-helpers.js +100 -290
- package/plugins/core/lib/querying-writing/knex-json-api-transformers.js +31 -120
- package/plugins/core/lib/querying-writing/query-field-helpers.js +108 -32
- package/plugins/core/lib/querying-writing/query-field-types.d.ts +60 -0
- package/plugins/core/lib/querying-writing/relationship-contracts.js +33 -13
- package/plugins/core/lib/querying-writing/request-contracts.js +93 -38
- package/plugins/core/lib/querying-writing/resource-id-normalization.js +23 -5
- package/plugins/core/lib/querying-writing/response-options.js +35 -0
- package/plugins/core/lib/querying-writing/schema-helpers.js +203 -324
- package/plugins/core/lib/querying-writing/scope-validations.js +92 -290
- package/plugins/core/lib/querying-writing/simplified-helpers.js +113 -712
- package/plugins/core/lib/storage/canonical-data-helpers.js +683 -0
- package/plugins/core/lib/storage/canonical-storage-mapping.js +51 -20
- package/plugins/core/lib/storage/ordinary-data-helpers.js +710 -0
- package/plugins/core/lib/storage/storage-adapter.js +128 -62
- package/plugins/core/lib/storage/storage-mapping.js +78 -28
- package/plugins/core/lib/storage/storage-types.d.ts +502 -0
- package/plugins/core/lib/writing/many-to-many-manipulations.js +114 -284
- package/plugins/core/lib/writing/relationship-processor.js +94 -158
- package/plugins/core/lib/writing/resource-version.js +172 -0
- package/plugins/core/lib/writing/reverse-relationship-manipulations.js +110 -0
- package/plugins/core/rest-api-anyapi-knex-plugin.js +290 -2475
- package/plugins/core/rest-api-autofilter-plugin.js +17 -19
- package/plugins/core/rest-api-cors-plugin.js +52 -205
- package/plugins/core/rest-api-knex-plugin.js +45 -1077
- package/plugins/core/rest-api-label-plugin.js +25 -38
- package/plugins/core/rest-api-plugin-hooks/register-relationship-routes.js +5 -5
- package/plugins/core/rest-api-plugin-hooks/register-scope-routes.js +9 -6
- package/plugins/core/rest-api-plugin-hooks/turn-scope-init-into-vars.js +7 -26
- package/plugins/core/rest-api-plugin-hooks/validate-include-configurations.js +20 -26
- package/plugins/core/rest-api-plugin-methods/add-route.js +4 -6
- package/plugins/core/rest-api-plugin-methods/apply-query-filters.js +42 -13
- package/plugins/core/rest-api-plugin-methods/check-permissions.js +11 -5
- package/plugins/core/rest-api-plugin-methods/common.js +291 -395
- package/plugins/core/rest-api-plugin-methods/delete-relationship.js +49 -47
- package/plugins/core/rest-api-plugin-methods/delete.js +31 -25
- package/plugins/core/rest-api-plugin-methods/enrich-attributes.js +108 -148
- package/plugins/core/rest-api-plugin-methods/get-related.js +113 -305
- package/plugins/core/rest-api-plugin-methods/get-relationship.js +25 -18
- package/plugins/core/rest-api-plugin-methods/get.js +36 -68
- package/plugins/core/rest-api-plugin-methods/lifecycle-types.d.ts +225 -0
- package/plugins/core/rest-api-plugin-methods/patch-relationship.js +28 -25
- package/plugins/core/rest-api-plugin-methods/patch.js +46 -99
- package/plugins/core/rest-api-plugin-methods/post-relationship.js +49 -53
- package/plugins/core/rest-api-plugin-methods/post.js +51 -51
- package/plugins/core/rest-api-plugin-methods/put.js +69 -148
- package/plugins/core/rest-api-plugin-methods/query.js +37 -81
- package/plugins/core/rest-api-plugin-methods/release.js +2 -1
- package/plugins/core/rest-api-plugin.js +32 -46
- package/plugins/core/rest-api-positioning-plugin.js +183 -628
- package/plugins/core/rest-api-query-projections-plugin.js +19 -102
- package/plugins/core/rest-api-row-policy-plugin.js +3 -3
- package/plugins/core/socketio-plugin.js +364 -344
- package/plugins/storage/local-storage.js +40 -36
- package/types/anyapi.d.ts +30 -0
- package/types/autofilter.d.ts +57 -0
- package/types/bulk-methods.d.ts +63 -0
- package/types/errors.d.ts +118 -0
- package/types/fastify-options.d.ts +19 -0
- package/types/file-detectors.d.ts +33 -0
- package/types/file-storage.d.ts +48 -0
- package/types/hook-context.d.ts +180 -0
- package/types/plugin-options.d.ts +65 -0
- package/types/relationship-methods.d.ts +74 -0
- package/types/representations.d.ts +50 -0
- package/types/resource-methods.d.ts +95 -0
- package/types/resource-schema.d.ts +157 -0
- package/types/row-policy.d.ts +31 -0
- package/types/runtime.d.ts +132 -0
- package/types/transactions.d.ts +21 -0
- package/.claude/agents/library-guide-expert.md +0 -30
- package/.claude/agents/rest-api-internals-expert.md +0 -54
- package/.claude/settings.local.json +0 -70
- package/AGENTS.md +0 -34
- package/CLAUDE.md +0 -160
- package/CRUSH.md +0 -55
- package/JSKIT_BACKEND_MIGRATION_TRACKER.txt +0 -180
- package/SINGLE_VALIDATION_CONTRACT_TODO.txt +0 -172
- package/TODO.todo +0 -13
- package/connectors_ideas/fastify.js +0 -217
- package/connectors_ideas/koa-plugin.js +0 -112
- package/docs/.bundle/config +0 -2
- package/docs/COMPARISON.md +0 -433
- package/docs/GUIDE/GUIDE_1_Initial_Setup.md +0 -1057
- package/docs/GUIDE/GUIDE_2_1_The_Starting_Point.md +0 -88
- package/docs/GUIDE/GUIDE_2_2_Manipulating_And_Searching_Tables.md +0 -808
- package/docs/GUIDE/GUIDE_2_3_BelongsTo_Relationships.md +0 -491
- package/docs/GUIDE/GUIDE_2_4_HasMany_Records.md +0 -398
- package/docs/GUIDE/GUIDE_2_5_HasMany_Polymorphic.md +0 -898
- package/docs/GUIDE/GUIDE_2_6_Many_To_Many.md +0 -712
- package/docs/GUIDE/GUIDE_2_7_Pagination_And_Ordering.md +0 -1247
- package/docs/GUIDE/GUIDE_2_8_Effects_of_PUT_and_PATCH.md +0 -748
- package/docs/GUIDE/GUIDE_2_9_Relationships_Urls.md +0 -517
- package/docs/GUIDE/GUIDE_2_Data_And_Relations.md +0 -66
- package/docs/GUIDE/GUIDE_3_Field_Transformations.md +0 -1252
- package/docs/GUIDE/GUIDE_5_Non-Db_Resources.md +0 -1107
- package/docs/GUIDE/GUIDE_6_Positioning.md +0 -692
- package/docs/GUIDE/GUIDE_7_Hooks_Data_Management_And_Plugins.md +0 -1491
- package/docs/GUIDE/GUIDE_X_Bulk_Operations.md +0 -732
- package/docs/GUIDE/GUIDE_X_Cors.md +0 -1957
- package/docs/GUIDE/GUIDE_X_Fastify.md +0 -78
- package/docs/GUIDE/GUIDE_X_File_Uploads.md +0 -729
- package/docs/GUIDE/GUIDE_X_Knex_Schema_And_Migrations.md +0 -370
- package/docs/GUIDE/GUIDE_X_Query_Projections.md +0 -159
- package/docs/GUIDE/GUIDE_X_SocketIO.md +0 -1040
- package/docs/GUIDE/GUIDE_X_Writing_Plugins.md +0 -201
- package/docs/Gemfile +0 -6
- package/docs/Gemfile.lock +0 -308
- package/docs/ONBOARDING.md +0 -315
- package/docs/_config.yml +0 -36
- package/docs/_layouts/default.html +0 -123
- package/docs/apple-touch-icon.svg +0 -4
- package/docs/assets/css/style.scss +0 -392
- package/docs/favicon.svg +0 -4
- package/docs/index.md +0 -164
- package/docs/mini-guides/file-uploads-complete.md +0 -161
- package/eslint.config.js +0 -13
- package/examples/url-override-example.js +0 -137
- package/fixing_plan.md +0 -112
- package/plugins/core/lib/anyapi/query/anyapi-query-adapter.js +0 -657
- package/plugins/core/lib/querying-writing/return-record-settings.js +0 -47
- package/plugins/core/lib/writing/knex-json-api-transformers-writing.js +0 -110
- package/plugins/storage/s3-storage.js +0 -93
- package/quickTest.js +0 -136
- package/scripts/debug-anyapi-review-filter.mjs +0 -63
- package/scripts/inspect-anyapi-descriptor-country.mjs +0 -19
- package/scripts/inspect-anyapi-descriptor.mjs +0 -19
- package/scripts/run-docs.js +0 -133
- package/test-debug.js +0 -59
- package/tests/README.md +0 -202
- package/tests/TEST_TEMPLATE.test.js +0 -178
- package/tests/anyapi-basic.test.js +0 -105
- package/tests/anyapi-cursor-pagination.test.js +0 -148
- package/tests/anyapi-custom-idproperty.test.js +0 -163
- package/tests/autofilter.test.js +0 -463
- package/tests/bulk-operations.test.js +0 -657
- package/tests/computed-fields.test.js +0 -586
- package/tests/cors.test.js +0 -834
- package/tests/curl.test.js +0 -467
- package/tests/custom-id-mysql-returning-fallback.test.js +0 -128
- package/tests/custom-id-simplified-post.test.js +0 -75
- package/tests/custom-idproperty-relationships.test.js +0 -932
- package/tests/db-introspection.test.js +0 -521
- package/tests/db-table-operations.test.js +0 -798
- package/tests/fastify-plugin.test.js +0 -184
- package/tests/field-getters.test.js +0 -370
- package/tests/field-setters.test.js +0 -306
- package/tests/file-handling.test.js +0 -155
- package/tests/fixtures/api-configs.js +0 -2566
- package/tests/full-jsonapi-belongsto-linkage.test.js +0 -150
- package/tests/helpers/fake-fastify.js +0 -99
- package/tests/helpers/storage-mode.js +0 -65
- package/tests/helpers/test-utils.js +0 -203
- package/tests/http-connectors-parity.test.js +0 -395
- package/tests/id-normalization.test.js +0 -444
- package/tests/include-depth-validation.test.js +0 -331
- package/tests/includes.test.js +0 -427
- package/tests/local-storage.test.js +0 -96
- package/tests/logical-id-write-validation.test.js +0 -151
- package/tests/nested-includes.test.js +0 -617
- package/tests/pagination-boundaries.test.js +0 -154
- package/tests/pagination-contract.test.js +0 -122
- package/tests/pagination-cursor-multifield.test.js +0 -641
- package/tests/pagination-enhanced.test.js +0 -563
- package/tests/polymorphic-relationships.test.js +0 -347
- package/tests/polymorphic-simple.test.js +0 -148
- package/tests/positioning-advanced.test.js +0 -692
- package/tests/positioning-extended.test.js +0 -915
- package/tests/positioning-multifilter.test.js +0 -699
- package/tests/positioning.test.js +0 -560
- package/tests/post-commit-hooks.test.js +0 -120
- package/tests/projected-fields.test.js +0 -319
- package/tests/public-exports.test.js +0 -15
- package/tests/put-create-parity.test.js +0 -216
- package/tests/queries.test.js +0 -521
- package/tests/query-limits.test.js +0 -584
- package/tests/relationship-endpoints.test.js +0 -620
- package/tests/relationship-include-storage-adapter.test.js +0 -56
- package/tests/relationships.test.js +0 -681
- package/tests/rest-api.test.js +0 -337
- package/tests/return-record-settings.test.js +0 -184
- package/tests/row-policy.test.js +0 -489
- package/tests/s3-storage.test.js +0 -38
- package/tests/searchschema-knex-behavior.test.js +0 -187
- package/tests/searchschema-merge.test.js +0 -151
- package/tests/socketio.test.js +0 -860
- package/tests/storage-mapping.test.js +0 -362
- package/tests/temporal-boundaries.test.js +0 -208
- package/tests/test-development-guide.md +0 -370
- package/tests/virtual-fields.test.js +0 -404
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
178
|
+
return JSON.stringify([tenant, resource])
|
|
86
179
|
}
|
|
87
180
|
|
|
88
|
-
|
|
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
|
-
|
|
185
|
+
if (this.cache.size > this.cacheSize) this.cache.delete(this.cache.keys().next().value)
|
|
94
186
|
}
|
|
95
187
|
|
|
96
|
-
async
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
188
|
+
async registerResource (definition, options = {}) {
|
|
189
|
+
return (await this.register(definition, options)).descriptor
|
|
190
|
+
}
|
|
100
191
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
240
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
const
|
|
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('
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
425
|
-
if (canonicalOverrides) {
|
|
426
|
-
|
|
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
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
503
|
-
if (
|
|
504
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
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
|
-
|
|
542
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
if (
|
|
548
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
930
|
+
return this.#assignBelongsTo(fieldDef, used, canonicalOverride, pools)
|
|
718
931
|
}
|
|
719
932
|
|
|
720
933
|
const { type } = fieldDef
|
|
721
|
-
if (!type
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
761
|
-
|
|
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,
|
|
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
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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
|
-
|
|
808
|
-
|
|
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
|
|
817
|
-
slotColumn:
|
|
993
|
+
slotIndex,
|
|
994
|
+
slotColumn: idColumn,
|
|
818
995
|
relationshipRow: {
|
|
819
996
|
name: alias,
|
|
820
997
|
type: 'belongsTo',
|
|
821
998
|
target: fieldDef.belongsTo,
|
|
822
|
-
slotIndex
|
|
823
|
-
idColumn
|
|
824
|
-
typeColumn
|
|
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
|
}
|