@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 +3 -3
- package/src/api/__tests__/api.test.ts +7 -1
- package/src/crypto/__tests__/kms-wiring.test.ts +9 -0
- package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +19 -0
- package/src/crypto/kms-wiring.ts +8 -1
- package/src/crypto/pg-kms-adapter.ts +24 -17
- package/src/engine/feature-manifest.ts +0 -4
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +59 -2
- package/src/entrypoint/index.ts +7 -0
- package/src/jobs/index.ts +1 -1
- package/src/jobs/job-runner.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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.
|
|
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(
|
|
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);
|
package/src/crypto/kms-wiring.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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";
|
package/src/entrypoint/index.ts
CHANGED
|
@@ -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
|
|
2
|
+
export { createJobRunner } from "./job-runner";
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -45,7 +45,7 @@ export function schedulerIdForJobName(jobName: string): string {
|
|
|
45
45
|
return `scheduler-${jobName.replace(/[.:]/g, "-")}`;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
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
|
}
|