@cosmicdrift/kumiko-framework 0.155.0 → 0.156.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 (74) hide show
  1. package/README.md +9 -38
  2. package/package.json +2 -2
  3. package/src/__tests__/entity-permalink-open.integration.test.ts +94 -0
  4. package/src/__tests__/raw-table.integration.test.ts +17 -40
  5. package/src/crypto/__tests__/pii-field-encryption.test.ts +77 -0
  6. package/src/crypto/index.ts +1 -0
  7. package/src/crypto/pii-field-encryption.ts +19 -0
  8. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +27 -1
  9. package/src/db/__tests__/blind-index.integration.test.ts +16 -0
  10. package/src/db/__tests__/collect-table-metas.test.ts +6 -6
  11. package/src/db/__tests__/feature-table-sources.test.ts +7 -7
  12. package/src/db/__tests__/migrate-generator.test.ts +21 -0
  13. package/src/db/__tests__/number-field-fractional.integration.test.ts +1 -1
  14. package/src/db/__tests__/tenant-db-where-merge.test.ts +39 -0
  15. package/src/db/collect-table-metas.ts +6 -7
  16. package/src/db/entity-table-meta.ts +1 -1
  17. package/src/db/feature-table-sources.ts +5 -11
  18. package/src/db/migrate-generator.ts +39 -6
  19. package/src/db/queries/shadow-swap.ts +85 -3
  20. package/src/db/tenant-db.ts +8 -0
  21. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +102 -0
  22. package/src/engine/__tests__/build-config-feature-schema.test.ts +16 -0
  23. package/src/engine/__tests__/feature-crud-shorthand.test.ts +28 -0
  24. package/src/engine/__tests__/field-access.test.ts +23 -1
  25. package/src/engine/__tests__/raw-table.test.ts +134 -84
  26. package/src/engine/__tests__/registry.test.ts +40 -0
  27. package/src/engine/boot-validator/__tests__/config-deps.test.ts +52 -2
  28. package/src/engine/boot-validator/action-wiring.ts +2 -2
  29. package/src/engine/boot-validator/config-deps.ts +35 -0
  30. package/src/engine/boot-validator/index.ts +2 -0
  31. package/src/engine/boot-validator/pii-retention.ts +35 -3
  32. package/src/engine/build-config-feature-schema.ts +3 -3
  33. package/src/engine/config-helpers.ts +2 -0
  34. package/src/engine/define-feature.ts +2 -6
  35. package/src/engine/entity-handlers.ts +2 -4
  36. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
  37. package/src/engine/feature-ast/__tests__/parse.test.ts +170 -0
  38. package/src/engine/feature-ast/__tests__/patch.test.ts +31 -0
  39. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +6 -1
  40. package/src/engine/feature-ast/extractors/events.ts +322 -0
  41. package/src/engine/feature-ast/extractors/handlers.ts +222 -0
  42. package/src/engine/feature-ast/extractors/hooks.ts +243 -0
  43. package/src/engine/feature-ast/extractors/index.ts +32 -24
  44. package/src/engine/feature-ast/extractors/jobs-routes.ts +221 -0
  45. package/src/engine/feature-ast/extractors/projections-screens.ts +269 -0
  46. package/src/engine/feature-ast/extractors/round5.ts +3 -3
  47. package/src/engine/feature-ast/parse.ts +3 -3
  48. package/src/engine/feature-ast/render.ts +16 -11
  49. package/src/engine/feature-builder-state.ts +0 -3
  50. package/src/engine/feature-config-events-jobs.ts +3 -6
  51. package/src/engine/feature-entity-handlers.ts +9 -1
  52. package/src/engine/feature-ui-extensions.ts +10 -40
  53. package/src/engine/field-access.ts +13 -2
  54. package/src/engine/object-form.ts +15 -0
  55. package/src/engine/registry-facade.ts +4 -0
  56. package/src/engine/registry-ingest.ts +19 -29
  57. package/src/engine/registry-state.ts +1 -7
  58. package/src/engine/registry-validate.ts +5 -28
  59. package/src/engine/registry.ts +0 -2
  60. package/src/engine/types/config.ts +7 -0
  61. package/src/engine/types/feature.ts +40 -68
  62. package/src/engine/types/fields.ts +6 -0
  63. package/src/engine/types/index.ts +0 -3
  64. package/src/es-ops/README.md +1 -1
  65. package/src/es-ops/__tests__/runner.integration.test.ts +74 -0
  66. package/src/es-ops/context.ts +4 -2
  67. package/src/es-ops/types.ts +8 -1
  68. package/src/pipeline/msp-rebuild.ts +4 -0
  69. package/src/pipeline/projection-rebuild.ts +35 -0
  70. package/src/search/__tests__/meilisearch-adapter.integration.test.ts +8 -1
  71. package/src/stack/test-stack.ts +17 -2
  72. package/src/ui-types/index.ts +1 -0
  73. package/src/engine/__tests__/unmanaged-table.test.ts +0 -172
  74. package/src/engine/feature-ast/extractors/round4.ts +0 -1227
package/README.md CHANGED
@@ -28,9 +28,10 @@ Bun is the intended runtime and test runner.
28
28
  ## At-a-glance
29
29
 
30
30
  ```typescript
31
- import { defineFeature, createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
31
+ import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
32
32
 
33
33
  export const taskEntity = createEntity({
34
+ table: "read_tasks",
34
35
  fields: {
35
36
  title: createTextField({ required: true, searchable: true }),
36
37
  done: createTextField(),
@@ -38,44 +39,14 @@ export const taskEntity = createEntity({
38
39
  softDelete: true,
39
40
  });
40
41
 
41
- const taskTable = buildDrizzleTable("task", taskEntity);
42
- const taskExecutor = createEventStoreExecutor(taskTable, taskEntity, { entityName: "task" });
43
-
44
42
  export const taskFeature = defineFeature("tasks", (r) => {
45
- r.entity("task", taskEntity);
46
-
47
- // Write handlers go through createEventStoreExecutor events + projection in one TX,
48
- // optimistic locking, access control, all explicit.
49
- r.writeHandler(
50
- "task:create",
51
- z.object({ title: z.string() }),
52
- async (event, ctx) => taskExecutor.create(event.payload, event.user, ctx.db),
53
- { access: { roles: ["User"] } },
54
- );
55
- r.writeHandler(
56
- "task:update",
57
- z.object({ id: z.uuid(), version: z.number(), changes: z.object({ title: z.string().optional() }) }),
58
- async (event, ctx) => taskExecutor.update(event.payload, event.user, ctx.db),
59
- { access: { roles: ["User"] } },
60
- );
61
- r.writeHandler(
62
- "task:delete",
63
- z.object({ id: z.uuid() }),
64
- async (event, ctx) => taskExecutor.delete(event.payload, event.user, ctx.db),
65
- { access: { roles: ["Admin"] } },
66
- );
67
- r.queryHandler(
68
- "task:list",
69
- z.object({}),
70
- async (query, ctx) => taskExecutor.list(query.payload, query.user, ctx.db),
71
- { access: { openToAll: true } },
72
- );
73
- r.queryHandler(
74
- "task:detail",
75
- z.object({ id: z.uuid() }),
76
- async (query, ctx) => taskExecutor.detail(query.payload, query.user, ctx.db),
77
- { access: { openToAll: true } },
78
- );
43
+ // r.crud wires create/update/delete/restore + list/detail queries in one
44
+ // call — events + projection in the same TX, optimistic locking, access
45
+ // control, all explicit via the options below.
46
+ r.crud("task", taskEntity, {
47
+ write: { access: { roles: ["User"] } },
48
+ read: { access: { openToAll: true } },
49
+ });
79
50
 
80
51
  // Read-model fed from task events, rebuildable via the CLI
81
52
  r.projection({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.155.0",
3
+ "version": "0.156.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>",
@@ -193,7 +193,7 @@
193
193
  "zod": "^4.4.3"
194
194
  },
195
195
  "devDependencies": {
196
- "@cosmicdrift/kumiko-dispatcher-live": "0.155.0",
196
+ "@cosmicdrift/kumiko-dispatcher-live": "0.156.0",
197
197
  "bun-types": "^1.3.13",
198
198
  "pino-pretty": "^13.1.3"
199
199
  },
@@ -0,0 +1,94 @@
1
+ // Issue #912 — Teilbare Entity-Permalinks. Der Copy-Link-Button selbst ist
2
+ // UI-Zucker (baut eine URL, kopiert sie); die eigentliche Anforderung des
3
+ // Issues ist "ein Permalink darf nie Auth/Tenant-Isolation umgehen". Ein
4
+ // entityEdit-Permalink öffnet beim Klick exactly denselben
5
+ // `<feature>:query:<entity>:detail`-Call, den jede Entity schon für ihr
6
+ // Update-Formular nutzt — es gibt keinen separaten Permalink-Resolve-Pfad.
7
+ // Dieser Test beweist den Deny für beide Achsen, die einen Leak verhindern
8
+ // müssten: Rollen-Gate (dispatcher-seitig, default-deny) und Tenant-Scoping
9
+ // (strukturell über ctx.db).
10
+
11
+ import { describe, expect, test } from "bun:test";
12
+ import { setupTestStackFromFeatures } from "@cosmicdrift/kumiko-dev-server/setup-test-stack-from-features";
13
+ import {
14
+ createEntity,
15
+ createTextField,
16
+ defineEntityCreateHandler,
17
+ defineEntityDetailHandler,
18
+ defineFeature,
19
+ } from "@cosmicdrift/kumiko-framework/engine";
20
+ import {
21
+ createTestUser,
22
+ pushEntityProjectionTables,
23
+ testTenantId,
24
+ } from "@cosmicdrift/kumiko-framework/stack";
25
+
26
+ const noteEntity = createEntity({
27
+ table: "read_permalink_notes",
28
+ fields: {
29
+ title: createTextField({ required: true, maxLength: 160, allowPlaintext: "is-business-data" }),
30
+ },
31
+ });
32
+
33
+ const permalinkFeature = defineFeature("permalink-fixture", (r) => {
34
+ r.entity("note", noteEntity);
35
+ const access = { roles: ["TenantAdmin"] } as const;
36
+ r.writeHandler(defineEntityCreateHandler("note", noteEntity, { access }));
37
+ r.queryHandler(defineEntityDetailHandler("note", noteEntity, { access }));
38
+ });
39
+
40
+ describe("entityEdit permalink open-path denies cleanly", () => {
41
+ test("unauthorized role → AccessDeniedError, not the record", async () => {
42
+ const stack = await setupTestStackFromFeatures([permalinkFeature]);
43
+ try {
44
+ const tenantId = testTenantId(701);
45
+ const owner = createTestUser({ id: 701, tenantId, roles: ["TenantAdmin"] });
46
+ const outsider = createTestUser({ id: 702, tenantId, roles: ["TeamMember"] });
47
+ await pushEntityProjectionTables(stack, stack.registry);
48
+
49
+ const created = await stack.http.writeOk<{ id: string }>(
50
+ "permalink-fixture:write:note:create",
51
+ { title: "Q3 Forecast" },
52
+ owner,
53
+ );
54
+
55
+ const res = await stack.http.query(
56
+ "permalink-fixture:query:note:detail",
57
+ { id: created.id },
58
+ outsider,
59
+ );
60
+ expect(res.ok).toBe(false);
61
+ const body = (await res.json()) as { isSuccess?: boolean; error?: { code?: string } };
62
+ expect(body.isSuccess).not.toBe(true);
63
+ expect(body.error?.code).toBe("access_denied");
64
+ } finally {
65
+ await stack.cleanup();
66
+ }
67
+ });
68
+
69
+ test("cross-tenant open → not-found, not the other tenant's record", async () => {
70
+ const stack = await setupTestStackFromFeatures([permalinkFeature]);
71
+ try {
72
+ const tenantA = testTenantId(703);
73
+ const tenantB = testTenantId(704);
74
+ const ownerA = createTestUser({ id: 703, tenantId: tenantA, roles: ["TenantAdmin"] });
75
+ const adminB = createTestUser({ id: 704, tenantId: tenantB, roles: ["TenantAdmin"] });
76
+ await pushEntityProjectionTables(stack, stack.registry);
77
+
78
+ const created = await stack.http.writeOk<{ id: string }>(
79
+ "permalink-fixture:write:note:create",
80
+ { title: "Tenant A Secret" },
81
+ ownerA,
82
+ );
83
+
84
+ const row = await stack.http.queryOk<Record<string, unknown> | null>(
85
+ "permalink-fixture:query:note:detail",
86
+ { id: created.id },
87
+ adminB,
88
+ );
89
+ expect(row).toBeNull();
90
+ } finally {
91
+ await stack.cleanup();
92
+ }
93
+ });
94
+ });
@@ -4,7 +4,7 @@
4
4
  // kumiko-platform/docs/plans/architecture/table-ddl-guard.md (Stufe 3).
5
5
 
6
6
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
- import { table, text, timestamp } from "../db/dialect";
7
+ import { defineUnmanagedTable } from "../db/entity-table-meta";
8
8
  import { asRawClient, insertOne, selectMany } from "../db/query";
9
9
  import { defineFeature } from "../engine";
10
10
  import { setupTestStack, type TestStack, unsafePushTables } from "../stack";
@@ -13,30 +13,19 @@ import { setupTestStack, type TestStack, unsafePushTables } from "../stack";
13
13
  // write-only by an integration handler, read-only by a query, never
14
14
  // event-sourced (the data isn't a domain fact, it's a side-effect
15
15
  // snapshot).
16
- const stripeWebhookCache = table("rt_int_stripe_webhook_cache", {
17
- eventId: text("event_id").primaryKey(),
18
- payload: text("payload").notNull(),
19
- receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
20
- });
21
-
22
- // A second physical table reachable through two distinct r.rawTable
23
- // registrations — pins the seenTables-by-reference dedupe at push time.
24
- // Two logical names, one CREATE.
25
- const sharedSyncCache = table("rt_int_shared_sync_cache", {
26
- syncId: text("sync_id").primaryKey(),
27
- payload: text("payload").notNull(),
16
+ const stripeWebhookCacheMeta = defineUnmanagedTable({
17
+ tableName: "rt_int_stripe_webhook_cache",
18
+ columns: [
19
+ { name: "event_id", pgType: "text", notNull: true, primaryKey: true },
20
+ { name: "payload", pgType: "text", notNull: true },
21
+ { name: "received_at", pgType: "timestamptz", notNull: true, defaultSql: "now()" },
22
+ ],
28
23
  });
29
24
 
30
25
  const webhookCacheFeature = defineFeature("webhook-cache", (r) => {
31
- r.rawTable("stripe", stripeWebhookCache, {
26
+ r.rawTable(stripeWebhookCacheMeta, {
32
27
  reason: "external Stripe webhook payload cache — write-only by webhook handler",
33
28
  });
34
- r.rawTable("primary-sync", sharedSyncCache, {
35
- reason: "shared sync-state cache, primary writer",
36
- });
37
- r.rawTable("secondary-sync", sharedSyncCache, {
38
- reason: "same physical table, different logical role for read consumers",
39
- });
40
29
  });
41
30
 
42
31
  let stack: TestStack;
@@ -54,9 +43,9 @@ describe("r.rawTable — DB roundtrip via setupTestStack", () => {
54
43
  const eventId = "evt_test_123";
55
44
  const payload = JSON.stringify({ type: "invoice.paid", amount: 4200 });
56
45
 
57
- await insertOne(stack.db, stripeWebhookCache, { eventId, payload });
46
+ await insertOne(stack.db, stripeWebhookCacheMeta, { eventId, payload });
58
47
 
59
- const rows = await selectMany(stack.db, stripeWebhookCache, { eventId: eventId });
48
+ const rows = await selectMany(stack.db, stripeWebhookCacheMeta, { eventId: eventId });
60
49
 
61
50
  expect(rows).toHaveLength(1);
62
51
  expect(rows[0]?.payload).toBe(payload);
@@ -65,11 +54,11 @@ describe("r.rawTable — DB roundtrip via setupTestStack", () => {
65
54
 
66
55
  test("registry exposes the raw table with its reason and featureName", () => {
67
56
  const all = stack.registry.getAllRawTables();
68
- const entry = all.get("stripe");
57
+ const entry = all.get("rt_int_stripe_webhook_cache");
69
58
  expect(entry).toBeDefined();
70
59
  expect(entry?.featureName).toBe("webhook-cache");
71
60
  expect(entry?.reason).toContain("Stripe webhook payload cache");
72
- expect(entry?.table).toBe(stripeWebhookCache);
61
+ expect(entry?.meta).toBe(stripeWebhookCacheMeta);
73
62
  });
74
63
 
75
64
  test("INSERT bypasses the event store — no kumiko_events row produced", async () => {
@@ -82,7 +71,7 @@ describe("r.rawTable — DB roundtrip via setupTestStack", () => {
82
71
  );
83
72
  const beforeCount = Number(before[0]?.count ?? 0);
84
73
 
85
- await insertOne(stack.db, stripeWebhookCache, {
74
+ await insertOne(stack.db, stripeWebhookCacheMeta, {
86
75
  eventId: "evt_no_event_emitted",
87
76
  payload: "{}",
88
77
  });
@@ -95,22 +84,10 @@ describe("r.rawTable — DB roundtrip via setupTestStack", () => {
95
84
  expect(afterCount).toBe(beforeCount);
96
85
  });
97
86
 
98
- test("two registrations sharing one PgTable result in one CREATE (dedupe by reference)", async () => {
99
- // primary-sync + secondary-sync both target sharedSyncCache. If the
100
- // setupTestStack dedupe (seenTables-by-table-reference) had silently
101
- // broken, beforeAll's setupTestStack would have raised a 42P07 on
102
- // the second push and never reached this test.
103
- await insertOne(stack.db, sharedSyncCache, { syncId: "sync_1", payload: "{}" });
104
- const rows = await selectMany(stack.db, sharedSyncCache, { syncId: "sync_1" });
105
- expect(rows).toHaveLength(1);
106
- // Both registrations are visible in the registry — same physical
107
- // target, different logical handles.
108
- expect(stack.registry.getAllRawTables().get("primary-sync")?.table).toBe(sharedSyncCache);
109
- expect(stack.registry.getAllRawTables().get("secondary-sync")?.table).toBe(sharedSyncCache);
110
- });
111
-
112
87
  test("a second push on the same rawTable is idempotent — CREATE IF NOT EXISTS", async () => {
113
88
  // unsafePushTables uses CREATE TABLE IF NOT EXISTS — idempotent by design.
114
- await expect(unsafePushTables(stack.db, { idem: stripeWebhookCache })).resolves.toBeUndefined();
89
+ await expect(
90
+ unsafePushTables(stack.db, { idem: stripeWebhookCacheMeta }),
91
+ ).resolves.toBeUndefined();
115
92
  });
116
93
  });
@@ -4,7 +4,9 @@ import { InMemoryKmsAdapter } from "../in-memory-kms-adapter";
4
4
  import { KeyNotFoundError, type KmsContext, subjectIdFromKey } from "../kms-adapter";
5
5
  import {
6
6
  decryptPiiFieldValues,
7
+ decryptPiiValueForSubject,
7
8
  encryptPiiFieldValues,
9
+ encryptPiiValueForSubject,
8
10
  isPiiCiphertext,
9
11
  PII_ERASED_SENTINEL,
10
12
  } from "../pii-field-encryption";
@@ -214,3 +216,78 @@ describe("encryptPiiFieldValues / decryptPiiFieldValues", () => {
214
216
  );
215
217
  });
216
218
  });
219
+
220
+ describe("piiEncrypted alias (kumiko-platform#457)", () => {
221
+ test("piiEncrypted + tenantOwned round-trips through the same subject-KMS pipeline", async () => {
222
+ const kms = new InMemoryKmsAdapter();
223
+ const brandingWithAccess = createEntity({
224
+ fields: {
225
+ iban: createTextField({
226
+ piiEncrypted: true,
227
+ tenantOwned: true,
228
+ access: { read: ["TenantAdmin"] },
229
+ }),
230
+ },
231
+ table: "pii_branding_iban",
232
+ });
233
+ const fields = collectPiiSubjectFields(brandingWithAccess);
234
+ expect(fields).toEqual(["iban"]);
235
+
236
+ const row = { id: UUID_A, tenantId: UUID_B, iban: "DE89370400440532013000" };
237
+ const stored = await encryptPiiFieldValues(row, brandingWithAccess, fields, kms, KMS_CTX);
238
+ expect(isPiiCiphertext(stored["iban"])).toBe(true);
239
+ expect(String(stored["iban"])).toStartWith(`kumiko-pii:v1:tenant:${UUID_B}:`);
240
+
241
+ const read = await decryptPiiFieldValues(stored, fields, kms, KMS_CTX);
242
+ expect(read["iban"]).toBe("DE89370400440532013000");
243
+ });
244
+
245
+ test("piiEncrypted field is covered by subject erasure (Art. 17, kumiko-platform#461)", async () => {
246
+ const kms = new InMemoryKmsAdapter();
247
+ const brandingWithAccess = createEntity({
248
+ fields: {
249
+ iban: createTextField({ piiEncrypted: true, tenantOwned: true }),
250
+ },
251
+ table: "pii_branding_iban_erasure",
252
+ });
253
+ const fields = collectPiiSubjectFields(brandingWithAccess);
254
+ const row = { id: UUID_A, tenantId: UUID_B, iban: "DE89370400440532013000" };
255
+ const stored = await encryptPiiFieldValues(row, brandingWithAccess, fields, kms, KMS_CTX);
256
+
257
+ // Erasure is subject-keyed, not field-flag-keyed — kms.eraseKey doesn't
258
+ // know or care that this field is piiEncrypted vs. plain tenantOwned.
259
+ await kms.eraseKey({ kind: "tenant", tenantId: UUID_B });
260
+
261
+ const read = await decryptPiiFieldValues(stored, fields, kms, KMS_CTX);
262
+ expect(read["iban"]).toBe(PII_ERASED_SENTINEL);
263
+ });
264
+ });
265
+
266
+ describe("encryptPiiValueForSubject / decryptPiiValueForSubject (kumiko-platform#459)", () => {
267
+ test("round-trips a single value for a tenant subject", async () => {
268
+ const kms = new InMemoryKmsAdapter();
269
+ const subject = { kind: "tenant" as const, tenantId: UUID_B };
270
+ const stored = await encryptPiiValueForSubject(kms, subject, "DE89370400440532013000", KMS_CTX);
271
+ expect(isPiiCiphertext(stored)).toBe(true);
272
+ expect(stored).toStartWith(`kumiko-pii:v1:tenant:${UUID_B}:`);
273
+
274
+ const read = await decryptPiiValueForSubject(kms, stored, KMS_CTX);
275
+ expect(read).toBe("DE89370400440532013000");
276
+ });
277
+
278
+ test("erased subject: decrypt yields the sentinel", async () => {
279
+ const kms = new InMemoryKmsAdapter();
280
+ const subject = { kind: "user" as const, userId: UUID_A };
281
+ const stored = await encryptPiiValueForSubject(kms, subject, "+49 151 00000000", KMS_CTX);
282
+ await kms.eraseKey(subject);
283
+
284
+ const read = await decryptPiiValueForSubject(kms, stored, KMS_CTX);
285
+ expect(read).toBe(PII_ERASED_SENTINEL);
286
+ });
287
+
288
+ test("plaintext passes through decrypt unchanged (pre-engine rows)", async () => {
289
+ const kms = new InMemoryKmsAdapter();
290
+ const read = await decryptPiiValueForSubject(kms, "plain-value", KMS_CTX);
291
+ expect(read).toBe("plain-value");
292
+ });
293
+ });
@@ -46,6 +46,7 @@ export {
46
46
  configuredPiiSubjectKms,
47
47
  configurePiiSubjectKms,
48
48
  decryptPiiFieldValues,
49
+ decryptPiiValueForSubject,
49
50
  type EncryptPiiOptions,
50
51
  encryptPiiFieldValues,
51
52
  encryptPiiValueForSubject,
@@ -99,6 +99,25 @@ export async function encryptPiiValueForSubject(
99
99
  return encryptValue(subject, dek, value);
100
100
  }
101
101
 
102
+ // Single-value decrypt for callers that don't have an entity/field-map to
103
+ // pass through decryptPiiFieldValues (config values). The subject lives
104
+ // inside the ciphertext itself, so no subject/scope resolution is needed
105
+ // on this side — only the encrypt direction has to pick one.
106
+ export async function decryptPiiValueForSubject(
107
+ kms: LocalKeyKmsAdapter,
108
+ value: string,
109
+ kmsCtx: KmsContext,
110
+ ): Promise<string> {
111
+ if (!isPiiCiphertext(value)) return value;
112
+ const { subject, blob } = parseCiphertext(value);
113
+ try {
114
+ return decryptValue(await kms.getKey(subject, kmsCtx), blob);
115
+ } catch (e) {
116
+ if (!(e instanceof KeyErasedError)) throw e;
117
+ return PII_ERASED_SENTINEL;
118
+ }
119
+ }
120
+
102
121
  export interface EncryptPiiOptions {
103
122
  readonly onlyKeys?: Iterable<string>;
104
123
  // Write-time tenant for tenantOwned fields on rows without a tenantId column.
@@ -85,6 +85,7 @@ describe("assert-no-unreachable-live-rows / #722 ghost-row guard", () => {
85
85
  const result = await rebuildProjection(projectionName, { db: testDb.db, registry });
86
86
 
87
87
  expect(result.eventsProcessed).toBe(2);
88
+ expect(result.columnDriftCount).toBe(0);
88
89
  expect(await snapshotTable()).toEqual(before);
89
90
  });
90
91
 
@@ -131,7 +132,10 @@ describe("assert-no-unreachable-live-rows / #722 ghost-row guard", () => {
131
132
  );
132
133
 
133
134
  // No throw — the row is event-backed. Rebuild replays the create event.
134
- await rebuildProjection(projectionName, { db: testDb.db, registry });
135
+ // #916: the direct write IS reported as column drift (non-blocking)
136
+ // this is the exact #494 healing case the guard leaves to replay.
137
+ const result = await rebuildProjection(projectionName, { db: testDb.db, registry });
138
+ expect(result.columnDriftCount).toBe(1);
135
139
 
136
140
  const [rebuilt] = await selectMany(testDb.db, userTable, { id: a.data.id as string });
137
141
  expect(rebuilt?.["email"]).toBe("a@test.de");
@@ -139,6 +143,28 @@ describe("assert-no-unreachable-live-rows / #722 ghost-row guard", () => {
139
143
  expect(rebuilt?.["firstName"]).toBe("Alice");
140
144
  });
141
145
 
146
+ test("columnDriftCount reports the TRUE total, not capped at the 20-row sample limit", async () => {
147
+ const crud = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
148
+ const created = await Promise.all(
149
+ Array.from({ length: 21 }, (_, i) =>
150
+ crud.create({ email: `u${i}@test.de`, firstName: `Name${i}` }, adminUser, tdb),
151
+ ),
152
+ );
153
+ for (const c of created) {
154
+ if (!c.isSuccess) throw new Error("setup failed");
155
+ await asRawClient(testDb.db).unsafe(
156
+ `UPDATE read_unreachable_users SET first_name = 'DirectWrite' WHERE id = $1`,
157
+ [c.data.id],
158
+ );
159
+ }
160
+
161
+ const result = await rebuildProjection(projectionName, { db: testDb.db, registry });
162
+
163
+ // #916: the sample query is LIMITed to 20 rows, but the count must reflect
164
+ // all 21 — a min(actual, 20) would silently understate severity to ops.
165
+ expect(result.columnDriftCount).toBe(21);
166
+ });
167
+
142
168
  test("archived stream (fw#832 tombstone) does NOT trip the guard", async () => {
143
169
  // archiveStream never deletes the .created event — projection rebuild
144
170
  // deliberately excludes archived streams from replay (fw#832, see
@@ -213,6 +213,22 @@ describe("blind-index rebuild + forget", () => {
213
213
  expect(await fetchOne(tdb, personTable, { email: "marc@example.com" })).toBeUndefined();
214
214
  });
215
215
 
216
+ test("erased subject → column drift excludes bidx (#916, not counted)", async () => {
217
+ // Pre-swap, live still holds the OLD bidx hash while the shadow already
218
+ // computed NULL for the erased subject — a real live-vs-shadow divergence,
219
+ // but the exact legitimate class countColumnDrift is designed to ignore.
220
+ const created = await crud.create({ email: "marc@example.com" }, adminUser, tdb);
221
+ if (!created.isSuccess) throw new Error("create failed");
222
+
223
+ await kms.eraseKey({ kind: "user", userId: String(created.data.id) });
224
+
225
+ const registry = createRegistry([personFeature]);
226
+ const result = await rebuildProjection(implicitName, { db: testDb.db, registry });
227
+
228
+ expect(result.columnDriftCount).toBe(0);
229
+ expect((await rawRow(String(created.data.id)))["email_bidx"]).toBeNull();
230
+ });
231
+
216
232
  test("nullBlindIndexesForSubject nulls bidx immediately (no rebuild needed)", async () => {
217
233
  const created = await crud.create({ email: "marc@example.com" }, adminUser, tdb);
218
234
  if (!created.isSuccess) throw new Error("create failed");
@@ -40,13 +40,13 @@ describe("collectTableMetas (#255)", () => {
40
40
  // side-effect-only MSP — no table, must be skipped without throwing
41
41
  r.multiStreamProjection({ name: "unit-notify", apply: { "unit.created": async () => {} } });
42
42
  r.rawTable(
43
- "cache",
44
- table("unit_cache", {
45
- id: uuid("id").primaryKey(),
46
- }) as unknown as SchemaTable,
43
+ defineUnmanagedTable({
44
+ tableName: "unit_cache",
45
+ columns: [{ name: "id", pgType: "uuid", notNull: true, primaryKey: true }],
46
+ }),
47
47
  { reason: "test fixture" },
48
48
  );
49
- r.unmanagedTable(
49
+ r.rawTable(
50
50
  defineUnmanagedTable({
51
51
  tableName: "read_unit_log",
52
52
  columns: [{ name: "id", pgType: "serial", notNull: true, primaryKey: true }],
@@ -60,7 +60,7 @@ describe("collectTableMetas (#255)", () => {
60
60
  expect(names).toContain("read_unit_counters"); // r.projection
61
61
  expect(names).toContain("read_unit_audit"); // r.multiStreamProjection
62
62
  expect(names).toContain("unit_cache"); // r.rawTable
63
- expect(names).toContain("read_unit_log"); // r.unmanagedTable
63
+ expect(names).toContain("read_unit_log"); // r.rawTable (second registration)
64
64
  expect(names).toHaveLength(5);
65
65
  });
66
66
 
@@ -1,9 +1,9 @@
1
- // Regression test for #255-class drift: unmanagedTables must appear in
1
+ // Regression test for #255-class drift: rawTables must appear in
2
2
  // enumerateFeatureTableSources so setupTestStack's auto-push and
3
3
  // collectTableMetas see the exact same table set. Before this fix,
4
- // unmanagedTables were only handled by collectTableMetas directly, so any
4
+ // unmanaged tables were only handled by collectTableMetas directly, so any
5
5
  // app relying on setupTestStack's ephemeral DB (Playwright/e2e) never got
6
- // its unmanaged tables created — e.g. the bundled "sessions" feature's
6
+ // its raw tables created — e.g. the bundled "sessions" feature's
7
7
  // read_user_sessions, breaking every login in an ephemeral test stack.
8
8
 
9
9
  import { describe, expect, test } from "bun:test";
@@ -16,10 +16,10 @@ const probeMeta = defineUnmanagedTable({
16
16
  columns: [{ name: "id", pgType: "text", notNull: true, primaryKey: true }],
17
17
  });
18
18
 
19
- describe("enumerateFeatureTableSources — unmanagedTables", () => {
20
- test("includes a feature's unmanagedTable as a table source", () => {
19
+ describe("enumerateFeatureTableSources — rawTables", () => {
20
+ test("includes a feature's rawTable as a table source", () => {
21
21
  const feature = defineFeature("probe", (r) => {
22
- r.unmanagedTable(probeMeta, { reason: "direct-write store" });
22
+ r.rawTable(probeMeta, { reason: "direct-write store" });
23
23
  });
24
24
  const sources = enumerateFeatureTableSources(feature);
25
25
  const entry = sources.find((s) => s.origin.includes("ftst_probe"));
@@ -27,7 +27,7 @@ describe("enumerateFeatureTableSources — unmanagedTables", () => {
27
27
  expect(entry?.table).toBe(probeMeta);
28
28
  });
29
29
 
30
- test("a feature with no unmanagedTable contributes nothing extra", () => {
30
+ test("a feature with no rawTable contributes nothing extra", () => {
31
31
  const feature = defineFeature("plain", () => {});
32
32
  expect(enumerateFeatureTableSources(feature)).toEqual([]);
33
33
  });
@@ -95,6 +95,10 @@ describe("renderMigrationSql — managed recreate vs unmanaged in-place", () =>
95
95
  expect(sql).toContain('DROP TABLE IF EXISTS "read_secrets";');
96
96
  expect(sql).toContain('CREATE TABLE IF NOT EXISTS "read_secrets"');
97
97
  expect(sql).not.toContain("ADD COLUMN");
98
+ expect(sql).toContain(
99
+ "-- WARN: destructive change (new NOT NULL column(s) without default: envelope) forces DROP+CREATE + full event replay.",
100
+ );
101
+ expect(sql).toContain("Consider an Expand/Contract split across two releases");
98
102
  });
99
103
 
100
104
  test("managed: column rename (drop + add NOT NULL) → DROP+CREATE with new shape", () => {
@@ -121,6 +125,7 @@ describe("renderMigrationSql — managed recreate vs unmanaged in-place", () =>
121
125
  const sql = renderMigrationSql(diffSnapshots(prev, next), { name: "note", sequenceNumber: 4 });
122
126
  expect(sql).toContain('ALTER TABLE "read_a" ADD COLUMN "note"');
123
127
  expect(sql).not.toContain("DROP TABLE");
128
+ expect(sql).not.toContain("WARN: destructive change");
124
129
  });
125
130
 
126
131
  test("unmanaged: NOT NULL column without default → in-place ADD (real data, never recreated)", () => {
@@ -134,5 +139,21 @@ describe("renderMigrationSql — managed recreate vs unmanaged in-place", () =>
134
139
  });
135
140
  expect(sql).toContain('ALTER TABLE "app_data" ADD COLUMN "envelope"');
136
141
  expect(sql).not.toContain("DROP TABLE");
142
+ expect(sql).not.toContain("WARN: destructive change");
143
+ });
144
+
145
+ test("managed: multiple recreate reasons at once → all named in the warning", () => {
146
+ const prev = snapshotFromMetas([
147
+ meta("read_b", { name: "old_col", pgType: "text", notNull: false }, "managed"),
148
+ ]);
149
+ const next = snapshotFromMetas([
150
+ meta("read_b", { name: "envelope", pgType: "jsonb", notNull: true }, "managed"),
151
+ ]);
152
+ const sql = renderMigrationSql(diffSnapshots(prev, next), {
153
+ name: "multi",
154
+ sequenceNumber: 6,
155
+ });
156
+ expect(sql).toContain("dropped column(s): old_col");
157
+ expect(sql).toContain("new NOT NULL column(s) without default: envelope");
137
158
  });
138
159
  });
@@ -50,7 +50,7 @@ describe("createNumberField({ integer: true }) — still rejects fractional valu
50
50
  await expect(
51
51
  seedRow(stack.db, statsTable, {
52
52
  expectedValue: 1,
53
- wholeCount: 3.5 as unknown as number,
53
+ wholeCount: 3.5,
54
54
  tenantId: "00000000-0000-4000-8000-000000000001",
55
55
  }),
56
56
  ).rejects.toThrow(/invalid input syntax for type integer/);
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
2
2
  import { createEntity, createTextField } from "../../engine";
3
3
  import { testTenantId } from "../../stack";
4
4
  import type { TableColumns } from "../dialect";
5
+ import { buildEntityTableMeta } from "../entity-table-meta";
5
6
  import { buildEntityTable } from "../table-builder";
6
7
  import { createTenantDb } from "../tenant-db";
7
8
 
@@ -116,3 +117,41 @@ describe("tenant-db WHERE merge — narrowing within the enforced scope", () =>
116
117
  expect(captured[0]?.values).not.toContain(foreign);
117
118
  });
118
119
  });
120
+
121
+ // Root-cause regression for the cross-tenant leak fixed in hasTenantColumn:
122
+ // unmanaged direct-write stores (buildEntityTableMeta, e.g. userSessionTable,
123
+ // apiTokenTable) store tenantId as a snake_case column-meta entry, not a
124
+ // direct `table.tenantId` property — a naive property check silently treated
125
+ // them as tenant-less and skipped the WHERE-scope entirely.
126
+ describe("tenant-db WHERE merge — unmanaged EntityTableMeta tables (buildEntityTableMeta)", () => {
127
+ const unmanagedEntity = createEntity({
128
+ table: "merge_meta_items",
129
+ fields: { tenantId: createTextField({ required: true }), name: createTextField() },
130
+ });
131
+ const unmanagedTable = buildEntityTableMeta("merge-meta-item", unmanagedEntity);
132
+
133
+ test("selectMany still applies the tenant scope (pre-fix: no WHERE at all)", async () => {
134
+ const captured: Captured[] = [];
135
+ const tdb = createTenantDb(recordingDb(captured), own);
136
+
137
+ // selectMany's Table param types the branded-EntityTable shape only;
138
+ // EntityTableMeta reads work identically at runtime (both normalize via
139
+ // asEntityTableMeta) — same unbranded-view cast as `table` above.
140
+ await tdb.selectMany(unmanagedTable as unknown as TableColumns, { name: "needle" });
141
+
142
+ expect(captured[0]?.sql).toMatch(/tenant_id" IN /i);
143
+ expect(captured[0]?.values).toContain(own);
144
+ expect(captured[0]?.values).not.toContain(foreign);
145
+ });
146
+
147
+ test("updateMany still forces own tenantId (pre-fix: no scope on writes either)", async () => {
148
+ const captured: Captured[] = [];
149
+ const tdb = createTenantDb(recordingDb(captured), own);
150
+
151
+ await tdb.updateMany(unmanagedTable, { name: "x" }, { tenantId: foreign });
152
+
153
+ const update = captured.find((c) => /UPDATE/i.test(c.sql));
154
+ expect(update?.values).toContain(own);
155
+ expect(update?.values).not.toContain(foreign);
156
+ });
157
+ });