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