@cosmicdrift/kumiko-framework 0.105.1 → 0.108.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 (92) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/full-stack.integration.test.ts +3 -2
  3. package/src/__tests__/schema-cli.integration.test.ts +29 -0
  4. package/src/__tests__/transition-guard.integration.test.ts +2 -2
  5. package/src/api/__tests__/batch.integration.test.ts +3 -2
  6. package/src/api/__tests__/dispatcher-live.integration.test.ts +3 -2
  7. package/src/api/__tests__/jwt.test.ts +27 -0
  8. package/src/api/__tests__/pat-scope.test.ts +36 -0
  9. package/src/api/__tests__/request-id-middleware.test.ts +51 -0
  10. package/src/api/auth-middleware.ts +65 -1
  11. package/src/api/auth-routes.ts +11 -0
  12. package/src/api/index.ts +3 -1
  13. package/src/api/jwt.ts +5 -5
  14. package/src/api/pat-scope.ts +14 -0
  15. package/src/api/request-context.ts +3 -0
  16. package/src/api/request-id-middleware.ts +2 -0
  17. package/src/api/routes.ts +22 -0
  18. package/src/api/server.ts +29 -1
  19. package/src/bun-db/__tests__/batch-methods.test.ts +3 -2
  20. package/src/bun-db/__tests__/query-guards.test.ts +3 -2
  21. package/src/bun-db/__tests__/write-brand.test.ts +48 -0
  22. package/src/bun-db/query.ts +40 -9
  23. package/src/db/__tests__/assert-exists-in.integration.test.ts +2 -2
  24. package/src/db/__tests__/eagerload.integration.test.ts +2 -2
  25. package/src/db/__tests__/event-store-executor.integration.test.ts +138 -1
  26. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +3 -1
  27. package/src/db/__tests__/multi-row-insert.integration.test.ts +5 -4
  28. package/src/db/__tests__/schema-migration.integration.test.ts +4 -3
  29. package/src/db/__tests__/source-shadow-create.integration.test.ts +3 -2
  30. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +3 -2
  31. package/src/db/__tests__/tenant-db.integration.test.ts +7 -6
  32. package/src/db/apply-entity-event.ts +19 -8
  33. package/src/db/event-store-executor.ts +91 -8
  34. package/src/db/queries/shadow-swap.ts +1 -1
  35. package/src/db/query.ts +1 -0
  36. package/src/db/table-builder.ts +23 -1
  37. package/src/db/tenant-db.ts +6 -0
  38. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -5
  39. package/src/engine/__tests__/boot-validator.test.ts +210 -0
  40. package/src/engine/__tests__/build-config-feature-schema.test.ts +21 -0
  41. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
  42. package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
  43. package/src/engine/__tests__/projection-helpers.test.ts +2 -2
  44. package/src/engine/__tests__/required-surface-keys.test.ts +134 -1
  45. package/src/engine/__tests__/soft-delete-cleanup.test.ts +49 -13
  46. package/src/engine/boot-validator/entity-handler.ts +45 -0
  47. package/src/engine/boot-validator/gdpr-storage.ts +2 -1
  48. package/src/engine/boot-validator/index.ts +14 -1
  49. package/src/engine/boot-validator/screens-nav.ts +90 -6
  50. package/src/engine/build-app-schema.ts +15 -7
  51. package/src/engine/define-feature.ts +17 -0
  52. package/src/engine/define-handler.ts +16 -2
  53. package/src/engine/entity-handlers.ts +32 -13
  54. package/src/engine/extensions/user-data.ts +6 -0
  55. package/src/engine/index.ts +6 -1
  56. package/src/engine/projection-helpers.ts +8 -5
  57. package/src/engine/registry.ts +47 -2
  58. package/src/engine/schema-builder.ts +3 -1
  59. package/src/engine/soft-delete-cleanup.ts +41 -4
  60. package/src/engine/steps/unsafe-projection-delete.ts +5 -1
  61. package/src/engine/tier-resolver-extension.ts +11 -0
  62. package/src/engine/types/feature.ts +29 -21
  63. package/src/engine/types/fields.ts +12 -0
  64. package/src/engine/types/handlers.ts +13 -0
  65. package/src/engine/types/index.ts +2 -0
  66. package/src/engine/types/projection.ts +33 -5
  67. package/src/event-store/index.ts +8 -0
  68. package/src/event-store/rebuild-dead-letter.ts +111 -0
  69. package/src/files/__tests__/storage-tracking.integration.test.ts +8 -0
  70. package/src/files/file-routes.ts +1 -1
  71. package/src/pipeline/__tests__/dispatcher.test.ts +43 -0
  72. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
  73. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +24 -0
  74. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
  75. package/src/pipeline/dispatcher.ts +4 -10
  76. package/src/pipeline/msp-rebuild.ts +36 -3
  77. package/src/pipeline/projection-rebuild.ts +55 -3
  78. package/src/pipeline/projections-runner.ts +1 -1
  79. package/src/schema-cli.ts +24 -15
  80. package/src/secrets/__tests__/contains-secret.test.ts +34 -0
  81. package/src/secrets/types.ts +8 -1
  82. package/src/testing/db-cleanup.ts +4 -1
  83. package/src/testing/index.ts +1 -0
  84. package/src/testing/seed.ts +50 -0
  85. package/src/time/__tests__/boot-tz-warning.test.ts +24 -2
  86. package/src/time/__tests__/geo-tz.test.ts +9 -3
  87. package/src/time/__tests__/iana.test.ts +9 -0
  88. package/src/time/boot-tz-warning.ts +5 -1
  89. package/src/time/iana.ts +17 -15
  90. package/src/time/tz-context.ts +6 -1
  91. package/src/utils/__tests__/serialization.test.ts +6 -0
  92. package/src/utils/serialization.ts +10 -3
@@ -0,0 +1,48 @@
1
+ // Type-level contract for the ES-write brand (#742): the public write helpers
2
+ // reject a managed EntityTable at COMPILE time (executor-only), while reads on
3
+ // it and writes on unmanaged tables stay allowed. The bodies below are never
4
+ // invoked — tsc checks them, bun:test does not run them. Each `@ts-expect-error`
5
+ // turns into an "unused directive" compile error if the brand ever stops firing,
6
+ // so a regression breaks the build, not just this test.
7
+
8
+ import { expect, test } from "bun:test";
9
+ import { defineUnmanagedTable } from "../../db/entity-table-meta";
10
+ import { buildEntityTable } from "../../db/table-builder";
11
+ import { createEntity, createTextField } from "../../engine";
12
+ import { type AnyDb, deleteMany, insertOne, selectMany, updateMany } from "../query";
13
+
14
+ const brandedEntity = buildEntityTable(
15
+ "brandSample",
16
+ createEntity({ table: "brand_sample", fields: { title: createTextField({ required: true }) } }),
17
+ );
18
+
19
+ const unmanagedTable = defineUnmanagedTable({
20
+ tableName: "brand_unmanaged",
21
+ columns: [{ name: "id", pgType: "uuid", notNull: true, primaryKey: true }],
22
+ });
23
+
24
+ async function _writeRejectsManagedEntity(db: AnyDb): Promise<void> {
25
+ // @ts-expect-error — managed EntityTable is executor-only; direct insert is a compile error.
26
+ await insertOne(db, brandedEntity, { title: "x" });
27
+ // @ts-expect-error — direct update on a managed EntityTable is rejected.
28
+ await updateMany(db, brandedEntity, { title: "y" }, { id: "1" });
29
+ // @ts-expect-error — direct delete on a managed EntityTable is rejected.
30
+ await deleteMany(db, brandedEntity, { id: "1" });
31
+ }
32
+
33
+ async function _writeAllowsUnmanagedTable(db: AnyDb): Promise<void> {
34
+ // Unmanaged tables carry no brand — direct writes stay allowed.
35
+ await insertOne(db, unmanagedTable, { id: "1" });
36
+ await deleteMany(db, unmanagedTable, { id: "1" });
37
+ }
38
+
39
+ async function _readAllowsManagedEntity(db: AnyDb): Promise<void> {
40
+ // Reads on a managed EntityTable are always fine (reads keep the permissive param).
41
+ await selectMany(db, brandedEntity, { id: "1" });
42
+ }
43
+
44
+ test("ES-write brand: compile-time contracts are wired", () => {
45
+ expect(_writeRejectsManagedEntity).toBeDefined();
46
+ expect(_writeAllowsUnmanagedTable).toBeDefined();
47
+ expect(_readAllowsManagedEntity).toBeDefined();
48
+ });
@@ -20,7 +20,7 @@
20
20
  // reference, NICHT als runtime-API-call)
21
21
 
22
22
  import type { EntityTableMeta } from "../db/entity-table-meta";
23
- import { toSnakeCase } from "../db/table-builder";
23
+ import { type NotExecutorOnly, toSnakeCase } from "../db/table-builder";
24
24
  import { camelCase as envCamelCase } from "../env";
25
25
  import { parseJsonSafe } from "../utils/safe-json";
26
26
 
@@ -109,6 +109,23 @@ export function asRawClient(db: unknown): RawClient {
109
109
  );
110
110
  }
111
111
 
112
+ // postgres.js / Bun.SQL reject the WHOLE begin() when any statement inside
113
+ // it errored — even if the JS error was caught (the driver tracks per-block
114
+ // query failures). Error confinement therefore must go through the driver's
115
+ // own savepoint(), not manual SAVEPOINT statements. The callback receives
116
+ // the savepoint-scoped tx; run every confined statement on THAT handle.
117
+ export async function runInSavepoint<T>(tx: unknown, fn: (sp: unknown) => Promise<T>): Promise<T> {
118
+ const raw = asRawClient(tx) as unknown as {
119
+ savepoint?: <TR>(cb: (sp: unknown) => Promise<TR>) => Promise<TR>;
120
+ };
121
+ if (typeof raw.savepoint !== "function") {
122
+ throw new Error(
123
+ "runInSavepoint: transaction handle has no savepoint() — pass the driver tx (postgres.js TransactionSql / Bun.SQL).",
124
+ );
125
+ }
126
+ return raw.savepoint(fn);
127
+ }
128
+
112
129
  /**
113
130
  * When handlers call `selectMany(ctx.db, …)` instead of `ctx.db.selectMany(…)`,
114
131
  * unwrap via asRawClient would bypass TenantDb scoping. Duck-type TenantDb and
@@ -204,6 +221,16 @@ export type SelectOptions = {
204
221
  // biome-ignore lint/suspicious/noExplicitAny: legacy drizzle pgTable surface
205
222
  type TableLike = EntityTableMeta | any;
206
223
 
224
+ // Write-Param: alles was TableLike akzeptiert AUSSER einer gebrandeten
225
+ // EntityTable (managed projection). Ein direkter Write auf eine Entity ist ein
226
+ // Compile-Fehler — der einzige Schreibweg ist der Executor (Event ->
227
+ // rebuild-safe). Unmanaged EntityTableMeta + die am Executor-Seam auf
228
+ // TableColumns<any> eraste Table tragen den Brand nicht -> erlaubt. Test-Seeds
229
+ // gehen über den sanktionierten testing-Seam (seedRow/…), der den Brand strippt.
230
+ // Method-form writes (ctx.db.insertOne/…) keep an erased param and are covered
231
+ // by the guard-direct-entity-writes AST guard instead (see tenant-db.ts).
232
+ type WritableTable = EntityTableMeta & NotExecutorOnly;
233
+
207
234
  export type TableInfo = {
208
235
  readonly name: string;
209
236
  // field-name (camelCase oder snake_case) → snake_case column-name
@@ -566,7 +593,7 @@ export async function fetchOne<TRow = any>(
566
593
  // biome-ignore lint/suspicious/noExplicitAny: see selectMany default
567
594
  export async function insertMany<TRow = any>(
568
595
  db: AnyDb,
569
- table: TableLike,
596
+ table: WritableTable,
570
597
  rows: ReadonlyArray<Record<string, unknown>>,
571
598
  ): Promise<readonly TRow[]> {
572
599
  if (rows.length === 0) return [];
@@ -604,7 +631,7 @@ export async function insertMany<TRow = any>(
604
631
  // biome-ignore lint/suspicious/noExplicitAny: see selectMany default
605
632
  export async function insertOne<TRow = any>(
606
633
  db: AnyDb,
607
- table: TableLike,
634
+ table: WritableTable,
608
635
  values: Record<string, unknown>,
609
636
  ): Promise<TRow | undefined> {
610
637
  const scoped = tenantDbDelegate(db);
@@ -642,7 +669,7 @@ export async function insertOne<TRow = any>(
642
669
  // biome-ignore lint/suspicious/noExplicitAny: see selectMany default
643
670
  export async function updateMany<TRow = any>(
644
671
  db: AnyDb,
645
- table: TableLike,
672
+ table: WritableTable,
646
673
  set: Record<string, unknown>,
647
674
  where: WhereObject,
648
675
  ): Promise<readonly TRow[]> {
@@ -679,7 +706,11 @@ export async function updateMany<TRow = any>(
679
706
  return coerceRows(raw, info) as readonly TRow[];
680
707
  }
681
708
 
682
- export async function deleteMany(db: AnyDb, table: TableLike, where: WhereObject): Promise<void> {
709
+ export async function deleteMany(
710
+ db: AnyDb,
711
+ table: WritableTable,
712
+ where: WhereObject,
713
+ ): Promise<void> {
683
714
  const scoped = tenantDbDelegate(db);
684
715
  if (scoped) {
685
716
  return scoped.deleteMany(table, where);
@@ -783,7 +814,7 @@ export async function countWhere(
783
814
  // biome-ignore lint/suspicious/noExplicitAny: see selectMany default
784
815
  export async function upsertOnConflict<TRow = any>(
785
816
  db: AnyDb,
786
- table: TableLike,
817
+ table: WritableTable,
787
818
  values: Record<string, unknown>,
788
819
  options: UpsertOnConflictOptions,
789
820
  ): Promise<TRow | undefined> {
@@ -833,7 +864,7 @@ export async function upsertOnConflict<TRow = any>(
833
864
  // biome-ignore lint/suspicious/noExplicitAny: see selectMany default
834
865
  export async function upsertByPk<TRow = any>(
835
866
  db: AnyDb,
836
- table: TableLike,
867
+ table: WritableTable,
837
868
  values: Record<string, unknown>,
838
869
  updateOnConflict?: Record<string, unknown>,
839
870
  ): Promise<TRow | undefined> {
@@ -855,7 +886,7 @@ export type IncrementCounterOptions = {
855
886
  // biome-ignore lint/suspicious/noExplicitAny: see selectMany default
856
887
  export async function incrementCounter<TRow = any>(
857
888
  db: AnyDb,
858
- table: TableLike,
889
+ table: WritableTable,
859
890
  values: Record<string, unknown>,
860
891
  increments: Record<string, number>,
861
892
  options: IncrementCounterOptions = {},
@@ -914,7 +945,7 @@ export async function incrementCounter<TRow = any>(
914
945
 
915
946
  export async function deleteManyBatched(
916
947
  db: AnyDb,
917
- table: TableLike,
948
+ table: WritableTable,
918
949
  where: WhereObject,
919
950
  options: DeleteManyBatchedOptions,
920
951
  ): Promise<DeleteManyBatchedResult> {
@@ -4,7 +4,7 @@
4
4
  // eine dangling/cross-tenant Reference. Schwerpunkt: Tenant-Isolation.
5
5
 
6
6
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
- import { insertMany } from "../../bun-db";
7
+ import { seedRows } from "@cosmicdrift/kumiko-framework/testing";
8
8
  import { createEntity, createTextField } from "../../engine";
9
9
  import { NotFoundError } from "../../errors";
10
10
  import { setupTestStack, type TestStack, testTenantId, unsafeCreateEntityTable } from "../../stack";
@@ -29,7 +29,7 @@ let stack: TestStack;
29
29
  beforeAll(async () => {
30
30
  stack = await setupTestStack({ features: [] });
31
31
  await unsafeCreateEntityTable(stack.db, orderEntity);
32
- await insertMany(stack.db, orderTable, [
32
+ await seedRows(stack.db, orderTable, [
33
33
  { id: ID_A, tenantId: tenantA, name: "A-Order" },
34
34
  { id: ID_B, tenantId: tenantB, name: "B-Order" },
35
35
  ]);
@@ -3,7 +3,7 @@
3
3
  // werden (TenantDb filtert), sonst leakt eagerload fremde Rows nach _refs.
4
4
 
5
5
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
- import { insertMany } from "../../bun-db";
6
+ import { seedRows } from "@cosmicdrift/kumiko-framework/testing";
7
7
  import { createEntity, createTextField } from "../../engine";
8
8
  import type { EntityDefinition } from "../../engine/types";
9
9
  import { setupTestStack, type TestStack, testTenantId, unsafeCreateEntityTable } from "../../stack";
@@ -55,7 +55,7 @@ beforeAll(async () => {
55
55
  await unsafeCreateEntityTable(stack.db, authorEntity);
56
56
  dbA = createTenantDb(stack.db, tenantA, "tenant");
57
57
 
58
- await insertMany(stack.db, authorTable, [
58
+ await seedRows(stack.db, authorTable, [
59
59
  { id: A1, tenantId: tenantA, name: "Ada" },
60
60
  { id: A2, tenantId: tenantA, name: "Linus" },
61
61
  { id: BX, tenantId: tenantB, name: "Foreign" },
@@ -313,6 +313,16 @@ const encryptedEntity = createEntity({
313
313
  });
314
314
  const encryptedTable = buildEntityTable("esExecEncrypted", encryptedEntity);
315
315
 
316
+ const encryptedSoftDeleteEntity = createEntity({
317
+ table: "read_es_exec_enc_soft",
318
+ fields: {
319
+ email: createTextField({ required: true }),
320
+ secretNote: createTextField({ encrypted: true }),
321
+ },
322
+ softDelete: true,
323
+ });
324
+ const encryptedSoftDeleteTable = buildEntityTable("esExecEncSoft", encryptedSoftDeleteEntity);
325
+
316
326
  describe("event-store-executor — encrypted fields", () => {
317
327
  const encryption = createEncryptionProvider(ENCRYPTION_TEST_KEY);
318
328
  const crud = createEventStoreExecutor(encryptedTable, encryptedEntity, {
@@ -320,15 +330,22 @@ describe("event-store-executor — encrypted fields", () => {
320
330
  encryption,
321
331
  });
322
332
 
333
+ const softDeleteCrud = createEventStoreExecutor(
334
+ encryptedSoftDeleteTable,
335
+ encryptedSoftDeleteEntity,
336
+ { entityName: "esExecEncSoft", encryption },
337
+ );
338
+
323
339
  beforeAll(async () => {
324
340
  process.env["ENCRYPTION_KEY"] = ENCRYPTION_TEST_KEY;
325
341
  resetEntityFieldEncryptionCacheForTests();
326
342
  await unsafeCreateEntityTable(testDb.db, encryptedEntity, "esExecEncrypted");
343
+ await unsafeCreateEntityTable(testDb.db, encryptedSoftDeleteEntity, "esExecEncSoft");
327
344
  });
328
345
 
329
346
  beforeEach(async () => {
330
347
  await asRawClient(testDb.db).unsafe(
331
- `TRUNCATE kumiko_events, read_es_exec_encrypted RESTART IDENTITY CASCADE`,
348
+ `TRUNCATE kumiko_events, read_es_exec_encrypted, read_es_exec_enc_soft RESTART IDENTITY CASCADE`,
332
349
  );
333
350
  });
334
351
 
@@ -372,6 +389,76 @@ describe("event-store-executor — encrypted fields", () => {
372
389
  if (!updated.isSuccess) throw new Error("update failed");
373
390
  expect(updated.data.data["secretNote"]).toBe("new-note");
374
391
  });
392
+
393
+ test("update's persisted event carries ciphertext (not plaintext) for an encrypted field in `previous`", async () => {
394
+ // Regression: `previous` in the STORED event came from loadById(), which
395
+ // decrypts — appending it unchanged would put the plaintext of an
396
+ // `encrypted` field into the immutable kumiko_events log even though the
397
+ // row itself is stored as ciphertext.
398
+ const created = await crud.create(
399
+ { email: "prev-enc@test.de", secretNote: "old-plaintext-note" },
400
+ adminUser,
401
+ tdb,
402
+ );
403
+ if (!created.isSuccess) throw new Error("create failed");
404
+
405
+ // Change an UNRELATED field — `secretNote` still rides along in `previous`
406
+ // unchanged, at its pre-update (decrypted) value.
407
+ const updated = await crud.update(
408
+ { id: created.data.id, version: 1, changes: { email: "prev-enc-2@test.de" } },
409
+ adminUser,
410
+ tdb,
411
+ );
412
+ if (!updated.isSuccess) throw new Error("update failed");
413
+
414
+ const rows = (await asRawClient(testDb.db).unsafe(
415
+ `SELECT payload FROM kumiko_events WHERE type = 'esExecEncrypted.updated' ORDER BY id DESC LIMIT 1`,
416
+ )) as Array<{ payload: { previous?: { secretNote?: string } } }>;
417
+ const storedPrevious = rows[0]?.payload.previous?.secretNote;
418
+ expect(storedPrevious).toBeDefined();
419
+ expect(storedPrevious).not.toBe("old-plaintext-note");
420
+ });
421
+
422
+ test("delete's persisted event carries ciphertext (not plaintext) for an encrypted field in `previous`", async () => {
423
+ const created = await crud.create(
424
+ { email: "del-enc@test.de", secretNote: "delete-plaintext-note" },
425
+ adminUser,
426
+ tdb,
427
+ );
428
+ if (!created.isSuccess) throw new Error("create failed");
429
+
430
+ const deleted = await crud.delete({ id: created.data.id }, adminUser, tdb);
431
+ if (!deleted.isSuccess) throw new Error("delete failed");
432
+
433
+ const rows = (await asRawClient(testDb.db).unsafe(
434
+ `SELECT payload FROM kumiko_events WHERE type = 'esExecEncrypted.deleted' ORDER BY id DESC LIMIT 1`,
435
+ )) as Array<{ payload: { previous?: { secretNote?: string } } }>;
436
+ const storedPrevious = rows[0]?.payload.previous?.secretNote;
437
+ expect(storedPrevious).toBeDefined();
438
+ expect(storedPrevious).not.toBe("delete-plaintext-note");
439
+ });
440
+
441
+ test("restore returns plaintext (data + previous) for an encrypted field, not ciphertext (725/2)", async () => {
442
+ // Regression: restore() read `restored`/`data` straight from
443
+ // applyEntityEvent/selectMany (both ciphertext for an `encrypted` field)
444
+ // and returned them without decryptForRead — unlike create/update/list/
445
+ // detail, which all decrypt before handing the row to the caller.
446
+ const created = await softDeleteCrud.create(
447
+ { email: "restore-enc@test.de", secretNote: "restore-plaintext-note" },
448
+ adminUser,
449
+ tdb,
450
+ );
451
+ if (!created.isSuccess) throw new Error("create failed");
452
+
453
+ const deleted = await softDeleteCrud.delete({ id: created.data.id }, adminUser, tdb);
454
+ if (!deleted.isSuccess) throw new Error("delete failed");
455
+
456
+ const restored = await softDeleteCrud.restore({ id: created.data.id }, adminUser, tdb);
457
+ if (!restored.isSuccess) throw new Error("restore failed");
458
+
459
+ expect(restored.data.data["secretNote"]).toBe("restore-plaintext-note");
460
+ expect(restored.data.previous?.["secretNote"]).toBe("restore-plaintext-note");
461
+ });
375
462
  });
376
463
 
377
464
  describe("event-store-executor — entity cache read-through", () => {
@@ -418,3 +505,53 @@ describe("event-store-executor — entity cache read-through", () => {
418
505
  expect(second?.["email"]).toBe("from-cache@test.de");
419
506
  });
420
507
  });
508
+
509
+ describe("event-store-executor — entity cache + encrypted fields", () => {
510
+ // Regression: detail() cached the already-decrypted row verbatim, so an
511
+ // `encrypted` field's plaintext ended up in a second at-rest store (Redis)
512
+ // the field-encryption feature doesn't cover.
513
+ const store = new Map<string, Record<string, unknown>>();
514
+ const entityCache: EntityCache = {
515
+ get: async (tenantId, name, id) => store.get(`${tenantId}:${name}:${id}`) ?? null,
516
+ mget: async () => new Map(),
517
+ set: async (tenantId, name, id, data) => {
518
+ store.set(`${tenantId}:${name}:${id}`, data);
519
+ },
520
+ mset: async (tenantId, name, entries) => {
521
+ for (const { id, data } of entries) store.set(`${tenantId}:${name}:${id}`, data);
522
+ },
523
+ del: async (tenantId, name, id) => {
524
+ store.delete(`${tenantId}:${name}:${id}`);
525
+ },
526
+ };
527
+ const encryption = createEncryptionProvider(ENCRYPTION_TEST_KEY);
528
+ const cachedEncryptedCrud = createEventStoreExecutor(encryptedTable, encryptedEntity, {
529
+ entityName: "esExecEncrypted",
530
+ entityCache,
531
+ encryption,
532
+ });
533
+
534
+ beforeEach(() => store.clear());
535
+
536
+ test("cached row is ciphertext at rest; detail() still returns plaintext to the caller", async () => {
537
+ const created = await cachedEncryptedCrud.create(
538
+ { email: "cache-enc@test.de", secretNote: "cache-plaintext-note" },
539
+ adminUser,
540
+ tdb,
541
+ );
542
+ if (!created.isSuccess) throw new Error("create failed");
543
+ const id = created.data.id;
544
+ const storeKey = `${adminUser.tenantId}:esExecEncrypted:${id}`;
545
+
546
+ const first = await cachedEncryptedCrud.detail({ id }, adminUser, tdb);
547
+ expect(first?.["secretNote"]).toBe("cache-plaintext-note");
548
+
549
+ const cachedRaw = store.get(storeKey);
550
+ expect(cachedRaw?.["secretNote"]).toBeDefined();
551
+ expect(cachedRaw?.["secretNote"]).not.toBe("cache-plaintext-note");
552
+
553
+ // Second read (cache hit) must still decrypt back to the real plaintext.
554
+ const second = await cachedEncryptedCrud.detail({ id }, adminUser, tdb);
555
+ expect(second?.["secretNote"]).toBe("cache-plaintext-note");
556
+ });
557
+ });
@@ -190,10 +190,11 @@ describe("implicit-projection / Live==Rebuild equivalence", () => {
190
190
  const projection = registry.getAllProjections().get("implicittest:projection:user-entity");
191
191
  expect(projection).toBeDefined();
192
192
  if (!projection) return;
193
- // 4 Auto-Verben weil softDelete=true restored kommt dazu
193
+ // Auto-Verben: created/updated/deleted/forgotten immer, restored nur bei softDelete=true
194
194
  expect(Object.keys(projection.apply).sort()).toEqual([
195
195
  "user.created",
196
196
  "user.deleted",
197
+ "user.forgotten",
197
198
  "user.restored",
198
199
  "user.updated",
199
200
  ]);
@@ -215,6 +216,7 @@ describe("implicit-projection / Live==Rebuild equivalence", () => {
215
216
  expect(Object.keys(projection.apply).sort()).toEqual([
216
217
  "widget.created",
217
218
  "widget.deleted",
219
+ "widget.forgotten",
218
220
  "widget.updated",
219
221
  ]);
220
222
  });
@@ -8,7 +8,8 @@
8
8
  // cause.
9
9
 
10
10
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11
- import { insertOne, selectMany } from "../../db/query";
11
+ import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
12
+ import { selectMany } from "../../db/query";
12
13
  import { buildEntityTable } from "../../db/table-builder";
13
14
  import { createEntity, createTextField } from "../../engine";
14
15
  import { setupTestStack, type TestStack, unsafeCreateEntityTable } from "../../stack";
@@ -51,7 +52,7 @@ describe("instant() customType is forgiving with ISO strings", () => {
51
52
  // would call .toString() on a string and produce a malformed driver
52
53
  // value that PG rejects.
53
54
  const isoString = "2026-01-15T12:00:00Z";
54
- await insertOne(stack.db, tsTable, {
55
+ await seedRow(stack.db, tsTable, {
55
56
  name: "x",
56
57
  tenantId: "00000000-0000-4000-8000-000000000001",
57
58
  insertedAt: isoString as unknown as Temporal.Instant,
@@ -64,12 +65,12 @@ describe("instant() customType is forgiving with ISO strings", () => {
64
65
 
65
66
  describe("multi-row INSERT", () => {
66
67
  test("two rows with no id supplied → both rows persist (PG gen_random_uuid per row)", async () => {
67
- await insertOne(stack.db, linkTable, {
68
+ await seedRow(stack.db, linkTable, {
68
69
  leftId: "L1",
69
70
  rightId: "R1",
70
71
  tenantId: "00000000-0000-4000-8000-000000000001",
71
72
  });
72
- await insertOne(stack.db, linkTable, {
73
+ await seedRow(stack.db, linkTable, {
73
74
  leftId: "L2",
74
75
  rightId: "R2",
75
76
  tenantId: "00000000-0000-4000-8000-000000000001",
@@ -1,6 +1,7 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
2
3
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
3
- import { asRawClient, insertOne, selectMany } from "../../db/query";
4
+ import { asRawClient, selectMany } from "../../db/query";
4
5
  import {
5
6
  createBooleanField,
6
7
  createDateField,
@@ -172,7 +173,7 @@ describe("schema migration workflows", () => {
172
173
  await unsafePushTables(testDb.db, { project: initialTable });
173
174
 
174
175
  // Insert a row first (to prove ADD COLUMN with default doesn't break existing rows)
175
- await insertOne(testDb.db, initialTable, {
176
+ await seedRow(testDb.db, initialTable, {
176
177
  tenantId: "00000000-0000-4000-8000-000000000001",
177
178
  name: "Test Project",
178
179
  });
@@ -205,7 +206,7 @@ describe("schema migration workflows", () => {
205
206
  const initialTable = buildEntityTable("user", initialEntity);
206
207
  await unsafePushTables(testDb.db, { user: initialTable });
207
208
 
208
- await insertOne(testDb.db, initialTable, {
209
+ await seedRow(testDb.db, initialTable, {
209
210
  tenantId: "00000000-0000-4000-8000-000000000001",
210
211
  email: "x@y.z",
211
212
  });
@@ -12,7 +12,8 @@
12
12
  // integration proof the unit test cannot give.
13
13
 
14
14
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
15
- import { insertOne, selectMany } from "../../db/query";
15
+ import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
16
+ import { selectMany } from "../../db/query";
16
17
  import { buildEntityTable } from "../../db/table-builder";
17
18
  import { createEntity, createTextField } from "../../engine";
18
19
  import { setupTestStack, type TestStack, unsafeCreateEntityTable } from "../../stack";
@@ -40,7 +41,7 @@ describe("entity with a `source` field — create-path is shadow-proof", () => {
40
41
  // Passing a real Temporal.Instant exercises the timestamptz serializer
41
42
  // path that the shadow used to bypass. Pre-fix this threw "Cannot use
42
43
  // valueOf"; post-fix the row persists and round-trips as a Temporal.Instant.
43
- await insertOne(stack.db, sourceTable, {
44
+ await seedRow(stack.db, sourceTable, {
44
45
  source: "import",
45
46
  tenantId: "00000000-0000-4000-8000-000000000001",
46
47
  insertedAt: Temporal.Instant.from("2026-01-15T12:00:00Z"),
@@ -5,8 +5,9 @@
5
5
  // bleibt als Schnell-Pin bestehen).
6
6
 
7
7
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import { updateRows } from "@cosmicdrift/kumiko-framework/testing";
8
9
  import { z } from "zod";
9
- import { selectMany, updateMany } from "../../bun-db";
10
+ import { selectMany } from "../../bun-db";
10
11
  import {
11
12
  createEntity,
12
13
  createTextField,
@@ -53,7 +54,7 @@ const probeFeature = defineFeature("where-merge-probe", (r) => {
53
54
  schema: z.object({ tenantId: z.string(), title: z.string() }),
54
55
  access: { roles: ["User"] },
55
56
  handler: async (event, ctx) => {
56
- const count = await updateMany(
57
+ const count = await updateRows(
57
58
  ctx.db,
58
59
  noteTable,
59
60
  { tenantId: event.payload.tenantId },
@@ -1,5 +1,6 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import { asRawClient, insertOne, selectMany } from "../../db/query";
2
+ import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
3
+ import { asRawClient, selectMany } from "../../db/query";
3
4
  import { createBooleanField, createEntity, createTextField } from "../../engine";
4
5
  import {
5
6
  createTestDb,
@@ -197,7 +198,7 @@ describe("scoped mode (default)", () => {
197
198
  describe("reference data (tenantId = 0)", () => {
198
199
  test("scoped select includes rows with tenantId = 0", async () => {
199
200
  // Seed reference data with tenantId = 0 (like seedReferenceData does)
200
- await insertOne(testDb.db, table, {
201
+ await seedRow(testDb.db, table, {
201
202
  name: "GlobalRef",
202
203
  status: "ref",
203
204
  tenantId: "00000000-0000-4000-8000-000000000000",
@@ -217,7 +218,7 @@ describe("scoped mode (default)", () => {
217
218
  });
218
219
 
219
220
  test("scoped update does NOT affect tenantId = 0 rows", async () => {
220
- await insertOne(testDb.db, table, {
221
+ await seedRow(testDb.db, table, {
221
222
  name: "RefNoUpdate",
222
223
  status: "ref",
223
224
  tenantId: "00000000-0000-4000-8000-000000000000",
@@ -238,7 +239,7 @@ describe("scoped mode (default)", () => {
238
239
  });
239
240
 
240
241
  test("scoped delete does NOT affect tenantId = 0 rows", async () => {
241
- await insertOne(testDb.db, table, {
242
+ await seedRow(testDb.db, table, {
242
243
  name: "RefNoDelete",
243
244
  status: "ref",
244
245
  tenantId: "00000000-0000-4000-8000-000000000000",
@@ -350,8 +351,8 @@ describe("system mode (r.systemScope())", () => {
350
351
  describe("tables without tenantId column", () => {
351
352
  test("select returns all rows (no tenant filter)", async () => {
352
353
  // Insert two rows via raw db
353
- await insertOne(testDb.db, systemTable, { label: "System-A" });
354
- await insertOne(testDb.db, systemTable, { label: "System-B" });
354
+ await seedRow(testDb.db, systemTable, { label: "System-A" });
355
+ await seedRow(testDb.db, systemTable, { label: "System-B" });
355
356
 
356
357
  const tdb = createTenantDb(testDb.db, tenant1.tenantId);
357
358
  const rows = await tdb.selectMany(systemTable);
@@ -33,12 +33,14 @@
33
33
  // <entity>.updated → UPDATE WHERE id=aggregateId
34
34
  // <entity>.deleted → soft-delete-UPDATE wenn entity.softDelete, sonst hard-DELETE
35
35
  // <entity>.restored → undelete-UPDATE (nur bei softDelete sinnvoll)
36
+ // <entity>.forgotten → hard-DELETE immer (Art.17-Purge, auch bei softDelete);
37
+ // via executor.forget(). Rebuild replayt created→forgotten
38
+ // → Row weg, rebuild-safe (was ein direktes deleteMany nicht ist).
36
39
  //
37
40
  // Domain-Events (r.defineEvent) auf demselben Aggregate werden hier NICHT
38
41
  // behandelt — die liefen im Live-Pfad nie durch den Executor und müssen
39
42
  // von expliziten r.projection-apply-Handlern oder r.multiStreamProjection
40
- // behandelt werden. ImplicitProjection registriert daher nur die 4
41
- // Auto-Verben.
43
+ // behandelt werden. ImplicitProjection registriert die Auto-Verben.
42
44
  //
43
45
  // Return-Shape: ApplyResult mit `kind` + optionaler `row`.
44
46
  // - "applied" → Schreibung lief durch. `row` enthält die geschriebene
@@ -57,22 +59,22 @@ import type { TableColumns } from "./dialect";
57
59
  // biome-ignore lint/suspicious/noExplicitAny: Drizzle-Tabellen sind generisch typed; framework code erasiert die Spalten-Union absichtlich.
58
60
  type Table = TableColumns<any>;
59
61
 
60
- export type AutoVerb = "created" | "updated" | "deleted" | "restored";
62
+ export type AutoVerb = "created" | "updated" | "deleted" | "restored" | "forgotten";
63
+
64
+ const AUTO_VERBS: readonly AutoVerb[] = ["created", "updated", "deleted", "restored", "forgotten"];
61
65
 
62
66
  export type ApplyResult =
63
67
  | { readonly kind: "applied"; readonly verb: AutoVerb; readonly row: DbRow | null }
64
68
  | { readonly kind: "skipped" };
65
69
 
66
- /** Parsed event.type → AutoVerb wenn das Event eines der 4 Auto-Verben
70
+ /** Parsed event.type → AutoVerb wenn das Event eines der Auto-Verben
67
71
  * auf dem gegebenen Aggregate ist. null sonst (Domain-Event). */
68
72
  export function parseAutoVerb(event: StoredEvent): AutoVerb | null {
69
73
  const prefix = `${event.aggregateType}.`;
70
74
  if (!event.type.startsWith(prefix)) return null;
71
75
  const verb = event.type.slice(prefix.length);
72
- if (verb === "created" || verb === "updated" || verb === "deleted" || verb === "restored") {
73
- return verb;
74
- }
75
- return null;
76
+ // @cast-boundary: verb is validated against the AutoVerb list before the cast.
77
+ return (AUTO_VERBS as readonly string[]).includes(verb) ? (verb as AutoVerb) : null;
76
78
  }
77
79
 
78
80
  /** Idempotente Anwendung eines Auto-Events auf die Entity-Tabelle.
@@ -193,5 +195,14 @@ export async function applyEntityEvent(
193
195
  );
194
196
  return { kind: "applied", verb, row: rows[0] ?? null };
195
197
  }
198
+
199
+ case "forgotten": {
200
+ // Hard-delete regardless of softDelete: forget/purge (Art. 17) removes the
201
+ // row entirely. On rebuild the aggregate replays created → forgotten, so
202
+ // the row ends up gone — the rebuild-safe erasure the soft-delete verb
203
+ // (which only flips isDeleted) cannot provide.
204
+ await deleteMany(tx, table, { id: event.aggregateId });
205
+ return { kind: "applied", verb, row: null };
206
+ }
196
207
  }
197
208
  }