@cosmicdrift/kumiko-framework 0.220.1 → 0.221.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 (69) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/store-table.integration.test.ts +2 -1
  3. package/src/__tests__/upgrade-cli.test.ts +81 -12
  4. package/src/api/api-constants.ts +10 -0
  5. package/src/api/index.ts +1 -0
  6. package/src/api/server.ts +17 -1
  7. package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +2 -0
  8. package/src/bun-db/__tests__/coerce-row-temporal.test.ts +2 -1
  9. package/src/bun-db/index.ts +1 -0
  10. package/src/bun-db/query.ts +30 -14
  11. package/src/crypto/index.ts +1 -0
  12. package/src/crypto/is-self-pii-field.ts +8 -0
  13. package/src/crypto/subject-resolver.ts +4 -3
  14. package/src/db/__tests__/event-store-executor-list.integration.test.ts +31 -2
  15. package/src/db/__tests__/migrate-generator.test.ts +12 -0
  16. package/src/db/__tests__/multi-row-insert.integration.test.ts +2 -0
  17. package/src/db/__tests__/schema-migration.integration.test.ts +1 -0
  18. package/src/db/__tests__/source-shadow-create.integration.test.ts +2 -0
  19. package/src/db/blind-index-cleanup.ts +36 -19
  20. package/src/db/event-store-executor-context.ts +2 -2
  21. package/src/db/event-store-executor-read.ts +7 -6
  22. package/src/db/event-store-executor-write.ts +103 -49
  23. package/src/db/index.ts +2 -0
  24. package/src/db/migrate-generator.ts +14 -0
  25. package/src/db/queries/__tests__/unsafe-read-retrying.test.ts +8 -1
  26. package/src/db/queries/backfill-pii.ts +13 -10
  27. package/src/db/queries/raw-sql.ts +14 -2
  28. package/src/db/queries/seed-context.ts +8 -4
  29. package/src/derivatives/__tests__/variant-route.integration.test.ts +3 -0
  30. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +32 -26
  31. package/src/engine/__tests__/boot-validator.test.ts +29 -3
  32. package/src/engine/__tests__/role-assignment.test.ts +41 -17
  33. package/src/engine/__tests__/schema-builder.test.ts +3 -3
  34. package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +147 -14
  35. package/src/engine/boot-validator/entity-handler.ts +5 -0
  36. package/src/engine/boot-validator/pii-retention.ts +16 -4
  37. package/src/engine/boot-validator/screens.ts +9 -2
  38. package/src/engine/embedded-derived.ts +11 -10
  39. package/src/engine/feature-ast/__tests__/patch.test.ts +10 -0
  40. package/src/engine/feature-ast/patch.ts +9 -0
  41. package/src/engine/role-assignment.ts +36 -18
  42. package/src/errors/__tests__/classes.test.ts +5 -0
  43. package/src/errors/__tests__/write-failures.test.ts +3 -3
  44. package/src/errors/kumiko-error.ts +11 -11
  45. package/src/event-store/__tests__/backfill-pii.integration.test.ts +58 -0
  46. package/src/event-store/__tests__/perf.integration.test.ts +5 -1
  47. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +1 -0
  48. package/src/event-store/event-store.ts +7 -0
  49. package/src/event-store/index.ts +1 -0
  50. package/src/files/__tests__/files.integration.test.ts +181 -2
  51. package/src/files/__tests__/storage-tracking.integration.test.ts +3 -0
  52. package/src/files/file-routes.ts +53 -6
  53. package/src/i18n/__tests__/mail-registry.test.ts +13 -1
  54. package/src/i18n/__tests__/request-locale.test.ts +19 -0
  55. package/src/i18n/index.ts +7 -1
  56. package/src/i18n/mail-registry.ts +10 -0
  57. package/src/i18n/request-locale.ts +11 -2
  58. package/src/i18n/required-surface-keys.ts +3 -1
  59. package/src/lifecycle/signal-handlers.ts +2 -0
  60. package/src/pipeline/__tests__/distributed-lock.integration.test.ts +12 -0
  61. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +22 -38
  62. package/src/pipeline/distributed-lock.ts +3 -0
  63. package/src/schema-cli.ts +9 -4
  64. package/src/scripts/codemod/crypto-shredding-testing-move.ts +64 -32
  65. package/src/search/purge-subject.ts +4 -3
  66. package/src/search/reindex-entity.ts +2 -2
  67. package/src/stack/__tests__/request-helper.integration.test.ts +24 -10
  68. package/src/ui-types/index.ts +1 -0
  69. package/src/upgrade-cli.ts +100 -14
@@ -13,11 +13,12 @@
13
13
 
14
14
  import { collectLookupableFields } from "../crypto/blind-index";
15
15
  import { quoteIdent, subjectCiphertextLikePattern } from "../crypto/ciphertext-pattern";
16
+ import { isSelfPiiField } from "../crypto/is-self-pii-field";
16
17
  import type { FeatureDefinition } from "../engine/types";
17
18
  import { toSnakeCase } from "../utils/case";
18
19
  import type { DbRunner } from "./connection";
19
20
  import { resolveTableName } from "./entity-table-meta";
20
- import { executeRawQuery } from "./queries/raw-sql";
21
+ import { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql";
21
22
 
22
23
  export async function nullBlindIndexesForSubject(
23
24
  db: DbRunner,
@@ -47,38 +48,54 @@ export async function nullBlindIndexesForSubject(
47
48
 
48
49
  // Tenant-scope oracle for crypto-shredding's forget-subject (mh#349): a
49
50
  // "user"-kind subject id is often not a real user (share-token recipient,
50
- // email subscriber, ...) — those entities self-own their PII (`personal:
51
- // "self"`, i.e. their own row id IS the subject) and carry a real tenant_id,
51
+ // email subscriber, ...) — those entities self-own their PII (`pii: true`,
52
+ // i.e. their own row id IS the subject) and carry a real tenant_id,
52
53
  // unlike read_users (systemStream, tenant_id always SYSTEM_TENANT_ID). This
53
54
  // checks whether the subject row lives in the given tenant, so a tenant-
54
55
  // scoped DPO can still forget subjects it truly owns without needing a
55
56
  // tenant-membership row (which only exists for real users).
57
+ //
58
+ // Invariant: `id` must never be client-settable on self-PII entities — the
59
+ // framework create path strips client ids; app write paths MUST do the same
60
+ // or a DPO could plant a foreign subject id in their tenant and pass this
61
+ // oracle (#2348). Upgrade path: require an event-store provenance check
62
+ // (`aggregate_id = subjectId` under the actor tenant) when apps need
63
+ // client-supplied ids.
56
64
  export async function subjectRowExistsInTenant(
57
65
  db: DbRunner,
58
66
  features: ReadonlyMap<string, FeatureDefinition>,
59
67
  subjectId: string,
60
68
  tenantId: string,
61
69
  ): Promise<boolean> {
70
+ // Prefetch which self-PII projection tables actually exist — probing a
71
+ // missing relation used to throw (and get swallowed), which both hid
72
+ // real schema bugs and risked poisoning the Bun.SQL connection for the
73
+ // rest of the forget TX (fw#2348 / framework#356).
74
+ const candidateTables: string[] = [];
62
75
  for (const feature of features.values()) {
63
76
  for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
64
- const hasSelfPiiField = Object.values(entity.fields).some(
65
- (field) => "pii" in field && field.pii === true,
66
- );
77
+ const hasSelfPiiField = Object.values(entity.fields).some(isSelfPiiField);
67
78
  if (!hasSelfPiiField) continue;
68
- const tableName = resolveTableName(entityName, entity, undefined);
69
- try {
70
- const rows = await executeRawQuery(
71
- db,
72
- `SELECT 1 FROM ${quoteIdent(tableName)} WHERE id = $1 AND tenant_id = $2 LIMIT 1`,
73
- [subjectId, tenantId],
74
- );
75
- if (rows.length > 0) return true;
76
- } catch {
77
- // Table missing, id-type mismatch, ... — not evidence the subject is
78
- // owned here. Fail-closed must come from an honest "not found" across
79
- // every entity, never from a query blowing up on one of them.
80
- }
79
+ candidateTables.push(resolveTableName(entityName, entity, undefined));
81
80
  }
82
81
  }
82
+ const existing = new Set<string>();
83
+ for (const tableName of candidateTables) {
84
+ const rows = await executeRawQueryRead<{ exists: boolean }>(
85
+ db,
86
+ `SELECT to_regclass(quote_ident($1)) IS NOT NULL AS exists`,
87
+ [tableName],
88
+ );
89
+ if (rows[0]?.exists === true) existing.add(tableName);
90
+ }
91
+ for (const tableName of candidateTables) {
92
+ if (!existing.has(tableName)) continue;
93
+ const rows = await executeRawQueryRead(
94
+ db,
95
+ `SELECT 1 FROM ${quoteIdent(tableName)} WHERE id = $1 AND tenant_id = $2 LIMIT 1`,
96
+ [subjectId, tenantId],
97
+ );
98
+ if (rows.length > 0) return true;
99
+ }
83
100
  return false;
84
101
  }
@@ -8,7 +8,7 @@ import {
8
8
  type KmsContext,
9
9
  type LocalKeyKmsAdapter,
10
10
  } from "../crypto";
11
- import { executeRawQuery } from "../db/queries/raw-sql";
11
+ import { executeRawQueryRead } from "../db/queries/raw-sql";
12
12
  import type { WhereObject } from "../db/query";
13
13
  import { shiftParams } from "../engine/ownership";
14
14
  import type {
@@ -381,7 +381,7 @@ export function buildExecutorContext(
381
381
  whereParts.push(shifted.sqlText);
382
382
  for (const p of shifted.params) params.push(p);
383
383
  const sqlText = `SELECT * FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
384
- return [...(await executeRawQuery<Record<string, unknown>>(db.raw, sqlText, params))];
384
+ return [...(await executeRawQueryRead<Record<string, unknown>>(db.raw, sqlText, params))];
385
385
  }
386
386
 
387
387
  return {
@@ -1,6 +1,6 @@
1
1
  import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
2
2
  import { computeBlindIndex, configuredBlindIndexKey } from "../crypto";
3
- import { executeRawQuery } from "../db/queries/raw-sql";
3
+ import { executeRawQueryRead } from "../db/queries/raw-sql";
4
4
  import { coerceRow, extractTableInfo } from "../db/query";
5
5
  import { buildOwnershipClause, shiftParams } from "../engine/ownership";
6
6
  import type { EntityId } from "../engine/types";
@@ -138,9 +138,10 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
138
138
  },
139
139
  });
140
140
  }
141
- // system-mode lists (r.systemScope / cross-tenant) index under SYSTEM_TENANT_ID;
142
- // searching the caller's session tenant misses the roster and can 500 on Meili.
143
- const searchTenantId = db.mode === "system" ? SYSTEM_TENANT_ID : user.tenantId;
141
+ // Same choke-point as writes/index: systemStream docs live under
142
+ // SYSTEM_TENANT_ID; everything else under the session tenant db.mode
143
+ // must not invent a second predicate (kumiko-framework#2412).
144
+ const searchTenantId = streamTenantFor(user);
144
145
  const results = await effectiveSearchAdapter.search(searchTenantId, payload.search, {
145
146
  filterType: entityName,
146
147
  });
@@ -275,7 +276,7 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
275
276
  const whereClauseSqlText = whereSql.length > 0 ? ` WHERE ${whereSql.join(" AND ")}` : "";
276
277
  const listSql = `SELECT * FROM "${tableName}"${whereClauseSqlText}${orderByClause} LIMIT ${limit}${offsetClause}`;
277
278
 
278
- const rawRows = await executeRawQuery<Record<string, unknown>>(db.raw, listSql, params);
279
+ const rawRows = await executeRawQueryRead<Record<string, unknown>>(db.raw, listSql, params);
279
280
  // Per-row read-side rehydrate + snake→camel coercion for driver-agnostic field names.
280
281
  // Coerce BEFORE rehydrate/decrypt: the raw SELECT * rows carry snake_case
281
282
  // column names, while compound-type lookups (rehydrateMoney et al.) and the
@@ -321,7 +322,7 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
321
322
  total = filterIds.length;
322
323
  } else {
323
324
  const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
324
- const countRows = await executeRawQuery<{ count: number }>(db.raw, countSql, params);
325
+ const countRows = await executeRawQueryRead<{ count: number }>(db.raw, countSql, params);
325
326
  total = countRows[0]?.count ?? 0;
326
327
  }
327
328
  }
@@ -18,7 +18,7 @@ import {
18
18
  import { generateId } from "../utils";
19
19
  import { applyEntityEvent } from "./apply-entity-event";
20
20
  import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
21
- import type { DbRow, DbRunner } from "./connection";
21
+ import type { DbRow } from "./connection";
22
22
  import type { EventStoreExecutor } from "./event-store-executor";
23
23
  import {
24
24
  buildEventMetadata,
@@ -200,8 +200,8 @@ export function createWriteVerbs(
200
200
  // its own (same pattern as ctx.tryAppendEvent), and falls back to a
201
201
  // plain call when db.raw is a bare pool connection with no active
202
202
  // transaction to poison (seeds/tests calling the executor directly).
203
- event = await runInSavepointIfSupported(db.raw, (sp) =>
204
- append(sp as DbRunner, {
203
+ event = await runInSavepointIfSupported(db.raw, async (sp) =>
204
+ append(sp, {
205
205
  aggregateId,
206
206
  aggregateType: entityName,
207
207
  tenantId: streamTenantFor(user),
@@ -251,8 +251,8 @@ export function createWriteVerbs(
251
251
  // failed INSERT back; the error still propagates and stays catchable.
252
252
  let result: Awaited<ReturnType<typeof applyEntityEvent>>;
253
253
  try {
254
- result = await runInSavepointIfSupported(db.raw, (sp) =>
255
- applyEntityEvent(event, table, entity, sp as DbRunner),
254
+ result = await runInSavepointIfSupported(db.raw, async (sp) =>
255
+ applyEntityEvent(event, table, entity, sp),
256
256
  );
257
257
  } catch (e) {
258
258
  const mapped = tryMapUniqueViolation(e, entityName);
@@ -429,7 +429,7 @@ export function createWriteVerbs(
429
429
  // confines a losing writer's unique-violation to a nested scope
430
430
  // instead of poisoning the whole outer transaction.
431
431
  const event = await runInSavepointIfSupported(db.raw, (sp) =>
432
- append(sp as DbRunner, {
432
+ append(sp, {
433
433
  aggregateId: String(payload.id),
434
434
  aggregateType: entityName,
435
435
  tenantId: streamTenantFor(user),
@@ -456,8 +456,8 @@ export function createWriteVerbs(
456
456
  // failed nested one throws 500 instead of failing cleanly.
457
457
  let result: Awaited<ReturnType<typeof applyEntityEvent>>;
458
458
  try {
459
- result = await runInSavepointIfSupported(db.raw, (sp) =>
460
- applyEntityEvent(event, table, entity, sp as DbRunner),
459
+ result = await runInSavepointIfSupported(db.raw, async (sp) =>
460
+ applyEntityEvent(event, table, entity, sp),
461
461
  );
462
462
  } catch (e) {
463
463
  const mapped = tryMapUniqueViolation(e, entityName);
@@ -552,26 +552,38 @@ export function createWriteVerbs(
552
552
  // to rebuild from the event log alone. `existing` came from loadById(),
553
553
  // which decrypts — re-encrypt before persisting so plaintext doesn't
554
554
  // land in the immutable log.
555
- const event = await append(db.raw, {
556
- aggregateId: String(payload.id),
557
- aggregateType: entityName,
558
- tenantId: streamTenantFor(user),
559
- expectedVersion: currentVersion,
560
- type: entityEventName(entityName, "deleted"),
561
- payload: { previous: await encryptForStorage(existing, user) },
562
- metadata: buildEventMetadata(user),
563
- });
555
+ let event: Awaited<ReturnType<typeof append>>;
556
+ try {
557
+ event = await runInSavepointIfSupported(db.raw, async (sp) =>
558
+ append(sp, {
559
+ aggregateId: String(payload.id),
560
+ aggregateType: entityName,
561
+ tenantId: streamTenantFor(user),
562
+ expectedVersion: currentVersion,
563
+ type: entityEventName(entityName, "deleted"),
564
+ payload: { previous: await encryptForStorage(existing, user) },
565
+ metadata: buildEventMetadata(user),
566
+ }),
567
+ );
568
+ } catch (e) {
569
+ if (e instanceof EventStoreVersionConflict) {
570
+ return writeFailure(
571
+ new FrameworkVersionConflict({
572
+ entityId: payload.id,
573
+ expectedVersion: currentVersion,
574
+ currentVersion: -1,
575
+ }),
576
+ );
577
+ }
578
+ if (e instanceof EventStoreIdempotentAppendConflict) {
579
+ return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
580
+ }
581
+ throw e;
582
+ }
564
583
 
565
- // Live==Rebuild via applyEntityEvent. Delete-Operation hat keine
566
- // sensitive-Drift weil das Event-Payload nur `previous` ist und das
567
- // wird vom soft/hard-delete-Code gar nicht in die Tabelle geschrieben
568
- // (nur isDeleted/deletedAt/version-Bump). Live + Replay schreiben
569
- // dasselbe — kein payload-override nötig.
570
- // Savepoint like the create/update paths: a raw DB error out of the
571
- // projection delete poisons the enclosing tx otherwise, surfacing as a
572
- // 500 on the next write that shares it rather than a clean failure.
573
- const deleteResult = await runInSavepointIfSupported(db.raw, (sp) =>
574
- applyEntityEvent(event, table, entity, sp as DbRunner),
584
+ // Live==Rebuild via applyEntityEvent. Savepoint like create/update.
585
+ const deleteResult = await runInSavepointIfSupported(db.raw, async (sp) =>
586
+ applyEntityEvent(event, table, entity, sp),
575
587
  );
576
588
  if (deleteResult.kind !== "applied") {
577
589
  return writeFailure(
@@ -626,19 +638,40 @@ export function createWriteVerbs(
626
638
  streamTenantFor(user),
627
639
  );
628
640
 
629
- const event = await append(db.raw, {
630
- aggregateId: String(payload.id),
631
- aggregateType: entityName,
632
- tenantId: streamTenantFor(user),
633
- expectedVersion: currentVersion,
634
- type: entityEventName(entityName, "forgotten"),
635
- // Re-encrypt like delete(): `existing` came decrypted from loadById —
636
- // plaintext must not land in the immutable log, least of all on forget.
637
- payload: { previous: await encryptForStorage(existing, user) },
638
- metadata: buildEventMetadata(user),
639
- });
641
+ let event: Awaited<ReturnType<typeof append>>;
642
+ try {
643
+ event = await runInSavepointIfSupported(db.raw, async (sp) =>
644
+ append(sp, {
645
+ aggregateId: String(payload.id),
646
+ aggregateType: entityName,
647
+ tenantId: streamTenantFor(user),
648
+ expectedVersion: currentVersion,
649
+ type: entityEventName(entityName, "forgotten"),
650
+ // Re-encrypt like delete(): `existing` came decrypted from loadById —
651
+ // plaintext must not land in the immutable log, least of all on forget.
652
+ payload: { previous: await encryptForStorage(existing, user) },
653
+ metadata: buildEventMetadata(user),
654
+ }),
655
+ );
656
+ } catch (e) {
657
+ if (e instanceof EventStoreVersionConflict) {
658
+ return writeFailure(
659
+ new FrameworkVersionConflict({
660
+ entityId: payload.id,
661
+ expectedVersion: currentVersion,
662
+ currentVersion: -1,
663
+ }),
664
+ );
665
+ }
666
+ if (e instanceof EventStoreIdempotentAppendConflict) {
667
+ return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
668
+ }
669
+ throw e;
670
+ }
640
671
 
641
- const forgetResult = await applyEntityEvent(event, table, entity, db.raw);
672
+ const forgetResult = await runInSavepointIfSupported(db.raw, async (sp) =>
673
+ applyEntityEvent(event, table, entity, sp),
674
+ );
642
675
  if (forgetResult.kind !== "applied") {
643
676
  return writeFailure(
644
677
  new InternalError({ message: "projection forget: applyEntityEvent skipped" }),
@@ -710,20 +743,41 @@ export function createWriteVerbs(
710
743
  // `previous` to re-increment on restore without re-querying the entity
711
744
  // table. `data` is the raw stored row — pii/encrypted fields are
712
745
  // already ciphertext, no re-encrypt needed.
713
- const event = await append(db.raw, {
714
- aggregateId: String(payload.id),
715
- aggregateType: entityName,
716
- tenantId: streamTenantFor(user),
717
- expectedVersion: currentVersion,
718
- type: entityEventName(entityName, "restored"),
719
- payload: { previous: data },
720
- metadata: buildEventMetadata(user),
721
- });
746
+ let event: Awaited<ReturnType<typeof append>>;
747
+ try {
748
+ event = await runInSavepointIfSupported(db.raw, (sp) =>
749
+ append(sp, {
750
+ aggregateId: String(payload.id),
751
+ aggregateType: entityName,
752
+ tenantId: streamTenantFor(user),
753
+ expectedVersion: currentVersion,
754
+ type: entityEventName(entityName, "restored"),
755
+ payload: { previous: data },
756
+ metadata: buildEventMetadata(user),
757
+ }),
758
+ );
759
+ } catch (e) {
760
+ if (e instanceof EventStoreVersionConflict) {
761
+ return writeFailure(
762
+ new FrameworkVersionConflict({
763
+ entityId: payload.id,
764
+ expectedVersion: currentVersion,
765
+ currentVersion: -1,
766
+ }),
767
+ );
768
+ }
769
+ if (e instanceof EventStoreIdempotentAppendConflict) {
770
+ return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
771
+ }
772
+ throw e;
773
+ }
722
774
 
723
775
  // Live==Rebuild via applyEntityEvent. Restore schreibt nur isDeleted=
724
776
  // false + version-Bump in die Tabelle — keine sensitive-Drift, daher
725
777
  // kein payload-override nötig.
726
- const restoreResult = await applyEntityEvent(event, table, entity, db.raw);
778
+ const restoreResult = await runInSavepointIfSupported(db.raw, async (sp) =>
779
+ applyEntityEvent(event, table, entity, sp),
780
+ );
727
781
  if (restoreResult.kind !== "applied" || restoreResult.row === null) {
728
782
  return writeFailure(new InternalError({ message: "projection restore returned no row" }));
729
783
  }
package/src/db/index.ts CHANGED
@@ -76,6 +76,7 @@ export {
76
76
  } from "./feature-table-sources";
77
77
  export { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "./located-timestamp";
78
78
  export {
79
+ assertValidMigrationName,
79
80
  diffSnapshots,
80
81
  type GenerateMigrationInput,
81
82
  type GenerateMigrationOutput,
@@ -109,6 +110,7 @@ export {
109
110
  type PgErrorInfo,
110
111
  } from "./pg-error";
111
112
  export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock";
113
+ export { executeRawQuery } from "./queries/raw-sql";
112
114
  export type { SelectOptions, WhereObject, WhereValue } from "./query-api";
113
115
  export {
114
116
  asRawClient,
@@ -415,7 +415,21 @@ export type GenerateMigrationOutput = {
415
415
  readonly diff: SchemaDiff;
416
416
  };
417
417
 
418
+ // Shared allowlist for migration names — used by the CLI and by
419
+ // generateMigration so a programmatic caller cannot skip the check and
420
+ // smuggle path segments / newlines into the filename or SQL header.
421
+ export const MIGRATION_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
422
+
423
+ export function assertValidMigrationName(name: string): void {
424
+ if (!MIGRATION_NAME_RE.test(name)) {
425
+ throw new Error(
426
+ `Invalid migration name "${name}" — must start with a letter or digit, then letters/digits/"-"/"_" only (max 64 chars).`,
427
+ );
428
+ }
429
+ }
430
+
418
431
  export function generateMigration(input: GenerateMigrationInput): GenerateMigrationOutput {
432
+ assertValidMigrationName(input.name);
419
433
  const nextSnapshot = snapshotFromMetas(input.metas);
420
434
  const diff = diffSnapshots(input.prevSnapshot, nextSnapshot);
421
435
  const sqlContent = renderMigrationSql(diff, {
@@ -26,18 +26,23 @@ function closedConnectionError(): Error {
26
26
  return Object.assign(new Error("The connection was closed."), { name: "AbortError" });
27
27
  }
28
28
 
29
+ type RecordedCall = { readonly sql: string; readonly params: readonly unknown[] | undefined };
30
+
29
31
  type FakeClient = {
30
32
  unsafe: (sql: string, params?: readonly unknown[]) => Promise<readonly unknown[]>;
31
33
  begin: () => never;
32
34
  calls: number;
35
+ recordedCalls: RecordedCall[];
33
36
  };
34
37
 
35
38
  function fakeClient(failures: Error[], row: Record<string, unknown>): FakeClient {
36
39
  const remaining = [...failures];
37
40
  const client: FakeClient = {
38
41
  calls: 0,
39
- unsafe: async () => {
42
+ recordedCalls: [],
43
+ unsafe: async (sql, params) => {
40
44
  client.calls++;
45
+ client.recordedCalls.push({ sql, params });
41
46
  const err = remaining.shift();
42
47
  if (err) throw err;
43
48
  return [row];
@@ -55,6 +60,8 @@ describe("framework db/queries — closed-connection retry (#2323)", () => {
55
60
  const result = await selectStreamMaxVersion(db as never, "agg1", "t1");
56
61
  expect(result).toBe(5);
57
62
  expect(db.calls).toBe(2);
63
+ expect(db.recordedCalls).toHaveLength(2);
64
+ expect(db.recordedCalls[0]).toEqual(db.recordedCalls[1]);
58
65
  });
59
66
 
60
67
  test("selectAggregateMaxVersion retries once and returns the version", async () => {
@@ -40,8 +40,13 @@
40
40
  import { asRawClient } from "../../bun-db";
41
41
  import { quoteIdent } from "../../crypto/ciphertext-pattern";
42
42
  import { configuredEventPiiCatalog } from "../../crypto/event-pii";
43
- import type { KmsContext, LocalKeyKmsAdapter, SubjectId } from "../../crypto/kms-adapter";
44
- import { KeyErasedError, KeyNotFoundError } from "../../crypto/kms-adapter";
43
+ import {
44
+ isLocalKeyKmsAdapter,
45
+ KeyErasedError,
46
+ KeyNotFoundError,
47
+ type KmsContext,
48
+ type SubjectId,
49
+ } from "../../crypto/kms-adapter";
45
50
  import {
46
51
  configuredPiiSubjectKms,
47
52
  encryptPiiValueForSubject,
@@ -117,6 +122,10 @@ export async function backfillEventPiiEncryption(
117
122
  "runProdApp({ kms }) / configurePiiSubjectKms(adapter) before running the backfill.",
118
123
  );
119
124
  }
125
+ if (!isLocalKeyKmsAdapter(kms)) {
126
+ throw new Error("backfillEventPiiEncryption requires a local-key KMS adapter");
127
+ }
128
+ const subjectKms = kms;
120
129
  const batchSize = options.batchSize ?? 500;
121
130
  const raw = asRawClient(db);
122
131
  const kmsCtx: KmsContext = { requestId: "pii-backfill" };
@@ -353,13 +362,7 @@ export async function backfillEventPiiEncryption(
353
362
  }
354
363
  if (options.dryRun) return predictEncryptOutcome(subject);
355
364
  try {
356
- section[field] = await encryptPiiValueForSubject(
357
- kms as LocalKeyKmsAdapter,
358
- subject,
359
- value,
360
- kmsCtx,
361
- field,
362
- );
365
+ section[field] = await encryptPiiValueForSubject(subjectKms, subject, value, kmsCtx, field);
363
366
  return "encrypted";
364
367
  } catch (e) {
365
368
  if (e instanceof KeyErasedError) {
@@ -376,7 +379,7 @@ export async function backfillEventPiiEncryption(
376
379
  // writing to the subject-keys store.
377
380
  async function predictEncryptOutcome(subject: SubjectId): Promise<FieldOutcome> {
378
381
  try {
379
- await (kms as LocalKeyKmsAdapter).getKey(subject, kmsCtx);
382
+ await subjectKms.getKey(subject, kmsCtx);
380
383
  return "encrypted";
381
384
  } catch (e) {
382
385
  if (e instanceof KeyErasedError) return "erased";
@@ -1,7 +1,9 @@
1
1
  import type { AnyDb } from "../query";
2
- import { asRawClient } from "../query";
2
+ import { asRawClient, unsafeReadRetrying } from "../query";
3
3
 
4
- /** Escape hatch for caller-built SQL (ownership clauses, entity list queries). */
4
+ /** Escape hatch for caller-built SQL that may write (or lock). No closed-connection retry —
5
+ * retrying an ambiguous write risks double-apply (#1358). Prefer {@link executeRawQueryRead}
6
+ * for SELECT-only paths. */
5
7
  export async function executeRawQuery<T = Record<string, unknown>>(
6
8
  db: AnyDb,
7
9
  sqlText: string,
@@ -10,6 +12,16 @@ export async function executeRawQuery<T = Record<string, unknown>>(
10
12
  return (await asRawClient(db).unsafe(sqlText, params)) as readonly T[];
11
13
  }
12
14
 
15
+ /** SELECT-only escape hatch with the #1163 closed-connection retry. Do not pass
16
+ * INSERT/UPDATE/DELETE — retry re-executes the statement. */
17
+ export async function executeRawQueryRead<T = Record<string, unknown>>(
18
+ db: AnyDb,
19
+ sqlText: string,
20
+ params: readonly unknown[] = [],
21
+ ): Promise<readonly T[]> {
22
+ return unsafeReadRetrying<T>(db, sqlText, params);
23
+ }
24
+
13
25
  export async function pingDatabase(db: AnyDb): Promise<void> {
14
26
  await asRawClient(db).unsafe("SELECT 1");
15
27
  }
@@ -1,5 +1,5 @@
1
1
  import type { AnyDb } from "../query";
2
- import { asRawClient } from "../query";
2
+ import { unsafeReadRetrying } from "../query";
3
3
 
4
4
  export type SeedUserRow = {
5
5
  readonly id: string;
@@ -21,7 +21,8 @@ export type SeedTenantDbRow = {
21
21
  };
22
22
 
23
23
  export async function selectUserByEmail(db: AnyDb, email: string): Promise<SeedUserRow | null> {
24
- const rows = (await asRawClient(db).unsafe(
24
+ const rows = (await unsafeReadRetrying(
25
+ db,
25
26
  `SELECT id::text AS id, email, tenant_id::text AS tenant_id
26
27
  FROM read_users
27
28
  WHERE email = $1
@@ -37,7 +38,8 @@ export async function selectMembershipsOfUser(
37
38
  db: AnyDb,
38
39
  userId: string,
39
40
  ): Promise<readonly SeedMembershipDbRow[]> {
40
- return (await asRawClient(db).unsafe(
41
+ return (await unsafeReadRetrying(
42
+ db,
41
43
  `SELECT m.user_id::text AS user_id,
42
44
  m.tenant_id::text AS tenant_id,
43
45
  e.tenant_id::text AS stream_tenant_id,
@@ -50,9 +52,11 @@ export async function selectMembershipsOfUser(
50
52
  }
51
53
 
52
54
  export async function selectAllTenants(db: AnyDb): Promise<readonly SeedTenantDbRow[]> {
53
- return (await asRawClient(db).unsafe(
55
+ return (await unsafeReadRetrying(
56
+ db,
54
57
  `SELECT id::text AS id, name, key AS tenant_key
55
58
  FROM read_tenants
56
59
  ORDER BY inserted_at`,
60
+ [],
57
61
  )) as readonly SeedTenantDbRow[];
58
62
  }
@@ -103,6 +103,9 @@ describe("GET /api/files/:id/variant/:name", () => {
103
103
  expect(res.headers.get("Content-Type")).toBe("image/webp");
104
104
  expect(res.headers.get("Cache-Control")).toBe("private, max-age=31536000, immutable");
105
105
  expect(new Uint8Array(await res.arrayBuffer())).toEqual(VARIANT_BYTES);
106
+ // fw#2442 — variant responses never derive from fileRef.fileName, so
107
+ // there is no Content-Disposition here for a field-encrypted name to leak into.
108
+ expect(res.headers.get("Content-Disposition")).toBeNull();
106
109
  });
107
110
 
108
111
  // setupTestStack doesn't wrap with the app-wide security-headers default, so a pass proves the route sets its own.
@@ -700,7 +700,7 @@ describe("validateBoot — retention", () => {
700
700
  expect(matchingWarn).toBeUndefined();
701
701
  });
702
702
 
703
- test("blockDelete with only a subjectRef-only field and no anonymize stays silent (#1645, narrowed by #2336)", () => {
703
+ test("blockDelete with only a subjectRef-only field warns about EXT_USER_DATA delete hook (#2338)", () => {
704
704
  const feature = defineFeature("test", (r) => {
705
705
  r.entity(
706
706
  "lease",
@@ -715,36 +715,42 @@ describe("validateBoot — retention", () => {
715
715
  );
716
716
  });
717
717
  validateBoot([feature]);
718
- const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
718
+ const anonymizeWarn = warnSpy.mock.calls.find((args: unknown[]) =>
719
719
  String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
720
720
  );
721
- expect(matchingWarn).toBeUndefined();
721
+ expect(anonymizeWarn).toBeUndefined();
722
+ const extWarn = warnSpy.mock.calls.find((args: unknown[]) =>
723
+ String(args[0]).includes("EXT_USER_DATA delete hook for Art.17"),
724
+ );
725
+ expect(extWarn).toBeDefined();
722
726
  });
723
727
 
724
- test("blockDelete with a subjectRef field plus an anonymizable subject field and no anonymize still warns (#2336)", () => {
725
- const feature = defineFeature("test", (r) => {
726
- r.entity(
727
- "lease",
728
- createEntity({
729
- fields: {
730
- authorId: createTextField({
731
- personal: "ref",
732
- }),
733
- customerName: createTextField({
734
- personal: "self",
735
- find: "none",
736
- }),
737
- },
738
- retention: { keepFor: "10y", strategy: "blockDelete" },
739
- }),
728
+ test.each([
729
+ ["self", { personal: "self" as const, find: "none" as const }],
730
+ ["userOwned", { personal: { of: "authorId" } as const, find: "none" as const }],
731
+ ["tenantOwned", { personal: "tenant" as const, find: "none" as const }],
732
+ ])(
733
+ "blockDelete warns when subjectRef coexists with anonymizable %s field (#2336)",
734
+ (_label, personal) => {
735
+ const feature = defineFeature("test", (r) => {
736
+ r.entity(
737
+ "lease",
738
+ createEntity({
739
+ fields: {
740
+ authorId: createTextField({ personal: "ref" }),
741
+ subjectField: createTextField(personal),
742
+ },
743
+ retention: { keepFor: "10y", strategy: "blockDelete" },
744
+ }),
745
+ );
746
+ });
747
+ validateBoot([feature]);
748
+ const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
749
+ String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
740
750
  );
741
- });
742
- validateBoot([feature]);
743
- const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
744
- String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
745
- );
746
- expect(matchingWarn).toBeDefined();
747
- });
751
+ expect(matchingWarn).toBeDefined();
752
+ },
753
+ );
748
754
 
749
755
  test('retention.keepFor with invalid format "30days" warns', () => {
750
756
  const feature = defineFeature("test", (r) => {