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