@cosmicdrift/kumiko-framework 0.385.0 → 0.387.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 +21 -57
- package/README.md +2 -2
- package/agents/widgets.md +39 -39
- package/dist/api/api-constants.js +2 -2
- package/dist/api/auth-middleware.js +7 -7
- package/dist/api/auth-rate-limiters.js +7 -3
- package/dist/api/auth-routes-mfa.js +1 -1
- package/dist/api/auth-routes-session.js +1 -1
- package/dist/api/auth-routes-shared.js +7 -3
- package/dist/api/auth-routes-signup.js +1 -1
- package/dist/api/client-ip.js +3 -1
- package/dist/api/extra-route.js +1 -1
- package/dist/api/pii-leak-guard.js +4 -2
- package/dist/api/readiness.js +5 -5
- package/dist/api/redis-sse-broker.js +8 -6
- package/dist/api/request-context.d.ts +2 -0
- package/dist/api/request-context.js +16 -7
- package/dist/api/routes.js +2 -2
- package/dist/api/server.js +34 -33
- package/dist/api/sse-broker.js +1 -1
- package/dist/api/sse-route.d.ts +13 -13
- package/dist/api/sse-route.js +15 -15
- package/dist/auth/index.js +4 -4
- package/dist/auth/roles.d.ts +4 -4
- package/dist/auth/roles.js +24 -24
- package/dist/compliance/duration-spec.js +18 -20
- package/dist/compliance/index.js +2 -3
- package/dist/compliance/override-schema.d.ts +4 -4
- package/dist/compliance/override-schema.js +19 -22
- package/dist/compliance/profiles.d.ts +26 -29
- package/dist/compliance/profiles.js +62 -69
- package/dist/compliance/sub-processors.d.ts +28 -30
- package/dist/compliance/sub-processors.js +17 -21
- package/dist/consumer-cli.js +4 -4
- package/dist/crypto/blind-index.js +18 -17
- package/dist/crypto/ciphertext-pattern.js +1 -1
- package/dist/crypto/event-pii.js +3 -3
- package/dist/crypto/kek-source.d.ts +1 -1
- package/dist/crypto/kek-source.js +3 -2
- package/dist/crypto/kms-adapter.d.ts +1 -1
- package/dist/crypto/kms-adapter.js +4 -6
- package/dist/crypto/kms-wiring.d.ts +1 -2
- package/dist/crypto/kms-wiring.js +2 -2
- package/dist/crypto/pg-kms-adapter.js +2 -6
- package/dist/crypto/pii-field-encryption.js +4 -4
- package/dist/crypto/subject-resolver.d.ts +1 -1
- package/dist/crypto/subject-resolver.js +2 -2
- package/dist/db/apply-entity-event.d.ts +6 -6
- package/dist/db/apply-entity-event.js +55 -55
- package/dist/db/blind-index-cleanup.js +7 -7
- package/dist/db/collect-table-metas.js +10 -11
- package/dist/db/compound-types.d.ts +1 -1
- package/dist/db/compound-types.js +12 -12
- package/dist/db/cross-tenant-reads.d.ts +8 -0
- package/dist/db/cross-tenant-reads.js +23 -0
- package/dist/db/cursor.js +3 -4
- package/dist/db/dialect.d.ts +2 -2
- package/dist/db/dialect.js +2 -2
- package/dist/db/eagerload.d.ts +3 -3
- package/dist/db/eagerload.js +21 -12
- package/dist/db/entity-table-meta.d.ts +2 -2
- package/dist/db/entity-table-meta.js +4 -4
- package/dist/db/event-store-executor-context.d.ts +1 -1
- package/dist/db/event-store-executor-context.js +13 -13
- package/dist/db/event-store-executor-read.js +27 -28
- package/dist/db/event-store-executor-write.js +65 -70
- package/dist/db/located-timestamp.d.ts +11 -11
- package/dist/db/located-timestamp.js +29 -30
- package/dist/db/migrate-generator.d.ts +1 -1
- package/dist/db/migrate-generator.js +26 -19
- package/dist/db/migrate-runner.js +25 -25
- package/dist/db/money.d.ts +6 -6
- package/dist/db/money.js +29 -27
- package/dist/db/queries/backfill-pii.js +5 -5
- package/dist/db/queries/ddl.js +1 -1
- package/dist/db/queries/es-ops.d.ts +1 -1
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +3 -3
- package/dist/db/queries/event-store.js +4 -2
- package/dist/db/queries/projection-rebuild.js +3 -3
- package/dist/db/queries/raw-sql.d.ts +2 -2
- package/dist/db/queries/raw-sql.js +2 -2
- package/dist/db/queries/shadow-swap.js +19 -19
- package/dist/db/queries/test-stack.d.ts +4 -1
- package/dist/db/queries/test-stack.js +15 -2
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +6 -6
- package/dist/db/rebuild-marker.js +20 -21
- package/dist/db/replay-migration-sql.js +2 -2
- package/dist/db/schema-inspection.js +4 -5
- package/dist/db/table-builder.d.ts +1 -2
- package/dist/db/table-builder.js +82 -82
- package/dist/db/tenant-db.d.ts +2 -2
- package/dist/db/tenant-db.js +9 -2
- package/dist/derivatives/delete-stored-file.js +5 -2
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/engine/boot-validator/access-roles.js +4 -3
- package/dist/engine/boot-validator/api-ext.js +14 -14
- package/dist/engine/boot-validator/config-deps.js +7 -6
- package/dist/engine/boot-validator/detail-screens.js +3 -3
- package/dist/engine/boot-validator/entity-handler.js +60 -61
- package/dist/engine/boot-validator/gdpr-storage.js +4 -4
- package/dist/engine/boot-validator/i18n-keys.js +1 -1
- package/dist/engine/boot-validator/index.d.ts +2 -2
- package/dist/engine/boot-validator/index.js +20 -20
- package/dist/engine/boot-validator/nav.js +5 -5
- package/dist/engine/boot-validator/ownership.js +12 -9
- package/dist/engine/boot-validator/parent-ref.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +50 -57
- package/dist/engine/boot-validator/projection-list-screens.js +7 -7
- package/dist/engine/boot-validator/query-refs.js +1 -1
- package/dist/engine/boot-validator/screens-actions.js +15 -16
- package/dist/engine/boot-validator/screens-forms.js +10 -11
- package/dist/engine/boot-validator/screens-lists.js +14 -15
- package/dist/engine/boot-validator/screens.js +34 -40
- package/dist/engine/boot-validator/security-baseline.js +3 -2
- package/dist/engine/boot-validator/transfer-graph.js +2 -2
- package/dist/engine/boot-validator/workspaces.js +3 -3
- package/dist/engine/bridge-stub.js +3 -4
- package/dist/engine/build-app-schema.js +75 -75
- package/dist/engine/build-config-feature-schema.js +50 -50
- package/dist/engine/build-target.js +20 -23
- package/dist/engine/codemod/pipeline-codemod.js +20 -13
- package/dist/engine/config-helpers.js +4 -4
- package/dist/engine/constants.js +2 -2
- package/dist/engine/create-app.js +2 -2
- package/dist/engine/define-feature.js +12 -11
- package/dist/engine/define-handler.js +12 -11
- package/dist/engine/define-step.js +1 -1
- package/dist/engine/define-workflow.d.ts +5 -5
- package/dist/engine/define-workflow.js +6 -6
- package/dist/engine/entity-handlers.js +13 -14
- package/dist/engine/event-def.js +2 -2
- package/dist/engine/extension-names.d.ts +16 -17
- package/dist/engine/extension-names.js +37 -38
- package/dist/engine/extensions/escape-hatch-usage.js +1 -1
- package/dist/engine/extensions/tenant-data.js +2 -2
- package/dist/engine/extensions/user-data.d.ts +23 -24
- package/dist/engine/factories.d.ts +14 -13
- package/dist/engine/factories.js +38 -37
- package/dist/engine/feature-ast/entity-field-types.js +1 -1
- package/dist/engine/feature-ast/extractors/handlers.js +1 -1
- package/dist/engine/feature-ast/extractors/hooks.js +1 -1
- package/dist/engine/feature-ast/extractors/shared.d.ts +2 -3
- package/dist/engine/feature-ast/extractors/shared.js +2 -3
- package/dist/engine/feature-ast/parse.js +13 -13
- package/dist/engine/feature-ast/patch.js +1 -1
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +8 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -1
- package/dist/engine/feature-ast/patterns.d.ts +2 -2
- package/dist/engine/feature-ast/render.js +2 -2
- package/dist/engine/feature-config-events-jobs.js +15 -12
- package/dist/engine/feature-entity-handlers.js +9 -5
- package/dist/engine/feature-manifest.d.ts +9 -9
- package/dist/engine/feature-manifest.js +6 -7
- package/dist/engine/feature-ui-extensions.d.ts +3 -3
- package/dist/engine/feature-ui-extensions.js +30 -22
- package/dist/engine/field-access.js +5 -6
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.js +4 -4
- package/dist/engine/object-form.d.ts +1 -0
- package/dist/engine/object-form.js +14 -4
- package/dist/engine/ownership.js +3 -3
- package/dist/engine/parse-ref-target.js +5 -7
- package/dist/engine/pattern-library/index.js +2 -2
- package/dist/engine/pattern-library/library.js +2 -2
- package/dist/engine/pattern-library/types.js +3 -3
- package/dist/engine/pipeline.js +2 -2
- package/dist/engine/project-app-schema-for-roles.js +1 -1
- package/dist/engine/projection-helpers.js +9 -9
- package/dist/engine/qualified-name.js +2 -2
- package/dist/engine/registry-facade.js +1 -1
- package/dist/engine/registry-ingest.js +12 -12
- package/dist/engine/registry-state.js +17 -19
- package/dist/engine/registry-validate.js +12 -12
- package/dist/engine/run-in.js +2 -2
- package/dist/engine/run-pipeline.js +7 -0
- package/dist/engine/schema-builder.js +40 -38
- package/dist/engine/screen-filter-ops.js +17 -20
- package/dist/engine/screen-helpers.js +4 -3
- package/dist/engine/state-machine.d.ts +12 -12
- package/dist/engine/state-machine.js +7 -7
- package/dist/engine/steps/_resolver-utils.js +4 -3
- package/dist/engine/steps/for-each.js +4 -4
- package/dist/engine/steps/unsafe-projection-delete.js +1 -1
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -4
- package/dist/engine/steps/wait-for-event.js +4 -4
- package/dist/engine/steps/workflow-system-events.js +3 -3
- package/dist/engine/tier-resolver-extension.d.ts +29 -27
- package/dist/engine/tier-resolver-extension.js +26 -26
- package/dist/engine/transfer-adjacency.js +2 -2
- package/dist/engine/validate-projection-allowlist.js +7 -27
- package/dist/engine/walk-step-instances.d.ts +2 -0
- package/dist/engine/walk-step-instances.js +38 -0
- package/dist/engine/where-rule-lint.js +1 -1
- package/dist/engine/write-handler-qn-extract.js +1 -1
- package/dist/entrypoint/index.js +3 -3
- package/dist/env/index.d.ts +7 -6
- package/dist/env/index.js +10 -9
- package/dist/errors/transition-details.js +7 -7
- package/dist/errors/write-error-info.d.ts +6 -6
- package/dist/errors/write-error-info.js +6 -6
- package/dist/es-ops/context.d.ts +9 -9
- package/dist/es-ops/context.js +28 -28
- package/dist/es-ops/index.js +7 -9
- package/dist/es-ops/operations-schema.js +4 -10
- package/dist/es-ops/runner.d.ts +11 -11
- package/dist/es-ops/runner.js +49 -53
- package/dist/es-ops/types.d.ts +55 -55
- package/dist/es-ops/types.js +11 -11
- package/dist/event-store/admin-api.js +6 -7
- package/dist/event-store/errors.js +1 -1
- package/dist/event-store/event-store.js +2 -2
- package/dist/event-store/rebuild-dead-letter.js +1 -1
- package/dist/files/feature.js +9 -10
- package/dist/files/file-handle.js +5 -6
- package/dist/files/file-ref-entity.js +15 -15
- package/dist/files/file-ref-table.js +7 -8
- package/dist/files/file-routes.d.ts +7 -0
- package/dist/files/file-routes.js +11 -5
- package/dist/files/in-memory-provider.js +8 -9
- package/dist/files/local-provider.js +18 -25
- package/dist/files/provider-resolver.d.ts +7 -7
- package/dist/files/storage-tracking.js +2 -2
- package/dist/files/zip-stream.d.ts +12 -19
- package/dist/files/zip-stream.js +44 -63
- package/dist/i18n/required-surface-keys.d.ts +1 -1
- package/dist/i18n/required-surface-keys.js +8 -8
- package/dist/internal/stack-support.d.ts +1 -1
- package/dist/internal/stack-support.js +1 -1
- package/dist/jobs/job-runner-helpers.d.ts +1 -2
- package/dist/jobs/job-runner-helpers.js +8 -18
- package/dist/jobs/job-runner-schedulers.d.ts +1 -1
- package/dist/jobs/job-runner-schedulers.js +3 -4
- package/dist/jobs/job-runner.js +17 -18
- package/dist/lifecycle/lifecycle.js +3 -5
- package/dist/lifecycle/signal-handlers.js +5 -4
- package/dist/logging/utils.d.ts +2 -1
- package/dist/logging/utils.js +38 -10
- package/dist/migrations/index.js +1 -1
- package/dist/migrations/kumiko-drift.js +14 -13
- package/dist/migrations/pending-rebuilds.d.ts +21 -20
- package/dist/migrations/pending-rebuilds.js +27 -27
- package/dist/migrations/projection-table-index.d.ts +3 -3
- package/dist/migrations/projection-table-index.js +8 -8
- package/dist/observability/metric-validator.js +1 -1
- package/dist/observability/metrics-handle.js +3 -3
- package/dist/pipeline/append-event-core.js +11 -2
- package/dist/pipeline/cascade-handler.d.ts +1 -1
- package/dist/pipeline/dispatch-batch.js +2 -2
- package/dist/pipeline/dispatch-query.js +10 -10
- package/dist/pipeline/dispatch-shared.d.ts +1 -0
- package/dist/pipeline/dispatch-shared.js +42 -19
- package/dist/pipeline/dispatch-stream.js +1 -1
- package/dist/pipeline/dispatch-write.js +3 -3
- package/dist/pipeline/dispatcher-utils.d.ts +1 -1
- package/dist/pipeline/dispatcher-utils.js +1 -1
- package/dist/pipeline/event-consumer-state.js +2 -2
- package/dist/pipeline/event-dispatcher-delivery.js +1 -1
- package/dist/pipeline/event-dispatcher.js +17 -28
- package/dist/pipeline/irreversible-operation-gate.js +1 -1
- package/dist/pipeline/lifecycle-pipeline.js +9 -9
- package/dist/pipeline/member-read-only-transaction.js +1 -3
- package/dist/pipeline/msp-rebuild.js +1 -1
- package/dist/pipeline/projection-rebuild.js +13 -12
- package/dist/pipeline/system-hooks.js +39 -41
- package/dist/pipeline/system-identity-switch.js +1 -3
- package/dist/pipeline/tenant-timezone-cache.js +1 -1
- package/dist/random/generate.d.ts +29 -29
- package/dist/random/generate.js +30 -30
- package/dist/random/words.js +20 -20
- package/dist/rate-limit/middleware.js +9 -5
- package/dist/redis/client.js +2 -2
- package/dist/redis/pubsub-signal.js +14 -17
- package/dist/schema-cli.js +116 -22
- package/dist/search/index.js +4 -4
- package/dist/search/purge-subject.js +4 -5
- package/dist/search/reindex-entity.js +6 -9
- package/dist/time/boot-tz-warning.d.ts +2 -2
- package/dist/time/boot-tz-warning.js +8 -8
- package/dist/time/iana.js +10 -10
- package/dist/time/index.js +9 -9
- package/dist/time/legacy-date.d.ts +2 -2
- package/dist/time/legacy-date.js +6 -8
- package/dist/time/polyfill.js +1 -1
- package/dist/time/tz-context.d.ts +1 -1
- package/dist/time/tz-context.js +1 -1
- package/dist/ui-types/app-schema.js +7 -7
- package/dist/ui-types/list-row-meta.js +4 -4
- package/dist/upgrade-cli.js +10 -9
- package/dist/utils/compare.js +1 -1
- package/package.json +4 -4
- package/src/changes.json +54 -0
- package/src/scripts/codemod/crypto-shredding-testing-move.ts +2 -2
- package/src/scripts/codemod/migrate-cross-tenant.ts +1 -1
- package/src/scripts/codemod/migrate-open-to-all.ts +1 -1
- package/src/scripts/codemod/pii-personal-migration.ts +17 -8
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +2 -2
package/dist/db/eagerload.js
CHANGED
|
@@ -24,12 +24,15 @@ import { filterReadFields } from "../engine/field-access.js";
|
|
|
24
24
|
import { buildOwnershipClause, combineClauses, shiftParams, tableNameOf, } from "../engine/ownership.js";
|
|
25
25
|
import { parseRefTargetEntityName } from "../engine/parse-ref-target.js";
|
|
26
26
|
import { isUuid, SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
27
|
+
import { scrubLogValue } from "../logging/scrub.js";
|
|
28
|
+
import { createFallbackLogger } from "../logging/utils.js";
|
|
27
29
|
import { collectEncryptedFieldNames, decryptEntityFieldValues, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
|
|
28
30
|
import { buildParentRefClause } from "./parent-ref-clause.js";
|
|
29
31
|
import { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
|
|
30
32
|
import { executeRawQueryRead } from "./queries/raw-sql.js";
|
|
31
33
|
import { buildEntityTable, physicalColumnName } from "./table-builder.js";
|
|
32
34
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
35
|
+
const log = createFallbackLogger("eagerload");
|
|
33
36
|
function isReferenceField(field) {
|
|
34
37
|
return field.type === "reference";
|
|
35
38
|
}
|
|
@@ -105,7 +108,11 @@ async function buildRefLookupMap(rawRefRows, refEntity, refEntityName, viewer, p
|
|
|
105
108
|
decrypted = await decryptReferencedRow(r, piiFields, encryptedFields, kms);
|
|
106
109
|
}
|
|
107
110
|
catch (e) {
|
|
108
|
-
|
|
111
|
+
log.warn("failed to decrypt referenced row", {
|
|
112
|
+
entity: refEntityName,
|
|
113
|
+
id: String(r["id"]),
|
|
114
|
+
err: scrubLogValue(e),
|
|
115
|
+
});
|
|
109
116
|
continue;
|
|
110
117
|
}
|
|
111
118
|
const id = decrypted["id"];
|
|
@@ -126,26 +133,26 @@ function collectRefIds(rows, rf) {
|
|
|
126
133
|
}
|
|
127
134
|
return ids;
|
|
128
135
|
}
|
|
129
|
-
/**
|
|
130
|
-
*
|
|
136
|
+
/** Eagerloads a list of rows without mutating them; returns shallow copies
|
|
137
|
+
* with an added `_refs` property. */
|
|
131
138
|
export async function enrichWithReferences(rows, entity, resolveEntity, db, viewer) {
|
|
132
139
|
const refFields = collectReferenceFields(entity);
|
|
133
140
|
if (refFields.length === 0 || rows.length === 0) {
|
|
134
141
|
return rows.map((r) => ({ ...r }));
|
|
135
142
|
}
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
143
|
+
// For each reference field, collect IDs in a deduplicated Set, then run one
|
|
144
|
+
// SELECT WHERE id IN (...). Build the maps in parallel so the lookups do
|
|
145
|
+
// not run serially (Promise.all).
|
|
139
146
|
const lookupMaps = await Promise.all(refFields.map(async (rf) => {
|
|
140
147
|
const ids = collectRefIds(rows, rf);
|
|
141
148
|
if (ids.size === 0)
|
|
142
149
|
return { fieldName: rf.fieldName, multiple: rf.multiple, map: new Map() };
|
|
143
150
|
const refEntity = resolveEntity(rf.refEntityName);
|
|
144
151
|
if (refEntity === undefined) {
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
// UUID
|
|
152
|
+
// Authoring error or race condition (the entity was renamed without a
|
|
153
|
+
// registry reload). The boot validator normally catches this;
|
|
154
|
+
// as a runtime safeguard, an empty map makes the renderer fall back
|
|
155
|
+
// to the UUID instead of crashing.
|
|
149
156
|
return { fieldName: rf.fieldName, multiple: rf.multiple, map: new Map() };
|
|
150
157
|
}
|
|
151
158
|
const refTable = buildEntityTable(rf.refEntityName, refEntity);
|
|
@@ -159,7 +166,9 @@ export async function enrichWithReferences(rows, entity, resolveEntity, db, view
|
|
|
159
166
|
const scope = await resolveRefAccessScope(refEntity, refTable, refTableName, idArray, db, viewer);
|
|
160
167
|
if (scope.kind === "none")
|
|
161
168
|
return emptyLookup;
|
|
162
|
-
const lookedUpRows =
|
|
169
|
+
const lookedUpRows = await selectMany(db, refTable, {
|
|
170
|
+
id: idArray,
|
|
171
|
+
});
|
|
163
172
|
const rawRefRows = scope.kind === "restricted"
|
|
164
173
|
? lookedUpRows.filter((r) => scope.readableIds.has(String(r["id"])))
|
|
165
174
|
: lookedUpRows;
|
|
@@ -190,7 +199,7 @@ export async function enrichWithReferences(rows, entity, resolveEntity, db, view
|
|
|
190
199
|
return { ...row, _refs: refs };
|
|
191
200
|
});
|
|
192
201
|
}
|
|
193
|
-
/** Single-
|
|
202
|
+
/** Single-row variant for detail calls. */
|
|
194
203
|
export async function enrichRowWithReferences(row, entity, resolveEntity, db, viewer) {
|
|
195
204
|
const enriched = await enrichWithReferences([row], entity, resolveEntity, db, viewer);
|
|
196
205
|
return enriched[0] ?? { ...row, _refs: {} };
|
|
@@ -11,7 +11,7 @@ export declare function resolveTableName(entityName: string, entity: EntityDefin
|
|
|
11
11
|
* (convention: `store_*`) — or build columns by hand with `defineUnmanagedTable`.
|
|
12
12
|
*
|
|
13
13
|
* Named `derive*` (not `build*Meta`) so it is not mistaken for the unmanaged
|
|
14
|
-
* escape hatch
|
|
14
|
+
* escape hatch.
|
|
15
15
|
*/
|
|
16
16
|
export declare function deriveEntityTableMeta(entityName: string, entity: EntityDefinition, options: BuildEntityTableMetaOptions & {
|
|
17
17
|
readonly source: "unmanaged";
|
|
@@ -20,7 +20,7 @@ export declare function deriveEntityTableMeta(entityName: string, entity: Entity
|
|
|
20
20
|
export declare function assertBackingTableSuperset(entityName: string, fieldMeta: EntityTableMeta, tableMeta: EntityTableMeta): void;
|
|
21
21
|
/**
|
|
22
22
|
* Hand-built EntityTableMeta for direct-write stores (no entity base columns).
|
|
23
|
-
* Prefer a `store_*` table name; `read_` is reserved for managed projections
|
|
23
|
+
* Prefer a `store_*` table name; `read_` is reserved for managed projections.
|
|
24
24
|
*
|
|
25
25
|
* Escape hatch, not a shortcut: no audit trail, no automatic tenant_id index,
|
|
26
26
|
* no softDelete — the app author owns tenant-scoping and retention for this
|
|
@@ -173,7 +173,7 @@ function fieldToColumnMeta(name, field, entity) {
|
|
|
173
173
|
return [{ name: snake, pgType: "jsonb", notNull: true, defaultSql: "'{}'::jsonb" }];
|
|
174
174
|
case "date":
|
|
175
175
|
// Real PG `date` — Temporal.PlainDate, no time-of-day/timezone
|
|
176
|
-
// component. See plainDate() in dialect.ts
|
|
176
|
+
// component. See plainDate() in dialect.ts.
|
|
177
177
|
return [{ name: snake, pgType: "date", notNull: field.required === true }];
|
|
178
178
|
case "timestamp":
|
|
179
179
|
return [{ name: snake, pgType: "timestamptz", notNull: field.required === true }];
|
|
@@ -299,7 +299,7 @@ export function deriveEntityTableMeta(entityName, entity, options) {
|
|
|
299
299
|
const indexName = def.name ?? `${tableName}_${cols.join("_")}_${suffix}`;
|
|
300
300
|
const explicitWhereSql = sqlExpressionText(def.where);
|
|
301
301
|
// Soft-deleted rows keep their value, so a full unique index would
|
|
302
|
-
// permanently block reuse; kept in lock-step with table-builder.ts
|
|
302
|
+
// permanently block reuse; kept in lock-step with table-builder.ts.
|
|
303
303
|
const whereSql = explicitWhereSql ??
|
|
304
304
|
(def.where === undefined && def.unique === true && entity.softDelete === true
|
|
305
305
|
? SOFT_DELETE_LIVE_ROW_PREDICATE
|
|
@@ -323,7 +323,7 @@ export function deriveEntityTableMeta(entityName, entity, options) {
|
|
|
323
323
|
.map((c) => `"${c}" IS NOT NULL`);
|
|
324
324
|
// Soft-deleted rows keep their bidx hash — without this, a restored
|
|
325
325
|
// row can't reuse a value a soft-deleted sibling still holds
|
|
326
|
-
//
|
|
326
|
+
// Kept in lock-step with table-builder.ts.
|
|
327
327
|
const whereParts = entity.softDelete === true
|
|
328
328
|
? [...notNullParts, SOFT_DELETE_LIVE_ROW_PREDICATE]
|
|
329
329
|
: notNullParts;
|
|
@@ -384,7 +384,7 @@ function columnsByNameMeta(meta) {
|
|
|
384
384
|
}
|
|
385
385
|
/**
|
|
386
386
|
* Hand-built EntityTableMeta for direct-write stores (no entity base columns).
|
|
387
|
-
* Prefer a `store_*` table name; `read_` is reserved for managed projections
|
|
387
|
+
* Prefer a `store_*` table name; `read_` is reserved for managed projections.
|
|
388
388
|
*
|
|
389
389
|
* Escape hatch, not a shortcut: no audit trail, no automatic tenant_id index,
|
|
390
390
|
* no softDelete — the app author owns tenant-scoping and retention for this
|
|
@@ -7,7 +7,7 @@ import type { SearchAdapter } from "../search/types.ts";
|
|
|
7
7
|
import type { TableColumns } from "./dialect.ts";
|
|
8
8
|
import type { EventStoreExecutorOptions } from "./event-store-executor.ts";
|
|
9
9
|
import type { TenantDb } from "./tenant-db.ts";
|
|
10
|
-
export type Table = TableColumns
|
|
10
|
+
export type Table = TableColumns;
|
|
11
11
|
export declare function buildFilterWhere(field: string, op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in", value: unknown): WhereObject | null;
|
|
12
12
|
export declare function ownershipSubject(user: SessionUser, streamTenantId: TenantId | undefined): SessionUser;
|
|
13
13
|
export declare function isForeignTenantOnGlobalEntity(entity: EntityDefinition, tenantIdValue: unknown): boolean;
|
|
@@ -13,10 +13,10 @@ import { constraintOf, isUniqueViolation } from "./pg-error.js";
|
|
|
13
13
|
import { toSnakeCase } from "./table-builder.js";
|
|
14
14
|
import { columnHandleName, schemaTableName } from "./table-introspection.js";
|
|
15
15
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
16
|
-
// Screen
|
|
17
|
-
//
|
|
18
|
-
// `op`
|
|
19
|
-
// empty
|
|
16
|
+
// Screen filter: maps an operator to a WhereObject condition.
|
|
17
|
+
// The boot validator checks field existence, filterability, and operator/type compatibility.
|
|
18
|
+
// `op` is normalized to {eq,ne,lt,gt,lte,gte,in}; "in" with an
|
|
19
|
+
// empty array explicitly matches nothing.
|
|
20
20
|
export function buildFilterWhere(field, op, value) {
|
|
21
21
|
switch (op) {
|
|
22
22
|
case "eq":
|
|
@@ -70,11 +70,11 @@ function scalarDefault(field) {
|
|
|
70
70
|
return undefined;
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
-
//
|
|
74
|
-
// (create
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
// applyEntityEvent
|
|
73
|
+
// Maps PG-23505 (unique-violation) caught from applyEntityEvent
|
|
74
|
+
// (create and update paths) to WriteFailure(UniqueViolationError 409).
|
|
75
|
+
// Other errors propagate via re-throw. Extracted because this pattern
|
|
76
|
+
// occurs in two places in the executor; the caller wraps the
|
|
77
|
+
// applyEntityEvent call in try-catch and delegates the mapping here.
|
|
78
78
|
//
|
|
79
79
|
// Returns WriteFailure on match, null otherwise (caller re-throws).
|
|
80
80
|
export function tryMapUniqueViolation(e, entityName) {
|
|
@@ -161,8 +161,8 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
161
161
|
}
|
|
162
162
|
// Pre-compute the set of sensitive field names once. The event log stores
|
|
163
163
|
// these fields as table ciphertext (boot validates sensitive ⇒ pii |
|
|
164
|
-
// encrypted
|
|
165
|
-
// responses never carry the value
|
|
164
|
+
// encrypted) — the set only strips the caller-facing event echo so
|
|
165
|
+
// responses never carry the value.
|
|
166
166
|
const sensitiveFields = new Set();
|
|
167
167
|
for (const [name, field] of Object.entries(entity.fields)) {
|
|
168
168
|
if ("sensitive" in field && field.sensitive === true) {
|
|
@@ -184,8 +184,8 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
184
184
|
return resolveEntityFieldEncryption();
|
|
185
185
|
}
|
|
186
186
|
// No adapter configured = crypto-shredding off; pii fields stay plaintext
|
|
187
|
-
// (
|
|
188
|
-
// PgKmsAdapter
|
|
187
|
+
// (previous behavior). The hard boot gate ships with the prod-grade
|
|
188
|
+
// PgKmsAdapter.
|
|
189
189
|
function piiKms() {
|
|
190
190
|
return options.kms ?? configuredPiiSubjectKms();
|
|
191
191
|
}
|
|
@@ -17,9 +17,9 @@ import { buildEntityTable, physicalColumnName } from "./table-builder.js";
|
|
|
17
17
|
import { schemaTableName } from "./table-introspection.js";
|
|
18
18
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
19
19
|
// The two read verbs (list/detail) of the event-store-executor. Split out
|
|
20
|
-
// of event-store-executor.ts
|
|
21
|
-
//
|
|
22
|
-
//
|
|
20
|
+
// of event-store-executor.ts — behavior-preserving relocation, not a redesign:
|
|
21
|
+
// unchanged from the original, now behind an explicit ExecutorContext instead
|
|
22
|
+
// of the factory's local scope.
|
|
23
23
|
// Defense-in-depth pagination guard. The handler boundary (entityListSchema)
|
|
24
24
|
// already validates limit/offset, but the executor is public API for custom
|
|
25
25
|
// handlers that pass their payload straight through — a non-integer limit
|
|
@@ -90,7 +90,7 @@ function keysetBoundarySql(sortCol, idCol, cursor, descending, params) {
|
|
|
90
90
|
}
|
|
91
91
|
// multiSelect stores its options as a jsonb array — a filter value is one
|
|
92
92
|
// option, not the whole array, so eq/ne/in must check array containment
|
|
93
|
-
// (`@>`) instead of scalar `=`/`<>`/`IN` against the jsonb column
|
|
93
|
+
// (`@>`) instead of scalar `=`/`<>`/`IN` against the jsonb column.
|
|
94
94
|
// lt/gt/lte/gte have no containment analogue; the boot-validator already
|
|
95
95
|
// blocks them for screen-declared filters (screen-filter-ops.ts
|
|
96
96
|
// EQUALITY_ONLY), but a client-supplied facet filter reaches here
|
|
@@ -144,7 +144,7 @@ function applyScreenFilter(table, colSql, whereSql, params, f) {
|
|
|
144
144
|
return;
|
|
145
145
|
}
|
|
146
146
|
for (const [field, value] of Object.entries(screen)) {
|
|
147
|
-
//
|
|
147
|
+
// `x <> NULL` is never true — mirror buildWhereClause's IS [NOT] NULL handling in db/query.ts.
|
|
148
148
|
if (value === null) {
|
|
149
149
|
whereSql.push(`${colSql(field)} IS NULL`);
|
|
150
150
|
}
|
|
@@ -175,7 +175,7 @@ function applyScreenFilter(table, colSql, whereSql, params, f) {
|
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
177
|
else {
|
|
178
|
-
// Blind-index OR rewrite
|
|
178
|
+
// Blind-index OR rewrite, lock-step with buildWhereClause
|
|
179
179
|
// in db/query.ts — equality on lookupable fields matches the
|
|
180
180
|
// plaintext arm OR the HMAC arm.
|
|
181
181
|
const bidxKey = configuredBlindIndexKey();
|
|
@@ -225,7 +225,7 @@ function deriveParentNarrowTypes(entity, payload) {
|
|
|
225
225
|
}
|
|
226
226
|
return narrowed;
|
|
227
227
|
}
|
|
228
|
-
//
|
|
228
|
+
// Above this many target-row matches, drop the reference clause
|
|
229
229
|
// entirely instead of truncating: an arbitrary 200-row slice of "which
|
|
230
230
|
// targets match" would silently hide rows a full-cardinality match would
|
|
231
231
|
// have found. Native text search on the entity's own fields still applies.
|
|
@@ -237,7 +237,7 @@ const MAX_REFERENCE_SEARCH_IDS = 200;
|
|
|
237
237
|
// treated as complete (a cap of N+1 could never be observed).
|
|
238
238
|
const MAX_SEARCH_CANDIDATE_IDS = 1000;
|
|
239
239
|
// Registry-declared `searchable` reference fields, plus the implicit
|
|
240
|
-
// tenantId→tenant.name row-meta reference
|
|
240
|
+
// tenantId→tenant.name row-meta reference — both resolve a text
|
|
241
241
|
// search term against a target row's label instead of the raw FK column.
|
|
242
242
|
function collectReferenceSearchDescriptors(table, referenceSearch, dbMode) {
|
|
243
243
|
if (!referenceSearch)
|
|
@@ -264,9 +264,9 @@ function isSoftDeletedTargetHidden(targetEntity, targetTable) {
|
|
|
264
264
|
return targetEntity.softDelete === true && targetTable["isDeleted"] !== undefined;
|
|
265
265
|
}
|
|
266
266
|
// Tenant-scoped ILIKE lookup against one reference target — never raw/
|
|
267
|
-
// unscoped SQL, a match in a foreign tenant must never surface a row here
|
|
268
|
-
//
|
|
269
|
-
//
|
|
267
|
+
// unscoped SQL, a match in a foreign tenant must never surface a row here.
|
|
268
|
+
// Mirrors the main table's own tenant filter (see the `table["tenantId"]`
|
|
269
|
+
// check further down in list()).
|
|
270
270
|
async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db) {
|
|
271
271
|
// The label must not leak through match counts: a viewer who cannot read the
|
|
272
272
|
// target row or its label field would otherwise probe names by searching.
|
|
@@ -388,14 +388,14 @@ function referenceTargetTable(name, entity) {
|
|
|
388
388
|
// cursor carries the label that was ordered by instead of the FK's UUID.
|
|
389
389
|
const SORT_LABEL_ALIAS = "__kumiko_sort_label";
|
|
390
390
|
const STREAM_VERSION_ALIAS = "__streamVersion";
|
|
391
|
-
//
|
|
391
|
+
// Resolve a `sortable: true` reference field's label inline for
|
|
392
392
|
// ORDER BY. A correlated scalar subquery, not a join: the result set keeps one
|
|
393
393
|
// row per entity row, the read model stays untouched, and the lookup rides the
|
|
394
394
|
// target's primary key. Tenant-scoped exactly like the search path — a foreign
|
|
395
395
|
// tenant's label must never influence this tenant's ordering.
|
|
396
396
|
// undefined means "cannot order by this label", which drops the sort to plain
|
|
397
397
|
// id order; falling back to the UUID column would look deliberate to the user
|
|
398
|
-
// while being arbitrary, which
|
|
398
|
+
// while being arbitrary, which is worse than no sorting.
|
|
399
399
|
function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolveEntity, db, user) {
|
|
400
400
|
const targetEntity = resolveEntity(descriptor.targetEntityName);
|
|
401
401
|
if (targetEntity === undefined) {
|
|
@@ -479,20 +479,19 @@ export function createReadVerbs(ctx) {
|
|
|
479
479
|
const colSql = (field) => `"${physicalCol(field)}"`;
|
|
480
480
|
// Field-level read access gates filtering and sorting the same way it
|
|
481
481
|
// gates the projected row: a caller who cannot read a field must not be
|
|
482
|
-
// able to probe its values through result counts or ordering
|
|
483
|
-
// Hoisted above the search block
|
|
484
|
-
//
|
|
485
|
-
//
|
|
482
|
+
// able to probe its values through result counts or ordering.
|
|
483
|
+
// Hoisted above the search block — a searchable-reference clause is the
|
|
484
|
+
// same probe surface as a filter/sort and must be gated the same way,
|
|
485
|
+
// before the reference-match query even runs.
|
|
486
486
|
const fieldReadClause = (field) => buildOwnershipClause(user, normalizeAccessEntry(entity.fields[field]?.access?.read), table);
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
// beim Definition-Time noch keinen Server-Context hat).
|
|
487
|
+
// The build-time options.searchAdapter takes precedence; use the runtime override
|
|
488
|
+
// as a fallback because defaultEntityQueryHandler receives the search adapter only
|
|
489
|
+
// at runtime, when createEventStoreExecutor has no server context yet.
|
|
491
490
|
const effectiveSearchAdapter = searchAdapter ?? runtimeOptions?.searchAdapter;
|
|
492
491
|
let searchTruncated = false;
|
|
493
492
|
if (payload.search) {
|
|
494
493
|
const searchTerm = payload.search;
|
|
495
|
-
//
|
|
494
|
+
// A search term with no adapter wired must fail loud, not
|
|
496
495
|
// silently return the unfiltered list dressed up as a search result.
|
|
497
496
|
if (!effectiveSearchAdapter) {
|
|
498
497
|
throw new UnprocessableError("search_adapter_not_wired", {
|
|
@@ -504,7 +503,7 @@ export function createReadVerbs(ctx) {
|
|
|
504
503
|
}
|
|
505
504
|
// Same choke-point as writes/index: systemStream docs live under
|
|
506
505
|
// SYSTEM_TENANT_ID; everything else under the session tenant — db.mode
|
|
507
|
-
// must not invent a second predicate
|
|
506
|
+
// must not invent a second predicate.
|
|
508
507
|
const searchTenantId = streamTenantFor(user);
|
|
509
508
|
const results = await effectiveSearchAdapter.search(searchTenantId, searchTerm, {
|
|
510
509
|
filterType: entityName,
|
|
@@ -512,14 +511,14 @@ export function createReadVerbs(ctx) {
|
|
|
512
511
|
});
|
|
513
512
|
searchTruncated = results.length >= MAX_SEARCH_CANDIDATE_IDS;
|
|
514
513
|
filterIds = results.map((r) => r.entityId);
|
|
515
|
-
//
|
|
514
|
+
// Union in reference-column label matches: a searchable
|
|
516
515
|
// reference field (or the implicit tenantId→tenant.name row-meta
|
|
517
516
|
// reference) whose target row's labelField matches the search term
|
|
518
517
|
// also counts as a hit on this entity, not just a raw text-field hit.
|
|
519
518
|
const referenceSearch = runtimeOptions?.referenceSearch;
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
//
|
|
519
|
+
// A caller who cannot read the reference column itself must not be able
|
|
520
|
+
// to infer its value by matching the target's label — same probe
|
|
521
|
+
// surface as filtering/sorting on it directly.
|
|
523
522
|
const descriptors = collectReferenceSearchDescriptors(table, referenceSearch, db.mode).filter((d) => fieldReadClause(d.ownColumn).kind === "pass");
|
|
524
523
|
const referenceMatches = referenceSearch
|
|
525
524
|
? (await Promise.all(descriptors.map((d) => resolveReferenceMatches(d, searchTerm, referenceSearch.resolveEntity, db, user, effectiveSearchAdapter)))).filter((m) => m !== undefined)
|
|
@@ -556,7 +555,7 @@ export function createReadVerbs(ctx) {
|
|
|
556
555
|
? payload.sort
|
|
557
556
|
: undefined;
|
|
558
557
|
const sortDescending = payload.sortDirection === "desc";
|
|
559
|
-
//
|
|
558
|
+
// A reference column stores a UUID, so it is never sorted by
|
|
560
559
|
// directly: either the field opted into `sortable` and we order by the
|
|
561
560
|
// target row's label, or the sort is dropped to plain id order.
|
|
562
561
|
let sortField;
|
|
@@ -36,7 +36,7 @@ async function projectEntityEvent(db, runner, event) {
|
|
|
36
36
|
await runInSavepointIfSupported(runner, (sp) => runProjectionsForEvent(event, registry, sp));
|
|
37
37
|
}
|
|
38
38
|
// The five write verbs (create/update/delete/forget/restore) of the event-
|
|
39
|
-
// store-executor. Split out of event-store-executor.ts
|
|
39
|
+
// store-executor. Split out of event-store-executor.ts —
|
|
40
40
|
// behavior-preserving relocation, not a redesign: every closure below is
|
|
41
41
|
// unchanged from the original, just relocated behind an explicit
|
|
42
42
|
// ExecutorContext instead of capturing the factory's local scope directly.
|
|
@@ -61,10 +61,10 @@ function selectOptionNotAvailableFailure(entries) {
|
|
|
61
61
|
}));
|
|
62
62
|
}
|
|
63
63
|
// preSave runs before ownership checks: authorization must evaluate the row
|
|
64
|
-
// as it will actually be persisted, including hook-derived fields
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
64
|
+
// as it will actually be persisted, including hook-derived fields. A throwing
|
|
65
|
+
// hook is an app-author bug (bad business rule, not a framework fault) — map it
|
|
66
|
+
// to a clean writeFailure instead of letting it propagate as an internal_error
|
|
67
|
+
// 500.
|
|
68
68
|
async function runPreSave(preSave, changes, previous, isNew, entityName, action, outputSchema) {
|
|
69
69
|
if (!preSave)
|
|
70
70
|
return { data: changes };
|
|
@@ -72,7 +72,7 @@ async function runPreSave(preSave, changes, previous, isNew, entityName, action,
|
|
|
72
72
|
const hookResult = await preSave(changes, previous, isNew);
|
|
73
73
|
// A hook that echoes `id`/`version` back (e.g. `{ ...changes, id: x }`)
|
|
74
74
|
// must not leak them into the persisted row — aggregateId already comes
|
|
75
|
-
// from generateId()/the loaded row, not from hook output
|
|
75
|
+
// from generateId()/the loaded row, not from hook output.
|
|
76
76
|
const { id: _hookId, version: _hookVersion, ...safe } = hookResult;
|
|
77
77
|
// Hook output skips the handler's input validation yet goes straight into the projection.
|
|
78
78
|
// Validated only, not replaced by the parse result: hooks may set fields in storage form.
|
|
@@ -245,12 +245,11 @@ export function createWriteVerbs(ctx) {
|
|
|
245
245
|
// per declared field. Role-level was already checked by the
|
|
246
246
|
// dispatcher; here we enforce ownership-rules against the new row.
|
|
247
247
|
//
|
|
248
|
-
// Which fields get checked is scoped to the pre-hook payload
|
|
248
|
+
// Which fields get checked is scoped to the pre-hook payload
|
|
249
249
|
// — a hook-derived field the user never submitted must not be
|
|
250
250
|
// field-ownership-checked against the user. The rule for a checked
|
|
251
251
|
// field is still evaluated against the full post-hook row (`data`) —
|
|
252
|
-
// an ownership rule can reference a column only a hook populates
|
|
253
|
-
// (kumiko-framework#1672).
|
|
252
|
+
// an ownership rule can reference a column only a hook populates.
|
|
254
253
|
const fieldDeniedCreate = checkWriteFieldOwnership(entity, applyDefaults(payloadWithoutId), ownershipSubject(user, streamTenantId), undefined, data);
|
|
255
254
|
if (fieldDeniedCreate) {
|
|
256
255
|
return writeFailure(new UnprocessableError("ownership_denied", {
|
|
@@ -267,39 +266,37 @@ export function createWriteVerbs(ctx) {
|
|
|
267
266
|
const unavailableOnCreate = selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, data));
|
|
268
267
|
if (unavailableOnCreate)
|
|
269
268
|
return unavailableOnCreate;
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
// subjectSource carries the freshly minted aggregateId: the create
|
|
274
|
-
//
|
|
269
|
+
// All compound types (locatedTimestamp, money, ...) use the same pipeline: the
|
|
270
|
+
// caller sends the combined API form, the framework stores the flat DB form (see
|
|
271
|
+
// db/compound-types.ts).
|
|
272
|
+
// subjectSource carries the freshly minted aggregateId: the create payload has no id
|
|
273
|
+
// column, but a pii:true self-subject resolves from it.
|
|
275
274
|
const flatCreateData = flattenCompoundTypes(data, entity);
|
|
276
275
|
const flatData = await encryptForStorage(flatCreateData, user, {
|
|
277
276
|
subjectSource: { ...flatCreateData, id: aggregateId },
|
|
278
277
|
keyTenantId: streamTenantId,
|
|
279
278
|
});
|
|
280
|
-
// 1. Append event
|
|
281
|
-
// or both roll back; the dispatcher wraps
|
|
282
|
-
// flatData is already table ciphertext for pii/encrypted fields, so
|
|
283
|
-
//
|
|
284
|
-
// row byte-identically (#967).
|
|
279
|
+
// 1. Append the event in the same transaction as the projection write — both must
|
|
280
|
+
// succeed or both roll back; the dispatcher wraps them in one transaction.
|
|
281
|
+
// flatData is already table ciphertext for pii/encrypted fields, so the immutable
|
|
282
|
+
// log never sees plaintext and replay reproduces the row byte-for-byte.
|
|
285
283
|
//
|
|
286
|
-
// `expectedVersion: 0`
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
// selben catch (siehe line 493+).
|
|
284
|
+
// `expectedVersion: 0` means the stream does not yet exist. With deterministic
|
|
285
|
+
// aggregate-ID patterns (for example, uuidv5(tenantId|naturalKey)), a create can
|
|
286
|
+
// legitimately collide — the same ID has an existing stream — so return
|
|
287
|
+
// version_conflict instead of internal_error. Update uses the same catch.
|
|
291
288
|
let event;
|
|
292
289
|
try {
|
|
293
290
|
// Savepoint-scoped: postgres.js poisons the WHOLE surrounding
|
|
294
291
|
// begin() once any statement inside it errors, even if the JS error
|
|
295
|
-
// is caught
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
292
|
+
// is caught — a losing concurrent create's unique-violation
|
|
293
|
+
// would otherwise abort the caller's outer transaction and surface as
|
|
294
|
+
// internal_error at commit time instead of the version_conflict this
|
|
295
|
+
// catch classifies. runInSavepointIfSupported confines the failed
|
|
296
|
+
// INSERT to a nested scope that rolls back on its own (same pattern as
|
|
297
|
+
// ctx.tryAppendEvent), and falls back to a plain call when the runner
|
|
298
|
+
// is a bare pool connection with no active transaction to poison
|
|
299
|
+
// (seeds/tests calling the executor directly).
|
|
303
300
|
event = await runInSavepointIfSupported(runner, async (sp) => append(sp, {
|
|
304
301
|
aggregateId,
|
|
305
302
|
aggregateType: entityName,
|
|
@@ -325,22 +322,20 @@ export function createWriteVerbs(ctx) {
|
|
|
325
322
|
}
|
|
326
323
|
throw e;
|
|
327
324
|
}
|
|
328
|
-
// 2. Update projection
|
|
329
|
-
// rebuildProjection
|
|
330
|
-
//
|
|
325
|
+
// 2. Update the projection through applyEntityEvent — the same code path used by
|
|
326
|
+
// rebuildProjection for replay, with the same StoredEvent, so live and rebuild
|
|
327
|
+
// produce the same result by construction.
|
|
331
328
|
//
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
// "
|
|
338
|
-
// Savepoint like the append above: an unclassified DB error
|
|
339
|
-
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
// throws raw as a 500 instead of failing cleanly. The savepoint rolls the
|
|
343
|
-
// failed INSERT back; the error still propagates and stays catchable.
|
|
329
|
+
// Application-level unique violations (for example, (tenantId, email) on a User
|
|
330
|
+
// entity or (tenantId, slug) on an Article) produce pg-23505 from the projection
|
|
331
|
+
// INSERT. Without this catch, the error propagates as an unhandled exception and
|
|
332
|
+
// becomes a 500 internal_error. Map it to UniqueViolationError 409 so the
|
|
333
|
+
// designer/frontend can show a clear "duplicate" message instead of a cryptic
|
|
334
|
+
// "internal server error".
|
|
335
|
+
// Savepoint like the append above: an unclassified DB error from the projection
|
|
336
|
+
// INSERT would otherwise poison the enclosing transaction (postgres-js rejects the
|
|
337
|
+
// whole begin() once any statement in it fails). The savepoint rolls back the failed
|
|
338
|
+
// INSERT while allowing the error to propagate and remain catchable.
|
|
344
339
|
let result;
|
|
345
340
|
try {
|
|
346
341
|
result = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
|
|
@@ -355,8 +350,8 @@ export function createWriteVerbs(ctx) {
|
|
|
355
350
|
return writeFailure(new InternalError({ message: "projection insert returned no row" }));
|
|
356
351
|
}
|
|
357
352
|
const row = result.row;
|
|
358
|
-
// Read-
|
|
359
|
-
//
|
|
353
|
+
// Read-side conversion: DB form → combined API form for all
|
|
354
|
+
// compound types in one pass.
|
|
360
355
|
const projection = await decryptForRead(rehydrateCompoundTypes(row, entity));
|
|
361
356
|
if (entityCache && entityName) {
|
|
362
357
|
await entityCache.del(streamTenantId ?? user.tenantId, entityName, aggregateId);
|
|
@@ -376,7 +371,7 @@ export function createWriteVerbs(ctx) {
|
|
|
376
371
|
isNew: true,
|
|
377
372
|
entityName,
|
|
378
373
|
// Persisted event carries ciphertext by design — the caller-facing
|
|
379
|
-
// echo must be plaintext like every other response field
|
|
374
|
+
// echo must be plaintext like every other response field.
|
|
380
375
|
event: echoEvent,
|
|
381
376
|
}),
|
|
382
377
|
};
|
|
@@ -430,7 +425,7 @@ export function createWriteVerbs(ctx) {
|
|
|
430
425
|
// touch on this specific row.
|
|
431
426
|
//
|
|
432
427
|
// Which fields get checked is scoped to `payload.changes` (the user's
|
|
433
|
-
// actual submission), NOT `changes` (post-preSave-hook data
|
|
428
|
+
// actual submission), NOT `changes` (post-preSave-hook data)
|
|
434
429
|
// — a hook-derived field the user never submitted (e.g. a system hook
|
|
435
430
|
// setting `assignedTo`) has no business being field-ownership-checked
|
|
436
431
|
// against the *user*, and doing so rejects writes the user is fully
|
|
@@ -438,7 +433,7 @@ export function createWriteVerbs(ctx) {
|
|
|
438
433
|
// already runs on this same pre-hook payload for consistency. The rule
|
|
439
434
|
// for a checked field is still evaluated against the full post-hook
|
|
440
435
|
// row (`changes` merged onto `previous`) — an ownership rule can
|
|
441
|
-
// reference a column only a hook populates
|
|
436
|
+
// reference a column only a hook populates.
|
|
442
437
|
const fieldDeniedUpdate = checkWriteFieldOwnership(entity, payload.changes, ownershipSubject(user, streamTenantId), previous, changes);
|
|
443
438
|
if (fieldDeniedUpdate) {
|
|
444
439
|
return writeFailure(new UnprocessableError("ownership_denied", {
|
|
@@ -497,16 +492,17 @@ export function createWriteVerbs(ctx) {
|
|
|
497
492
|
}
|
|
498
493
|
}
|
|
499
494
|
try {
|
|
500
|
-
// Compound-
|
|
495
|
+
// Compound-type conversion in one pass.
|
|
501
496
|
// subjectSource: partial changes may carry a pii field without its
|
|
502
|
-
// ownerField — the merged row still
|
|
503
|
-
// skipUnchanged
|
|
504
|
-
// encryption so pii/encrypted fields
|
|
505
|
-
//
|
|
497
|
+
// ownerField — the merged row still identifies the subject.
|
|
498
|
+
// skipUnchanged is opt-in: an identical resubmitted key is dropped before
|
|
499
|
+
// encryption so pii/encrypted fields receive no fresh ciphertext when nothing
|
|
500
|
+
// actually changed.
|
|
501
|
+
// Direct callers (KEK rotation, backfill) rely on the default to force re-encryption.
|
|
506
502
|
const submittedChanges = updateOptions?.skipUnchanged
|
|
507
503
|
? Object.fromEntries(Object.entries(changes).filter(([key, value]) => !isUnchangedValue(value, previous[key])))
|
|
508
504
|
: changes;
|
|
509
|
-
//
|
|
505
|
+
// Crypto-shredding follows the current owner, so personal
|
|
510
506
|
// fields not in this update are re-written under the new owner's key.
|
|
511
507
|
// Owner -> absent is not re-keyed; the value stays under the old owner.
|
|
512
508
|
const changesToPersist = {
|
|
@@ -529,8 +525,8 @@ export function createWriteVerbs(ctx) {
|
|
|
529
525
|
// re-encrypt it before it's persisted so plaintext of pii/encrypted
|
|
530
526
|
// fields doesn't land in the immutable log (flatChanges is already
|
|
531
527
|
// ciphertext from encryptForStorage above).
|
|
532
|
-
// Owner chain for the ownerless-then-assigned row: previous owner, then
|
|
533
|
-
// owner this event sets, then whenAbsent
|
|
528
|
+
// Owner chain for the ownerless-then-assigned row: previous owner, then
|
|
529
|
+
// the owner this event sets, then whenAbsent.
|
|
534
530
|
const encryptedPrevious = await encryptForStorage(previous, user, {
|
|
535
531
|
keyTenantId: streamTenantId,
|
|
536
532
|
ownerFallback: submittedChanges,
|
|
@@ -552,17 +548,16 @@ export function createWriteVerbs(ctx) {
|
|
|
552
548
|
},
|
|
553
549
|
metadata: buildEventMetadata(user),
|
|
554
550
|
}));
|
|
555
|
-
// Live
|
|
556
|
-
// apply
|
|
557
|
-
//
|
|
551
|
+
// Live and rebuild use applyEntityEvent with the same StoredEvent —
|
|
552
|
+
// apply reads only `changes`, which contain identical ciphertext in live writes
|
|
553
|
+
// and replays.
|
|
558
554
|
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
// failed nested one throws 500 instead of failing cleanly.
|
|
555
|
+
// An update that violates a unique index (for example, an email update to an
|
|
556
|
+
// already-existing value) is reported as 409 unique_violation instead of 500
|
|
557
|
+
// internal_error.
|
|
558
|
+
// Savepoint like the create path: a raw DB error from the projection UPDATE would
|
|
559
|
+
// otherwise poison the enclosing transaction, so a sibling write after a failed
|
|
560
|
+
// nested one would throw 500 instead of failing cleanly.
|
|
566
561
|
let result;
|
|
567
562
|
try {
|
|
568
563
|
result = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
|