@cosmicdrift/kumiko-framework 0.363.0 → 0.365.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/AGENTS.md +338 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +446 -0
- package/agents/examples/anonymous-access.md +288 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +319 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +315 -0
- package/agents/examples/custom-handlers.md +444 -0
- package/agents/examples/dashboard-stats.md +229 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +414 -0
- package/agents/examples/error-contract.md +535 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +274 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +215 -0
- package/agents/examples/ledger-invoicing.md +236 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +346 -0
- package/agents/examples/ownership.md +404 -0
- package/agents/examples/pdf-audit-export.md +306 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +250 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +418 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +75 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +7 -0
- package/dist/api/auth-routes.d.ts +0 -1
- package/dist/api/auth-routes.js +49 -46
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/routes.js +46 -17
- package/dist/api/server.d.ts +4 -3
- package/dist/api/server.js +39 -10
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/connection.js +3 -16
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +15 -3
- package/dist/bun-db/query.js +76 -24
- package/dist/crypto/owner-transition-rekey.d.ts +1 -0
- package/dist/crypto/owner-transition-rekey.js +7 -3
- package/dist/crypto/pii-field-encryption.d.ts +1 -0
- package/dist/crypto/pii-field-encryption.js +9 -2
- package/dist/crypto/subject-resolver.d.ts +1 -0
- package/dist/crypto/subject-resolver.js +7 -0
- package/dist/db/api.d.ts +4 -3
- package/dist/db/apply-entity-event.js +26 -2
- package/dist/db/blind-index-cleanup.js +2 -1
- package/dist/db/bun-provider.js +9 -18
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/dialect.d.ts +1 -1
- package/dist/db/dialect.js +2 -0
- package/dist/db/eagerload.js +4 -3
- package/dist/db/event-store-executor-context.d.ts +6 -6
- package/dist/db/event-store-executor-context.js +25 -22
- package/dist/db/event-store-executor-read.js +31 -16
- package/dist/db/event-store-executor-write.d.ts +1 -1
- package/dist/db/event-store-executor-write.js +84 -63
- package/dist/db/index.d.ts +2 -1
- package/dist/db/index.js +2 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +1 -1
- package/dist/db/migrate-runner.d.ts +1 -0
- package/dist/db/migrate-runner.js +64 -38
- package/dist/db/queries/backfill-pii.js +63 -25
- package/dist/db/queries/event-consumer.d.ts +6 -6
- package/dist/db/queries/event-consumer.js +2 -1
- package/dist/db/queries/event-store-transfer.js +15 -4
- package/dist/db/queries/event-store.d.ts +1 -1
- package/dist/db/queries/event-store.js +11 -10
- package/dist/db/queries/pii-backfill-state.js +3 -2
- package/dist/db/queries/postgres-array-literal.d.ts +2 -0
- package/dist/db/queries/postgres-array-literal.js +6 -0
- package/dist/db/queries/projection-rebuild.d.ts +1 -1
- package/dist/db/queries/projection-rebuild.js +3 -2
- package/dist/db/queries/shadow-swap.d.ts +1 -1
- package/dist/db/queries/shadow-swap.js +12 -8
- package/dist/db/query.d.ts +1 -1
- package/dist/db/query.js +1 -1
- package/dist/db/retired-framework-tables.js +22 -4
- package/dist/db/table-builder.js +6 -2
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +4 -0
- package/dist/db/tenant-db.js +50 -5
- package/dist/derivatives/delete-stored-file.d.ts +4 -0
- package/dist/derivatives/delete-stored-file.js +9 -0
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/index.d.ts +1 -1
- package/dist/derivatives/index.js +1 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +87 -35
- package/dist/engine/boot-validator/entity-list-screens.js +2 -2
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +8 -8
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/query-refs.js +14 -2
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +153 -95
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/dedupe-features.js +31 -4
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +8 -1
- package/dist/engine/entity-handlers.js +14 -7
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
- package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
- package/dist/engine/feature-ast/render.js +10 -2
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -3
- package/dist/engine/index.js +1 -1
- package/dist/engine/schema-builder.js +2 -1
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +2 -2
- package/dist/engine/soft-delete-cleanup.js +10 -3
- package/dist/engine/steps/_read-source.js +5 -2
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
- package/dist/engine/template-variables.d.ts +3 -0
- package/dist/engine/template-variables.js +3 -0
- package/dist/engine/tier-resolver-extension.d.ts +1 -0
- package/dist/engine/tier-resolver-extension.js +6 -0
- package/dist/engine/types/index.d.ts +1 -1
- package/dist/entrypoint/index.d.ts +2 -2
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/docs-url.js +6 -6
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/subject-erased.d.ts +1 -0
- package/dist/errors/subject-erased.js +6 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/admin-api.js +3 -2
- package/dist/event-store/errors.d.ts +9 -0
- package/dist/event-store/errors.js +20 -0
- package/dist/event-store/event-store.d.ts +6 -0
- package/dist/event-store/event-store.js +28 -6
- package/dist/event-store/index.d.ts +1 -1
- package/dist/event-store/index.js +1 -1
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +24 -1
- package/dist/files/create-file-ref.js +67 -25
- package/dist/files/dispose-removed-file-refs.js +21 -6
- package/dist/files/feature.d.ts +1 -0
- package/dist/files/feature.js +2 -1
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +12 -0
- package/dist/files/file-routes.js +10 -6
- package/dist/files/index.d.ts +3 -3
- package/dist/files/index.js +2 -2
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +46 -21
- package/dist/files/resolve-content-type.js +40 -4
- package/dist/files/storage-tracking.js +5 -4
- package/dist/files/types.js +5 -8
- package/dist/files/zip-entry-names.d.ts +1 -0
- package/dist/files/zip-entry-names.js +46 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +5 -1
- package/dist/jobs/job-runner.js +124 -37
- package/dist/observability/escape-hatch-report.d.ts +2 -1
- package/dist/observability/escape-hatch-report.js +48 -7
- package/dist/observability/index.d.ts +1 -1
- package/dist/observability/index.js +1 -1
- package/dist/observability/metrics-handle.d.ts +1 -0
- package/dist/observability/metrics-handle.js +7 -0
- package/dist/observability/standard-metrics.js +4 -4
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.d.ts +2 -1
- package/dist/pipeline/dispatch-query.js +6 -6
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +136 -48
- package/dist/pipeline/dispatch-stream.js +10 -4
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/dispatcher.js +3 -1
- package/dist/pipeline/event-dispatcher.d.ts +2 -2
- package/dist/pipeline/event-dispatcher.js +11 -9
- package/dist/pipeline/event-retention.d.ts +1 -0
- package/dist/pipeline/event-retention.js +7 -1
- package/dist/pipeline/index.d.ts +1 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/notify-scope.js +3 -1
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +29 -9
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/schema-cli.js +12 -1
- package/dist/search/meilisearch-adapter.js +36 -9
- package/dist/stack/db.d.ts +2 -2
- package/dist/stack/test-stack.js +2 -1
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/multipart-helper.d.ts +6 -0
- package/dist/testing/multipart-helper.js +1 -1
- package/dist/testing/zip-fixture.d.ts +1 -0
- package/dist/testing/zip-fixture.js +33 -0
- package/dist/ui-types/card-column-roles.js +4 -2
- package/dist/ui-types/index.d.ts +1 -1
- package/dist/ui-types/index.js +1 -1
- package/dist/upgrade-cli.js +46 -13
- package/package.json +7 -5
- package/src/changes.json +512 -8
|
@@ -2,20 +2,21 @@ import { checkWriteFieldOwnership } from "../engine/field-access.js";
|
|
|
2
2
|
import { instructionFieldNames } from "../engine/instruction-fields.js";
|
|
3
3
|
import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership.js";
|
|
4
4
|
import { buildInsertSchema, buildUpdateSchema } from "../engine/schema-builder.js";
|
|
5
|
-
import { findUnavailableSelectOptions, isRecordLocked, } from "../engine/screen-helpers.js";
|
|
6
|
-
import {
|
|
7
|
-
import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RecordLockedError, UnprocessableError, writeFailure, } from "../errors/index.js";
|
|
5
|
+
import { findUnavailableSelectOptions, isRecordLocked, isUnchangedValue, } from "../engine/screen-helpers.js";
|
|
6
|
+
import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, IdempotentReplayError, InternalError, isSubjectErasedConflict, NotFoundError, PreconditionFailedError, RecordLockedError, UnprocessableError, writeFailure, } from "../errors/index.js";
|
|
8
7
|
import { append, IdempotentAppendConflictError as EventStoreIdempotentAppendConflict, VersionConflictError as EventStoreVersionConflict, getStreamVersion, } from "../event-store/index.js";
|
|
9
8
|
import { assertInstructionFieldWriteAllowed, assertIrreversibleOperationAllowed, isIrreversibleEntityVerb, } from "../pipeline/irreversible-operation-gate.js";
|
|
10
9
|
import { hasLiveProjectionsForEvent, runProjectionsForEvent, } from "../pipeline/projections-runner.js";
|
|
11
10
|
import { generateId } from "../utils/index.js";
|
|
12
11
|
import { applyEntityEvent } from "./apply-entity-event.js";
|
|
13
12
|
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
13
|
+
import { WritePurposes, } from "./event-store-executor.js";
|
|
14
14
|
import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, ownershipSubject, tryMapUniqueViolation, } from "./event-store-executor-context.js";
|
|
15
|
+
import { isFrameworkSystemUser, isLockGuardBypassed } from "./lock-guard.js";
|
|
15
16
|
import { projectionRegistryOf } from "./projection-registry-binding.js";
|
|
16
17
|
import { runInSavepointIfSupported } from "./query.js";
|
|
17
18
|
import { assertPersonalDataWrite, tableNameOf } from "./tenant-db.js";
|
|
18
|
-
import {
|
|
19
|
+
import { tenantDbWriteRunner } from "./tenant-db-runner.js";
|
|
19
20
|
// Custom projections for a write that did not go through the dispatcher (hook or
|
|
20
21
|
// job writing a second aggregate). Savepoint like applyEntityEvent: a throwing
|
|
21
22
|
// projection fails the write and the caller's transaction rolls it back.
|
|
@@ -29,30 +30,11 @@ async function projectEntityEvent(db, runner, event) {
|
|
|
29
30
|
return;
|
|
30
31
|
await runInSavepointIfSupported(runner, (sp) => runProjectionsForEvent(event, registry, sp));
|
|
31
32
|
}
|
|
32
|
-
// Art. 17 erasure runs as the framework operator, not as a row owner; a
|
|
33
|
-
// per-role ownership map can never cover it, and a silent deny means the
|
|
34
|
-
// erasure never happened.
|
|
35
|
-
function isFrameworkSystemUser(user) {
|
|
36
|
-
return user.id === SYSTEM_USER_ID && user.roles.includes(SYSTEM_ROLE);
|
|
37
|
-
}
|
|
38
33
|
// The five write verbs (create/update/delete/forget/restore) of the event-
|
|
39
34
|
// store-executor. Split out of event-store-executor.ts (#1005, Welle 2) —
|
|
40
35
|
// behavior-preserving relocation, not a redesign: every closure below is
|
|
41
36
|
// unchanged from the original, just relocated behind an explicit
|
|
42
37
|
// ExecutorContext instead of capturing the factory's local scope directly.
|
|
43
|
-
// updateOptions.skipUnchanged (#464) — opt-in: a resubmitted-but-identical
|
|
44
|
-
// key is dropped before encryption so pii/encrypted fields don't get a fresh
|
|
45
|
-
// AEAD ciphertext (new nonce) for no real change, and the event's `changes`
|
|
46
|
-
// don't carry a phantom diff. Opt-in, not the executor's default, because
|
|
47
|
-
// direct executor.update() callers rely on the current behavior to FORCE a
|
|
48
|
-
// re-encrypt of an unchanged plaintext — KEK-rotation (auth-mfa reencrypt.job)
|
|
49
|
-
// and the user-data-rights #494 backfill both resubmit the current value on
|
|
50
|
-
// purpose to land a fresh event/ciphertext. Only the generic entity update
|
|
51
|
-
// handler (entity-handlers.ts) sets this flag; those two call their own
|
|
52
|
-
// executor directly and never see it.
|
|
53
|
-
function isUnchangedValue(a, b) {
|
|
54
|
-
return JSON.stringify(a) === JSON.stringify(b);
|
|
55
|
-
}
|
|
56
38
|
function selectOptionNotAvailableFailure(entries) {
|
|
57
39
|
const [first] = entries;
|
|
58
40
|
if (!first)
|
|
@@ -126,13 +108,16 @@ export function createWriteVerbs(ctx) {
|
|
|
126
108
|
// statutory deletion must reach locked rows, and forget (Art. 17) never checks the lock
|
|
127
109
|
// at all. The options are server-code only; no request field is ever copied into them.
|
|
128
110
|
function recordLockedFailure(row, id, user, options) {
|
|
129
|
-
if (options
|
|
130
|
-
(options?.writePurpose !== undefined && isFrameworkSystemUser(user)) ||
|
|
131
|
-
!isRecordLocked(entity.lockedWhen, row)) {
|
|
111
|
+
if (isLockGuardBypassed(user, options) || !isRecordLocked(entity.lockedWhen, row)) {
|
|
132
112
|
return undefined;
|
|
133
113
|
}
|
|
134
114
|
return writeFailure(new RecordLockedError({ entityName, entityId: id }));
|
|
135
115
|
}
|
|
116
|
+
// Erasure writes run after the subject key is gone; they store the erased
|
|
117
|
+
// sentinel for that subject's fields instead of failing on re-encrypt.
|
|
118
|
+
function erasureSentinelOption(options) {
|
|
119
|
+
return options?.writePurpose === WritePurposes.erasure ? { onErasedSubject: "sentinel" } : {};
|
|
120
|
+
}
|
|
136
121
|
const eventVersion = entity.eventVersion ?? 1;
|
|
137
122
|
let insertSchema;
|
|
138
123
|
let updateSchema;
|
|
@@ -157,6 +142,45 @@ export function createWriteVerbs(ctx) {
|
|
|
157
142
|
}
|
|
158
143
|
return { row: row };
|
|
159
144
|
}
|
|
145
|
+
// A forgotten subject has no key any more: restore re-encrypts the stored row and reports
|
|
146
|
+
// that as a failure result like any other write conflict instead of throwing.
|
|
147
|
+
async function appendRestoredEvent(runner, user, id, previousPlain, streamTenantId, currentVersion) {
|
|
148
|
+
try {
|
|
149
|
+
const previous = await encryptForStorage(previousPlain, user, {
|
|
150
|
+
keyTenantId: streamTenantId,
|
|
151
|
+
});
|
|
152
|
+
const event = await runInSavepointIfSupported(runner, (sp) => append(sp, {
|
|
153
|
+
aggregateId: String(id),
|
|
154
|
+
aggregateType: entityName,
|
|
155
|
+
tenantId: streamTenantFor(user, streamTenantId),
|
|
156
|
+
expectedVersion: currentVersion,
|
|
157
|
+
type: entityEventName(entityName, "restored"),
|
|
158
|
+
eventVersion,
|
|
159
|
+
payload: { previous },
|
|
160
|
+
metadata: buildEventMetadata(user),
|
|
161
|
+
}));
|
|
162
|
+
return { event };
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
if (isSubjectErasedConflict(e))
|
|
166
|
+
return { failure: writeFailure(e) };
|
|
167
|
+
if (e instanceof EventStoreVersionConflict) {
|
|
168
|
+
return {
|
|
169
|
+
failure: writeFailure(new FrameworkVersionConflict({
|
|
170
|
+
entityId: id,
|
|
171
|
+
expectedVersion: currentVersion,
|
|
172
|
+
currentVersion: -1,
|
|
173
|
+
})),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (e instanceof EventStoreIdempotentAppendConflict) {
|
|
177
|
+
return {
|
|
178
|
+
failure: writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey })),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
throw e;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
160
184
|
async function expectedStreamVersion(db, id, streamTenant, expect) {
|
|
161
185
|
const snapshot = await loadExpectSnapshot(db, id, streamTenant, Object.keys(expect));
|
|
162
186
|
const row = snapshot.row;
|
|
@@ -180,7 +204,7 @@ export function createWriteVerbs(ctx) {
|
|
|
180
204
|
const streamTenantMismatch = streamTenantCreateFailure(db, payload, streamTenantId);
|
|
181
205
|
if (streamTenantMismatch)
|
|
182
206
|
return streamTenantMismatch;
|
|
183
|
-
const runner =
|
|
207
|
+
const runner = tenantDbWriteRunner(db, "ctx.db executor create");
|
|
184
208
|
// Respect an explicit id in the payload (seed pattern, SCIM import). Without
|
|
185
209
|
// one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
|
|
186
210
|
// event payload so defaults + downstream consumers don't see a redundant id field.
|
|
@@ -349,7 +373,7 @@ export function createWriteVerbs(ctx) {
|
|
|
349
373
|
};
|
|
350
374
|
},
|
|
351
375
|
async update(payload, user, db, updateOptions) {
|
|
352
|
-
const runner =
|
|
376
|
+
const runner = tenantDbWriteRunner(db, "ctx.db executor update");
|
|
353
377
|
if (isForeignTenantOnGlobalEntity(entity, payload.changes["tenantId"])) {
|
|
354
378
|
throw new AccessDeniedError({
|
|
355
379
|
message: `${entityName}.update: entity is tenancy: "global" — payload.changes.tenantId must ` +
|
|
@@ -423,7 +447,12 @@ export function createWriteVerbs(ctx) {
|
|
|
423
447
|
const lockedUpdate = recordLockedFailure(previous, payload.id, user, updateOptions);
|
|
424
448
|
if (lockedUpdate)
|
|
425
449
|
return lockedUpdate;
|
|
426
|
-
|
|
450
|
+
// Statutory retention/erasure writes may change a condition field (e.g. null a PII
|
|
451
|
+
// reference) and must not be blocked by the select rule — same exemption as the lock.
|
|
452
|
+
const isStatutorySystemWrite = updateOptions?.writePurpose !== undefined && isFrameworkSystemUser(user);
|
|
453
|
+
const unavailableOnUpdate = isStatutorySystemWrite
|
|
454
|
+
? undefined
|
|
455
|
+
: selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, mergedNew, previous));
|
|
427
456
|
if (unavailableOnUpdate)
|
|
428
457
|
return unavailableOnUpdate;
|
|
429
458
|
const streamTenant = streamTenantFor(user, streamTenantId);
|
|
@@ -462,6 +491,9 @@ export function createWriteVerbs(ctx) {
|
|
|
462
491
|
// Compound-Types Auto-Convert (alle in einem Pass).
|
|
463
492
|
// subjectSource: partial changes may carry a pii field without its
|
|
464
493
|
// ownerField — the merged row still names the subject.
|
|
494
|
+
// skipUnchanged (#464) is opt-in: an identical resubmitted key is dropped before
|
|
495
|
+
// encryption so pii/encrypted fields get no fresh ciphertext for no real change.
|
|
496
|
+
// Direct callers (KEK rotation, #494 backfill) rely on the default to force a re-encrypt.
|
|
465
497
|
const submittedChanges = updateOptions?.skipUnchanged
|
|
466
498
|
? Object.fromEntries(Object.entries(changes).filter(([key, value]) => !isUnchangedValue(value, previous[key])))
|
|
467
499
|
: changes;
|
|
@@ -477,6 +509,7 @@ export function createWriteVerbs(ctx) {
|
|
|
477
509
|
onlyKeys: Object.keys(changesToPersist),
|
|
478
510
|
subjectSource: mergedNew,
|
|
479
511
|
keyTenantId: streamTenantId,
|
|
512
|
+
...erasureSentinelOption(updateOptions),
|
|
480
513
|
});
|
|
481
514
|
// The event payload carries BOTH `changes` (what the user asked for) AND
|
|
482
515
|
// `previous` (the pre-update row). Cross-aggregate projections need the
|
|
@@ -492,6 +525,7 @@ export function createWriteVerbs(ctx) {
|
|
|
492
525
|
const encryptedPrevious = await encryptForStorage(previous, user, {
|
|
493
526
|
keyTenantId: streamTenantId,
|
|
494
527
|
ownerFallback: submittedChanges,
|
|
528
|
+
...erasureSentinelOption(updateOptions),
|
|
495
529
|
});
|
|
496
530
|
// Savepoint-scoped — see the create() append() above for why:
|
|
497
531
|
// confines a losing writer's unique-violation to a nested scope
|
|
@@ -582,7 +616,7 @@ export function createWriteVerbs(ctx) {
|
|
|
582
616
|
if (isIrreversibleEntityVerb("delete", entity)) {
|
|
583
617
|
assertIrreversibleOperationAllowed(`${entityName}.delete (entity has no softDelete)`);
|
|
584
618
|
}
|
|
585
|
-
const runner =
|
|
619
|
+
const runner = tenantDbWriteRunner(db, "ctx.db executor delete");
|
|
586
620
|
const existing = await loadById(payload.id, db);
|
|
587
621
|
if (!existing)
|
|
588
622
|
return writeFailure(new NotFoundError(entityName, payload.id));
|
|
@@ -628,7 +662,10 @@ export function createWriteVerbs(ctx) {
|
|
|
628
662
|
type: entityEventName(entityName, "deleted"),
|
|
629
663
|
eventVersion,
|
|
630
664
|
payload: {
|
|
631
|
-
previous: await encryptForStorage(existing, user, {
|
|
665
|
+
previous: await encryptForStorage(existing, user, {
|
|
666
|
+
keyTenantId: streamTenantId,
|
|
667
|
+
...erasureSentinelOption(options),
|
|
668
|
+
}),
|
|
632
669
|
},
|
|
633
670
|
metadata: buildEventMetadata(user),
|
|
634
671
|
}));
|
|
@@ -664,6 +701,7 @@ export function createWriteVerbs(ctx) {
|
|
|
664
701
|
data: existing,
|
|
665
702
|
entityName,
|
|
666
703
|
event: echoEvent,
|
|
704
|
+
...(isLockGuardBypassed(user, options) && { lockGuardBypassed: true }),
|
|
667
705
|
},
|
|
668
706
|
};
|
|
669
707
|
},
|
|
@@ -673,7 +711,7 @@ export function createWriteVerbs(ctx) {
|
|
|
673
711
|
// without the isDeleted filter so trashed (soft-deleted) rows are erased too.
|
|
674
712
|
async forget(payload, user, db, options) {
|
|
675
713
|
assertIrreversibleOperationAllowed(`${entityName}.forget`);
|
|
676
|
-
const runner =
|
|
714
|
+
const runner = tenantDbWriteRunner(db, "ctx.db executor forget");
|
|
677
715
|
const raw = await db.fetchOne(table, { id: payload.id });
|
|
678
716
|
if (!raw)
|
|
679
717
|
return writeFailure(new NotFoundError(entityName, payload.id));
|
|
@@ -709,7 +747,10 @@ export function createWriteVerbs(ctx) {
|
|
|
709
747
|
// Re-encrypt like delete(): `existing` came decrypted from loadById —
|
|
710
748
|
// plaintext must not land in the immutable log, least of all on forget.
|
|
711
749
|
payload: {
|
|
712
|
-
previous: await encryptForStorage(existing, user, {
|
|
750
|
+
previous: await encryptForStorage(existing, user, {
|
|
751
|
+
keyTenantId: streamTenantId,
|
|
752
|
+
onErasedSubject: "sentinel",
|
|
753
|
+
}),
|
|
713
754
|
},
|
|
714
755
|
metadata: buildEventMetadata(user),
|
|
715
756
|
}));
|
|
@@ -744,6 +785,7 @@ export function createWriteVerbs(ctx) {
|
|
|
744
785
|
data: existing,
|
|
745
786
|
entityName,
|
|
746
787
|
event: echoEvent,
|
|
788
|
+
lockGuardBypassed: true,
|
|
747
789
|
},
|
|
748
790
|
};
|
|
749
791
|
},
|
|
@@ -753,7 +795,7 @@ export function createWriteVerbs(ctx) {
|
|
|
753
795
|
i18nKey: "errors.softDeleteNotEnabled",
|
|
754
796
|
}));
|
|
755
797
|
}
|
|
756
|
-
const runner =
|
|
798
|
+
const runner = tenantDbWriteRunner(db, "ctx.db executor restore");
|
|
757
799
|
const streamTenantId = options?.streamTenantId;
|
|
758
800
|
const target = await loadSoftDeletedRow(db, payload.id, streamTenantId);
|
|
759
801
|
if ("failure" in target)
|
|
@@ -780,34 +822,14 @@ export function createWriteVerbs(ctx) {
|
|
|
780
822
|
// Restore carries the soft-deleted snapshot as `previous` — mirror of
|
|
781
823
|
// delete for symmetry. Projections that decremented on delete use
|
|
782
824
|
// `previous` to re-increment on restore without re-querying the entity
|
|
783
|
-
// table.
|
|
784
|
-
//
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
expectedVersion: currentVersion,
|
|
792
|
-
type: entityEventName(entityName, "restored"),
|
|
793
|
-
eventVersion,
|
|
794
|
-
payload: { previous: data },
|
|
795
|
-
metadata: buildEventMetadata(user),
|
|
796
|
-
}));
|
|
797
|
-
}
|
|
798
|
-
catch (e) {
|
|
799
|
-
if (e instanceof EventStoreVersionConflict) {
|
|
800
|
-
return writeFailure(new FrameworkVersionConflict({
|
|
801
|
-
entityId: payload.id,
|
|
802
|
-
expectedVersion: currentVersion,
|
|
803
|
-
currentVersion: -1,
|
|
804
|
-
}));
|
|
805
|
-
}
|
|
806
|
-
if (e instanceof EventStoreIdempotentAppendConflict) {
|
|
807
|
-
return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
|
|
808
|
-
}
|
|
809
|
-
throw e;
|
|
810
|
-
}
|
|
825
|
+
// table. Rows predating the subject-KMS rollout may still hold plaintext
|
|
826
|
+
// pii, and envelope encryption is not idempotent: decrypt, then
|
|
827
|
+
// re-encrypt as delete does.
|
|
828
|
+
const previousPlain = await decryptForRead(data);
|
|
829
|
+
const appended = await appendRestoredEvent(runner, user, payload.id, previousPlain, streamTenantId, currentVersion);
|
|
830
|
+
if ("failure" in appended)
|
|
831
|
+
return appended.failure;
|
|
832
|
+
const event = appended.event;
|
|
811
833
|
// Live==Rebuild via applyEntityEvent. Restore only writes isDeleted=false
|
|
812
834
|
// plus the version bump, so there is no sensitive-field drift and no
|
|
813
835
|
// payload override is needed.
|
|
@@ -835,7 +857,6 @@ export function createWriteVerbs(ctx) {
|
|
|
835
857
|
// same as every other executor method — `data`/`restored` are raw rows
|
|
836
858
|
// (db.fetchOne / applyEntityEvent), never decrypted before this point.
|
|
837
859
|
const restoredHydrated = await decryptForRead(rehydrateCompoundTypes(restored, entity));
|
|
838
|
-
const previousPlain = await decryptForRead(data);
|
|
839
860
|
const echoEvent = { ...event, payload: { previous: stripSensitive(previousPlain) } };
|
|
840
861
|
await projectEntityEvent(db, runner, echoEvent);
|
|
841
862
|
return {
|
package/dist/db/index.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export { type AppliedMigration, type ApplyResult, type BaselineResult, baselineM
|
|
|
24
24
|
export { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
|
|
25
25
|
export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, type PgErrorInfo, } from "./pg-error.js";
|
|
26
26
|
export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
|
|
27
|
+
export { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
|
|
27
28
|
export { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.js";
|
|
28
29
|
export type { SelectOptions, WhereObject, WhereValue } from "./query-api.js";
|
|
29
30
|
export { aggregateWhere, asRawClient, countWhere, deleteMany, fetchOne, insertMany, insertOne, runInSavepoint, selectMany, transaction, updateMany, } from "./query-api.js";
|
|
@@ -35,4 +36,4 @@ export { findCommentedDropTables, isRetiredFrameworkTable, type RetiredFramework
|
|
|
35
36
|
export { tableExists } from "./schema-inspection.js";
|
|
36
37
|
export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
|
|
37
38
|
export type { TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb } from "./tenant-db.js";
|
|
38
|
-
export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, } from "./tenant-db.js";
|
|
39
|
+
export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";
|
package/dist/db/index.js
CHANGED
|
@@ -18,6 +18,7 @@ export { baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, Migr
|
|
|
18
18
|
export { flattenMoney, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
|
|
19
19
|
export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, } from "./pg-error.js";
|
|
20
20
|
export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
|
|
21
|
+
export { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
|
|
21
22
|
export { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.js";
|
|
22
23
|
export { aggregateWhere, asRawClient, countWhere, deleteMany, fetchOne, insertMany, insertOne, runInSavepoint, selectMany, transaction, updateMany, } from "./query-api.js";
|
|
23
24
|
export { readRebuildMarker, rebuildTablesFromDiff, writeRebuildMarker, } from "./rebuild-marker.js";
|
|
@@ -27,4 +28,4 @@ export { diffReplayAgainstSnapshot, replayMigrationsDir, } from "./replay-migrat
|
|
|
27
28
|
export { findCommentedDropTables, isRetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.js";
|
|
28
29
|
export { tableExists } from "./schema-inspection.js";
|
|
29
30
|
export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
|
|
30
|
-
export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, } from "./tenant-db.js";
|
|
31
|
+
export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { SessionUser } from "../engine/types/index.js";
|
|
2
|
+
import type { LockGuardOption } from "./event-store-executor.js";
|
|
3
|
+
export declare function isFrameworkSystemUser(user: SessionUser): boolean;
|
|
4
|
+
export declare function isLockGuardBypassed(user: SessionUser, options: LockGuardOption | undefined): boolean;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user.js";
|
|
2
|
+
// Art. 17 erasure runs as the framework operator, not as a row owner; a
|
|
3
|
+
// per-role ownership map can never cover it, and a silent deny means the
|
|
4
|
+
// erasure never happened.
|
|
5
|
+
export function isFrameworkSystemUser(user) {
|
|
6
|
+
return user.id === SYSTEM_USER_ID && user.roles.includes(SYSTEM_ROLE);
|
|
7
|
+
}
|
|
8
|
+
// Framework system writes that name a writePurpose (retention, erasure) are exempt:
|
|
9
|
+
// statutory deletion must reach locked rows. The options are server-code only; no
|
|
10
|
+
// request field is ever copied into them.
|
|
11
|
+
export function isLockGuardBypassed(user, options) {
|
|
12
|
+
return (options?.unsafeSkipLockGuard === true ||
|
|
13
|
+
(options?.writePurpose !== undefined && isFrameworkSystemUser(user)));
|
|
14
|
+
}
|
|
@@ -437,7 +437,7 @@ export function assertUniqueTableNames(metas) {
|
|
|
437
437
|
const duplicates = [...counts].filter(([, count]) => count > 1);
|
|
438
438
|
if (duplicates.length > 0) {
|
|
439
439
|
throw new Error(duplicates
|
|
440
|
-
.map(([tableName, count]) => `ENTITY_METAS lists table "${tableName}" ${count} times — the migration diff would silently use only one definition. collectTableMetas(features) already includes every table a feature registers (r.storeTable, entities, projections); remove the
|
|
440
|
+
.map(([tableName, count]) => `ENTITY_METAS lists table "${tableName}" ${count} times — the migration diff would silently use only one definition. collectTableMetas(features) already includes every table a feature registers (r.storeTable, entities, projections); remove the duplicate entry for "${tableName}" (a hand-added meta, or two features registering the same table).`)
|
|
441
441
|
.join("\n"));
|
|
442
442
|
}
|
|
443
443
|
}
|
|
@@ -12,6 +12,7 @@ export type ApplyResult = {
|
|
|
12
12
|
readonly applied: readonly string[];
|
|
13
13
|
readonly skipped: readonly string[];
|
|
14
14
|
};
|
|
15
|
+
export declare const MIGRATION_ADVISORY_LOCK_KEY = 1802857833;
|
|
15
16
|
export declare function splitSqlStatements(sqlText: string): readonly string[];
|
|
16
17
|
export declare function loadMigrationsFromDir(dir: string, preprocess?: (sql: string) => string): readonly Migration[];
|
|
17
18
|
export declare function readMigrationSqlTexts(dir: string): readonly string[];
|
|
@@ -17,11 +17,11 @@ import { createHash } from "node:crypto";
|
|
|
17
17
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
20
|
-
import { asRawClient } from "../bun-db/query.js";
|
|
20
|
+
import { asRawClient, hasBegin, } from "../bun-db/query.js";
|
|
21
21
|
// Advisory-lock key (random 32-bit int, stable). Verhindert dass zwei
|
|
22
22
|
// gleichzeitig bootende Pods beide migrate apply laufen lassen (zweiter
|
|
23
23
|
// blockiert bis erster fertig).
|
|
24
|
-
const
|
|
24
|
+
export const MIGRATION_ADVISORY_LOCK_KEY = 0x6b756d69; // "kumi" in ASCII
|
|
25
25
|
const MIGRATIONS_TABLE_DDL = `
|
|
26
26
|
CREATE TABLE IF NOT EXISTS "_kumiko_migrations" (
|
|
27
27
|
"id" text PRIMARY KEY NOT NULL,
|
|
@@ -169,24 +169,35 @@ export function readMigrationSqlTexts(dir) {
|
|
|
169
169
|
// without it fall through to the drizzle wrappers and otherwise fail here,
|
|
170
170
|
// before any SQL runs, with the migrate-runner message.
|
|
171
171
|
function migrationClient(db) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return asRawClient(db);
|
|
175
|
-
for (const wrapped of [
|
|
172
|
+
for (const candidate of [
|
|
173
|
+
db,
|
|
176
174
|
propertyOf(db, "$client"),
|
|
177
175
|
propertyOf(propertyOf(db, "session"), "client"),
|
|
178
176
|
]) {
|
|
179
|
-
if (typeof propertyOf(
|
|
180
|
-
|
|
177
|
+
if (typeof propertyOf(candidate, "unsafe") !== "function")
|
|
178
|
+
continue;
|
|
179
|
+
const client = asRawClient(candidate);
|
|
180
|
+
if (hasBegin(client))
|
|
181
|
+
return client;
|
|
181
182
|
}
|
|
182
|
-
throw new Error("migrate-runner: db argument has no .unsafe() (need Bun.SQL or drizzle DbConnection)");
|
|
183
|
+
throw new Error("migrate-runner: db argument has no .unsafe() and .begin() (need Bun.SQL or drizzle DbConnection)");
|
|
183
184
|
}
|
|
184
185
|
// Raw-SQL via postgres-js or Bun.sql .unsafe() — same shape for both.
|
|
185
186
|
async function executeRaw(db, sqlText) {
|
|
186
187
|
await migrationClient(db).unsafe(sqlText);
|
|
187
188
|
}
|
|
189
|
+
async function reserveConnection(db) {
|
|
190
|
+
const client = migrationClient(db);
|
|
191
|
+
if (typeof client.reserve !== "function") {
|
|
192
|
+
throw new Error("migrate-runner: db argument has no .reserve() (need Bun.SQL or postgres-js Sql)");
|
|
193
|
+
}
|
|
194
|
+
return client.reserve();
|
|
195
|
+
}
|
|
188
196
|
export async function fetchAppliedMigrations(db) {
|
|
189
|
-
|
|
197
|
+
return fetchAppliedFrom(migrationClient(db));
|
|
198
|
+
}
|
|
199
|
+
async function fetchAppliedFrom(client) {
|
|
200
|
+
const result = await client.unsafe(`SELECT id, checksum FROM "_kumiko_migrations" ORDER BY id`);
|
|
190
201
|
const rows = Array.isArray(result) ? result : [];
|
|
191
202
|
const applied = [];
|
|
192
203
|
for (const row of rows) {
|
|
@@ -215,40 +226,55 @@ export class MigrationChecksumMismatchError extends Error {
|
|
|
215
226
|
// _kumiko_migrations-Lookup übersprungen. Checksum-Mismatch wirft fail-loud.
|
|
216
227
|
// Advisory-Lock verhindert concurrent-apply-races.
|
|
217
228
|
export async function runMigrations(db, migrations) {
|
|
218
|
-
|
|
219
|
-
|
|
229
|
+
// Session-level advisory lock and per-migration BEGIN/COMMIT must share one
|
|
230
|
+
// session. Pool calls may land on different connections (Bun.SQL round-robins),
|
|
231
|
+
// which would leave the lock held on a session nobody unlocks.
|
|
232
|
+
const conn = await reserveConnection(db);
|
|
220
233
|
try {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
234
|
+
await conn.unsafe(MIGRATIONS_TABLE_DDL);
|
|
235
|
+
await conn.unsafe(`SELECT pg_advisory_lock(${MIGRATION_ADVISORY_LOCK_KEY})`);
|
|
236
|
+
try {
|
|
237
|
+
const applied = new Map((await fetchAppliedFrom(conn)).map((a) => [a.id, a.checksum]));
|
|
238
|
+
const appliedIds = [];
|
|
239
|
+
const skippedIds = [];
|
|
240
|
+
for (const m of migrations) {
|
|
241
|
+
const prevChecksum = applied.get(m.id);
|
|
242
|
+
if (prevChecksum !== undefined) {
|
|
243
|
+
if (prevChecksum !== m.checksum) {
|
|
244
|
+
throw new MigrationChecksumMismatchError(m.id, prevChecksum, m.checksum);
|
|
245
|
+
}
|
|
246
|
+
skippedIds.push(m.id);
|
|
247
|
+
continue;
|
|
229
248
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
249
|
+
// Apply file content + INSERT tracking-row in einer TX. Wenn ein
|
|
250
|
+
// Statement bricht, Rollback inkl. Tracking-Row → kein partial-apply
|
|
251
|
+
// stuck in den books.
|
|
252
|
+
await conn.unsafe("BEGIN");
|
|
253
|
+
try {
|
|
254
|
+
for (const stmt of m.statements) {
|
|
255
|
+
await conn.unsafe(stmt);
|
|
256
|
+
}
|
|
257
|
+
await conn.unsafe(`INSERT INTO "_kumiko_migrations" ("id", "checksum") VALUES ($1, $2)`, [
|
|
258
|
+
m.id,
|
|
259
|
+
m.checksum,
|
|
260
|
+
]);
|
|
261
|
+
await conn.unsafe("COMMIT");
|
|
262
|
+
}
|
|
263
|
+
catch (e) {
|
|
264
|
+
// A failed ROLLBACK means the session is gone (its lock and tx with it); keep the migration error.
|
|
265
|
+
await conn.unsafe("ROLLBACK").catch(() => undefined);
|
|
266
|
+
throw e;
|
|
240
267
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
268
|
+
appliedIds.push(m.id);
|
|
269
|
+
}
|
|
270
|
+
return { applied: appliedIds, skipped: skippedIds };
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
await conn.unsafe(`SELECT pg_advisory_unlock(${MIGRATION_ADVISORY_LOCK_KEY})`);
|
|
247
274
|
}
|
|
248
|
-
return { applied: appliedIds, skipped: skippedIds };
|
|
249
275
|
}
|
|
250
276
|
finally {
|
|
251
|
-
await
|
|
277
|
+
await conn.release();
|
|
252
278
|
}
|
|
253
279
|
}
|
|
254
280
|
// Convenience: load + run in einem Call. Wird vom `kumiko migrate apply`
|