@cosmicdrift/kumiko-framework 0.252.0 → 0.253.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.252.0",
3
+ "version": "0.253.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.252.0",
201
+ "@cosmicdrift/kumiko-types": "0.253.0",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.252.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.253.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -6,8 +6,12 @@
6
6
  // them in place, per field, under the owning subject's DEK:
7
7
  //
8
8
  // - entity lifecycle events (<entity>.created/updated/deleted/forgotten/
9
- // restored) for every entity with PII subject annotations
10
- // - custom events from the event-PII catalog (r.defineEvent piiFields)
9
+ // restored) for every entity with PII subject annotations — generic over
10
+ // subject kind (user/tenant/record, fw#2790), since it goes through the
11
+ // same collectPiiSubjectFields/resolveSubjectForField the live write
12
+ // path uses
13
+ // - custom events from the event-PII catalog (r.defineEvent piiFields) —
14
+ // user-subject only, see the note at its call site (fw#2801)
11
15
  //
12
16
  // Already-forgotten subjects must NOT get a fresh key minted for their old
13
17
  // plaintext — three erased-detection layers write [[erased]] instead:
@@ -282,6 +286,8 @@ export async function backfillEventPiiEncryption(
282
286
  for (const [field, spec] of Object.entries(catalogFields)) {
283
287
  const subjectId = payload[spec.subjectField];
284
288
  if (typeof subjectId !== "string" || subjectId.length === 0) continue;
289
+ // Catalog entries only ever resolve a user subject — a tenant/record
290
+ // subject in a custom event is not backfillable today (fw#2801).
285
291
  const outcome = await encryptField(payload, field, { kind: "user", userId: subjectId });
286
292
  bump(outcome);
287
293
  }
@@ -20,6 +20,7 @@ import {
20
20
  isPiiCiphertext,
21
21
  KeyNotFoundError,
22
22
  PgKmsAdapter,
23
+ PII_CIPHERTEXT_PREFIX,
23
24
  PII_ERASED_SENTINEL,
24
25
  } from "../../crypto";
25
26
  import { applyEntityEvent } from "../../db/apply-entity-event";
@@ -72,6 +73,20 @@ const notesFeature = defineFeature("notes", (r) => {
72
73
  r.entity("note", noteEntity);
73
74
  });
74
75
 
76
+ // personal: { of: "id" } → recordOwned (kumiko-framework#2786): the row
77
+ // itself is the subject, not a user/tenant reachable via an owner field.
78
+ // fw#2790 — this fixture proves the pre-existing generic backfill already
79
+ // covers the record subject kind for lifecycle events, no new code needed.
80
+ const surveyEntity = createEntity({
81
+ fields: {
82
+ answer: createTextField({ personal: { of: "id" }, find: "none" }),
83
+ },
84
+ });
85
+ const surveyTable = buildEntityTable("survey", surveyEntity);
86
+ const surveyFeature = defineFeature("survey", (r) => {
87
+ r.entity("survey", surveyEntity);
88
+ });
89
+
75
90
  let testDb: TestDb;
76
91
  let registry: Registry;
77
92
  let kms: InMemoryKmsAdapter;
@@ -96,10 +111,11 @@ async function appendPlain(
96
111
 
97
112
  beforeAll(async () => {
98
113
  testDb = await createTestDb();
99
- registry = createRegistry([crmFeature, mailerFeature, notesFeature]);
114
+ registry = createRegistry([crmFeature, mailerFeature, notesFeature, surveyFeature]);
100
115
  await createEventsTable(testDb.db);
101
116
  await unsafeCreateEntityTable(testDb.db, contactEntity, "contact");
102
117
  await unsafeCreateEntityTable(testDb.db, noteEntity, "note");
118
+ await unsafeCreateEntityTable(testDb.db, surveyEntity, "survey");
103
119
  });
104
120
 
105
121
  afterAll(async () => {
@@ -111,6 +127,7 @@ beforeEach(async () => {
111
127
  await raw.unsafe(`TRUNCATE "kumiko_events" RESTART IDENTITY`);
112
128
  await raw.unsafe(`TRUNCATE "${contactTable.tableName}"`);
113
129
  await raw.unsafe(`TRUNCATE "${noteTable.tableName}"`);
130
+ await raw.unsafe(`TRUNCATE "${surveyTable.tableName}"`);
114
131
  // Plaintext era: NO KMS while the legacy events are appended.
115
132
  resetPiiSubjectKmsForTests();
116
133
  resetBlindIndexKeyForTests();
@@ -590,6 +607,104 @@ describe("backfillEventPiiEncryption: owner-resolution chain (fw#2266)", () => {
590
607
  });
591
608
  });
592
609
 
610
+ // fw#2790 — "Altbestand: Freitext-Klartext in kumiko_events". #2596 added
611
+ // the recordOwned ("record") subject kind for row-scoped free text with no
612
+ // user/tenant owner. Nothing in this file's resolution path (subject-
613
+ // resolver.ts / pii-field-encryption.ts) special-cases subject kind, so the
614
+ // expectation is that this backfill already covers it for lifecycle events
615
+ // with zero new code — these tests pin that down rather than assume it.
616
+ describe("backfillEventPiiEncryption: record subject (kumiko-framework#2786)", () => {
617
+ test("encrypts a lifecycle payload under record:<entity>:<id>, not user:<id>", async () => {
618
+ const s1 = generateId();
619
+ await appendPlain(s1, "survey", "survey.created", { id: s1, answer: "plaintext answer" });
620
+
621
+ armKms();
622
+ const result = await backfillEventPiiEncryption(testDb.db, registry);
623
+
624
+ expect(result.failures).toEqual([]);
625
+ expect(result.updatedEvents).toBe(1);
626
+ expect(result.encryptedFields).toBe(1);
627
+ // Neither owner-resolution fallback is needed: the subject is the row's
628
+ // own id, always present on the event — stage 2/3 stay unused.
629
+ expect(result.ownerFromProjection).toBe(0);
630
+ expect(result.erasedUnresolvable).toBe(0);
631
+
632
+ const events = await loadAggregate(testDb.db, s1, TENANT);
633
+ const created = events[0]?.payload as Record<string, unknown>;
634
+ expect(isPiiCiphertext(created["answer"])).toBe(true);
635
+ // Exact prefix, not just "contains": the entity segment must be the
636
+ // registry name forget-subject resolves ("survey"), not the table name
637
+ // or a divergent aggregate_type — a wrong segment here would make the
638
+ // ciphertext unreachable by record-forget (fw#2790).
639
+ expect(String(created["answer"])).toStartWith(`${PII_CIPHERTEXT_PREFIX}record:survey:${s1}:`);
640
+ expect(String(created["answer"])).not.toContain(`user:${s1}`);
641
+ });
642
+
643
+ test("encrypts changes+previous on an update the same way", async () => {
644
+ const s2 = generateId();
645
+ await appendPlain(s2, "survey", "survey.created", { id: s2, answer: "first" });
646
+ await appendPlain(
647
+ s2,
648
+ "survey",
649
+ "survey.updated",
650
+ { changes: { answer: "second" }, previous: { id: s2, answer: "first" } },
651
+ 1,
652
+ );
653
+
654
+ armKms();
655
+ const result = await backfillEventPiiEncryption(testDb.db, registry);
656
+ expect(result.failures).toEqual([]);
657
+ expect(result.updatedEvents).toBe(2);
658
+
659
+ const events = await loadAggregate(testDb.db, s2, TENANT);
660
+ const updated = events[1]?.payload as {
661
+ changes: Record<string, unknown>;
662
+ previous: Record<string, unknown>;
663
+ };
664
+ expect(String(updated.changes["answer"])).toStartWith(
665
+ `${PII_CIPHERTEXT_PREFIX}record:survey:${s2}:`,
666
+ );
667
+ expect(String(updated.previous["answer"])).toStartWith(
668
+ `${PII_CIPHERTEXT_PREFIX}record:survey:${s2}:`,
669
+ );
670
+ });
671
+
672
+ test("a pre-KMS-forgotten row gets [[erased]], not a fresh record key", async () => {
673
+ const s3 = generateId();
674
+ await appendPlain(s3, "survey", "survey.created", { id: s3, answer: "secret" });
675
+ await appendPlain(
676
+ s3,
677
+ "survey",
678
+ "survey.forgotten",
679
+ { previous: { id: s3, answer: "secret" } },
680
+ 1,
681
+ );
682
+
683
+ armKms();
684
+ const result = await backfillEventPiiEncryption(testDb.db, registry);
685
+
686
+ expect(result.failures).toEqual([]);
687
+ expect(result.erasedFields).toBe(2);
688
+ const events = await loadAggregate(testDb.db, s3, TENANT);
689
+ const created = events[0]?.payload as Record<string, unknown>;
690
+ expect(created["answer"]).toBe(PII_ERASED_SENTINEL);
691
+ const forgotten = events[1]?.payload as { previous: Record<string, unknown> };
692
+ expect(forgotten.previous["answer"]).toBe(PII_ERASED_SENTINEL);
693
+ });
694
+
695
+ test("idempotent: a second run touches nothing", async () => {
696
+ const s4 = generateId();
697
+ await appendPlain(s4, "survey", "survey.created", { id: s4, answer: "once" });
698
+
699
+ armKms();
700
+ const first = await backfillEventPiiEncryption(testDb.db, registry);
701
+ expect(first.updatedEvents).toBe(1);
702
+ const second = await backfillEventPiiEncryption(testDb.db, registry);
703
+ expect(second.updatedEvents).toBe(0);
704
+ expect(second.failures).toEqual([]);
705
+ });
706
+ });
707
+
593
708
  describe("backfillEventPiiEncryption: raw jsonb column type (fw#2253)", () => {
594
709
  // The UPDATE used to wrap outcome.payload in JSON.stringify before handing
595
710
  // it to the ::jsonb cast — Bun.SQL already serializes objects, so the
@@ -13,7 +13,10 @@
13
13
  // are the ceiling.
14
14
  //
15
15
  // Runs isolated in the `event-store-perf` CI job (test:integration:perf:eventstore,
16
- // #1940) — gate on p95 for typical latency; p99 keeps a separate tail budget for checkpoint/fsync spikes.
16
+ // #1940) — gate on p95 for typical latency; p99 keeps a separate tail budget for
17
+ // residual scheduling noise. CI's Postgres container runs with fsync/synchronous_commit/
18
+ // full_page_writes off (#2798) — it exists only to be measured and thrown away, so these
19
+ // numbers aren't comparable to a default-config local container.
17
20
 
18
21
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
19
22
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
@@ -98,12 +101,14 @@ describe("event-store performance — Gate A", () => {
98
101
  const p50 = percentile(samples, 0.5);
99
102
  const p95 = percentile(samples, 0.95);
100
103
  const p99 = percentile(samples, 0.99);
104
+ const over30 = samples.filter((s) => s >= 30).length;
105
+ const top5 = samples.slice(-5).map((s) => s.toFixed(1));
101
106
  console.log(
102
- ` Write-latency: p50=${p50.toFixed(2)}ms, p95=${p95.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=200)`,
107
+ ` Write-latency: p50=${p50.toFixed(2)}ms, p95=${p95.toFixed(2)}ms, p99=${p99.toFixed(2)}ms (n=200) over30=${over30} top5=${top5}`,
103
108
  );
104
109
 
105
110
  expect(p95).toBeLessThan(30);
106
- // Tail budget: absorbs cold-checkpoint/fsync spikes (observed up to 102ms at p50=1.2ms) while still catching order-of-magnitude regressions.
111
+ // Tail budget: absorbs residual runner scheduling noise while still catching order-of-magnitude regressions.
107
112
  expect(p99).toBeLessThan(250);
108
113
  });
109
114