@cosmicdrift/kumiko-framework 0.171.2 → 0.173.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.171.2",
3
+ "version": "0.173.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>",
@@ -182,7 +182,7 @@
182
182
  "./package.json": "./package.json"
183
183
  },
184
184
  "dependencies": {
185
- "@cosmicdrift/kumiko-types": "0.171.2",
185
+ "@cosmicdrift/kumiko-types": "0.173.0",
186
186
  "bullmq": "^5.76.7",
187
187
  "bun-types": "^1.3.13",
188
188
  "hono": "^4.12.27",
@@ -198,7 +198,7 @@
198
198
  "zod": "^4.4.3"
199
199
  },
200
200
  "devDependencies": {
201
- "@cosmicdrift/kumiko-dispatcher-live": "0.171.2",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.173.0",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -532,8 +532,14 @@ describe("POST /api/stream pre-pull race", () => {
532
532
  // First .next() takes longer than heartbeatMs → settledInTime=false →
533
533
  // streamSSE opens immediately and pumpStream emits ping until the chunk
534
534
  // arrives (framework#1547 route-level contract).
535
+ //
536
+ // 25x the heartbeat, not 3x: the assertion rides on the timer winning the
537
+ // race, and a loaded CI runner delays timers by tens of milliseconds. At
538
+ // 60ms the timer occasionally fired after the chunk, the route took the
539
+ // settled path, and the run failed with ["chunk","done"] — a red main that
540
+ // blocks the release job.
535
541
  const dispatcher = stubDispatcher(async function* () {
536
- await Bun.sleep(60);
542
+ await Bun.sleep(500);
537
543
  yield { i: 0 };
538
544
  });
539
545
  const app = mountStreamApp(dispatcher, 20);
@@ -124,6 +124,15 @@ describe("resolveKmsWiring", () => {
124
124
  );
125
125
  });
126
126
 
127
+ test("rejects rotation settings when the main trio is absent", () => {
128
+ expect(() =>
129
+ resolveKmsWiring({
130
+ PLATFORM_KEK_PREVIOUS: KEK_B,
131
+ PLATFORM_KEK_PREVIOUS_VERSION: "1",
132
+ }),
133
+ ).toThrow(/all-or-none/);
134
+ });
135
+
127
136
  test.each([
128
137
  ["PLATFORM_KEK_PREVIOUS without version", { PLATFORM_KEK_PREVIOUS: KEK_B }],
129
138
  ["version without PLATFORM_KEK_PREVIOUS", { PLATFORM_KEK_PREVIOUS_VERSION: "1" }],
@@ -93,6 +93,25 @@ describe("PgKmsAdapter — pg specifics", () => {
93
93
  expect(rows[0]?.["erase_reason"]).toBe("user-forget");
94
94
  });
95
95
 
96
+ test("concurrent cold-start createSchema across processes does not crash", async () => {
97
+ const coldDb = await createTestDb();
98
+ const coldUrl = baseUrl.replace(/\/[^/]+$/, `/${coldDb.dbName}`);
99
+ const kek = randomBytes(32).toString("base64");
100
+ const adapters = Array.from(
101
+ { length: 4 },
102
+ () => new PgKmsAdapter({ databaseUrl: coldUrl, platformKek: kek, maxConnections: 1 }),
103
+ );
104
+ try {
105
+ // Simulates API + worker processes booting simultaneously against a
106
+ // schema-less DB — each adapter races createSchema on its own connection.
107
+ const results = await Promise.all(adapters.map((a) => a.health()));
108
+ expect(results.every((r) => r.ok)).toBe(true);
109
+ } finally {
110
+ await Promise.all(adapters.map((a) => a.close()));
111
+ await coldDb.cleanup();
112
+ }
113
+ });
114
+
96
115
  test("repeat erase keeps the original tombstone audit fields", async () => {
97
116
  const user = freshUser();
98
117
  await adapter.createKey(user, ctx);
@@ -115,10 +115,17 @@ function assertTrioConsistent(env: KmsWiringEnv, logPrefix: string | undefined):
115
115
  }
116
116
  if (Boolean(env.PLATFORM_KEK_PREVIOUS) !== Boolean(env.PLATFORM_KEK_PREVIOUS_VERSION)) {
117
117
  throw new Error(
118
- "PLATFORM_KEK_PREVIOUS and PLATFORM_KEK_PREVIOUS_VERSION must be set together " +
118
+ `${logPrefix ? `${logPrefix} ` : ""}PLATFORM_KEK_PREVIOUS and ` +
119
+ "PLATFORM_KEK_PREVIOUS_VERSION must be set together " +
119
120
  "(KEK rotation, runbook kek-rotation.md).",
120
121
  );
121
122
  }
123
+ if (!complete && (env.PLATFORM_KEK_PREVIOUS || env.PLATFORM_KEK_PREVIOUS_VERSION)) {
124
+ throw new Error(
125
+ `${logPrefix ? `${logPrefix} ` : ""}PLATFORM_KEK / SUBJECT_KEYS_DATABASE_URL / ` +
126
+ "KUMIKO_BLIND_INDEX_KEY are all-or-none — rotation settings require the complete KMS wiring.",
127
+ );
128
+ }
122
129
  return complete;
123
130
  }
124
131
 
@@ -46,6 +46,10 @@ function decodePlatformKek(base64: string): Buffer {
46
46
 
47
47
  const PG_UNIQUE_VIOLATION = "23505";
48
48
 
49
+ // pg_advisory_xact_lock key for createSchema — serializes concurrent cold-start
50
+ // DDL across processes sharing one subject-keys DB.
51
+ const SCHEMA_ADVISORY_LOCK_KEY = 0x6b_6d_73_31; // 'kms1'
52
+
49
53
  function isPgUniqueViolation(error: unknown): boolean {
50
54
  return (
51
55
  typeof error === "object" &&
@@ -182,23 +186,26 @@ export class PgKmsAdapter implements LocalKeyKmsAdapter {
182
186
  }
183
187
 
184
188
  private async createSchema(): Promise<void> {
185
- await this.sql`
186
- CREATE TABLE IF NOT EXISTS kumiko_subject_keys (
187
- subject_id TEXT PRIMARY KEY,
188
- cipher_key BYTEA,
189
- kek_version INTEGER NOT NULL DEFAULT 1,
190
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
191
- created_by TEXT,
192
- erased_at TIMESTAMPTZ,
193
- erased_by TEXT,
194
- erase_reason TEXT
195
- )`;
196
- await this.sql`
197
- CREATE INDEX IF NOT EXISTS kumiko_subject_keys_erased_idx
198
- ON kumiko_subject_keys (erased_at) WHERE erased_at IS NOT NULL`;
199
- await this.sql`
200
- CREATE INDEX IF NOT EXISTS kumiko_subject_keys_audit_idx
201
- ON kumiko_subject_keys (created_at, erased_at)`;
189
+ await this.sql.begin(async (tx) => {
190
+ await tx`SELECT pg_advisory_xact_lock(${SCHEMA_ADVISORY_LOCK_KEY})`;
191
+ await tx`
192
+ CREATE TABLE IF NOT EXISTS kumiko_subject_keys (
193
+ subject_id TEXT PRIMARY KEY,
194
+ cipher_key BYTEA,
195
+ kek_version INTEGER NOT NULL DEFAULT 1,
196
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
197
+ created_by TEXT,
198
+ erased_at TIMESTAMPTZ,
199
+ erased_by TEXT,
200
+ erase_reason TEXT
201
+ )`;
202
+ await tx`
203
+ CREATE INDEX IF NOT EXISTS kumiko_subject_keys_erased_idx
204
+ ON kumiko_subject_keys (erased_at) WHERE erased_at IS NOT NULL`;
205
+ await tx`
206
+ CREATE INDEX IF NOT EXISTS kumiko_subject_keys_audit_idx
207
+ ON kumiko_subject_keys (created_at, erased_at)`;
208
+ });
202
209
  }
203
210
  }
204
211
 
@@ -8,7 +8,6 @@
8
8
 
9
9
  import { compareByCodepoint } from "../utils";
10
10
  import { isEncryptedAtRest } from "./config-helpers";
11
- import type { ChangelogEntry } from "./feature-changelog";
12
11
  import { qualifyEntityName } from "./qualified-name";
13
12
  import type { Registry, UiHints } from "./types/feature";
14
13
 
@@ -65,9 +64,6 @@ export type ManifestFeature = {
65
64
  readonly uiHints?: UiHints;
66
65
  /** Optionaler Herkunfts-Tag (z.B. "enterprise") — gesetzt via Options. */
67
66
  readonly tier?: string;
68
- /** Per-feature changelog entries (from changes.json). Optional —
69
- * absent when no changes.json exists or feature has no entries. */
70
- readonly changelog?: readonly ChangelogEntry[];
71
67
  };
72
68
 
73
69
  export type FeatureManifest = {
@@ -5,7 +5,8 @@
5
5
  // pin the public guarantees:
6
6
  //
7
7
  // 1. API entrypoint has no eventDispatcher/jobRunner handles.
8
- // 2. Worker entrypoint has no HTTP app.
8
+ // 2. Worker entrypoint has no HTTP app, but does hand out the
9
+ // command-dispatcher — app-wired background components need it.
9
10
  // 3. All-in-one has both.
10
11
  // 4. Worker throws when there's literally nothing to consume (defensive
11
12
  // guard — buildServer always wires an SSE consumer so this only
@@ -14,10 +15,11 @@
14
15
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
15
16
  import { z } from "zod";
16
17
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
18
+ import { asRawClient } from "../../db/query";
17
19
  import { createRegistry, defineFeature } from "../../engine";
18
20
  import { createArchivedStreamsTable, createEventsTable } from "../../event-store";
19
21
  import { createEventConsumerStateTable } from "../../pipeline";
20
- import { createTestRedis, type TestRedis } from "../../stack";
22
+ import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
21
23
  import { createAllInOneEntrypoint, createApiEntrypoint, createWorkerEntrypoint } from "../index";
22
24
 
23
25
  const splitFeature = defineFeature("split", (r) => {
@@ -30,6 +32,24 @@ const splitFeature = defineFeature("split", (r) => {
30
32
  });
31
33
  });
32
34
 
35
+ const workerWriteFeature = defineFeature("workerWrite", (r) => {
36
+ const noted = r.defineEvent("noted", z.object({ note: z.string() }), { version: 1 });
37
+ r.writeHandler(
38
+ "note",
39
+ z.object({ note: z.string() }),
40
+ async (event, ctx) => {
41
+ await ctx.unsafeAppendEvent({
42
+ aggregateId: crypto.randomUUID(),
43
+ aggregateType: "worker-note",
44
+ type: noted.name,
45
+ payload: { note: event.payload.note },
46
+ });
47
+ return { isSuccess: true as const, data: { note: event.payload.note } };
48
+ },
49
+ { access: { openToAll: true } },
50
+ );
51
+ });
52
+
33
53
  const JWT = "split-deploy-test-secret-must-be-32-chars!!";
34
54
 
35
55
  // Per-test queue-name with a random suffix. Date.now() alone collided
@@ -86,6 +106,7 @@ describe("entrypoint factories", () => {
86
106
  expect(worker.mode).toBe("worker");
87
107
  expect(worker.eventDispatcher).toBeDefined();
88
108
  expect(worker.jobRunner).toBeDefined();
109
+ expect(worker.dispatcher).toBeDefined();
89
110
  expect("app" in worker).toBe(false);
90
111
  expect("jwt" in worker).toBe(false);
91
112
 
@@ -95,6 +116,42 @@ describe("entrypoint factories", () => {
95
116
  await worker.stop();
96
117
  });
97
118
 
119
+ // An app-wired component running in the worker (analysis service, IMAP
120
+ // supervisor) has to persist its result, and persisting goes through the
121
+ // write-path — JobContext has no write/query. The dispatcher is the only
122
+ // way in, so a worker that doesn't hand it out forces such a component
123
+ // into the API process, which defeats the split.
124
+ test("Worker dispatcher runs a write end-to-end — handler executes, event lands in the store", async () => {
125
+ const registry = createRegistry([workerWriteFeature]);
126
+ const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
127
+ const worker = createWorkerEntrypoint({
128
+ registry,
129
+ context: { db: testDb.db, redis: testRedis.redis },
130
+ jwtSecret: JWT,
131
+ redisUrl,
132
+ queueNamePrefix: uniquePrefix("split-dispatch"),
133
+ });
134
+
135
+ try {
136
+ const result = await worker.dispatcher.write(
137
+ "worker-write:write:note",
138
+ { note: "written from the worker" },
139
+ TestUsers.admin,
140
+ );
141
+ expect(result.isSuccess).toBe(true);
142
+
143
+ const rows = await asRawClient(testDb.db).unsafe(
144
+ `SELECT payload FROM kumiko_events WHERE type = 'worker-write:event:noted'`,
145
+ );
146
+ expect(rows).toHaveLength(1);
147
+ expect((rows[0] as { payload: { note: string } }).payload.note).toBe(
148
+ "written from the worker",
149
+ );
150
+ } finally {
151
+ await worker.stop();
152
+ }
153
+ });
154
+
98
155
  test("All-in-one entrypoint has both HTTP surface and background workers", async () => {
99
156
  const registry = createRegistry([splitFeature]);
100
157
  const redisUrl = process.env["REDIS_URL"] ?? "redis://localhost:16379";
@@ -142,6 +142,12 @@ export type WorkerEntrypoint = {
142
142
  readonly eventDispatcher: EventDispatcher;
143
143
  readonly jobRunner: JobRunner;
144
144
  readonly observability: ObservabilityProvider;
145
+ // Same dispatcher the API process exposes — a worker builds the identical
146
+ // server, only without routes. App-wired components that run in the worker
147
+ // and must persist their result need it: JobContext has no write/query
148
+ // (handlers.ts JobContext), so writing goes through dispatchSystemWrite,
149
+ // the pattern inbound-mail-foundation/watch-supervisor.ts established.
150
+ readonly dispatcher: Dispatcher;
145
151
  readonly mode: "worker";
146
152
  // Starts event-dispatcher poll + BullMQ worker. SIGTERM triggers
147
153
  // `lifecycle.drain()`, which stops both via registered hooks.
@@ -428,6 +434,7 @@ export function createWorkerEntrypoint(options: WorkerEntrypointOptions): Worker
428
434
  eventDispatcher,
429
435
  jobRunner,
430
436
  observability: server.observability,
437
+ dispatcher: server.dispatcher,
431
438
  mode: "worker",
432
439
  async start() {
433
440
  await eventDispatcher.start();
package/src/jobs/index.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export type { JobLogEntry, JobMeta, JobRunner, JobRunnerOptions } from "./job-runner";
2
- export { createJobRunner, schedulerIdForJobName } from "./job-runner";
2
+ export { createJobRunner } from "./job-runner";
@@ -45,7 +45,7 @@ export function schedulerIdForJobName(jobName: string): string {
45
45
  return `scheduler-${jobName.replace(/[.:]/g, "-")}`;
46
46
  }
47
47
 
48
- /** Pre-sanitize id (`.` only) — remove on boot so colon-form ghosts die. */
48
+ // ponytail: migration shim, remove after fw#1603 deploy is everywhere.
49
49
  function legacySchedulerIdForJobName(jobName: string): string {
50
50
  return `scheduler-${jobName.replace(/\./g, "-")}`;
51
51
  }