@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.
Files changed (163) hide show
  1. package/AGENTS.md +36 -5
  2. package/agents/examples/anonymous-access-multitenant.md +9 -6
  3. package/agents/examples/anonymous-access.md +14 -26
  4. package/agents/examples/auth-mfa.md +5 -9
  5. package/agents/examples/custom-fields-basic.md +7 -6
  6. package/agents/examples/custom-handlers.md +72 -25
  7. package/agents/examples/dashboard-stats.md +229 -0
  8. package/agents/examples/encrypted-tenant-config.md +17 -8
  9. package/agents/examples/error-contract.md +1 -5
  10. package/agents/examples/feature-toggles.md +28 -17
  11. package/agents/examples/ledger-banking.md +7 -6
  12. package/agents/examples/ledger-invoicing.md +7 -6
  13. package/agents/examples/legal-pages.md +2 -2
  14. package/agents/examples/managed-pages.md +2 -2
  15. package/agents/examples/notes-history-basic.md +7 -6
  16. package/agents/examples/ownership.md +30 -22
  17. package/agents/examples/pdf-audit-export.md +87 -51
  18. package/agents/examples/session-revocation.md +1 -10
  19. package/agents/examples/tags-basic.md +7 -7
  20. package/agents/examples/user-data-rights.md +25 -17
  21. package/agents/examples/webhook-step.md +11 -1
  22. package/agents/examples/workflow-engine.md +2 -2
  23. package/agents/examples.md +2 -1
  24. package/agents/features.md +4 -4
  25. package/dist/api/auth-routes.d.ts +0 -1
  26. package/dist/api/auth-routes.js +44 -43
  27. package/dist/api/index.d.ts +1 -1
  28. package/dist/api/index.js +1 -1
  29. package/dist/api/server.d.ts +2 -2
  30. package/dist/api/server.js +29 -4
  31. package/dist/bun-db/connection.js +3 -16
  32. package/dist/bun-db/index.d.ts +1 -1
  33. package/dist/bun-db/index.js +1 -1
  34. package/dist/bun-db/query.d.ts +7 -0
  35. package/dist/bun-db/query.js +50 -18
  36. package/dist/crypto/in-memory-kms-adapter.js +4 -7
  37. package/dist/crypto/owner-transition-rekey.d.ts +1 -0
  38. package/dist/crypto/owner-transition-rekey.js +7 -3
  39. package/dist/crypto/pg-kms-adapter.js +10 -6
  40. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  41. package/dist/crypto/pii-field-encryption.js +9 -2
  42. package/dist/crypto/subject-resolver.d.ts +1 -0
  43. package/dist/crypto/subject-resolver.js +7 -0
  44. package/dist/db/api.d.ts +4 -3
  45. package/dist/db/apply-entity-event.js +26 -2
  46. package/dist/db/blind-index-cleanup.js +2 -1
  47. package/dist/db/bun-provider.js +9 -18
  48. package/dist/db/dialect.d.ts +1 -1
  49. package/dist/db/dialect.js +2 -0
  50. package/dist/db/eagerload.js +2 -1
  51. package/dist/db/event-store-executor-context.d.ts +1 -0
  52. package/dist/db/event-store-executor-context.js +1 -0
  53. package/dist/db/event-store-executor-write.d.ts +1 -1
  54. package/dist/db/event-store-executor-write.js +85 -58
  55. package/dist/db/index.d.ts +1 -0
  56. package/dist/db/index.js +1 -0
  57. package/dist/db/migrate-runner.d.ts +1 -0
  58. package/dist/db/migrate-runner.js +56 -31
  59. package/dist/db/queries/backfill-pii.js +63 -25
  60. package/dist/db/queries/event-consumer.js +2 -1
  61. package/dist/db/queries/event-store-transfer.js +15 -4
  62. package/dist/db/queries/event-store.js +11 -10
  63. package/dist/db/queries/pii-backfill-state.js +3 -2
  64. package/dist/db/queries/postgres-array-literal.d.ts +2 -0
  65. package/dist/db/queries/postgres-array-literal.js +6 -0
  66. package/dist/db/queries/projection-rebuild.js +3 -2
  67. package/dist/db/queries/shadow-swap.d.ts +1 -1
  68. package/dist/db/queries/shadow-swap.js +12 -8
  69. package/dist/db/query.d.ts +1 -1
  70. package/dist/db/query.js +1 -1
  71. package/dist/db/retired-framework-tables.js +22 -4
  72. package/dist/db/table-builder.js +6 -2
  73. package/dist/db/tenant-db.d.ts +2 -0
  74. package/dist/db/tenant-db.js +20 -1
  75. package/dist/derivatives/delete-stored-file.d.ts +4 -0
  76. package/dist/derivatives/delete-stored-file.js +9 -0
  77. package/dist/derivatives/index.d.ts +1 -1
  78. package/dist/derivatives/index.js +1 -1
  79. package/dist/engine/boot-validator/entity-handler.js +3 -0
  80. package/dist/engine/boot-validator/query-refs.js +14 -2
  81. package/dist/engine/dedupe-features.js +31 -4
  82. package/dist/engine/entity-handlers.js +14 -7
  83. package/dist/engine/index.d.ts +2 -2
  84. package/dist/engine/index.js +1 -1
  85. package/dist/engine/registry-validate.d.ts +1 -0
  86. package/dist/engine/registry-validate.js +21 -0
  87. package/dist/engine/registry.js +2 -1
  88. package/dist/engine/screen-helpers.d.ts +1 -0
  89. package/dist/engine/screen-helpers.js +2 -2
  90. package/dist/engine/steps/_read-source.js +5 -2
  91. package/dist/engine/tier-resolver-extension.d.ts +1 -0
  92. package/dist/engine/tier-resolver-extension.js +6 -0
  93. package/dist/engine/types/index.d.ts +1 -1
  94. package/dist/entrypoint/index.d.ts +2 -2
  95. package/dist/errors/docs-url.js +6 -6
  96. package/dist/errors/index.d.ts +1 -1
  97. package/dist/errors/index.js +1 -1
  98. package/dist/errors/subject-erased.d.ts +1 -0
  99. package/dist/errors/subject-erased.js +6 -0
  100. package/dist/event-store/admin-api.js +3 -2
  101. package/dist/event-store/errors.d.ts +9 -0
  102. package/dist/event-store/errors.js +20 -0
  103. package/dist/event-store/event-store.d.ts +6 -0
  104. package/dist/event-store/event-store.js +28 -6
  105. package/dist/event-store/index.d.ts +1 -1
  106. package/dist/event-store/index.js +1 -1
  107. package/dist/files/create-file-ref.d.ts +2 -1
  108. package/dist/files/create-file-ref.js +16 -2
  109. package/dist/files/dispose-removed-file-refs.js +21 -6
  110. package/dist/files/feature.d.ts +1 -0
  111. package/dist/files/feature.js +2 -1
  112. package/dist/files/file-handle.js +4 -0
  113. package/dist/files/file-routes.js +9 -5
  114. package/dist/files/index.d.ts +1 -1
  115. package/dist/files/index.js +1 -1
  116. package/dist/files/purge-expired-file-ref-bytes.js +2 -3
  117. package/dist/files/resolve-content-type.js +40 -4
  118. package/dist/files/storage-tracking.js +5 -4
  119. package/dist/files/types.js +5 -8
  120. package/dist/files/zip-entry-names.d.ts +1 -0
  121. package/dist/files/zip-entry-names.js +46 -0
  122. package/dist/jobs/job-runner.d.ts +2 -0
  123. package/dist/jobs/job-runner.js +183 -48
  124. package/dist/jobs/max-per-tenant.d.ts +8 -0
  125. package/dist/jobs/max-per-tenant.js +14 -0
  126. package/dist/observability/escape-hatch-report.d.ts +1 -0
  127. package/dist/observability/escape-hatch-report.js +31 -2
  128. package/dist/observability/index.d.ts +1 -1
  129. package/dist/observability/index.js +1 -1
  130. package/dist/observability/metrics-handle.d.ts +1 -0
  131. package/dist/observability/metrics-handle.js +7 -0
  132. package/dist/observability/standard-metrics.js +4 -4
  133. package/dist/pipeline/dispatch-query.d.ts +2 -1
  134. package/dist/pipeline/dispatch-query.js +6 -6
  135. package/dist/pipeline/dispatch-shared.d.ts +1 -1
  136. package/dist/pipeline/dispatch-shared.js +41 -30
  137. package/dist/pipeline/dispatch-stream.js +10 -4
  138. package/dist/pipeline/dispatcher.js +3 -1
  139. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  140. package/dist/pipeline/event-dispatcher.js +11 -9
  141. package/dist/pipeline/event-retention.d.ts +1 -0
  142. package/dist/pipeline/event-retention.js +7 -1
  143. package/dist/pipeline/index.d.ts +2 -0
  144. package/dist/pipeline/index.js +1 -0
  145. package/dist/pipeline/multi-stream-apply-context.d.ts +1 -0
  146. package/dist/pipeline/multi-stream-apply-context.js +1 -0
  147. package/dist/pipeline/notify-scope.js +3 -1
  148. package/dist/pipeline/pre-transaction-reservations.js +2 -1
  149. package/dist/schema-cli.js +12 -1
  150. package/dist/stack/db.d.ts +2 -2
  151. package/dist/stack/test-stack.js +2 -1
  152. package/dist/testing/index.d.ts +1 -0
  153. package/dist/testing/index.js +1 -0
  154. package/dist/testing/multipart-helper.d.ts +6 -0
  155. package/dist/testing/multipart-helper.js +1 -1
  156. package/dist/testing/zip-fixture.d.ts +1 -0
  157. package/dist/testing/zip-fixture.js +33 -0
  158. package/dist/ui-types/card-column-roles.js +4 -2
  159. package/dist/ui-types/index.d.ts +1 -1
  160. package/dist/ui-types/index.js +1 -1
  161. package/dist/upgrade-cli.js +46 -13
  162. package/package.json +4 -4
  163. 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
@@ -1,12 +1,11 @@
1
- // Bun.SQL Provider — experimentell.
2
- // Default: postgres-js. Dieser Provider nur aktiv wenn DB_PROVIDER=bun.
3
- // Bekannter Bug: Extended-Query-Protocol Caching verursacht
4
- // PostgresError "bind message has 11 result formats but query has 1 columns"
5
- // bei sequentiellen Queries mit unterschiedlicher Spaltenzahl innerhalb
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 hat kein LISTEN — postgres-js-Peer für event-dispatcher.
9
- import postgres from "postgres";
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: listenClient,
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
  }
@@ -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<number>;
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;
@@ -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;
@@ -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 { EventStoreExecutor } from "./event-store-executor.js";
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
- let currentVersion = -1;
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
- const unavailableOnUpdate = selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, mergedNew, previous));
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, { keyTenantId: streamTenantId }),
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: -1,
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, { keyTenantId: streamTenantId }),
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: -1,
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 previousForStorage = await encryptForStorage(previousPlain, user, {
782
- keyTenantId: streamTenantId,
783
- });
784
- let event;
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.
@@ -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 ADVISORY_LOCK_KEY = 0x6b756d69; // "kumi" in ASCII
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
- const result = await migrationClient(db).unsafe(`SELECT id, checksum FROM "_kumiko_migrations" ORDER BY id`);
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
- await executeRaw(db, MIGRATIONS_TABLE_DDL);
220
- await executeRaw(db, `SELECT pg_advisory_lock(${ADVISORY_LOCK_KEY})`);
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
- const applied = new Map((await fetchAppliedMigrations(db)).map((a) => [a.id, a.checksum]));
223
- const appliedIds = [];
224
- const skippedIds = [];
225
- for (const m of migrations) {
226
- const prevChecksum = applied.get(m.id);
227
- if (prevChecksum !== undefined) {
228
- if (prevChecksum !== m.checksum) {
229
- throw new MigrationChecksumMismatchError(m.id, prevChecksum, m.checksum);
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
- skippedIds.push(m.id);
232
- continue;
233
- }
234
- // Apply file content + INSERT tracking-row in einer TX. Wenn ein
235
- // Statement bricht, Rollback inkl. Tracking-Row → kein partial-apply
236
- // stuck in den books.
237
- const client = migrationClient(db);
238
- await client.begin(async (tx) => {
239
- for (const stmt of m.statements) {
240
- await tx.unsafe(stmt);
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
- await tx.unsafe(`INSERT INTO "_kumiko_migrations" ("id", "checksum") VALUES ($1, $2)`, [
243
- m.id,
244
- m.checksum,
245
- ]);
246
- });
247
- appliedIds.push(m.id);
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 executeRaw(db, `SELECT pg_advisory_unlock(${ADVISORY_LOCK_KEY})`);
277
+ await conn.release();
253
278
  }
254
279
  }
255
280
  // Convenience: load + run in einem Call. Wird vom `kumiko migrate apply`