@cosmicdrift/kumiko-framework 0.105.2 → 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.
- package/package.json +2 -2
- package/src/__tests__/full-stack.integration.test.ts +3 -2
- package/src/__tests__/schema-cli.integration.test.ts +29 -0
- package/src/__tests__/transition-guard.integration.test.ts +2 -2
- package/src/api/__tests__/batch.integration.test.ts +3 -2
- package/src/api/__tests__/dispatcher-live.integration.test.ts +3 -2
- package/src/api/__tests__/pat-scope.test.ts +36 -0
- package/src/api/__tests__/request-id-middleware.test.ts +51 -0
- package/src/api/auth-middleware.ts +65 -1
- package/src/api/auth-routes.ts +11 -0
- package/src/api/index.ts +3 -1
- package/src/api/jwt.ts +1 -4
- package/src/api/pat-scope.ts +14 -0
- package/src/api/request-context.ts +3 -0
- package/src/api/request-id-middleware.ts +2 -0
- package/src/api/routes.ts +22 -0
- package/src/api/server.ts +29 -1
- package/src/bun-db/__tests__/batch-methods.test.ts +3 -2
- package/src/bun-db/__tests__/query-guards.test.ts +3 -2
- package/src/bun-db/__tests__/write-brand.test.ts +48 -0
- package/src/bun-db/query.ts +40 -9
- package/src/db/__tests__/assert-exists-in.integration.test.ts +2 -2
- package/src/db/__tests__/eagerload.integration.test.ts +2 -2
- package/src/db/__tests__/event-store-executor.integration.test.ts +138 -1
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +3 -1
- package/src/db/__tests__/multi-row-insert.integration.test.ts +5 -4
- package/src/db/__tests__/schema-migration.integration.test.ts +4 -3
- package/src/db/__tests__/source-shadow-create.integration.test.ts +3 -2
- package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +3 -2
- package/src/db/__tests__/tenant-db.integration.test.ts +7 -6
- package/src/db/apply-entity-event.ts +19 -8
- package/src/db/event-store-executor.ts +91 -8
- package/src/db/queries/shadow-swap.ts +1 -1
- package/src/db/query.ts +1 -0
- package/src/db/table-builder.ts +23 -1
- package/src/db/tenant-db.ts +6 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +6 -5
- package/src/engine/__tests__/boot-validator.test.ts +210 -0
- package/src/engine/__tests__/build-config-feature-schema.test.ts +21 -0
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +6 -0
- package/src/engine/__tests__/extend-entity-projection.test.ts +123 -0
- package/src/engine/__tests__/projection-helpers.test.ts +2 -2
- package/src/engine/__tests__/soft-delete-cleanup.test.ts +49 -13
- package/src/engine/boot-validator/entity-handler.ts +45 -0
- package/src/engine/boot-validator/gdpr-storage.ts +2 -1
- package/src/engine/boot-validator/index.ts +14 -1
- package/src/engine/boot-validator/screens-nav.ts +90 -6
- package/src/engine/build-app-schema.ts +15 -7
- package/src/engine/define-feature.ts +17 -0
- package/src/engine/define-handler.ts +16 -2
- package/src/engine/entity-handlers.ts +32 -13
- package/src/engine/index.ts +6 -1
- package/src/engine/projection-helpers.ts +8 -5
- package/src/engine/registry.ts +47 -2
- package/src/engine/schema-builder.ts +3 -1
- package/src/engine/soft-delete-cleanup.ts +41 -4
- package/src/engine/steps/unsafe-projection-delete.ts +5 -1
- package/src/engine/tier-resolver-extension.ts +11 -0
- package/src/engine/types/feature.ts +29 -21
- package/src/engine/types/fields.ts +12 -0
- package/src/engine/types/handlers.ts +13 -0
- package/src/engine/types/index.ts +2 -0
- package/src/engine/types/projection.ts +33 -5
- package/src/event-store/index.ts +8 -0
- package/src/event-store/rebuild-dead-letter.ts +111 -0
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +274 -0
- package/src/pipeline/dispatcher.ts +4 -10
- package/src/pipeline/msp-rebuild.ts +36 -3
- package/src/pipeline/projection-rebuild.ts +49 -3
- package/src/pipeline/projections-runner.ts +1 -1
- package/src/schema-cli.ts +24 -15
- package/src/secrets/__tests__/contains-secret.test.ts +34 -0
- package/src/secrets/types.ts +8 -1
- package/src/testing/db-cleanup.ts +4 -1
- package/src/testing/index.ts +1 -0
- package/src/testing/seed.ts +50 -0
- package/src/time/__tests__/boot-tz-warning.test.ts +7 -0
- package/src/time/__tests__/geo-tz.test.ts +9 -3
- package/src/time/__tests__/iana.test.ts +9 -0
- package/src/time/boot-tz-warning.ts +5 -1
- package/src/time/iana.ts +17 -15
- package/src/time/tz-context.ts +6 -1
- package/src/utils/__tests__/serialization.test.ts +6 -0
- package/src/utils/serialization.ts +10 -3
package/src/bun-db/query.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
//
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
354
|
-
await
|
|
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
|
|
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
|
|
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
|
-
|
|
73
|
-
|
|
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
|
}
|