@cosmicdrift/kumiko-framework 0.293.0 → 0.294.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 (46) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/schema-cli-rebuild.integration.test.ts +0 -2
  3. package/src/changes.json +6 -0
  4. package/src/crypto/__tests__/kek-source.test.ts +82 -0
  5. package/src/crypto/kek-source.ts +19 -19
  6. package/src/db/__tests__/apply-entity-event-tenant.integration.test.ts +0 -2
  7. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +1 -2
  8. package/src/db/__tests__/blind-index.integration.test.ts +0 -2
  9. package/src/db/__tests__/entity-field-encryption.test.ts +29 -0
  10. package/src/db/__tests__/event-store-executor-list.integration.test.ts +0 -2
  11. package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +0 -2
  12. package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +0 -2
  13. package/src/db/__tests__/event-store-executor-reference-sort.integration.test.ts +0 -2
  14. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +0 -2
  15. package/src/db/__tests__/event-store-executor.integration.test.ts +1 -2
  16. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +0 -2
  17. package/src/db/__tests__/unique-violation-mapping.integration.test.ts +0 -2
  18. package/src/db/entity-field-encryption.ts +5 -3
  19. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +0 -2
  20. package/src/engine/boot-validator/index.ts +5 -1
  21. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +1 -2
  22. package/src/entrypoint/__tests__/entrypoint-job-wiring.integration.test.ts +1 -2
  23. package/src/entrypoint/__tests__/split-deploy.integration.test.ts +1 -2
  24. package/src/env/__tests__/compose-env-schema.test.ts +97 -1
  25. package/src/env/index.ts +63 -3
  26. package/src/es-ops/__tests__/context.integration.test.ts +0 -2
  27. package/src/es-ops/__tests__/runner.integration.test.ts +0 -2
  28. package/src/event-store/__tests__/admin-api.integration.test.ts +1 -2
  29. package/src/event-store/__tests__/backfill-pii.integration.test.ts +0 -2
  30. package/src/event-store/__tests__/event-store.integration.test.ts +0 -2
  31. package/src/event-store/__tests__/get-stream-version-perf.integration.test.ts +1 -2
  32. package/src/event-store/__tests__/perf.integration.test.ts +1 -8
  33. package/src/event-store/__tests__/provenance-append.integration.test.ts +1 -2
  34. package/src/event-store/__tests__/snapshot.integration.test.ts +0 -2
  35. package/src/event-store/__tests__/upcaster-dead-letter.integration.test.ts +1 -2
  36. package/src/event-store/__tests__/upcaster.integration.test.ts +1 -2
  37. package/src/files/__tests__/files.integration.test.ts +1 -2
  38. package/src/jobs/__tests__/job-write-as.integration.test.ts +1 -2
  39. package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +0 -2
  40. package/src/pipeline/__tests__/blind-index-rebuild-guard.integration.test.ts +0 -2
  41. package/src/pipeline/__tests__/cascade-handler.integration.test.ts +0 -2
  42. package/src/pipeline/__tests__/perf-rebuild.integration.test.ts +0 -2
  43. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +1 -2
  44. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +1 -2
  45. package/src/search/__tests__/reindex-entity.integration.test.ts +0 -2
  46. package/src/search/__tests__/search-pii-derived-index.integration.test.ts +0 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.293.0",
3
+ "version": "0.294.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,8 +198,8 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-http": "0.293.0",
202
- "@cosmicdrift/kumiko-types": "0.293.0",
201
+ "@cosmicdrift/kumiko-http": "0.294.0",
202
+ "@cosmicdrift/kumiko-types": "0.294.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "bun-types": "^1.3.13",
205
205
  "hono": "^4.13.1",
@@ -215,7 +215,7 @@
215
215
  "zod": "^4.4.3"
216
216
  },
217
217
  "devDependencies": {
218
- "@cosmicdrift/kumiko-dispatcher-live": "0.293.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.294.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
@@ -18,7 +18,6 @@ import { buildEntityTable } from "../db/table-builder";
18
18
  import { createTenantDb, type TenantDb } from "../db/tenant-db";
19
19
  import { createEntity, createTextField, defineApply, defineFeature } from "../engine";
20
20
  import type { ProjectionDefinition } from "../engine/types";
21
- import { createEventsTable } from "../event-store";
22
21
  import { createProjectionStateTable } from "../pipeline";
23
22
  import { runSchemaCli, type SchemaCliOut } from "../schema-cli";
24
23
  import {
@@ -101,7 +100,6 @@ beforeAll(async () => {
101
100
  await ensureTemporalPolyfill();
102
101
  testDb = await createTestDb();
103
102
  await unsafeCreateEntityTable(testDb.db, itemEntity, "apply-item");
104
- await createEventsTable(testDb.db);
105
103
  await createProjectionStateTable(testDb.db);
106
104
  await unsafePushTables(testDb.db, { applyItemsPerGroup: counterTable });
107
105
  tdb = createTenantDb(testDb.db, admin.tenantId);
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.294.0",
4
+ "type": "improvement",
5
+ "title": "Declare Key Manager ciphertext slots in the env schema",
6
+ "detail": "An env field can carry `.meta({ kumiko: { kms: true } })` to be deliverable as `<NAME>_CIPHERTEXT`. `runProdApp` now decrypts the schema-declared slots once at boot (`kmsSlotsOf(schema)` → `resolvePlatformKeks({ slots })`) and hands the resolved env to the boot-time master-key probe (`ValidateBootOptions.env`) and to the master-key keyring, so a ciphertext-only `KUMIKO_SECRETS_MASTER_KEY_V<n>` boots. `parseEnv` treats a `kms` slot as satisfied by its ciphertext twin. `composeEnvSchema` declares the `<NAME>_CIPHERTEXT` twin of every `kms` field itself (an already declared twin is kept), so a versioned family needs one declaration per version. `KUMIKO_SECRETS_MASTER_KEY_V1` of the secrets feature is such a slot. `runWorkerApp` takes the same slots as `kmsSlots` (it parses no schema). A deployment missing `KUMIKO_SECRETS_MASTER_KEY_V1` but carrying its `_CIPHERTEXT` twin now passes `parseEnv` and fails at the master-key probe if the decrypt yields nothing, instead of failing in `parseEnv`. `resolvePlatformKeks` without `slots` keeps resolving the three platform slots until consumers declare theirs."
7
+ },
2
8
  {
3
9
  "version": "0.293.0",
4
10
  "type": "improvement",
@@ -356,4 +356,86 @@ describe("resolvePlatformKeks", () => {
356
356
  expect(result.KUMIKO_BLIND_INDEX_KEY).toBe("blind-index-plaintext");
357
357
  expect(calls.length).toBe(2);
358
358
  });
359
+
360
+ describe("with schema-declared slots", () => {
361
+ const KEY_MANAGER = { PLATFORM_KEK_KMS_KEY_ID: "key-1", PLATFORM_KEK_KMS_TOKEN: TOKEN };
362
+
363
+ test("resolves a versioned master-key family without the framework knowing the versions", async () => {
364
+ const { fetch, calls } = trackedFetch([
365
+ jsonResponse(200, { plaintext: "v1-plaintext" }),
366
+ jsonResponse(200, { plaintext: "v2-plaintext" }),
367
+ ]);
368
+ const env: KekSourceEnv = {
369
+ KUMIKO_SECRETS_MASTER_KEY_V1_CIPHERTEXT: CIPHERTEXT_A,
370
+ KUMIKO_SECRETS_MASTER_KEY_V2_CIPHERTEXT: CIPHERTEXT_B,
371
+ ...KEY_MANAGER,
372
+ };
373
+
374
+ const result = await resolvePlatformKeks(env, {
375
+ fetch,
376
+ slots: ["KUMIKO_SECRETS_MASTER_KEY_V1", "KUMIKO_SECRETS_MASTER_KEY_V2"],
377
+ });
378
+
379
+ expect(result["KUMIKO_SECRETS_MASTER_KEY_V1"]).toBe("v1-plaintext");
380
+ expect(result["KUMIKO_SECRETS_MASTER_KEY_V2"]).toBe("v2-plaintext");
381
+ expect(calls.length).toBe(2);
382
+ });
383
+
384
+ test("a plaintext beats its ciphertext per slot, the other slot still resolves", async () => {
385
+ const { fetch, calls } = trackedFetch([jsonResponse(200, { plaintext: "v2-plaintext" })]);
386
+ const env: KekSourceEnv = {
387
+ KUMIKO_SECRETS_MASTER_KEY_V1: "v1-plaintext",
388
+ KUMIKO_SECRETS_MASTER_KEY_V1_CIPHERTEXT: CIPHERTEXT_A,
389
+ KUMIKO_SECRETS_MASTER_KEY_V2_CIPHERTEXT: CIPHERTEXT_B,
390
+ ...KEY_MANAGER,
391
+ };
392
+
393
+ const result = await resolvePlatformKeks(env, {
394
+ fetch,
395
+ slots: ["KUMIKO_SECRETS_MASTER_KEY_V1", "KUMIKO_SECRETS_MASTER_KEY_V2"],
396
+ });
397
+
398
+ expect(result["KUMIKO_SECRETS_MASTER_KEY_V1"]).toBe("v1-plaintext");
399
+ expect(result["KUMIKO_SECRETS_MASTER_KEY_V2"]).toBe("v2-plaintext");
400
+ expect(calls.length).toBe(1);
401
+ });
402
+
403
+ test("does not touch the platform slots that the caller did not name", async () => {
404
+ const { fetch, calls } = trackedFetch([]);
405
+ const env: KekSourceEnv = { PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A, ...KEY_MANAGER };
406
+
407
+ const result = await resolvePlatformKeks(env, { fetch, slots: [] });
408
+
409
+ expect(result).toBe(env);
410
+ expect(calls.length).toBe(0);
411
+ });
412
+
413
+ test("logs the source line for a declared slot", async () => {
414
+ const { fetch } = trackedFetch([jsonResponse(200, { plaintext: "v1-plaintext" })]);
415
+ const lines: string[] = [];
416
+ const env: KekSourceEnv = {
417
+ KUMIKO_SECRETS_MASTER_KEY_V1_CIPHERTEXT: CIPHERTEXT_A,
418
+ ...KEY_MANAGER,
419
+ };
420
+
421
+ await resolvePlatformKeks(env, {
422
+ fetch,
423
+ slots: ["KUMIKO_SECRETS_MASTER_KEY_V1"],
424
+ log: (line) => lines.push(line),
425
+ });
426
+
427
+ expect(lines).toEqual([
428
+ "KUMIKO_SECRETS_MASTER_KEY_V1 source=key-manager keyId=key-1 region=fr-par",
429
+ ]);
430
+ });
431
+
432
+ test("still rejects a previous KEK without its version when that slot is declared", async () => {
433
+ const { fetch } = trackedFetch([jsonResponse(200, { plaintext: "previous-plaintext" })]);
434
+ const env: KekSourceEnv = { PLATFORM_KEK_PREVIOUS_CIPHERTEXT: CIPHERTEXT_B, ...KEY_MANAGER };
435
+
436
+ await expect(
437
+ resolvePlatformKeks(env, { fetch, slots: ["PLATFORM_KEK_PREVIOUS"] }),
438
+ ).rejects.toThrow(/PLATFORM_KEK_PREVIOUS_VERSION must be set/);
439
+ });
440
+ });
359
441
  });
@@ -1,20 +1,16 @@
1
- // Resolves an allowlisted set of secrets (RESOLVABLE_SLOTS) from a Key
2
- // Manager ciphertext when no plaintext is set, so they need not sit in the
3
- // pod env in the clear. A slot's plaintext always wins over its ciphertext
4
- // sibling, with no request made at all. Any other `*_CIPHERTEXT` in the env
5
- // is outside the allowlist and is ignored — a foreign ciphertext must never
6
- // fail boot.
1
+ // Resolves a caller-named set of secret slots from a Key Manager ciphertext
2
+ // when no plaintext is set, so they need not sit in the pod env in the clear.
3
+ // A slot's plaintext always wins over its ciphertext sibling, with no request
4
+ // made at all. Any `*_CIPHERTEXT` in the env that names no requested slot is
5
+ // ignored — a foreign ciphertext must never fail boot.
7
6
 
8
7
  const SCALEWAY_KEY_MANAGER_API_VERSION = "v1alpha1";
9
8
  const DEFAULT_REGION = "fr-par";
10
9
  const DECRYPT_TIMEOUT_MS = 5_000;
11
10
  const RETRY_DELAYS_MS = [200, 800];
12
- const RESOLVABLE_SLOTS = [
13
- "PLATFORM_KEK",
14
- "PLATFORM_KEK_PREVIOUS",
15
- "KUMIKO_BLIND_INDEX_KEY",
16
- ] as const;
17
- type ResolvableSlot = (typeof RESOLVABLE_SLOTS)[number];
11
+ // Used when the caller names no slots. Apps that predate `kms` schema meta
12
+ // rely on it; drop once every consumer declares its slots in the env schema.
13
+ const LEGACY_SLOTS = ["PLATFORM_KEK", "PLATFORM_KEK_PREVIOUS", "KUMIKO_BLIND_INDEX_KEY"] as const;
18
14
 
19
15
  export type KekSourceEnv = {
20
16
  readonly PLATFORM_KEK?: string | undefined;
@@ -35,6 +31,9 @@ export type KekSourceOptions = {
35
31
  readonly logPrefix?: string;
36
32
  /** Where the boot line naming the KEK source goes. Defaults to `console.info`. */
37
33
  readonly log?: (message: string) => void;
34
+ /** Env names to resolve, e.g. `kmsSlotsOf(schema)`. Defaults to the three
35
+ * platform slots. */
36
+ readonly slots?: readonly string[];
38
37
  };
39
38
 
40
39
  function isRetryableStatus(status: number): boolean {
@@ -107,7 +106,7 @@ async function decryptCiphertext(
107
106
  }
108
107
 
109
108
  async function resolveSlot(
110
- name: ResolvableSlot,
109
+ name: string,
111
110
  env: KekSourceEnv,
112
111
  options: KekSourceOptions,
113
112
  fetchImpl: typeof globalThis.fetch,
@@ -132,7 +131,7 @@ async function resolveSlot(
132
131
  // A leftover plaintext beside a ciphertext boots green while nothing was
133
132
  // migrated, which is indistinguishable from a finished cutover unless the
134
133
  // boot says which source won. Never carries a key value, only its origin.
135
- function describeKekSource(name: ResolvableSlot, env: KekSourceEnv): string | undefined {
134
+ function describeKekSource(name: string, env: KekSourceEnv): string | undefined {
136
135
  const plaintext = env[name];
137
136
  const ciphertext = env[`${name}_CIPHERTEXT`];
138
137
  if (plaintext) {
@@ -154,26 +153,27 @@ export async function resolvePlatformKeks(
154
153
  ): Promise<KekSourceEnv> {
155
154
  const fetchImpl = options.fetch ?? globalThis.fetch;
156
155
 
157
- const resolved: Partial<Record<ResolvableSlot, string | undefined>> = {};
158
- for (const name of RESOLVABLE_SLOTS) {
156
+ const slots = options.slots ?? LEGACY_SLOTS;
157
+ const resolved: Record<string, string | undefined> = {};
158
+ for (const name of slots) {
159
159
  resolved[name] = await resolveSlot(name, env, options, fetchImpl);
160
160
  }
161
161
 
162
162
  const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
163
163
  // biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
164
164
  const log = options.log ?? console.info;
165
- for (const name of RESOLVABLE_SLOTS) {
165
+ for (const name of slots) {
166
166
  const line = describeKekSource(name, env);
167
167
  if (line) log(`${prefix}${line}`);
168
168
  }
169
169
 
170
- if (resolved.PLATFORM_KEK_PREVIOUS && !env.PLATFORM_KEK_PREVIOUS_VERSION) {
170
+ if (resolved["PLATFORM_KEK_PREVIOUS"] && !env.PLATFORM_KEK_PREVIOUS_VERSION) {
171
171
  throw new Error(
172
172
  `${prefix}PLATFORM_KEK_PREVIOUS_VERSION must be set when PLATFORM_KEK_PREVIOUS is set.`,
173
173
  );
174
174
  }
175
175
 
176
- const changed = RESOLVABLE_SLOTS.some((name) => resolved[name] !== env[name]);
176
+ const changed = slots.some((name) => resolved[name] !== env[name]);
177
177
  if (!changed) return env;
178
178
 
179
179
  return { ...env, ...resolved };
@@ -21,7 +21,6 @@ import { asRawClient, selectMany } from "../../db/query";
21
21
  import { createEntity, createTextField } from "../../engine/factories";
22
22
  import type { TenantId } from "../../engine/types";
23
23
  import type { StoredEvent } from "../../event-store";
24
- import { createEventsTable } from "../../event-store";
25
24
  import { ensureTemporalPolyfill } from "../../time/polyfill";
26
25
  import { applyEntityEvent } from "../apply-entity-event";
27
26
  import { buildEntityTable } from "../table-builder";
@@ -39,7 +38,6 @@ let testDb: BunTestDb;
39
38
  beforeAll(async () => {
40
39
  await ensureTemporalPolyfill();
41
40
  testDb = await createTestDb();
42
- await createEventsTable(testDb.db);
43
41
  await asRawClient(testDb.db).unsafe(`
44
42
  CREATE TABLE read_apply_tenant_check (
45
43
  id uuid PRIMARY KEY,
@@ -14,7 +14,7 @@ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db
14
14
  import { asRawClient, insertOne, selectMany } from "../../db/query";
15
15
  import { createBooleanField, createEntity, createTextField, defineFeature } from "../../engine";
16
16
  import { createRegistry } from "../../engine/registry";
17
- import { archiveStream, createArchivedStreamsTable, createEventsTable } from "../../event-store";
17
+ import { archiveStream, createArchivedStreamsTable } from "../../event-store";
18
18
  import { rebuildProjection } from "../../pipeline";
19
19
  import { createProjectionStateTable } from "../../pipeline/projection-state";
20
20
  import { TestUsers, unsafeCreateEntityTable } from "../../stack";
@@ -49,7 +49,6 @@ beforeAll(async () => {
49
49
  await ensureTemporalPolyfill();
50
50
  testDb = await createTestDb();
51
51
  await unsafeCreateEntityTable(testDb.db, userEntity, "user");
52
- await createEventsTable(testDb.db);
53
52
  await createArchivedStreamsTable(testDb.db);
54
53
  await createProjectionStateTable(testDb.db);
55
54
  tdb = createTenantDb(testDb.db, adminUser.tenantId);
@@ -20,7 +20,6 @@ import {
20
20
  import { defineFeature } from "../../engine/define-feature";
21
21
  import { createEntity, createTextField } from "../../engine/factories";
22
22
  import { createRegistry } from "../../engine/registry";
23
- import { createEventsTable } from "../../event-store";
24
23
  import { rebuildProjection } from "../../pipeline";
25
24
  import { createProjectionStateTable } from "../../pipeline/projection-state";
26
25
  import { createTestDb, type TestDb, TestUsers, unsafeCreateEntityTable } from "../../stack";
@@ -65,7 +64,6 @@ const adminUser = TestUsers.admin;
65
64
  beforeAll(async () => {
66
65
  testDb = await createTestDb();
67
66
  await unsafeCreateEntityTable(testDb.db, personEntity, "person");
68
- await createEventsTable(testDb.db);
69
67
  await createProjectionStateTable(testDb.db);
70
68
  tdb = createTenantDb(testDb.db, adminUser.tenantId);
71
69
  });
@@ -5,6 +5,7 @@ import {
5
5
  collectEncryptedFieldNames,
6
6
  decryptEntityFieldValues,
7
7
  encryptEntityFieldValues,
8
+ validateEntityFieldEncryptionAvailable,
8
9
  } from "../entity-field-encryption";
9
10
 
10
11
  const TEST_KEY = Buffer.from("a]bJm#kP9xQ2@wN!vL$hR5yT8eU0iO3f").toString("base64");
@@ -43,3 +44,31 @@ describe("entity-field-encryption", () => {
43
44
  expect(stored["secretNote"]).not.toBe("note");
44
45
  });
45
46
  });
47
+
48
+ describe("validateEntityFieldEncryptionAvailable", () => {
49
+ test("accepts a keyring made only of the env it is handed", () => {
50
+ expect(() =>
51
+ validateEntityFieldEncryptionAvailable({ KUMIKO_SECRETS_MASTER_KEY_V1: TEST_KEY }),
52
+ ).not.toThrow();
53
+ });
54
+
55
+ test("rejects a handed env without any master key, whatever process.env holds", () => {
56
+ const previous = process.env["KUMIKO_SECRETS_MASTER_KEY_V1"];
57
+ process.env["KUMIKO_SECRETS_MASTER_KEY_V1"] = TEST_KEY;
58
+ try {
59
+ expect(() => validateEntityFieldEncryptionAvailable({})).toThrow(/no usable master key/);
60
+ } finally {
61
+ if (previous === undefined) delete process.env["KUMIKO_SECRETS_MASTER_KEY_V1"];
62
+ else process.env["KUMIKO_SECRETS_MASTER_KEY_V1"] = previous;
63
+ }
64
+ });
65
+
66
+ test("a pinned CURRENT_VERSION without that version's key still fails", () => {
67
+ expect(() =>
68
+ validateEntityFieldEncryptionAvailable({
69
+ KUMIKO_SECRETS_MASTER_KEY_V1: TEST_KEY,
70
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
71
+ }),
72
+ ).toThrow(/no usable master key/);
73
+ });
74
+ });
@@ -15,7 +15,6 @@ import {
15
15
  SYSTEM_TENANT_ID,
16
16
  } from "../../engine";
17
17
  import { UnprocessableError } from "../../errors";
18
- import { createEventsTable } from "../../event-store";
19
18
  import { TestUsers, unsafeCreateEntityTable } from "../../stack";
20
19
  import { ensureTemporalPolyfill } from "../../time/polyfill";
21
20
  import { encodeCursor } from "../cursor";
@@ -46,7 +45,6 @@ beforeAll(async () => {
46
45
  await ensureTemporalPolyfill();
47
46
  testDb = await createTestDb();
48
47
  await unsafeCreateEntityTable(testDb.db, entity, "pagerItem");
49
- await createEventsTable(testDb.db);
50
48
  tdb = createTenantDb(testDb.db, admin.tenantId);
51
49
  });
52
50
 
@@ -9,7 +9,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
9
9
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
10
10
  import { asRawClient } from "../../db/query";
11
11
  import { createEntity, createMoneyField, createTextField, from } from "../../engine";
12
- import { createEventsTable } from "../../event-store";
13
12
  import { TestUsers, unsafeCreateEntityTable } from "../../stack";
14
13
  import { createTestEnvelopeCipher } from "../../testing";
15
14
  import { ensureTemporalPolyfill } from "../../time/polyfill";
@@ -59,7 +58,6 @@ beforeAll(async () => {
59
58
  await ensureTemporalPolyfill();
60
59
  testDb = await createTestDb();
61
60
  await unsafeCreateEntityTable(testDb.db, entity, "moneyOrder");
62
- await createEventsTable(testDb.db);
63
61
  tdb = createTenantDb(testDb.db, admin.tenantId);
64
62
  configureEntityFieldEncryption(cipher);
65
63
  });
@@ -11,7 +11,6 @@ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db
11
11
  import { asRawClient } from "../../db/query";
12
12
  import { createEntity, createTextField, from } from "../../engine";
13
13
  import type { EntityDefinition } from "../../engine/types";
14
- import { createEventsTable } from "../../event-store";
15
14
  import { createInMemorySearchAdapter } from "../../search";
16
15
  import { TestUsers, unsafeCreateEntityTable } from "../../stack";
17
16
  import { seedRows } from "../../testing";
@@ -156,7 +155,6 @@ beforeAll(async () => {
156
155
  await unsafeCreateEntityTable(testDb.db, piiCustomerEntity, "refSearchPiiCustomer");
157
156
  await unsafeCreateEntityTable(testDb.db, piiOrderEntity, "refSearchPiiOrder");
158
157
  await unsafeCreateEntityTable(testDb.db, tenantMetaEntity, "tenant");
159
- await createEventsTable(testDb.db);
160
158
  tdbA = createTenantDb(testDb.db, admin.tenantId);
161
159
  tdbB = createTenantDb(testDb.db, otherTenantAdmin.tenantId);
162
160
  });
@@ -9,7 +9,6 @@ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db
9
9
  import { asRawClient } from "../../db/query";
10
10
  import { createEntity, createTextField } from "../../engine";
11
11
  import type { EntityDefinition } from "../../engine/types";
12
- import { createEventsTable } from "../../event-store";
13
12
  import { TestUsers, unsafeCreateEntityTable } from "../../stack";
14
13
  import { seedRows } from "../../testing";
15
14
  import { ensureTemporalPolyfill } from "../../time/polyfill";
@@ -107,7 +106,6 @@ beforeAll(async () => {
107
106
  await unsafeCreateEntityTable(testDb.db, customerEntity, "refSortCustomer");
108
107
  await unsafeCreateEntityTable(testDb.db, orderEntity, "refSortOrder");
109
108
  await unsafeCreateEntityTable(testDb.db, restrictedOrderEntity, "refSortRestrictedOrder");
110
- await createEventsTable(testDb.db);
111
109
  tdbA = createTenantDb(testDb.db, admin.tenantId);
112
110
  });
113
111
 
@@ -9,7 +9,6 @@ import { asRawClient, transaction } from "../../db/query";
9
9
  import { createEntity, createTextField } from "../../engine";
10
10
  import { from } from "../../engine/ownership";
11
11
  import { createSystemUser } from "../../engine/system-user";
12
- import { createEventsTable } from "../../event-store";
13
12
  import type { EntityCache } from "../../pipeline/entity-cache";
14
13
  import {
15
14
  createTestDb,
@@ -29,7 +28,6 @@ const nonAdmin = TestUsers.user;
29
28
 
30
29
  beforeAll(async () => {
31
30
  testDb = await createTestDb();
32
- await createEventsTable(testDb.db);
33
31
  tdb = createTenantDb(testDb.db, admin.tenantId);
34
32
  });
35
33
 
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
2
2
  import { InMemoryKmsAdapter, PII_CIPHERTEXT_PREFIX, PII_ERASED_SENTINEL } from "../../crypto";
3
3
  import { asRawClient } from "../../db/query";
4
4
  import { createBooleanField, createEntity, createTextField } from "../../engine";
5
- import { append, createEventsTable, loadEventsAfterVersion } from "../../event-store";
5
+ import { append, loadEventsAfterVersion } from "../../event-store";
6
6
  import type { EntityCache } from "../../pipeline/entity-cache";
7
7
  import {
8
8
  createTestDb,
@@ -41,7 +41,6 @@ const adminUser = TestUsers.admin;
41
41
  beforeAll(async () => {
42
42
  testDb = await createTestDb();
43
43
  await unsafeCreateEntityTable(testDb.db, entity, "esExecUser");
44
- await createEventsTable(testDb.db);
45
44
  tdb = createTenantDb(testDb.db, adminUser.tenantId);
46
45
  });
47
46
 
@@ -19,7 +19,6 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } fr
19
19
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
20
20
  import { createBooleanField, createEntity, createTextField, defineFeature } from "../../engine";
21
21
  import { createRegistry } from "../../engine/registry";
22
- import { createEventsTable } from "../../event-store";
23
22
  import { rebuildProjection } from "../../pipeline";
24
23
  import { createProjectionStateTable } from "../../pipeline/projection-state";
25
24
  import { TestUsers, unsafeCreateEntityTable } from "../../stack";
@@ -52,7 +51,6 @@ beforeAll(async () => {
52
51
  await ensureTemporalPolyfill();
53
52
  testDb = await createTestDb();
54
53
  await unsafeCreateEntityTable(testDb.db, userEntity, "user");
55
- await createEventsTable(testDb.db);
56
54
  await createProjectionStateTable(testDb.db);
57
55
  tdb = createTenantDb(testDb.db, adminUser.tenantId);
58
56
  });
@@ -17,7 +17,6 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
17
17
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
18
18
  import { asRawClient, selectMany, transaction } from "../../db/query";
19
19
  import { createEntity, createTextField } from "../../engine";
20
- import { createEventsTable } from "../../event-store";
21
20
  import { TestUsers, unsafeCreateEntityTable } from "../../stack";
22
21
  import { ensureTemporalPolyfill } from "../../time/polyfill";
23
22
  import { createEventStoreExecutor } from "../event-store-executor";
@@ -52,7 +51,6 @@ beforeAll(async () => {
52
51
  await ensureTemporalPolyfill();
53
52
  testDb = await createTestDb();
54
53
  await unsafeCreateEntityTable(testDb.db, userEntity, "unique-user");
55
- await createEventsTable(testDb.db);
56
54
  tdb = createTenantDb(testDb.db, admin.tenantId);
57
55
  });
58
56
 
@@ -134,15 +134,17 @@ export function resetEntityFieldEncryptionCacheForTests(): void {
134
134
  // surfaces missing AND malformed keys (wrong length, bad base64) at boot
135
135
  // instead of on the first encrypted read in prod. validateBoot runs before
136
136
  // configureEntityFieldEncryption, so the env probe is the common path.
137
- export function validateEntityFieldEncryptionAvailable(): void {
137
+ export function validateEntityFieldEncryptionAvailable(
138
+ env: Readonly<Record<string, string | undefined>> = process.env,
139
+ ): void {
138
140
  // skip: an injected cipher (test seam / custom KMS) satisfies availability
139
141
  if (injectedCipher) return;
140
142
  try {
141
143
  createEnvMasterKeyProvider({
142
144
  env: {
143
- ...process.env,
145
+ ...env,
144
146
  KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION:
145
- process.env["KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION"] ?? "1",
147
+ env["KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION"] ?? "1",
146
148
  },
147
149
  });
148
150
  } catch (err) {
@@ -1,6 +1,5 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { type BunTestDb, createTestDb } from "../../../bun-db/__tests__/bun-test-db";
3
- import { createEventsTable } from "../../../event-store/events-schema";
4
3
  import { asRawClient } from "../../query";
5
4
  import { ensureIdempotencyKeyIndex } from "../event-store";
6
5
 
@@ -8,7 +7,6 @@ let testDb: BunTestDb;
8
7
 
9
8
  beforeAll(async () => {
10
9
  testDb = await createTestDb();
11
- await createEventsTable(testDb.db);
12
10
  });
13
11
 
14
12
  afterAll(async () => {
@@ -100,6 +100,10 @@ export type ValidateBootOptions = {
100
100
  * screen is reachable another way (e.g. as a sub-page of a generated
101
101
  * settings hub). Suppresses the navAllowlist warning for these QNs. */
102
102
  readonly navAllowlistExempt?: ReadonlySet<string>;
103
+ /** Env the encrypted-field master-key probe reads. Defaults to
104
+ * `process.env`; runners pass their resolved env so a ciphertext-only
105
+ * key (already decrypted at boot) is visible to the probe. */
106
+ readonly env?: Readonly<Record<string, string | undefined>>;
103
107
  };
104
108
 
105
109
  /**
@@ -276,7 +280,7 @@ export function validateBoot(
276
280
  // catches malformed keys (wrong length, bad base64) at boot instead of
277
281
  // on the first encrypted read in prod. An injected cipher (test seam,
278
282
  // custom KMS provider) satisfies the requirement without env keys.
279
- validateEntityFieldEncryptionAvailable();
283
+ validateEntityFieldEncryptionAvailable(options?.env);
280
284
  }
281
285
 
282
286
  if (hasFileFields && !process.env[FILE_STORAGE_PROVIDER_ENV]) {
@@ -14,7 +14,7 @@ import { z } from "zod";
14
14
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
15
15
  import { asRawClient } from "../../db/query";
16
16
  import { createRegistry, defineFeature } from "../../engine";
17
- import { createArchivedStreamsTable, createEventsTable } from "../../event-store";
17
+ import { createArchivedStreamsTable } from "../../event-store";
18
18
  import { createJobRunner } from "../../jobs/job-runner";
19
19
  import { createEventConsumerStateTable } from "../../pipeline";
20
20
  import { createTestRedis, type TestRedis } from "../../stack";
@@ -63,7 +63,6 @@ let testRedis: TestRedis;
63
63
 
64
64
  beforeAll(async () => {
65
65
  [testDb, testRedis] = await Promise.all([createTestDb(), createTestRedis()]);
66
- await createEventsTable(testDb.db);
67
66
  await createArchivedStreamsTable(testDb.db);
68
67
  await createEventConsumerStateTable(testDb.db);
69
68
  });
@@ -16,7 +16,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
16
16
  import { z } from "zod";
17
17
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
18
18
  import { createRegistry, defineFeature } from "../../engine";
19
- import { createArchivedStreamsTable, createEventsTable } from "../../event-store";
19
+ import { createArchivedStreamsTable } from "../../event-store";
20
20
  import { createNoopProvider, createPrometheusMeter } from "../../observability";
21
21
  import { createEventConsumerStateTable } from "../../pipeline";
22
22
  import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
@@ -79,7 +79,6 @@ let testRedis: TestRedis;
79
79
 
80
80
  beforeAll(async () => {
81
81
  [testDb, testRedis] = await Promise.all([createTestDb(), createTestRedis()]);
82
- await createEventsTable(testDb.db);
83
82
  await createArchivedStreamsTable(testDb.db);
84
83
  await createEventConsumerStateTable(testDb.db);
85
84
  });
@@ -17,7 +17,7 @@ import { z } from "zod";
17
17
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
18
18
  import { asRawClient } from "../../db/query";
19
19
  import { createRegistry, defineFeature } from "../../engine";
20
- import { createArchivedStreamsTable, createEventsTable } from "../../event-store";
20
+ import { createArchivedStreamsTable } from "../../event-store";
21
21
  import { createEventConsumerStateTable } from "../../pipeline";
22
22
  import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
23
23
  import { createAllInOneEntrypoint, createApiEntrypoint, createWorkerEntrypoint } from "../index";
@@ -125,7 +125,6 @@ let testRedis: TestRedis;
125
125
 
126
126
  beforeAll(async () => {
127
127
  [testDb, testRedis] = await Promise.all([createTestDb(), createTestRedis()]);
128
- await createEventsTable(testDb.db);
129
128
  await createArchivedStreamsTable(testDb.db);
130
129
  await createEventConsumerStateTable(testDb.db);
131
130
  });
@@ -1,7 +1,15 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { z } from "zod";
3
3
  import { defineFeature } from "../../engine/define-feature";
4
- import { camelCase, composeEnvSchema, KumikoBootError, parseEnv, pulumiConfigKey } from "../index";
4
+ import {
5
+ camelCase,
6
+ composeEnvSchema,
7
+ KumikoBootError,
8
+ kmsSlotsOf,
9
+ parseEnv,
10
+ pulumiConfigKey,
11
+ readKumikoMeta,
12
+ } from "../index";
5
13
 
6
14
  describe("composeEnvSchema", () => {
7
15
  it("merges per-feature schemas and tags sources", () => {
@@ -295,3 +303,91 @@ describe("pulumiConfigKey + camelCase", () => {
295
303
  expect(pulumiConfigKey("JWT_SECRET", undefined, undefined)).toBe("jwtSecret");
296
304
  });
297
305
  });
306
+
307
+ describe("kms env slots", () => {
308
+ const schema = z.object({
309
+ MASTER_KEY: z
310
+ .string()
311
+ .min(1)
312
+ .meta({ kumiko: { kms: true } }),
313
+ MASTER_KEY_CIPHERTEXT: z.string().min(1).optional(),
314
+ PLAIN: z.string().min(1).optional(),
315
+ });
316
+
317
+ it("kmsSlotsOf lists exactly the fields declaring kms", () => {
318
+ expect(kmsSlotsOf(schema)).toEqual(["MASTER_KEY"]);
319
+ });
320
+
321
+ it("rejects a non-true kms value in the meta", () => {
322
+ const bad = z.string().meta({ kumiko: { kms: "yes" } });
323
+ expect(readKumikoMeta(bad)).toEqual({});
324
+ });
325
+
326
+ it("parseEnv accepts a required kms slot that is present only as ciphertext", () => {
327
+ expect(() => parseEnv(schema, { MASTER_KEY_CIPHERTEXT: "abc" })).not.toThrow();
328
+ });
329
+
330
+ it("parseEnv still requires a kms slot when no ciphertext twin is set", () => {
331
+ expect(() => parseEnv(schema, {})).toThrow(KumikoBootError);
332
+ });
333
+
334
+ it("parseEnv still validates a kms slot that is set", () => {
335
+ expect(() => parseEnv(schema, { MASTER_KEY: "" })).toThrow(KumikoBootError);
336
+ });
337
+ });
338
+
339
+ describe("composeEnvSchema kms twins", () => {
340
+ const kmsField = z
341
+ .string()
342
+ .min(1)
343
+ .meta({ kumiko: { kms: true } });
344
+
345
+ it("declares the ciphertext twin of every kms field, owned by the same source", () => {
346
+ const secrets = defineFeature("secrets", (r) => {
347
+ r.envSchema(z.object({ KEY_V1: kmsField }));
348
+ });
349
+ const { schema, sources } = composeEnvSchema({
350
+ features: [secrets],
351
+ extend: z.object({ KEY_V2: kmsField }),
352
+ });
353
+
354
+ expect(Object.keys(schema.shape).sort()).toEqual([
355
+ "KEY_V1",
356
+ "KEY_V1_CIPHERTEXT",
357
+ "KEY_V2",
358
+ "KEY_V2_CIPHERTEXT",
359
+ ]);
360
+ expect(sources["KEY_V1_CIPHERTEXT"]).toBe("secrets");
361
+ expect(sources["KEY_V2_CIPHERTEXT"]).toBe("app");
362
+ expect(() => parseEnv(schema, {})).toThrow(KumikoBootError);
363
+ expect(() =>
364
+ parseEnv(schema, { KEY_V1_CIPHERTEXT: "a", KEY_V2_CIPHERTEXT: "b" }),
365
+ ).not.toThrow();
366
+ });
367
+
368
+ it("keeps a twin the app already declared instead of conflicting", () => {
369
+ const declared = z.string().min(1).optional().describe("hand-written twin");
370
+ const { schema } = composeEnvSchema({
371
+ features: [],
372
+ extend: z.object({ KEY: kmsField, KEY_CIPHERTEXT: declared }),
373
+ });
374
+
375
+ expect(schema.shape["KEY_CIPHERTEXT"]).toBe(declared);
376
+ });
377
+
378
+ it("adds the twin for a kms field of an optional feature too", () => {
379
+ const secrets = defineFeature("secrets", (r) => {
380
+ r.envSchema(z.object({ KEY: kmsField }));
381
+ });
382
+ const { schema } = composeEnvSchema({ features: [secrets], optionalFeatures: ["secrets"] });
383
+
384
+ expect(Object.keys(schema.shape).sort()).toEqual(["KEY", "KEY_CIPHERTEXT"]);
385
+ expect(kmsSlotsOf(schema)).toEqual(["KEY"]);
386
+ });
387
+
388
+ it("adds nothing for a schema without kms fields", () => {
389
+ const { schema } = composeEnvSchema({ features: [], extend: z.object({ PLAIN: z.string() }) });
390
+
391
+ expect(Object.keys(schema.shape)).toEqual(["PLAIN"]);
392
+ });
393
+ });
package/src/env/index.ts CHANGED
@@ -34,12 +34,18 @@ export type KumikoEnvMeta = {
34
34
  /** Force the `--secret` flag in `pulumi config set`. Default false. */
35
35
  readonly secret?: boolean;
36
36
  };
37
+ /** The value may be delivered as a Scaleway Key Manager ciphertext in
38
+ * `<NAME>_CIPHERTEXT`. `runProdApp` decrypts it at boot when `<NAME>` itself
39
+ * is unset; a set plaintext always wins. A flag, not an object: every slot
40
+ * uses the same `_CIPHERTEXT` twin and one shared key (`PLATFORM_KEK_KMS_*`). */
41
+ readonly kms?: true;
37
42
  };
38
43
 
39
44
  function isKumikoMeta(value: unknown): value is KumikoEnvMeta {
40
45
  if (value === null || typeof value !== "object") return false;
41
46
  // @cast-boundary schema-walk — runtime-shape narrowing of zod-meta payload
42
- const v = value as { pulumi?: unknown };
47
+ const v = value as { pulumi?: unknown; kms?: unknown };
48
+ if (v.kms !== undefined && v.kms !== true) return false;
43
49
  if (v.pulumi === undefined) return true;
44
50
  if (v.pulumi === null || typeof v.pulumi !== "object") return false;
45
51
  // @cast-boundary schema-walk
@@ -60,6 +66,31 @@ export function readKumikoMeta(field: z.ZodType): KumikoEnvMeta {
60
66
  return {};
61
67
  }
62
68
 
69
+ /** Env names whose schema field declares `kms: true` — the slots
70
+ * `resolvePlatformKeks` resolves from a ciphertext. */
71
+ export function kmsSlotsOf(schema: z.ZodObject<z.ZodRawShape>): readonly string[] {
72
+ return Object.entries(zodShape(schema))
73
+ .filter(([, field]) => readKumikoMeta(field).kms === true)
74
+ .map(([name]) => name);
75
+ }
76
+
77
+ // A ciphertext-only slot is satisfied by its `_CIPHERTEXT` twin: the plaintext
78
+ // only exists after the boot-time decrypt, so requiring it here would reject
79
+ // exactly the deployment this meta enables.
80
+ function relaxCiphertextOnlySlots<S extends z.ZodObject<z.ZodRawShape>>(
81
+ schema: S,
82
+ env: Readonly<Record<string, string>>,
83
+ ): z.ZodObject<z.ZodRawShape> {
84
+ const shape = zodShape(schema);
85
+ const relaxed: Record<string, z.ZodType> = {};
86
+ for (const name of kmsSlotsOf(schema)) {
87
+ const field = shape[name];
88
+ if (field && env[name] === undefined && env[`${name}_CIPHERTEXT`])
89
+ relaxed[name] = field.optional();
90
+ }
91
+ return Object.keys(relaxed).length === 0 ? schema : schema.extend(relaxed);
92
+ }
93
+
63
94
  // --- Field-classification helpers (Zod v4 introspection) ---
64
95
 
65
96
  export type EnvFieldClass = "required" | "optional" | "withDefault";
@@ -144,10 +175,23 @@ export type ComposedEnvSchema = {
144
175
  readonly sources: Readonly<Record<string, string>>;
145
176
  };
146
177
 
178
+ // `.optional()` returns a new Zod instance that drops `.meta()`, which would
179
+ // hide the slot from `kmsSlotsOf` on the composed schema.
180
+ function optionalKeepingKmsMeta(field: z.ZodType): z.ZodType {
181
+ const optional = field.optional();
182
+ return readKumikoMeta(field).kms === true
183
+ ? optional.meta({ kumiko: readKumikoMeta(field) })
184
+ : optional;
185
+ }
186
+
147
187
  export function composeEnvSchema(options: ComposeEnvSchemaOptions): ComposedEnvSchema {
148
188
  const optionalSet = new Set(options.optionalFeatures ?? []);
149
189
  const merged: Record<string, z.ZodType> = {};
150
190
  const sources: Record<string, string> = {};
191
+ const kmsFields: { readonly name: string; readonly source: string }[] = [];
192
+ const noteKms = (name: string, field: z.ZodType, source: string): void => {
193
+ if (readKumikoMeta(field).kms === true) kmsFields.push({ name, source });
194
+ };
151
195
 
152
196
  // Framework-core first so a feature that accidentally declares the same
153
197
  // var (e.g. PORT) gets a clear conflict error citing "framework-core"
@@ -159,6 +203,7 @@ export function composeEnvSchema(options: ComposeEnvSchemaOptions): ComposedEnvS
159
203
  for (const [key, field] of Object.entries(zodShape(options.core))) {
160
204
  merged[key] = field;
161
205
  sources[key] = "framework-core";
206
+ noteKms(key, field, "framework-core");
162
207
  }
163
208
  }
164
209
 
@@ -178,8 +223,9 @@ export function composeEnvSchema(options: ComposeEnvSchemaOptions): ComposedEnvS
178
223
  },
179
224
  ]);
180
225
  }
181
- merged[key] = wrap ? field.optional() : field;
226
+ merged[key] = wrap ? optionalKeepingKmsMeta(field) : field;
182
227
  sources[key] = feature.name;
228
+ noteKms(key, field, feature.name);
183
229
  }
184
230
  }
185
231
 
@@ -198,9 +244,23 @@ export function composeEnvSchema(options: ComposeEnvSchemaOptions): ComposedEnvS
198
244
  }
199
245
  merged[key] = field;
200
246
  sources[key] = "app";
247
+ noteKms(key, field, "app");
201
248
  }
202
249
  }
203
250
 
251
+ // The twin follows from the slot, so a versioned family (`…_V2`) needs no
252
+ // second declaration. An already declared twin (apps that predate this) stays.
253
+ for (const { name, source } of kmsFields) {
254
+ const twin = `${name}_CIPHERTEXT`;
255
+ if (merged[twin] !== undefined) continue;
256
+ merged[twin] = z
257
+ .string()
258
+ .min(1)
259
+ .optional()
260
+ .describe(`Key-Manager ciphertext of ${name}; used when ${name} is unset.`);
261
+ sources[twin] = source;
262
+ }
263
+
204
264
  return {
205
265
  schema: z.object(merged),
206
266
  sources,
@@ -282,7 +342,7 @@ export function parseEnv<S extends z.ZodObject<z.ZodRawShape>>(
282
342
  if (v !== undefined) cleaned[k] = v;
283
343
  }
284
344
 
285
- const result = schema.safeParse(cleaned);
345
+ const result = relaxCiphertextOnlySlots(schema, cleaned).safeParse(cleaned);
286
346
  if (result.success) {
287
347
  // @cast-boundary schema-walk — z.infer<S> erasure across safeParse result
288
348
  return result.data as z.infer<S>;
@@ -21,7 +21,6 @@ import { join } from "node:path";
21
21
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
22
22
  import { asRawClient, selectMany } from "../../db/query";
23
23
  import { createRegistry } from "../../engine";
24
- import { createEventsTable } from "../../event-store";
25
24
  import { createDispatcher, type Dispatcher } from "../../pipeline";
26
25
  import { ensureTemporalPolyfill } from "../../time/polyfill";
27
26
  import { createSeedMigrationContext } from "../context";
@@ -38,7 +37,6 @@ let dispatcher: Dispatcher;
38
37
  beforeAll(async () => {
39
38
  await ensureTemporalPolyfill();
40
39
  testDb = await createTestDb();
41
- await createEventsTable(testDb.db);
42
40
  await createEsOperationsTable(testDb.db);
43
41
 
44
42
  const registry = createRegistry([]);
@@ -29,7 +29,6 @@ import {
29
29
  SYSTEM_TENANT_ID,
30
30
  } from "../../engine";
31
31
  import { VersionConflictError } from "../../errors";
32
- import { createEventsTable } from "../../event-store";
33
32
  import { createDispatcher, type Dispatcher } from "../../pipeline";
34
33
  import { createTestDb, type TestDb, unsafeCreateEntityTable } from "../../stack";
35
34
  import { ensureTemporalPolyfill } from "../../time/polyfill";
@@ -89,7 +88,6 @@ let registry: ReturnType<typeof createRegistry>;
89
88
  beforeAll(async () => {
90
89
  await ensureTemporalPolyfill();
91
90
  testDb = await createTestDb();
92
- await createEventsTable(testDb.db);
93
91
  await createEsOperationsTable(testDb.db);
94
92
  await unsafeCreateEntityTable(testDb.db, probeEntity, "esops-probe");
95
93
  registry = createRegistry([seedTestFeature]);
@@ -17,7 +17,7 @@ import { generateId as uuid } from "../../utils";
17
17
  import { appendRaw, appendRawBatch, type RawEventToAppend } from "../admin-api";
18
18
  import { IdempotentAppendConflictError, VersionConflictError } from "../errors";
19
19
  import { append, loadAggregate } from "../event-store";
20
- import { createEventsTable, eventsTable } from "../events-schema";
20
+ import { eventsTable } from "../events-schema";
21
21
 
22
22
  // Test-only spy: wrap a DbConnection's `.unsafe()` to capture the SQL
23
23
  // string of every query the framework runs. Used to assert batching
@@ -47,7 +47,6 @@ const legacyUser = "legacy-user-42";
47
47
 
48
48
  beforeAll(async () => {
49
49
  testDb = await createTestDb();
50
- await createEventsTable(testDb.db);
51
50
  });
52
51
 
53
52
  afterAll(async () => {
@@ -34,7 +34,6 @@ import type { Registry, TenantId } from "../../engine/types";
34
34
  import { createTestDb, type TestDb, unsafeCreateEntityTable } from "../../stack";
35
35
  import { generateId } from "../../utils";
36
36
  import { append, loadAggregate } from "../event-store";
37
- import { createEventsTable } from "../events-schema";
38
37
 
39
38
  const TENANT = "00000000-0000-4000-8000-000000000001" as TenantId;
40
39
  const BIDX_KEY = Buffer.alloc(32, 5).toString("base64");
@@ -145,7 +144,6 @@ beforeAll(async () => {
145
144
  surveyFeature,
146
145
  signalsFeature,
147
146
  ]);
148
- await createEventsTable(testDb.db);
149
147
  await unsafeCreateEntityTable(testDb.db, contactEntity, "contact");
150
148
  await unsafeCreateEntityTable(testDb.db, noteEntity, "note");
151
149
  await unsafeCreateEntityTable(testDb.db, surveyEntity, "survey");
@@ -5,7 +5,6 @@ import { ensureTemporalPolyfill } from "../../time/polyfill";
5
5
  import { generateId as uuid } from "../../utils";
6
6
  import {
7
7
  append,
8
- createEventsTable,
9
8
  IdempotentAppendConflictError,
10
9
  loadAggregate,
11
10
  loadAggregateAsOf,
@@ -25,7 +24,6 @@ const userA = uuid();
25
24
  beforeAll(async () => {
26
25
  await ensureTemporalPolyfill();
27
26
  testDb = await createTestDb();
28
- await createEventsTable(testDb.db);
29
27
  });
30
28
 
31
29
  afterAll(async () => {
@@ -15,7 +15,7 @@ import { insertMany } from "../../bun-db/query";
15
15
  import type { TenantId } from "../../engine/types";
16
16
  import { ensureTemporalPolyfill } from "../../time/polyfill";
17
17
  import { generateId as uuid } from "../../utils";
18
- import { createEventsTable, eventsTable, getStreamVersion } from "../index";
18
+ import { eventsTable, getStreamVersion } from "../index";
19
19
 
20
20
  let testDb: BunTestDb;
21
21
  const tenantId: TenantId = uuid();
@@ -24,7 +24,6 @@ const userId = uuid();
24
24
  beforeAll(async () => {
25
25
  await ensureTemporalPolyfill();
26
26
  testDb = await createTestDb();
27
- await createEventsTable(testDb.db);
28
27
  });
29
28
 
30
29
  afterAll(async () => {
@@ -24,13 +24,7 @@ import { asRawClient } from "../../db/query";
24
24
  import type { TenantId } from "../../engine/types";
25
25
  import { ensureTemporalPolyfill } from "../../time/polyfill";
26
26
  import { generateId as uuid } from "../../utils";
27
- import {
28
- append,
29
- createEventsTable,
30
- loadAggregate,
31
- loadAggregateWithSnapshot,
32
- saveSnapshot,
33
- } from "../index";
27
+ import { append, loadAggregate, loadAggregateWithSnapshot, saveSnapshot } from "../index";
34
28
 
35
29
  let testDb: BunTestDb;
36
30
  const tenantId = uuid() as TenantId;
@@ -39,7 +33,6 @@ const userId = uuid();
39
33
  beforeAll(async () => {
40
34
  await ensureTemporalPolyfill();
41
35
  testDb = await createTestDb();
42
- await createEventsTable(testDb.db);
43
36
  });
44
37
 
45
38
  afterAll(async () => {
@@ -5,7 +5,7 @@ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
5
5
  import { AccessDeniedError, InternalError } from "../../errors";
6
6
  import { ensureTemporalPolyfill } from "../../time/polyfill";
7
7
  import { generateId as uuid } from "../../utils";
8
- import { createEventsTable, loadAggregate, VersionConflictError } from "../index";
8
+ import { loadAggregate, VersionConflictError } from "../index";
9
9
  import { appendProvenanceEvent, type ProvenanceEventInput } from "../provenance-append";
10
10
 
11
11
  let testDb: BunTestDb;
@@ -37,7 +37,6 @@ function provenanceEvent(overrides: Partial<ProvenanceEventInput>): ProvenanceEv
37
37
  beforeAll(async () => {
38
38
  await ensureTemporalPolyfill();
39
39
  testDb = await createTestDb();
40
- await createEventsTable(testDb.db);
41
40
  tdb = createTenantDb(testDb.db, tenantA);
42
41
  });
43
42
 
@@ -22,7 +22,6 @@ import { generateId as uuid } from "../../utils";
22
22
  import {
23
23
  append,
24
24
  archiveStream,
25
- createEventsTable,
26
25
  loadAggregate,
27
26
  loadAggregateWithSnapshot,
28
27
  loadLatestSnapshot,
@@ -56,7 +55,6 @@ const reducer: SnapshotReducer<CounterState> = (state, event) => {
56
55
  beforeAll(async () => {
57
56
  await ensureTemporalPolyfill();
58
57
  bun = await createTestDb();
59
- await createEventsTable(bun.db);
60
58
  });
61
59
 
62
60
  afterAll(async () => {
@@ -11,7 +11,7 @@ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db
11
11
  import { asRawClient, insertOne, selectMany } from "../../db/query";
12
12
  import { ensureTemporalPolyfill } from "../../time/polyfill";
13
13
  import type { StoredEvent } from "../event-store";
14
- import { createEventsTable, eventsTable } from "../events-schema";
14
+ import { eventsTable } from "../events-schema";
15
15
  import { type EventUpcasters, makeUpcastCtx, upcastStoredEvents } from "../upcaster";
16
16
  import {
17
17
  createUpcasterDeadLetterTable,
@@ -81,7 +81,6 @@ const passthroughUpcasters: EventUpcasters = new Map([
81
81
  beforeAll(async () => {
82
82
  await ensureTemporalPolyfill();
83
83
  testDb = await createTestDb();
84
- await createEventsTable(testDb.db);
85
84
  await createUpcasterDeadLetterTable(testDb.db);
86
85
  });
87
86
 
@@ -25,7 +25,7 @@ import {
25
25
  unsafeCreateEntityTable,
26
26
  unsafePushTables,
27
27
  } from "../../stack";
28
- import { append, createEventsTable } from "../index";
28
+ import { append } from "../index";
29
29
  import { upcastStoredEvent } from "../upcaster";
30
30
 
31
31
  // --- Fixture entity + projection table ---
@@ -113,7 +113,6 @@ const orderExecutor = createEventStoreExecutor(orderTable, orderEntity, {
113
113
  beforeAll(async () => {
114
114
  testDb = await createTestDb();
115
115
  await unsafeCreateEntityTable(testDb.db, orderEntity, "upcast-order");
116
- await createEventsTable(testDb.db);
117
116
  const { createProjectionStateTable } = await import("../../pipeline");
118
117
  await createProjectionStateTable(testDb.db);
119
118
  await unsafePushTables(testDb.db, { upcastOrderSummary: orderSummaryTable });
@@ -14,7 +14,7 @@ import {
14
14
  defineFeature,
15
15
  type SessionUser,
16
16
  } from "../../engine";
17
- import { createEventsTable, loadAggregate } from "../../event-store";
17
+ import { loadAggregate } from "../../event-store";
18
18
  import {
19
19
  createTestDb,
20
20
  createTestUser,
@@ -79,7 +79,6 @@ beforeAll(async () => {
79
79
  await unsafeCreateEntityTable(testDb.db, testTenantEntity);
80
80
  // Event-store table: the upload route appends files:event:uploaded in the
81
81
  // same tx as the FileRef insert. Without events, upload would 500.
82
- await createEventsTable(testDb.db);
83
82
 
84
83
  const registry = createRegistry([tenantFeature]);
85
84
  const storageProvider = createLocalProvider(storagePath);
@@ -21,7 +21,7 @@ import {
21
21
  type WriteResult,
22
22
  } from "../../engine";
23
23
  import { createWorkerEntrypoint } from "../../entrypoint";
24
- import { createArchivedStreamsTable, createEventsTable } from "../../event-store";
24
+ import { createArchivedStreamsTable } from "../../event-store";
25
25
  import { createEventConsumerStateTable } from "../../pipeline";
26
26
  import { createTestRedis, type TestRedis } from "../../stack";
27
27
  import { waitFor } from "../../testing";
@@ -110,7 +110,6 @@ let testRedis: TestRedis;
110
110
 
111
111
  beforeAll(async () => {
112
112
  [testDb, testRedis] = await Promise.all([createTestDb(), createTestRedis()]);
113
- await createEventsTable(testDb.db);
114
113
  await createArchivedStreamsTable(testDb.db);
115
114
  await createEventConsumerStateTable(testDb.db);
116
115
  });
@@ -20,7 +20,6 @@ import {
20
20
  defineFeature,
21
21
  type ProjectionDefinition,
22
22
  } from "../../engine";
23
- import { createEventsTable } from "../../event-store";
24
23
  import type { JobRunner } from "../../jobs/job-runner";
25
24
  import { createProjectionStateTable } from "../../pipeline";
26
25
  import {
@@ -97,7 +96,6 @@ let markerDir: string;
97
96
  beforeAll(async () => {
98
97
  testDb = await createTestDb();
99
98
  await unsafeCreateEntityTable(testDb.db, itemEntity, "pending-item");
100
- await createEventsTable(testDb.db);
101
99
  await createProjectionStateTable(testDb.db);
102
100
  await unsafePushTables(testDb.db, {
103
101
  readPendingCounts: countsTable,
@@ -10,7 +10,6 @@ import { asRawClient } from "../../db/query";
10
10
  import { buildEntityTable } from "../../db/table-builder";
11
11
  import { createTenantDb, type TenantDb } from "../../db/tenant-db";
12
12
  import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
13
- import { createEventsTable } from "../../event-store";
14
13
  import { createProjectionStateTable, rebuildProjection } from "../../pipeline";
15
14
  import { createTestDb, type TestDb, TestUsers, unsafeCreateEntityTable } from "../../stack";
16
15
 
@@ -38,7 +37,6 @@ const crud = createEventStoreExecutor(personTable, personEntity, { entityName: "
38
37
  beforeAll(async () => {
39
38
  testDb = await createTestDb();
40
39
  await unsafeCreateEntityTable(testDb.db, personEntity, "person");
41
- await createEventsTable(testDb.db);
42
40
  await createProjectionStateTable(testDb.db);
43
41
  tdb = createTenantDb(testDb.db, admin.tenantId);
44
42
  });
@@ -11,7 +11,6 @@ import {
11
11
  defineFeature,
12
12
  type Registry,
13
13
  } from "../../engine";
14
- import { createEventsTable } from "../../event-store";
15
14
  import { TestUsers, unsafeCreateEntityTable } from "../../stack";
16
15
  import { ensureTemporalPolyfill } from "../../time/polyfill";
17
16
  import { createCascadeDeleteHook } from "../cascade-handler";
@@ -96,7 +95,6 @@ const memberEntity = createEntity({
96
95
  beforeAll(async () => {
97
96
  await ensureTemporalPolyfill();
98
97
  testDb = await createTestDb();
99
- await createEventsTable(testDb.db);
100
98
  tdb = createTenantDb(testDb.db, admin.tenantId);
101
99
 
102
100
  await unsafeCreateEntityTable(testDb.db, departmentEntity);
@@ -16,7 +16,6 @@ import { integer, table as pgTable, uuid as pgUuid } from "../../db/dialect";
16
16
  import { asRawClient } from "../../db/query";
17
17
  import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
18
18
  import type { ProjectionDefinition } from "../../engine/types";
19
- import { createEventsTable } from "../../event-store";
20
19
  import { createProjectionStateTable, rebuildProjection } from "../../pipeline";
21
20
  import { TestUsers, unsafePushTables } from "../../stack";
22
21
  import { ensureTemporalPolyfill } from "../../time/polyfill";
@@ -68,7 +67,6 @@ const qualifiedProjectionName = "perfrebuild:projection:task-count";
68
67
  beforeAll(async () => {
69
68
  await ensureTemporalPolyfill();
70
69
  testDb = await createTestDb();
71
- await createEventsTable(testDb.db);
72
70
  await createProjectionStateTable(testDb.db);
73
71
  await unsafePushTables(testDb.db, { perf_rebuild_task_count: taskCountTable });
74
72
  });
@@ -26,7 +26,7 @@ import {
26
26
  defineFeature,
27
27
  } from "../../engine";
28
28
  import type { ProjectionDefinition } from "../../engine/types";
29
- import { archiveStream, createEventsTable } from "../../event-store";
29
+ import { archiveStream } from "../../event-store";
30
30
  import {
31
31
  createProjectionStateTable,
32
32
  getAllProjectionProgress,
@@ -104,7 +104,6 @@ const executor = createEventStoreExecutor(itemTable, itemEntity, { entityName: "
104
104
  beforeAll(async () => {
105
105
  testDb = await createTestDb();
106
106
  await unsafeCreateEntityTable(testDb.db, itemEntity, "rebuild-item");
107
- await createEventsTable(testDb.db);
108
107
  await createProjectionStateTable(testDb.db);
109
108
  await unsafePushTables(testDb.db, { rebuildItemsPerGroup: itemsPerGroupTable });
110
109
  tdb = createTenantDb(testDb.db, admin.tenantId);
@@ -23,7 +23,7 @@ import { buildEntityTable } from "../../db/table-builder";
23
23
  import { createTenantDb, type TenantDb } from "../../db/tenant-db";
24
24
  import { createEntity, createTextField, defineApply, defineFeature } from "../../engine";
25
25
  import type { ProjectionDefinition } from "../../engine/types";
26
- import { append, createEventsTable } from "../../event-store";
26
+ import { append } from "../../event-store";
27
27
  import { listRebuildDeadLetters } from "../../event-store/rebuild-dead-letter";
28
28
  import {
29
29
  createProjectionStateTable,
@@ -140,7 +140,6 @@ const executor = createEventStoreExecutor(itemTable, itemEntity, { entityName: "
140
140
  beforeAll(async () => {
141
141
  stack = await setupTestStack({ features: [feature] });
142
142
  await unsafeCreateEntityTable(stack.db, itemEntity, "poison-item");
143
- await createEventsTable(stack.db);
144
143
  await createProjectionStateTable(stack.db);
145
144
  await unsafePushTables(stack.db, {
146
145
  poisonCounter: counterTable,
@@ -17,7 +17,6 @@ import {
17
17
  createTenantDb,
18
18
  } from "@cosmicdrift/kumiko-framework/db";
19
19
  import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
20
- import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
21
20
  import { reindexEntity } from "@cosmicdrift/kumiko-framework/search";
22
21
  import {
23
22
  setupTestStack,
@@ -67,7 +66,6 @@ beforeAll(async () => {
67
66
  stack = await setupTestStack({ features: [widgetFeature, contactFeature] });
68
67
  await unsafeCreateEntityTable(stack.db, widgetEntity);
69
68
  await unsafeCreateEntityTable(stack.db, contactEntity, "contact");
70
- await createEventsTable(stack.db);
71
69
  // Shared across both pii tests below (not per-test) — reindexEntity scans
72
70
  // the whole tenant table regardless of which test created which row, so a
73
71
  // fresh KMS instance per test would make earlier rows' subject keys
@@ -11,7 +11,6 @@ import {
11
11
  } from "../../crypto";
12
12
  import { asRawClient, buildEntityTable, createEventStoreExecutor, createTenantDb } from "../../db";
13
13
  import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
14
- import { createEventsTable } from "../../event-store";
15
14
  import { setupTestStack, type TestStack, TestUsers, unsafeCreateEntityTable } from "../../stack";
16
15
  import { purgeSearchDocumentsForSubject } from "../purge-subject";
17
16
 
@@ -53,7 +52,6 @@ const admin = TestUsers.admin;
53
52
  beforeAll(async () => {
54
53
  stack = await setupTestStack({ features: [contactFeature] });
55
54
  await unsafeCreateEntityTable(stack.db, contactEntity, "contact");
56
- await createEventsTable(stack.db);
57
55
  });
58
56
 
59
57
  afterAll(async () => {