@cosmicdrift/kumiko-framework 0.364.0 → 0.365.1
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 +36 -5
- package/agents/examples/anonymous-access-multitenant.md +9 -6
- package/agents/examples/anonymous-access.md +14 -26
- package/agents/examples/auth-mfa.md +5 -9
- package/agents/examples/custom-fields-basic.md +7 -6
- package/agents/examples/custom-handlers.md +72 -25
- package/agents/examples/dashboard-stats.md +229 -0
- package/agents/examples/encrypted-tenant-config.md +17 -8
- package/agents/examples/error-contract.md +1 -5
- package/agents/examples/feature-toggles.md +28 -17
- package/agents/examples/ledger-banking.md +7 -6
- package/agents/examples/ledger-invoicing.md +7 -6
- package/agents/examples/legal-pages.md +2 -2
- package/agents/examples/managed-pages.md +2 -2
- package/agents/examples/notes-history-basic.md +7 -6
- package/agents/examples/ownership.md +30 -22
- package/agents/examples/pdf-audit-export.md +87 -51
- package/agents/examples/session-revocation.md +1 -10
- package/agents/examples/tags-basic.md +7 -7
- package/agents/examples/user-data-rights.md +25 -17
- package/agents/examples/webhook-step.md +11 -1
- package/agents/examples/workflow-engine.md +2 -2
- package/agents/examples.md +2 -1
- package/agents/features.md +4 -4
- package/dist/api/auth-routes.d.ts +0 -1
- package/dist/api/auth-routes.js +44 -43
- package/dist/api/index.d.ts +1 -1
- package/dist/api/index.js +1 -1
- package/dist/api/server.d.ts +2 -2
- package/dist/api/server.js +29 -4
- 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 +7 -0
- package/dist/bun-db/query.js +50 -18
- package/dist/crypto/in-memory-kms-adapter.js +4 -7
- package/dist/crypto/owner-transition-rekey.d.ts +1 -0
- package/dist/crypto/owner-transition-rekey.js +7 -3
- package/dist/crypto/pg-kms-adapter.js +10 -6
- 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/dialect.d.ts +1 -1
- package/dist/db/dialect.js +2 -0
- package/dist/db/eagerload.js +2 -1
- package/dist/db/event-store-executor-context.d.ts +1 -0
- package/dist/db/event-store-executor-context.js +1 -0
- package/dist/db/event-store-executor-write.d.ts +1 -1
- package/dist/db/event-store-executor-write.js +85 -58
- package/dist/db/index.d.ts +1 -0
- package/dist/db/index.js +1 -0
- package/dist/db/migrate-runner.d.ts +1 -0
- package/dist/db/migrate-runner.js +56 -31
- package/dist/db/queries/backfill-pii.js +63 -25
- 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.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.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.d.ts +2 -0
- package/dist/db/tenant-db.js +20 -1
- package/dist/derivatives/delete-stored-file.d.ts +4 -0
- package/dist/derivatives/delete-stored-file.js +9 -0
- package/dist/derivatives/index.d.ts +1 -1
- package/dist/derivatives/index.js +1 -1
- package/dist/engine/boot-validator/entity-handler.js +3 -0
- package/dist/engine/boot-validator/query-refs.js +14 -2
- package/dist/engine/dedupe-features.js +31 -4
- package/dist/engine/entity-handlers.js +14 -7
- package/dist/engine/index.d.ts +2 -2
- package/dist/engine/index.js +1 -1
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +21 -0
- package/dist/engine/registry.js +2 -1
- package/dist/engine/screen-helpers.d.ts +1 -0
- package/dist/engine/screen-helpers.js +2 -2
- package/dist/engine/steps/_read-source.js +5 -2
- 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/errors/docs-url.js +6 -6
- package/dist/errors/index.d.ts +1 -1
- package/dist/errors/index.js +1 -1
- package/dist/errors/subject-erased.d.ts +1 -0
- package/dist/errors/subject-erased.js +6 -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/files/create-file-ref.d.ts +2 -1
- package/dist/files/create-file-ref.js +16 -2
- 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.js +4 -0
- package/dist/files/file-routes.js +9 -5
- package/dist/files/index.d.ts +1 -1
- package/dist/files/index.js +1 -1
- package/dist/files/purge-expired-file-ref-bytes.js +2 -3
- 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/job-runner.d.ts +2 -0
- package/dist/jobs/job-runner.js +183 -48
- package/dist/jobs/max-per-tenant.d.ts +8 -0
- package/dist/jobs/max-per-tenant.js +14 -0
- package/dist/observability/escape-hatch-report.d.ts +1 -0
- package/dist/observability/escape-hatch-report.js +31 -2
- 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/dispatch-query.d.ts +2 -1
- package/dist/pipeline/dispatch-query.js +6 -6
- package/dist/pipeline/dispatch-shared.d.ts +1 -1
- package/dist/pipeline/dispatch-shared.js +41 -30
- package/dist/pipeline/dispatch-stream.js +10 -4
- 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 +2 -0
- package/dist/pipeline/index.js +1 -0
- package/dist/pipeline/multi-stream-apply-context.d.ts +1 -0
- package/dist/pipeline/multi-stream-apply-context.js +1 -0
- package/dist/pipeline/notify-scope.js +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +2 -1
- package/dist/schema-cli.js +12 -1
- 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 +4 -4
- package/src/changes.json +323 -0
|
@@ -44,7 +44,10 @@
|
|
|
44
44
|
// - "skipped" → Event ist kein Auto-Verb (Domain-Event auf demselben
|
|
45
45
|
// Aggregate). Caller no-op.
|
|
46
46
|
import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
|
|
47
|
+
import { requestContext } from "../api/request-context.js";
|
|
47
48
|
import { collectLookupableFields, computeBlindIndexValues } from "../crypto/blind-index.js";
|
|
49
|
+
import { configuredPiiSubjectKms, decryptPiiFieldValues, isPiiCiphertext, PII_ERASED_SENTINEL, } from "../crypto/pii-field-encryption.js";
|
|
50
|
+
import { collectPiiFileReferenceFields } from "../crypto/subject-resolver.js";
|
|
48
51
|
import { deleteMany, insertOne, updateMany } from "../db/query.js";
|
|
49
52
|
import { InternalError } from "../errors/index.js";
|
|
50
53
|
const AUTO_VERBS = ["created", "updated", "deleted", "restored", "forgotten"];
|
|
@@ -57,6 +60,27 @@ export function parseAutoVerb(event) {
|
|
|
57
60
|
const verb = event.type.slice(prefix.length);
|
|
58
61
|
return isOneOf(AUTO_VERBS, verb) ? verb : null;
|
|
59
62
|
}
|
|
63
|
+
// Events written by the old PII backfill can hold ciphertext in file/image uuid
|
|
64
|
+
// columns. Replay restores the plain fileRef id; an erased subject yields null.
|
|
65
|
+
async function decryptFileReferenceCiphertext(entity, values) {
|
|
66
|
+
const ciphertextFields = collectPiiFileReferenceFields(entity).filter((name) => isPiiCiphertext(values[name]));
|
|
67
|
+
if (ciphertextFields.length === 0)
|
|
68
|
+
return values;
|
|
69
|
+
const kms = configuredPiiSubjectKms();
|
|
70
|
+
if (kms === undefined) {
|
|
71
|
+
throw new InternalError({
|
|
72
|
+
message: `applyEntityEvent: file field(s) ${ciphertextFields.join(", ")} hold PII ciphertext but no subject KMS is configured; configure the KMS before replaying.`,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const decrypted = await decryptPiiFieldValues(values, ciphertextFields, kms, {
|
|
76
|
+
requestId: requestContext.get()?.requestId ?? "apply-entity-event",
|
|
77
|
+
});
|
|
78
|
+
for (const name of ciphertextFields) {
|
|
79
|
+
if (decrypted[name] === PII_ERASED_SENTINEL)
|
|
80
|
+
decrypted[name] = null;
|
|
81
|
+
}
|
|
82
|
+
return decrypted;
|
|
83
|
+
}
|
|
60
84
|
/** Idempotente Anwendung eines Auto-Events auf die Entity-Tabelle.
|
|
61
85
|
* Wird sowohl beim Live-Append (innerhalb der Write-TX) als auch beim
|
|
62
86
|
* Rebuild (innerhalb der Rebuild-TX) gerufen — identische Logik. */
|
|
@@ -92,7 +116,7 @@ export async function applyEntityEvent(event, table, entity, tx) {
|
|
|
92
116
|
});
|
|
93
117
|
}
|
|
94
118
|
const row = await insertOne(tx, table, {
|
|
95
|
-
...event.payload,
|
|
119
|
+
...(await decryptFileReferenceCiphertext(entity, event.payload)),
|
|
96
120
|
// Blind-Index-Spalten aus dem Payload-Wert (ciphertext → decrypt →
|
|
97
121
|
// HMAC, plaintext → HMAC, erased → NULL). Hier statt im Executor,
|
|
98
122
|
// damit Live-Write und Rebuild denselben Wert produzieren und der
|
|
@@ -110,7 +134,7 @@ export async function applyEntityEvent(event, table, entity, tx) {
|
|
|
110
134
|
// payload-Shape: { changes, previous } — siehe event-store-executor.ts.
|
|
111
135
|
const rawChanges = (event.payload["changes"] ?? {}); // @cast-boundary engine-payload
|
|
112
136
|
const changes = {};
|
|
113
|
-
for (const [key, value] of Object.entries(rawChanges)) {
|
|
137
|
+
for (const [key, value] of Object.entries(await decryptFileReferenceCiphertext(entity, rawChanges))) {
|
|
114
138
|
const field = entity.fields[key];
|
|
115
139
|
// Historical events can carry fields since removed from the entity; the
|
|
116
140
|
// tolerance lives here (not in updateMany) so a live typo still throws.
|
|
@@ -15,6 +15,7 @@ import { quoteIdent, subjectCiphertextLikePattern } from "../crypto/ciphertext-p
|
|
|
15
15
|
import { isSelfPiiField } from "../crypto/is-self-pii-field.js";
|
|
16
16
|
import { toSnakeCase } from "../utils/case.js";
|
|
17
17
|
import { resolveTableName } from "./entity-table-meta.js";
|
|
18
|
+
import { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
|
|
18
19
|
import { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.js";
|
|
19
20
|
import { columnNamesOf, tableExists } from "./schema-inspection.js";
|
|
20
21
|
export async function nullBlindIndexesForSubject(db, features, subjectKey) {
|
|
@@ -55,7 +56,7 @@ export async function nullBlindIndexesForSubject(db, features, subjectKey) {
|
|
|
55
56
|
async function existingTableNames(db, tableNames) {
|
|
56
57
|
if (tableNames.length === 0)
|
|
57
58
|
return new Set();
|
|
58
|
-
const rows = await executeRawQueryRead(db, `SELECT t.name FROM unnest($1::text[]) AS t(name) WHERE to_regclass('public.' || quote_ident(t.name)) IS NOT NULL`, [tableNames]);
|
|
59
|
+
const rows = await executeRawQueryRead(db, `SELECT t.name FROM unnest($1::text[]) AS t(name) WHERE to_regclass('public.' || quote_ident(t.name)) IS NOT NULL`, [toPostgresArrayLiteral(tableNames)]);
|
|
59
60
|
return new Set(rows.map((r) => r.name));
|
|
60
61
|
}
|
|
61
62
|
// Tenant-scope oracle for crypto-shredding's forget-subject (mh#349): a
|
package/dist/db/bun-provider.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
// Bun.SQL
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// derselben Connection.
|
|
1
|
+
// Bun.SQL provider, opt-in via DB_PROVIDER=bun; postgres-js stays the default.
|
|
2
|
+
// Known bug (Bun <= 1.4.3, oven-sh/bun#29484): a cached plan is not re-prepared
|
|
3
|
+
// after DDL, so e.g. `SELECT *` after ADD COLUMN fails on that connection with
|
|
4
|
+
// 0A000 "cached plan must not change result type". prepare:false is no fix,
|
|
5
|
+
// it changes how jsonb params are bound.
|
|
7
6
|
//
|
|
8
|
-
// Bun.SQL
|
|
9
|
-
|
|
7
|
+
// LISTEN runs on Bun.SQL's own dedicated connection, which is exempt from the
|
|
8
|
+
// pool's idleTimeout and maxLifetime and re-LISTENs after a drop.
|
|
10
9
|
import { DEFAULT_DB_CLOSE_TIMEOUT_SECONDS, } from "./api.js";
|
|
11
10
|
export function createBunConnection(url, options = {}) {
|
|
12
11
|
const bunOpts = {};
|
|
@@ -17,21 +16,13 @@ export function createBunConnection(url, options = {}) {
|
|
|
17
16
|
if (options.connectTimeoutSeconds !== undefined)
|
|
18
17
|
bunOpts.connectionTimeout = options.connectTimeoutSeconds;
|
|
19
18
|
const db = new Bun.SQL(url, bunOpts);
|
|
20
|
-
// LISTEN peer — 1 connection reicht für NOTIFY-Wakeups
|
|
21
|
-
const pgOpts = { max: 1 };
|
|
22
|
-
if (options.idleTimeoutSeconds !== undefined)
|
|
23
|
-
pgOpts.idle_timeout = options.idleTimeoutSeconds;
|
|
24
|
-
if (options.connectTimeoutSeconds !== undefined)
|
|
25
|
-
pgOpts.connect_timeout = options.connectTimeoutSeconds;
|
|
26
|
-
const listenClient = postgres(url, pgOpts);
|
|
27
19
|
const closeTimeout = options.closeTimeoutSeconds ?? DEFAULT_DB_CLOSE_TIMEOUT_SECONDS;
|
|
28
20
|
return {
|
|
29
21
|
db,
|
|
30
|
-
client:
|
|
31
|
-
listenClient,
|
|
22
|
+
client: db,
|
|
23
|
+
listenClient: db,
|
|
32
24
|
close: async () => {
|
|
33
25
|
await db.end({ timeout: closeTimeout });
|
|
34
|
-
await listenClient.end({ timeout: closeTimeout });
|
|
35
26
|
},
|
|
36
27
|
};
|
|
37
28
|
}
|
package/dist/db/dialect.d.ts
CHANGED
|
@@ -54,7 +54,7 @@ export declare function instant(name: string, opts?: {
|
|
|
54
54
|
}): ColumnBuilder<Temporal.Instant>;
|
|
55
55
|
export declare function plainDate(name: string): ColumnBuilder<Temporal.PlainDate>;
|
|
56
56
|
export declare const decimalColumn: (name: string, precision: number, scale: number) => ColumnBuilder<number>;
|
|
57
|
-
export declare const moneyAmount: (name: string) => ColumnBuilder<
|
|
57
|
+
export declare const moneyAmount: (name: string) => ColumnBuilder<bigint>;
|
|
58
58
|
export type IndexBuilder = {
|
|
59
59
|
readonly __index: true;
|
|
60
60
|
on(...cols: ColumnHandle[]): IndexBuilderWithCols;
|
package/dist/db/dialect.js
CHANGED
|
@@ -58,6 +58,8 @@ export function pgTypeToSqlType(pgType) {
|
|
|
58
58
|
function _toSnakeCase(name) {
|
|
59
59
|
return name.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`).replace(/^_/, "");
|
|
60
60
|
}
|
|
61
|
+
// TValue is never inferred from the factory's declared return type, so a factory that forgets its
|
|
62
|
+
// type argument fails to compile instead of silently typing the column from its own annotation.
|
|
61
63
|
function buildColumn(sqlName, pgType, opts) {
|
|
62
64
|
let notNull = false;
|
|
63
65
|
let primaryKey = false;
|
package/dist/db/eagerload.js
CHANGED
|
@@ -26,6 +26,7 @@ import { parseRefTargetEntityName } from "../engine/parse-ref-target.js";
|
|
|
26
26
|
import { isUuid, SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
|
|
27
27
|
import { collectEncryptedFieldNames, decryptEntityFieldValues, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
|
|
28
28
|
import { buildParentRefClause } from "./parent-ref-clause.js";
|
|
29
|
+
import { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
|
|
29
30
|
import { executeRawQueryRead } from "./queries/raw-sql.js";
|
|
30
31
|
import { buildEntityTable, physicalColumnName } from "./table-builder.js";
|
|
31
32
|
import { tenantDbRunner } from "./tenant-db-runner.js";
|
|
@@ -77,7 +78,7 @@ async function resolveRefAccessScope(refEntity, refTable, refTableName, ids, db,
|
|
|
77
78
|
// No read path exists for non-uuid ids, so nothing can be access-checked.
|
|
78
79
|
if (refEntity.idType !== undefined && refEntity.idType !== "uuid")
|
|
79
80
|
return { kind: "none" };
|
|
80
|
-
const params = [ids];
|
|
81
|
+
const params = [toPostgresArrayLiteral(ids)];
|
|
81
82
|
let tenantClause = "";
|
|
82
83
|
if (refTable["tenantId"] !== undefined && db.mode === "tenant") {
|
|
83
84
|
params.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
@@ -62,6 +62,7 @@ export type ExecutorContext = {
|
|
|
62
62
|
subjectSource?: Record<string, unknown>;
|
|
63
63
|
keyTenantId?: TenantId | undefined;
|
|
64
64
|
ownerFallback?: Record<string, unknown>;
|
|
65
|
+
onErasedSubject?: "sentinel";
|
|
65
66
|
}) => Promise<Record<string, unknown>>;
|
|
66
67
|
readonly decryptForRead: (row: Record<string, unknown>, opts?: DecryptForReadOptions) => Promise<Record<string, unknown>>;
|
|
67
68
|
readonly ownerTransitionRekeys: (previous: Record<string, unknown>, mergedNew: Record<string, unknown>, submittedChanges: Record<string, unknown>) => Record<string, unknown>;
|
|
@@ -215,6 +215,7 @@ export function buildExecutorContext(table, entity, options) {
|
|
|
215
215
|
...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
|
|
216
216
|
...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
|
|
217
217
|
...(opts?.ownerFallback !== undefined && { ownerFallback: opts.ownerFallback }),
|
|
218
|
+
...(opts?.onErasedSubject !== undefined && { onErasedSubject: opts.onErasedSubject }),
|
|
218
219
|
});
|
|
219
220
|
}
|
|
220
221
|
catch (e) {
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EventStoreExecutor } from "./event-store-executor.js";
|
|
2
2
|
import { type ExecutorContext } from "./event-store-executor-context.js";
|
|
3
3
|
export declare function createWriteVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor, "create" | "update" | "delete" | "forget" | "restore">;
|
|
@@ -2,14 +2,15 @@ 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 { 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";
|
|
7
7
|
import { append, IdempotentAppendConflictError as EventStoreIdempotentAppendConflict, VersionConflictError as EventStoreVersionConflict, getStreamVersion, } from "../event-store/index.js";
|
|
8
8
|
import { assertInstructionFieldWriteAllowed, assertIrreversibleOperationAllowed, isIrreversibleEntityVerb, } from "../pipeline/irreversible-operation-gate.js";
|
|
9
9
|
import { hasLiveProjectionsForEvent, runProjectionsForEvent, } from "../pipeline/projections-runner.js";
|
|
10
10
|
import { generateId } from "../utils/index.js";
|
|
11
11
|
import { applyEntityEvent } from "./apply-entity-event.js";
|
|
12
12
|
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
|
|
13
|
+
import { WritePurposes, } from "./event-store-executor.js";
|
|
13
14
|
import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, ownershipSubject, tryMapUniqueViolation, } from "./event-store-executor-context.js";
|
|
14
15
|
import { isFrameworkSystemUser, isLockGuardBypassed } from "./lock-guard.js";
|
|
15
16
|
import { projectionRegistryOf } from "./projection-registry-binding.js";
|
|
@@ -34,19 +35,6 @@ async function projectEntityEvent(db, runner, event) {
|
|
|
34
35
|
// behavior-preserving relocation, not a redesign: every closure below is
|
|
35
36
|
// unchanged from the original, just relocated behind an explicit
|
|
36
37
|
// ExecutorContext instead of capturing the factory's local scope directly.
|
|
37
|
-
// updateOptions.skipUnchanged (#464) — opt-in: a resubmitted-but-identical
|
|
38
|
-
// key is dropped before encryption so pii/encrypted fields don't get a fresh
|
|
39
|
-
// AEAD ciphertext (new nonce) for no real change, and the event's `changes`
|
|
40
|
-
// don't carry a phantom diff. Opt-in, not the executor's default, because
|
|
41
|
-
// direct executor.update() callers rely on the current behavior to FORCE a
|
|
42
|
-
// re-encrypt of an unchanged plaintext — KEK-rotation (auth-mfa reencrypt.job)
|
|
43
|
-
// and the user-data-rights #494 backfill both resubmit the current value on
|
|
44
|
-
// purpose to land a fresh event/ciphertext. Only the generic entity update
|
|
45
|
-
// handler (entity-handlers.ts) sets this flag; those two call their own
|
|
46
|
-
// executor directly and never see it.
|
|
47
|
-
function isUnchangedValue(a, b) {
|
|
48
|
-
return JSON.stringify(a) === JSON.stringify(b);
|
|
49
|
-
}
|
|
50
38
|
function selectOptionNotAvailableFailure(entries) {
|
|
51
39
|
const [first] = entries;
|
|
52
40
|
if (!first)
|
|
@@ -125,6 +113,11 @@ export function createWriteVerbs(ctx) {
|
|
|
125
113
|
}
|
|
126
114
|
return writeFailure(new RecordLockedError({ entityName, entityId: id }));
|
|
127
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
|
+
}
|
|
128
121
|
const eventVersion = entity.eventVersion ?? 1;
|
|
129
122
|
let insertSchema;
|
|
130
123
|
let updateSchema;
|
|
@@ -149,6 +142,55 @@ export function createWriteVerbs(ctx) {
|
|
|
149
142
|
}
|
|
150
143
|
return { row: row };
|
|
151
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: await versionAfterConflict(runner, String(id), streamTenantFor(user, streamTenantId)),
|
|
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
|
+
}
|
|
184
|
+
// After a lost append race the stream version read before the append is stale;
|
|
185
|
+
// report the actual one. -1 when the lookup itself fails.
|
|
186
|
+
async function versionAfterConflict(runner, aggregateId, tenantId) {
|
|
187
|
+
try {
|
|
188
|
+
return await getStreamVersion(runner, aggregateId, tenantId);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return -1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
152
194
|
async function expectedStreamVersion(db, id, streamTenant, expect) {
|
|
153
195
|
const snapshot = await loadExpectSnapshot(db, id, streamTenant, Object.keys(expect));
|
|
154
196
|
const row = snapshot.row;
|
|
@@ -266,13 +308,7 @@ export function createWriteVerbs(ctx) {
|
|
|
266
308
|
}
|
|
267
309
|
catch (e) {
|
|
268
310
|
if (e instanceof EventStoreVersionConflict) {
|
|
269
|
-
|
|
270
|
-
try {
|
|
271
|
-
currentVersion = await getStreamVersion(runner, aggregateId, streamTenantFor(user, streamTenantId));
|
|
272
|
-
}
|
|
273
|
-
catch {
|
|
274
|
-
// Lookup failure — keep the sentinel.
|
|
275
|
-
}
|
|
311
|
+
const currentVersion = await versionAfterConflict(runner, aggregateId, streamTenantFor(user, streamTenantId));
|
|
276
312
|
return writeFailure(new FrameworkVersionConflict({
|
|
277
313
|
entityId: aggregateId,
|
|
278
314
|
expectedVersion: 0,
|
|
@@ -415,7 +451,12 @@ export function createWriteVerbs(ctx) {
|
|
|
415
451
|
const lockedUpdate = recordLockedFailure(previous, payload.id, user, updateOptions);
|
|
416
452
|
if (lockedUpdate)
|
|
417
453
|
return lockedUpdate;
|
|
418
|
-
|
|
454
|
+
// Statutory retention/erasure writes may change a condition field (e.g. null a PII
|
|
455
|
+
// reference) and must not be blocked by the select rule — same exemption as the lock.
|
|
456
|
+
const isStatutorySystemWrite = updateOptions?.writePurpose !== undefined && isFrameworkSystemUser(user);
|
|
457
|
+
const unavailableOnUpdate = isStatutorySystemWrite
|
|
458
|
+
? undefined
|
|
459
|
+
: selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, mergedNew, previous));
|
|
419
460
|
if (unavailableOnUpdate)
|
|
420
461
|
return unavailableOnUpdate;
|
|
421
462
|
const streamTenant = streamTenantFor(user, streamTenantId);
|
|
@@ -454,6 +495,9 @@ export function createWriteVerbs(ctx) {
|
|
|
454
495
|
// Compound-Types Auto-Convert (alle in einem Pass).
|
|
455
496
|
// subjectSource: partial changes may carry a pii field without its
|
|
456
497
|
// ownerField — the merged row still names the subject.
|
|
498
|
+
// skipUnchanged (#464) is opt-in: an identical resubmitted key is dropped before
|
|
499
|
+
// encryption so pii/encrypted fields get no fresh ciphertext for no real change.
|
|
500
|
+
// Direct callers (KEK rotation, #494 backfill) rely on the default to force a re-encrypt.
|
|
457
501
|
const submittedChanges = updateOptions?.skipUnchanged
|
|
458
502
|
? Object.fromEntries(Object.entries(changes).filter(([key, value]) => !isUnchangedValue(value, previous[key])))
|
|
459
503
|
: changes;
|
|
@@ -469,6 +513,7 @@ export function createWriteVerbs(ctx) {
|
|
|
469
513
|
onlyKeys: Object.keys(changesToPersist),
|
|
470
514
|
subjectSource: mergedNew,
|
|
471
515
|
keyTenantId: streamTenantId,
|
|
516
|
+
...erasureSentinelOption(updateOptions),
|
|
472
517
|
});
|
|
473
518
|
// The event payload carries BOTH `changes` (what the user asked for) AND
|
|
474
519
|
// `previous` (the pre-update row). Cross-aggregate projections need the
|
|
@@ -484,6 +529,7 @@ export function createWriteVerbs(ctx) {
|
|
|
484
529
|
const encryptedPrevious = await encryptForStorage(previous, user, {
|
|
485
530
|
keyTenantId: streamTenantId,
|
|
486
531
|
ownerFallback: submittedChanges,
|
|
532
|
+
...erasureSentinelOption(updateOptions),
|
|
487
533
|
});
|
|
488
534
|
// Savepoint-scoped — see the create() append() above for why:
|
|
489
535
|
// confines a losing writer's unique-violation to a nested scope
|
|
@@ -561,7 +607,7 @@ export function createWriteVerbs(ctx) {
|
|
|
561
607
|
return writeFailure(new FrameworkVersionConflict({
|
|
562
608
|
entityId: payload.id,
|
|
563
609
|
expectedVersion: payload.version ?? 0,
|
|
564
|
-
currentVersion,
|
|
610
|
+
currentVersion: await versionAfterConflict(runner, String(payload.id), streamTenant),
|
|
565
611
|
}));
|
|
566
612
|
}
|
|
567
613
|
if (e instanceof EventStoreIdempotentAppendConflict) {
|
|
@@ -620,7 +666,10 @@ export function createWriteVerbs(ctx) {
|
|
|
620
666
|
type: entityEventName(entityName, "deleted"),
|
|
621
667
|
eventVersion,
|
|
622
668
|
payload: {
|
|
623
|
-
previous: await encryptForStorage(existing, user, {
|
|
669
|
+
previous: await encryptForStorage(existing, user, {
|
|
670
|
+
keyTenantId: streamTenantId,
|
|
671
|
+
...erasureSentinelOption(options),
|
|
672
|
+
}),
|
|
624
673
|
},
|
|
625
674
|
metadata: buildEventMetadata(user),
|
|
626
675
|
}));
|
|
@@ -630,7 +679,7 @@ export function createWriteVerbs(ctx) {
|
|
|
630
679
|
return writeFailure(new FrameworkVersionConflict({
|
|
631
680
|
entityId: payload.id,
|
|
632
681
|
expectedVersion: currentVersion,
|
|
633
|
-
currentVersion:
|
|
682
|
+
currentVersion: await versionAfterConflict(runner, String(payload.id), streamTenantFor(user, streamTenantId)),
|
|
634
683
|
}));
|
|
635
684
|
}
|
|
636
685
|
if (e instanceof EventStoreIdempotentAppendConflict) {
|
|
@@ -702,7 +751,10 @@ export function createWriteVerbs(ctx) {
|
|
|
702
751
|
// Re-encrypt like delete(): `existing` came decrypted from loadById —
|
|
703
752
|
// plaintext must not land in the immutable log, least of all on forget.
|
|
704
753
|
payload: {
|
|
705
|
-
previous: await encryptForStorage(existing, user, {
|
|
754
|
+
previous: await encryptForStorage(existing, user, {
|
|
755
|
+
keyTenantId: streamTenantId,
|
|
756
|
+
onErasedSubject: "sentinel",
|
|
757
|
+
}),
|
|
706
758
|
},
|
|
707
759
|
metadata: buildEventMetadata(user),
|
|
708
760
|
}));
|
|
@@ -712,7 +764,7 @@ export function createWriteVerbs(ctx) {
|
|
|
712
764
|
return writeFailure(new FrameworkVersionConflict({
|
|
713
765
|
entityId: payload.id,
|
|
714
766
|
expectedVersion: currentVersion,
|
|
715
|
-
currentVersion:
|
|
767
|
+
currentVersion: await versionAfterConflict(runner, String(payload.id), streamTenantFor(user, streamTenantId)),
|
|
716
768
|
}));
|
|
717
769
|
}
|
|
718
770
|
if (e instanceof EventStoreIdempotentAppendConflict) {
|
|
@@ -777,36 +829,11 @@ export function createWriteVerbs(ctx) {
|
|
|
777
829
|
// table. Rows predating the subject-KMS rollout may still hold plaintext
|
|
778
830
|
// pii, and envelope encryption is not idempotent: decrypt, then
|
|
779
831
|
// re-encrypt as delete does.
|
|
780
|
-
const previousPlain = await decryptForRead(data);
|
|
781
|
-
const
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
try {
|
|
786
|
-
event = await runInSavepointIfSupported(runner, (sp) => append(sp, {
|
|
787
|
-
aggregateId: String(payload.id),
|
|
788
|
-
aggregateType: entityName,
|
|
789
|
-
tenantId: streamTenantFor(user, streamTenantId),
|
|
790
|
-
expectedVersion: currentVersion,
|
|
791
|
-
type: entityEventName(entityName, "restored"),
|
|
792
|
-
eventVersion,
|
|
793
|
-
payload: { previous: previousForStorage },
|
|
794
|
-
metadata: buildEventMetadata(user),
|
|
795
|
-
}));
|
|
796
|
-
}
|
|
797
|
-
catch (e) {
|
|
798
|
-
if (e instanceof EventStoreVersionConflict) {
|
|
799
|
-
return writeFailure(new FrameworkVersionConflict({
|
|
800
|
-
entityId: payload.id,
|
|
801
|
-
expectedVersion: currentVersion,
|
|
802
|
-
currentVersion: -1,
|
|
803
|
-
}));
|
|
804
|
-
}
|
|
805
|
-
if (e instanceof EventStoreIdempotentAppendConflict) {
|
|
806
|
-
return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
|
|
807
|
-
}
|
|
808
|
-
throw e;
|
|
809
|
-
}
|
|
832
|
+
const previousPlain = await decryptForRead(rehydrateCompoundTypes(data, entity));
|
|
833
|
+
const appended = await appendRestoredEvent(runner, user, payload.id, previousPlain, streamTenantId, currentVersion);
|
|
834
|
+
if ("failure" in appended)
|
|
835
|
+
return appended.failure;
|
|
836
|
+
const event = appended.event;
|
|
810
837
|
// Live==Rebuild via applyEntityEvent. Restore only writes isDeleted=false
|
|
811
838
|
// plus the version bump, so there is no sensitive-field drift and no
|
|
812
839
|
// payload override is needed.
|
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";
|
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";
|
|
@@ -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, hasBegin } 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,
|
|
@@ -186,8 +186,18 @@ function migrationClient(db) {
|
|
|
186
186
|
async function executeRaw(db, sqlText) {
|
|
187
187
|
await migrationClient(db).unsafe(sqlText);
|
|
188
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
|
+
}
|
|
189
196
|
export async function fetchAppliedMigrations(db) {
|
|
190
|
-
|
|
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`);
|
|
191
201
|
const rows = Array.isArray(result) ? result : [];
|
|
192
202
|
const applied = [];
|
|
193
203
|
for (const row of rows) {
|
|
@@ -216,40 +226,55 @@ export class MigrationChecksumMismatchError extends Error {
|
|
|
216
226
|
// _kumiko_migrations-Lookup übersprungen. Checksum-Mismatch wirft fail-loud.
|
|
217
227
|
// Advisory-Lock verhindert concurrent-apply-races.
|
|
218
228
|
export async function runMigrations(db, migrations) {
|
|
219
|
-
|
|
220
|
-
|
|
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);
|
|
221
233
|
try {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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;
|
|
230
248
|
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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;
|
|
241
267
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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})`);
|
|
248
274
|
}
|
|
249
|
-
return { applied: appliedIds, skipped: skippedIds };
|
|
250
275
|
}
|
|
251
276
|
finally {
|
|
252
|
-
await
|
|
277
|
+
await conn.release();
|
|
253
278
|
}
|
|
254
279
|
}
|
|
255
280
|
// Convenience: load + run in einem Call. Wird vom `kumiko migrate apply`
|