@cosmicdrift/kumiko-framework 0.364.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.
Files changed (150) 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/workflow-engine.md +2 -2
  22. package/agents/examples.md +2 -1
  23. package/agents/features.md +3 -3
  24. package/dist/api/auth-routes.d.ts +0 -1
  25. package/dist/api/auth-routes.js +44 -43
  26. package/dist/api/index.d.ts +1 -1
  27. package/dist/api/index.js +1 -1
  28. package/dist/api/server.d.ts +2 -2
  29. package/dist/api/server.js +21 -4
  30. package/dist/bun-db/connection.js +3 -16
  31. package/dist/bun-db/index.d.ts +1 -1
  32. package/dist/bun-db/index.js +1 -1
  33. package/dist/bun-db/query.d.ts +7 -0
  34. package/dist/bun-db/query.js +50 -18
  35. package/dist/crypto/owner-transition-rekey.d.ts +1 -0
  36. package/dist/crypto/owner-transition-rekey.js +7 -3
  37. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  38. package/dist/crypto/pii-field-encryption.js +9 -2
  39. package/dist/crypto/subject-resolver.d.ts +1 -0
  40. package/dist/crypto/subject-resolver.js +7 -0
  41. package/dist/db/api.d.ts +4 -3
  42. package/dist/db/apply-entity-event.js +26 -2
  43. package/dist/db/blind-index-cleanup.js +2 -1
  44. package/dist/db/bun-provider.js +9 -18
  45. package/dist/db/dialect.d.ts +1 -1
  46. package/dist/db/dialect.js +2 -0
  47. package/dist/db/eagerload.js +2 -1
  48. package/dist/db/event-store-executor-context.d.ts +1 -0
  49. package/dist/db/event-store-executor-context.js +1 -0
  50. package/dist/db/event-store-executor-write.d.ts +1 -1
  51. package/dist/db/event-store-executor-write.js +70 -47
  52. package/dist/db/index.d.ts +1 -0
  53. package/dist/db/index.js +1 -0
  54. package/dist/db/migrate-runner.d.ts +1 -0
  55. package/dist/db/migrate-runner.js +56 -31
  56. package/dist/db/queries/backfill-pii.js +63 -25
  57. package/dist/db/queries/event-consumer.js +2 -1
  58. package/dist/db/queries/event-store-transfer.js +15 -4
  59. package/dist/db/queries/event-store.js +11 -10
  60. package/dist/db/queries/pii-backfill-state.js +3 -2
  61. package/dist/db/queries/postgres-array-literal.d.ts +2 -0
  62. package/dist/db/queries/postgres-array-literal.js +6 -0
  63. package/dist/db/queries/projection-rebuild.js +3 -2
  64. package/dist/db/queries/shadow-swap.d.ts +1 -1
  65. package/dist/db/queries/shadow-swap.js +12 -8
  66. package/dist/db/query.d.ts +1 -1
  67. package/dist/db/query.js +1 -1
  68. package/dist/db/retired-framework-tables.js +22 -4
  69. package/dist/db/table-builder.js +6 -2
  70. package/dist/db/tenant-db.d.ts +2 -0
  71. package/dist/db/tenant-db.js +20 -1
  72. package/dist/derivatives/delete-stored-file.d.ts +4 -0
  73. package/dist/derivatives/delete-stored-file.js +9 -0
  74. package/dist/derivatives/index.d.ts +1 -1
  75. package/dist/derivatives/index.js +1 -1
  76. package/dist/engine/boot-validator/entity-handler.js +3 -0
  77. package/dist/engine/boot-validator/query-refs.js +14 -2
  78. package/dist/engine/dedupe-features.js +31 -4
  79. package/dist/engine/entity-handlers.js +14 -7
  80. package/dist/engine/index.d.ts +1 -1
  81. package/dist/engine/index.js +1 -1
  82. package/dist/engine/screen-helpers.d.ts +1 -0
  83. package/dist/engine/screen-helpers.js +2 -2
  84. package/dist/engine/steps/_read-source.js +5 -2
  85. package/dist/engine/tier-resolver-extension.d.ts +1 -0
  86. package/dist/engine/tier-resolver-extension.js +6 -0
  87. package/dist/entrypoint/index.d.ts +2 -2
  88. package/dist/errors/docs-url.js +6 -6
  89. package/dist/errors/index.d.ts +1 -1
  90. package/dist/errors/index.js +1 -1
  91. package/dist/errors/subject-erased.d.ts +1 -0
  92. package/dist/errors/subject-erased.js +6 -0
  93. package/dist/event-store/admin-api.js +3 -2
  94. package/dist/event-store/errors.d.ts +9 -0
  95. package/dist/event-store/errors.js +20 -0
  96. package/dist/event-store/event-store.d.ts +6 -0
  97. package/dist/event-store/event-store.js +28 -6
  98. package/dist/event-store/index.d.ts +1 -1
  99. package/dist/event-store/index.js +1 -1
  100. package/dist/files/create-file-ref.d.ts +2 -1
  101. package/dist/files/create-file-ref.js +16 -2
  102. package/dist/files/dispose-removed-file-refs.js +21 -6
  103. package/dist/files/feature.d.ts +1 -0
  104. package/dist/files/feature.js +2 -1
  105. package/dist/files/file-handle.js +4 -0
  106. package/dist/files/file-routes.js +9 -5
  107. package/dist/files/index.d.ts +1 -1
  108. package/dist/files/index.js +1 -1
  109. package/dist/files/purge-expired-file-ref-bytes.js +2 -3
  110. package/dist/files/resolve-content-type.js +40 -4
  111. package/dist/files/storage-tracking.js +5 -4
  112. package/dist/files/types.js +5 -8
  113. package/dist/files/zip-entry-names.d.ts +1 -0
  114. package/dist/files/zip-entry-names.js +46 -0
  115. package/dist/jobs/job-runner.d.ts +1 -0
  116. package/dist/jobs/job-runner.js +116 -33
  117. package/dist/observability/escape-hatch-report.d.ts +1 -0
  118. package/dist/observability/escape-hatch-report.js +31 -2
  119. package/dist/observability/index.d.ts +1 -1
  120. package/dist/observability/index.js +1 -1
  121. package/dist/observability/metrics-handle.d.ts +1 -0
  122. package/dist/observability/metrics-handle.js +7 -0
  123. package/dist/observability/standard-metrics.js +4 -4
  124. package/dist/pipeline/dispatch-query.d.ts +2 -1
  125. package/dist/pipeline/dispatch-query.js +6 -6
  126. package/dist/pipeline/dispatch-shared.d.ts +1 -1
  127. package/dist/pipeline/dispatch-shared.js +41 -30
  128. package/dist/pipeline/dispatch-stream.js +10 -4
  129. package/dist/pipeline/dispatcher.js +3 -1
  130. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  131. package/dist/pipeline/event-dispatcher.js +11 -9
  132. package/dist/pipeline/event-retention.d.ts +1 -0
  133. package/dist/pipeline/event-retention.js +7 -1
  134. package/dist/pipeline/notify-scope.js +3 -1
  135. package/dist/pipeline/pre-transaction-reservations.js +2 -1
  136. package/dist/schema-cli.js +12 -1
  137. package/dist/stack/db.d.ts +2 -2
  138. package/dist/stack/test-stack.js +2 -1
  139. package/dist/testing/index.d.ts +1 -0
  140. package/dist/testing/index.js +1 -0
  141. package/dist/testing/multipart-helper.d.ts +6 -0
  142. package/dist/testing/multipart-helper.js +1 -1
  143. package/dist/testing/zip-fixture.d.ts +1 -0
  144. package/dist/testing/zip-fixture.js +33 -0
  145. package/dist/ui-types/card-column-roles.js +4 -2
  146. package/dist/ui-types/index.d.ts +1 -1
  147. package/dist/ui-types/index.js +1 -1
  148. package/dist/upgrade-cli.js +46 -13
  149. package/package.json +4 -4
  150. package/src/changes.json +305 -0
@@ -1,6 +1,6 @@
1
1
  import type { CacheSyncBus } from "@cosmicdrift/kumiko-types/cache-sync-types";
2
2
  import { Hono } from "hono";
3
- import type { PgClient } from "../db/connection.js";
3
+ import type { PgListenClient } from "../db/connection.js";
4
4
  import { type AppContext, type Registry, type RunIn, type WriteResult } from "../engine/types/index.js";
5
5
  import type { ErrorDocsConfig } from "../errors/docs-url.js";
6
6
  import type { Lifecycle } from "../lifecycle/index.js";
@@ -43,7 +43,7 @@ export type ServerOptions = {
43
43
  jobTrigger?: boolean;
44
44
  accessInvalidation?: boolean;
45
45
  };
46
- pgClient?: PgClient;
46
+ pgClient?: PgListenClient;
47
47
  };
48
48
  observability?: ObservabilityProvider;
49
49
  observabilityOptions?: ObservabilityOptions;
@@ -1,6 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import { ROLES } from "../auth/roles.js";
3
- import { createTenantDb } from "../db/tenant-db.js";
3
+ import { createProjectingTenantDb } from "../db/tenant-db.js";
4
4
  import { createDerivativesContext } from "../derivatives/derivatives-context.js";
5
5
  import { buildAppSchema } from "../engine/build-app-schema.js";
6
6
  import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names.js";
@@ -69,6 +69,24 @@ export function withFileProviderResolver(registry, context, cacheSync) {
69
69
  }),
70
70
  };
71
71
  }
72
+ // A payload-bucket handler without a resolver would answer every call with a
73
+ // 500 at request time; fail at boot instead.
74
+ function assertNoPayloadRateLimitsWithoutResolver(registry) {
75
+ const offenders = [];
76
+ for (const feature of registry.features.values()) {
77
+ for (const [name, handler] of Object.entries(feature.writeHandlers)) {
78
+ if (handler.additionalRateLimits !== undefined) {
79
+ offenders.push(`${feature.name}:write:${name}`);
80
+ }
81
+ }
82
+ }
83
+ // skip: every handler's payload buckets are covered by the resolver
84
+ if (offenders.length === 0)
85
+ return;
86
+ throw new Error(`Handlers declare additionalRateLimits but no RateLimitResolver is configured: ${offenders.join(", ")}. ` +
87
+ "Wire a Redis client on context.redis (or pass context.rateLimit), or drop the payload buckets " +
88
+ "(for the waitlist: emailRateLimits: []).");
89
+ }
72
90
  export function buildServer(options) {
73
91
  assertSeedModeNotInProduction();
74
92
  // Single effective hop-count for every IP-derived rate-limit + requestMeta
@@ -221,6 +239,7 @@ export function buildServer(options) {
221
239
  const rateLimitResolver = options.context.rateLimit ??
222
240
  (wrappedRedis && wantsResolver ? createRateLimitResolver({ redis: wrappedRedis }) : undefined);
223
241
  if (!rateLimitResolver) {
242
+ assertNoPayloadRateLimitsWithoutResolver(options.registry);
224
243
  // The systemScope default limit only exists once a resolver does, and an app that
225
244
  // declared no explicit rateLimit anywhere never gets one auto-wired (wantsL3 stays
226
245
  // false) — so its cross-tenant handlers are silently unlimited.
@@ -696,9 +715,7 @@ export function buildServer(options) {
696
715
  canWriteRecord: createRecordWriteCheck({
697
716
  registry: options.registry,
698
717
  canReadRecord,
699
- tenantDbFor: (tenantId) => createTenantDb(fileDb, tenantId, "tenant", undefined, undefined, undefined, {
700
- projectionRegistry: options.registry,
701
- }),
718
+ tenantDbFor: (tenantId) => createProjectingTenantDb(fileDb, tenantId, options.registry),
702
719
  }),
703
720
  ...(routeOptions.accessGuard ? { accessGuard: routeOptions.accessGuard } : {}),
704
721
  ...(routeOptions.privilegedRoles ? { privilegedRoles: routeOptions.privilegedRoles } : {}),
@@ -3,10 +3,8 @@
3
3
  // nach drizzle-removal. Erstellt parallel zum legacy `db/connection.ts`
4
4
  // — apps migrieren schritt-für-schritt, alte Datei weg sobald 0 Konsumenten.
5
5
  //
6
- // **postgres-js** bleibt als kleiner Peer NUR für LISTEN/NOTIFY in
7
- // event-dispatcher.ts. Bun.sql 1.2.20 hat kein listen() (PR oven-sh/bun#25511
8
- // pending). Nach Landung des Bun-LISTEN-Supports: peer raus.
9
- import postgres from "postgres";
6
+ // LISTEN for the event dispatcher uses Bun.SQL's listen() on its own
7
+ // dedicated connection, outside the pool's idleTimeout.
10
8
  import { readPositiveIntEnv } from "../utils/env-parse.js";
11
9
  export function createBunDbConnection(url, options = {}) {
12
10
  const bunOpts = {};
@@ -18,22 +16,11 @@ export function createBunDbConnection(url, options = {}) {
18
16
  bunOpts.connectionTimeout = options.connectTimeoutSeconds;
19
17
  }
20
18
  const db = new Bun.SQL(url, bunOpts);
21
- // LISTEN-only peer — minimal pool (1 connection reicht), eigener idle/
22
- // connect-timeout falls gesetzt. Wenn das wegfällt, läuft event-dispatcher
23
- // auf polling-only ohne LISTEN-Wakeup.
24
- const pgOpts = { max: 1 };
25
- if (options.idleTimeoutSeconds !== undefined)
26
- pgOpts.idle_timeout = options.idleTimeoutSeconds;
27
- if (options.connectTimeoutSeconds !== undefined) {
28
- pgOpts.connect_timeout = options.connectTimeoutSeconds;
29
- }
30
- const listenClient = postgres(url, pgOpts);
31
19
  return {
32
20
  db,
33
- listenClient,
21
+ listenClient: db,
34
22
  close: async () => {
35
23
  await db.end();
36
- await listenClient.end();
37
24
  },
38
25
  };
39
26
  }
@@ -1,4 +1,4 @@
1
1
  export type { BunDbConnection, BunDbConnectionOptions, BunDbRunner, BunDbTx, PgListenClient, } from "./connection.js";
2
2
  export { bunDbConnectionOptionsFromEnv, createBunDbConnection } from "./connection.js";
3
3
  export type { InnerJoinRow, InnerJoinSpec, InsertOnConflictDoNothingOptions, JoinColumnPair, JsonTextMatch, SelectOptions, TableInfo, WhereObject, WhereOperator, WhereValue, } from "./query.js";
4
- export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, type DeleteManyBatchedOptions, type DeleteManyBatchedResult, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, type IncrementCounterOptions, incrementCounter, insertMany, insertOnConflictDoNothing, insertOne, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, runnerCanBeginTransaction, selectInnerJoin, selectMany, transaction, type UpsertOnConflictOptions, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
4
+ export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, type DeleteManyBatchedOptions, type DeleteManyBatchedResult, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, type IncrementCounterOptions, incrementCounter, insertMany, insertOnConflictDoNothing, insertOne, instantToTimestamptzParam, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, runnerCanBeginTransaction, selectInnerJoin, selectMany, transaction, type UpsertOnConflictOptions, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
@@ -1,4 +1,4 @@
1
1
  // bun-db: Bun.sql-basierte DB-API ohne drizzle.
2
2
  // Production-Pfad nach drizzle-removal.
3
3
  export { bunDbConnectionOptionsFromEnv, createBunDbConnection } from "./connection.js";
4
- export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOnConflictDoNothing, insertOne, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, runnerCanBeginTransaction, selectInnerJoin, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
4
+ export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOnConflictDoNothing, insertOne, instantToTimestamptzParam, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, runnerCanBeginTransaction, selectInnerJoin, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
@@ -1,4 +1,5 @@
1
1
  import type { AggregateRow, AggregateSpec } from "@cosmicdrift/kumiko-types/aggregate-types";
2
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
2
3
  import type { SelectOptions, WhereObject } from "@cosmicdrift/kumiko-types/where-clause-types";
3
4
  import type { EntityTableMeta } from "../db/entity-table-meta.js";
4
5
  import { type NotExecutorOnly } from "../db/table-builder.js";
@@ -6,11 +7,16 @@ import { asEntityTableMeta, requireEntityTableMeta } from "../db/table-introspec
6
7
  import type { DbRunner } from "@cosmicdrift/kumiko-types/db-connection";
7
8
  import type { BunDbRunner } from "./connection.js";
8
9
  export { asEntityTableMeta, requireEntityTableMeta };
10
+ export type ReservedClient = {
11
+ unsafe: RawClient["unsafe"];
12
+ release: () => unknown;
13
+ };
9
14
  export type RawClient = {
10
15
  unsafe: <TRow = unknown>(sql: string, params?: readonly unknown[]) => Promise<readonly TRow[]>;
11
16
  begin?: <T>(fn: (tx: BunDbRunner) => Promise<T>) => Promise<T>;
12
17
  savepoint?: <T>(fn: (sp: unknown) => Promise<T>) => Promise<T>;
13
18
  release?: unknown;
19
+ reserve?: () => Promise<ReservedClient>;
14
20
  };
15
21
  export type RawClientWithBegin = RawClient & {
16
22
  begin: NonNullable<RawClient["begin"]>;
@@ -34,6 +40,7 @@ export type TableInfo = {
34
40
  };
35
41
  export declare function extractTableInfo(table: TableLike): TableInfo;
36
42
  export declare function coerceRow<TOut = Record<string, unknown>>(row: Record<string, unknown>, info: TableInfo): TOut;
43
+ export declare function instantToTimestamptzParam(instant: Temporal.Instant): string;
37
44
  export declare function isClosedConnectionError(err: unknown): boolean;
38
45
  export declare function describeClosedConnectionFailure(raw: unknown, err: unknown, attempts: number): Record<string, unknown> | undefined;
39
46
  export declare function unsafeReadRetrying<TRow>(db: AnyDb, sqlText: string, params: readonly unknown[]): Promise<readonly TRow[]>;
@@ -51,7 +51,8 @@ function snakeToCamel(key) {
51
51
  // the only collision-safe path.
52
52
  export { asEntityTableMeta, requireEntityTableMeta };
53
53
  export function hasBegin(client) {
54
- return typeof client.begin === "function";
54
+ // Bun.SQL tx/savepoint handles keep a begin() that throws; only savepoint() tells them apart from a pool.
55
+ return typeof client.begin === "function" && typeof client.savepoint !== "function";
55
56
  }
56
57
  function isRawClient(value) {
57
58
  return typeof propertyOf(value, "unsafe") === "function";
@@ -113,16 +114,27 @@ export async function runInSavepointIfSupported(db, fn) {
113
114
  const asRunner = (sp) => sp;
114
115
  if (typeof raw.savepoint !== "function")
115
116
  return fn(asRunner(db));
117
+ // An error from inside fn must never be remapped, even if it carries a closed-connection code.
118
+ let fnStarted = false;
116
119
  try {
117
- return await raw.savepoint((sp) => fn(asRunner(sp)));
120
+ return await raw.savepoint((sp) => {
121
+ fnStarted = true;
122
+ return fn(asRunner(sp));
123
+ });
118
124
  }
119
125
  catch (e) {
120
- if (extractPgError(e)?.code === "25P01") {
126
+ const code = extractPgError(e)?.code;
127
+ if (!fnStarted && (code === "25P01" || code === "ERR_POSTGRES_CONNECTION_CLOSED")) {
121
128
  throw new InternalError({
122
- message: "runInSavepointIfSupported: savepoint on a transaction that has already committed — " +
123
- "this is almost always database work in an afterCommit hook. afterCommit is the default " +
124
- "phase of r.hook/postSave/postDelete. Use HookPhases.inTransaction for writes that must " +
125
- "roll back with the trigger, or ctx.dbOutsideTransaction for work that must survive a rollback.",
129
+ message: code === "25P01"
130
+ ? "runInSavepointIfSupported: savepoint on a transaction that has already committed — " +
131
+ "this is almost always database work in an afterCommit hook. afterCommit is the default " +
132
+ "phase of r.hook/postSave/postDelete. Use HookPhases.inTransaction for writes that must " +
133
+ "roll back with the trigger, or ctx.dbOutsideTransaction for work that must survive a rollback."
134
+ : "runInSavepointIfSupported: the transaction handle's connection is gone — the transaction " +
135
+ "already committed (almost always database work in an afterCommit hook) or the connection dropped. " +
136
+ "afterCommit is the default phase of r.hook/postSave/postDelete. Use HookPhases.inTransaction " +
137
+ "for writes that must roll back with the trigger, or ctx.dbOutsideTransaction for work that must survive a rollback.",
126
138
  cause: e instanceof Error ? e : undefined,
127
139
  });
128
140
  }
@@ -216,13 +228,8 @@ function quoteIdent(name) {
216
228
  //
217
229
  // Symmetric on write: Temporal.Instant doesn't bind directly into postgres-js
218
230
  // params — convert to ISO string when the column pgType is timestamptz.
219
- function toStringTagOf(v) {
220
- return typeof v === "object" && v !== null && Symbol.toStringTag in v
221
- ? v[Symbol.toStringTag]
222
- : undefined;
223
- }
224
231
  function isTemporalInstant(v) {
225
- return toStringTagOf(v) === "Temporal.Instant";
232
+ return propertyOf(v, Symbol.toStringTag) === "Temporal.Instant";
226
233
  }
227
234
  // Postgres bigint → JS: safe integers become `number` (matches createBigIntField /
228
235
  // drizzle mode:"number"); values outside ±2^53 stay `bigint` (money cents, raw
@@ -272,10 +279,10 @@ function instantFromDriver(value) {
272
279
  return null;
273
280
  }
274
281
  function isTemporalPlainDate(v) {
275
- return toStringTagOf(v) === "Temporal.PlainDate";
282
+ return propertyOf(v, Symbol.toStringTag) === "Temporal.PlainDate";
276
283
  }
277
284
  function isTemporalPlainDateTimeLike(v) {
278
- const tag = toStringTagOf(v);
285
+ const tag = propertyOf(v, Symbol.toStringTag);
279
286
  return ((tag === "Temporal.PlainDateTime" || tag === "Temporal.ZonedDateTime") &&
280
287
  typeof v === "object" &&
281
288
  v !== null &&
@@ -459,6 +466,31 @@ function prepareJsonbValue(value) {
459
466
  }
460
467
  return { kind: "param", sql: "::jsonb", bound: value };
461
468
  }
469
+ const INTEGER_PG_TYPES = new Set([
470
+ "integer",
471
+ "int4",
472
+ "smallint",
473
+ "int2",
474
+ "bigint",
475
+ "int8",
476
+ "serial",
477
+ "bigserial",
478
+ ]);
479
+ // postgres.js truncates timestamptz params to ms via Date; Bun.SQL sends µs and Postgres
480
+ // rounds into timestamptz(3). Truncating here keeps both drivers storing the same instant.
481
+ export function instantToTimestamptzParam(instant) {
482
+ return Temporal.Instant.fromEpochMilliseconds(instant.epochMilliseconds).toString();
483
+ }
484
+ function prepareDriverNeutralScalar(value, pgType) {
485
+ if ((pgType === "timestamptz" || pgType === "timestamptz(3)") && isTemporalInstant(value)) {
486
+ return { kind: "param", sql: "", bound: instantToTimestamptzParam(value) };
487
+ }
488
+ // Bun.SQL binds JS numbers typed, so Postgres would round 3.5 into an integer column; postgres.js sends text and rejects it.
489
+ if (typeof value === "number" && pgType !== undefined && INTEGER_PG_TYPES.has(pgType)) {
490
+ return { kind: "param", sql: "", bound: String(value) };
491
+ }
492
+ return undefined;
493
+ }
462
494
  function prepareValue(value, pgType) {
463
495
  if (isSqlExpression(value)) {
464
496
  return { kind: "literal", literal: value.text };
@@ -473,9 +505,9 @@ function prepareValue(value, pgType) {
473
505
  if (prepared)
474
506
  return prepared;
475
507
  }
476
- if ((pgType === "timestamptz" || pgType === "timestamptz(3)") && isTemporalInstant(value)) {
477
- return { kind: "param", sql: "", bound: value.toString() };
478
- }
508
+ const scalar = prepareDriverNeutralScalar(value, pgType);
509
+ if (scalar)
510
+ return scalar;
479
511
  if (pgType === "date") {
480
512
  const prepared = prepareDateValue(value);
481
513
  if (prepared)
@@ -1,2 +1,3 @@
1
1
  import type { EntityDefinition } from "../engine/types/fields.js";
2
+ export declare function isPlainUserOwnedField(field: EntityDefinition["fields"][string]): boolean;
2
3
  export declare function personalFieldsRekeyedOnOwnerTransition(entity: EntityDefinition, previous: Record<string, unknown>, mergedNew: Record<string, unknown>, submittedChanges: Record<string, unknown>): Record<string, unknown>;
@@ -1,6 +1,12 @@
1
1
  import { PII_ERASED_SENTINEL } from "./kms-adapter.js";
2
2
  import { isPiiCiphertext } from "./pii-ciphertext-format.js";
3
3
  import { nonEmptyString } from "./subject-resolver.js";
4
+ // userOwned and not recordOwned: the value follows the row's current owner.
5
+ export function isPlainUserOwnedField(field) {
6
+ if (!("userOwned" in field) || field.userOwned === undefined)
7
+ return false;
8
+ return !("recordOwned" in field && field.recordOwned === true);
9
+ }
4
10
  // Personal fields keep their stored value when an update moves the row to a
5
11
  // new owner, so without re-keying they would stay shreddable only by the old
6
12
  // owner. Returns the unchanged-by-the-caller values that must be written again
@@ -9,9 +15,7 @@ import { nonEmptyString } from "./subject-resolver.js";
9
15
  export function personalFieldsRekeyedOnOwnerTransition(entity, previous, mergedNew, submittedChanges) {
10
16
  const rekeyed = {};
11
17
  for (const [name, field] of Object.entries(entity.fields)) {
12
- if (!("userOwned" in field) || field.userOwned === undefined)
13
- continue;
14
- if ("recordOwned" in field && field.recordOwned === true)
18
+ if (!isPlainUserOwnedField(field) || !("userOwned" in field) || !field.userOwned)
15
19
  continue;
16
20
  if (name in submittedChanges)
17
21
  continue;
@@ -13,6 +13,7 @@ export interface EncryptPiiOptions {
13
13
  readonly subjectSource?: Record<string, unknown>;
14
14
  readonly entityName: string;
15
15
  readonly ownerFallback?: Record<string, unknown>;
16
+ readonly onErasedSubject?: "sentinel";
16
17
  }
17
18
  export type PiiEncryptedRow<TRow extends Record<string, unknown>> = {
18
19
  [K in keyof TRow]: TRow[K] extends string ? string : TRow[K] | string;
@@ -161,8 +161,15 @@ export async function encryptPiiFieldValues(row, entity, piiFields, kms, kmsCtx,
161
161
  // skip: collectPiiSubjectFields only yields annotated fields — null is unreachable, kept as a type guard
162
162
  if (subject === null)
163
163
  continue;
164
- const dek = await getOrCreateDek(kms, subject, kmsCtx);
165
- out[name] = encryptValue(subject, dek, plaintext, name, isJsonbField);
164
+ try {
165
+ const dek = await getOrCreateDek(kms, subject, kmsCtx);
166
+ out[name] = encryptValue(subject, dek, plaintext, name, isJsonbField);
167
+ }
168
+ catch (e) {
169
+ if (!(e instanceof KeyErasedError) || opts.onErasedSubject !== "sentinel")
170
+ throw e;
171
+ out[name] = PII_ERASED_SENTINEL;
172
+ }
166
173
  }
167
174
  return out;
168
175
  }
@@ -30,5 +30,6 @@ export interface EventSubjectEnvelope {
30
30
  export declare function resolveEventSubject(fieldName: string, spec: EventPiiSubject, payload: Record<string, unknown>, envelope: EventSubjectEnvelope): SubjectId | null;
31
31
  export declare function collectPiiSubjectFields(entity: EntityDefinition): readonly string[];
32
32
  export declare function collectPiiEncryptedFields(entity: EntityDefinition): readonly string[];
33
+ export declare function collectPiiFileReferenceFields(entity: EntityDefinition): readonly string[];
33
34
  /** Subject-annotated fields that may be plaintext in the derived search index (#1610). */
34
35
  export declare function collectSearchableSubjectFields(entity: EntityDefinition): readonly string[];
@@ -155,6 +155,13 @@ export function collectPiiEncryptedFields(entity) {
155
155
  .filter(([, field]) => hasSubjectAnnotation(field) && !isFileReferenceField(field))
156
156
  .map(([name]) => name);
157
157
  }
158
+ // The annotated file-reference fields: never ciphertext today, but events from the
159
+ // old backfill (which encrypted them) still carry it and must replay.
160
+ export function collectPiiFileReferenceFields(entity) {
161
+ return Object.entries(entity.fields)
162
+ .filter(([, field]) => hasSubjectAnnotation(field) && isFileReferenceField(field))
163
+ .map(([name]) => name);
164
+ }
158
165
  /** Subject-annotated fields that may be plaintext in the derived search index (#1610). */
159
166
  export function collectSearchableSubjectFields(entity) {
160
167
  return Object.entries(entity.fields)
package/dist/db/api.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { PgListenClient } from "@cosmicdrift/kumiko-types/db-connection";
1
2
  export type DbConnectionOptions = {
2
3
  readonly maxConnections?: number;
3
4
  readonly idleTimeoutSeconds?: number;
@@ -8,10 +9,10 @@ export declare const DEFAULT_DB_CLOSE_TIMEOUT_SECONDS = 5;
8
9
  export type DbPoolHandle = {
9
10
  /** Provider Connection — Calls gehen über asRawClient() oder direkt. */
10
11
  readonly db: any;
11
- /** Legacy postgres-js Client (für LISTEN peer) */
12
+ /** Raw driver client (postgres-js or Bun.SQL) */
12
13
  readonly client: any;
13
- /** Optionaler Bun.SQL LISTEN peer */
14
- readonly listenClient?: any;
14
+ /** LISTEN-capable client; Bun.SQL serves it from its own dedicated connection */
15
+ readonly listenClient?: PgListenClient;
15
16
  /** Pool schliessen */
16
17
  close: () => Promise<void>;
17
18
  };
@@ -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">;