@cosmicdrift/kumiko-server-runtime 0.172.0 → 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-server-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.173.0",
|
|
4
4
|
"description": "Production server-boot runtime for Kumiko apps: connections, schema-drift-gate, seeds, lifecycle, graceful shutdown. Symmetric to kumiko-dev-server's runDevApp, without dev/scaffold/codegen tooling.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
"types": "./src/run-prod-app.ts",
|
|
31
31
|
"default": "./src/run-prod-app.ts"
|
|
32
32
|
},
|
|
33
|
+
"./run-worker-app": {
|
|
34
|
+
"types": "./src/run-worker-app.ts",
|
|
35
|
+
"default": "./src/run-worker-app.ts"
|
|
36
|
+
},
|
|
33
37
|
"./bun-serve-options": {
|
|
34
38
|
"types": "./src/bun-serve-options.ts",
|
|
35
39
|
"default": "./src/bun-serve-options.ts"
|
|
@@ -72,8 +76,8 @@
|
|
|
72
76
|
}
|
|
73
77
|
},
|
|
74
78
|
"dependencies": {
|
|
75
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
76
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
79
|
+
"@cosmicdrift/kumiko-bundled-features": "0.173.0",
|
|
80
|
+
"@cosmicdrift/kumiko-framework": "0.173.0",
|
|
77
81
|
"temporal-polyfill": "^0.3.2"
|
|
78
82
|
},
|
|
79
83
|
"publishConfig": {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Boot-mode + KMS-gate tests for runWorkerApp — no real Postgres/Redis
|
|
2
|
+
// needed (like run-prod-app-env-source.test.ts): KUMIKO_DRY_RUN_ENV=boot
|
|
3
|
+
// exits BEFORE any connection, and the KMS health gate runs BEFORE
|
|
4
|
+
// createDbConnection/new Redis(...) — so neither path needs real infra.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
8
|
+
import { runWorkerApp } from "../run-worker-app";
|
|
9
|
+
import { makeProbeFeature, withClearedBootEnv } from "./boot-probe-fixture";
|
|
10
|
+
|
|
11
|
+
const probeFeature = makeProbeFeature({
|
|
12
|
+
name: "worker-boot-probe",
|
|
13
|
+
table: "worker_boot_probe",
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const DUMMY_ENV = {
|
|
17
|
+
DATABASE_URL: "postgres://smoke:smoke@127.0.0.1:1/smoke",
|
|
18
|
+
REDIS_URL: "redis://127.0.0.1:1",
|
|
19
|
+
JWT_SECRET: "smokesmokesmokesmokesmokesmokesmokesmoke",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
describe("runWorkerApp boot-mode", () => {
|
|
23
|
+
withClearedBootEnv();
|
|
24
|
+
|
|
25
|
+
test("KUMIKO_DRY_RUN_ENV=boot returns an inert handle without opening DB/Redis", async () => {
|
|
26
|
+
const logs: string[] = [];
|
|
27
|
+
const originalLog = console.log;
|
|
28
|
+
console.log = (...args: unknown[]) => {
|
|
29
|
+
logs.push(args.map(String).join(" "));
|
|
30
|
+
};
|
|
31
|
+
let handle: Awaited<ReturnType<typeof runWorkerApp>>;
|
|
32
|
+
try {
|
|
33
|
+
handle = await runWorkerApp({
|
|
34
|
+
features: [probeFeature],
|
|
35
|
+
migrations: false,
|
|
36
|
+
envSource: { ...DUMMY_ENV, KUMIKO_DRY_RUN_ENV: "boot" },
|
|
37
|
+
});
|
|
38
|
+
} finally {
|
|
39
|
+
console.log = originalLog;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
expect(handle).toBeDefined();
|
|
43
|
+
expect(typeof handle.stop).toBe("function");
|
|
44
|
+
expect(logs.some((line) => line.includes("boot validation OK"))).toBe(true);
|
|
45
|
+
await handle.stop();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("ensureTemporalPolyfill runs even in boot-mode — Temporal is defined right after boot validation", async () => {
|
|
49
|
+
// Regression pin for fw#1725: the bug that cost real time was a
|
|
50
|
+
// missing polyfill call. Boot-mode boots without running any job —
|
|
51
|
+
// this test only proves "the polyfill call happens"; the ordering-
|
|
52
|
+
// before-composeFeatures guarantee is covered by the integration
|
|
53
|
+
// test (run-worker-app.integration.test.ts) via a real job.
|
|
54
|
+
const originalLog = console.log;
|
|
55
|
+
console.log = () => {};
|
|
56
|
+
try {
|
|
57
|
+
const handle = await runWorkerApp({
|
|
58
|
+
features: [probeFeature],
|
|
59
|
+
migrations: false,
|
|
60
|
+
envSource: { ...DUMMY_ENV, KUMIKO_DRY_RUN_ENV: "boot" },
|
|
61
|
+
});
|
|
62
|
+
await handle.stop();
|
|
63
|
+
} finally {
|
|
64
|
+
console.log = originalLog;
|
|
65
|
+
}
|
|
66
|
+
expect(typeof (globalThis as { Temporal?: unknown }).Temporal).toBe("object");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("unhealthy KMS aborts boot before any DB/Redis connection is opened", async () => {
|
|
70
|
+
let healthChecked = false;
|
|
71
|
+
const unhealthyKms: KmsAdapter = {
|
|
72
|
+
capabilities: { mode: "local-key" },
|
|
73
|
+
createKey: async () => {},
|
|
74
|
+
getKey: async () => {
|
|
75
|
+
throw new Error("unreachable");
|
|
76
|
+
},
|
|
77
|
+
eraseKey: async () => {},
|
|
78
|
+
health: async () => {
|
|
79
|
+
healthChecked = true;
|
|
80
|
+
return { ok: false, latencyMs: 3 };
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
const originalLog = console.log;
|
|
84
|
+
console.log = () => {};
|
|
85
|
+
try {
|
|
86
|
+
await expect(
|
|
87
|
+
runWorkerApp({
|
|
88
|
+
features: [probeFeature],
|
|
89
|
+
migrations: false,
|
|
90
|
+
kms: unhealthyKms,
|
|
91
|
+
envSource: { ...DUMMY_ENV },
|
|
92
|
+
}),
|
|
93
|
+
).rejects.toThrow(/KMS health check failed/);
|
|
94
|
+
} finally {
|
|
95
|
+
console.log = originalLog;
|
|
96
|
+
}
|
|
97
|
+
expect(healthChecked).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// runWorkerApp integration: boots the dedicated worker process against
|
|
2
|
+
// real Postgres + Redis. Proves:
|
|
3
|
+
// - ensureTemporalPolyfill ran BEFORE the job executed (fw#1725: the
|
|
4
|
+
// bug that cost real time — without the polyfill, every job in the
|
|
5
|
+
// worker fails with "Temporal is not defined")
|
|
6
|
+
// - event-triggered jobs run end-to-end (afterCommit → BullMQ → handler)
|
|
7
|
+
// - the schema-drift gate aborts the boot on pending migrations
|
|
8
|
+
// - wireComponents gets db/redis/registry/dispatchSystemWrite/lifecycle
|
|
9
|
+
// and can register its own shutdown hooks
|
|
10
|
+
|
|
11
|
+
import { afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
12
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { createDbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
16
|
+
import { createEntity, createTextField, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import {
|
|
18
|
+
createArchivedStreamsTable,
|
|
19
|
+
createEventsTable,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
21
|
+
import {
|
|
22
|
+
createEventConsumerStateTable,
|
|
23
|
+
createProjectionStateTable,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
25
|
+
import { unsafeEnsureEntityTable } from "@cosmicdrift/kumiko-framework/stack";
|
|
26
|
+
import postgres from "postgres";
|
|
27
|
+
import { z } from "zod";
|
|
28
|
+
import { makeDispatchSystemWrite } from "../extra-routes-deps";
|
|
29
|
+
import { runWorkerApp, type WorkerAppHandle } from "../run-worker-app";
|
|
30
|
+
|
|
31
|
+
const jobRuns: Array<{ note: string; temporalWasDefined: boolean }> = [];
|
|
32
|
+
|
|
33
|
+
const workerProbeEntity = createEntity({
|
|
34
|
+
fields: { note: createTextField({ required: true }) },
|
|
35
|
+
table: "worker_probes",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const workerProbeFeature = defineFeature("worker-probe", (r) => {
|
|
39
|
+
r.entity("probe", workerProbeEntity);
|
|
40
|
+
r.writeHandler({
|
|
41
|
+
name: "ping",
|
|
42
|
+
schema: z.object({ note: z.string() }),
|
|
43
|
+
access: { roles: ["SystemAdmin"] },
|
|
44
|
+
handler: async (event) => ({
|
|
45
|
+
isSuccess: true as const,
|
|
46
|
+
data: { note: (event.payload as { note: string }).note },
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
// The job's only purpose: prove Temporal is already defined by the time
|
|
50
|
+
// the handler runs. Before fw#1725 there was no framework-side boot
|
|
51
|
+
// path for this — apps had to rebuild the polyfill call by hand
|
|
52
|
+
// (solon#42) and forgot it.
|
|
53
|
+
r.job(
|
|
54
|
+
"record-ping",
|
|
55
|
+
{ trigger: { on: "worker-probe:write:ping" }, runIn: "worker" },
|
|
56
|
+
async (payload) => {
|
|
57
|
+
const temporalWasDefined =
|
|
58
|
+
typeof (globalThis as { Temporal?: unknown }).Temporal === "object";
|
|
59
|
+
// Touches the global directly — throws "Temporal is not defined" if the
|
|
60
|
+
// polyfill never ran, which is the exact failure mode fw#1725 reports.
|
|
61
|
+
Temporal.Now.instant();
|
|
62
|
+
jobRuns.push({
|
|
63
|
+
note: (payload as { note: string }).note,
|
|
64
|
+
temporalWasDefined,
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const TENANT_ID = "00000000-0000-4000-8000-000000000002";
|
|
71
|
+
const TEST_DB = `kumiko_runworker_${Date.now().toString(36)}`;
|
|
72
|
+
const ADMIN_URL = process.env["TEST_DATABASE_URL"] ?? "";
|
|
73
|
+
|
|
74
|
+
const tempDirs: string[] = [];
|
|
75
|
+
let handles: WorkerAppHandle[] = [];
|
|
76
|
+
|
|
77
|
+
beforeAll(async () => {
|
|
78
|
+
if (!ADMIN_URL) throw new Error("TEST_DATABASE_URL must be set");
|
|
79
|
+
const adminClient = postgres(ADMIN_URL.replace(/\/[^/]+$/, "/postgres"));
|
|
80
|
+
try {
|
|
81
|
+
await adminClient.unsafe(`CREATE DATABASE "${TEST_DB}"`);
|
|
82
|
+
} finally {
|
|
83
|
+
await adminClient.end();
|
|
84
|
+
}
|
|
85
|
+
const url = ADMIN_URL.replace(/\/[^/]+$/, `/${TEST_DB}`);
|
|
86
|
+
const { db, close } = createDbConnection(url);
|
|
87
|
+
try {
|
|
88
|
+
await createEventsTable(db);
|
|
89
|
+
await createArchivedStreamsTable(db);
|
|
90
|
+
await createProjectionStateTable(db);
|
|
91
|
+
await createEventConsumerStateTable(db);
|
|
92
|
+
await unsafeEnsureEntityTable(db, workerProbeEntity, "probe");
|
|
93
|
+
} finally {
|
|
94
|
+
await close();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
afterEach(async () => {
|
|
99
|
+
for (const handle of handles) {
|
|
100
|
+
await handle.stop();
|
|
101
|
+
}
|
|
102
|
+
handles = [];
|
|
103
|
+
for (const dir of tempDirs) {
|
|
104
|
+
await rm(dir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
tempDirs.length = 0;
|
|
107
|
+
jobRuns.length = 0;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
async function boot(extra?: Partial<Parameters<typeof runWorkerApp>[0]>): Promise<WorkerAppHandle> {
|
|
111
|
+
const originalDbUrl = process.env["DATABASE_URL"];
|
|
112
|
+
process.env["DATABASE_URL"] = ADMIN_URL.replace(/\/[^/]+$/, `/${TEST_DB}`);
|
|
113
|
+
process.env["REDIS_URL"] = process.env["REDIS_URL"] ?? "redis://localhost:16379";
|
|
114
|
+
process.env["JWT_SECRET"] = "test-runworker-secret-32-chars-min!!";
|
|
115
|
+
try {
|
|
116
|
+
const handle = await runWorkerApp({
|
|
117
|
+
features: [workerProbeFeature],
|
|
118
|
+
migrations: false,
|
|
119
|
+
jobs: { queueNamePrefix: `test-worker-${Date.now().toString(36)}` },
|
|
120
|
+
...(extra ?? {}),
|
|
121
|
+
});
|
|
122
|
+
handles.push(handle);
|
|
123
|
+
return handle;
|
|
124
|
+
} finally {
|
|
125
|
+
if (originalDbUrl !== undefined) process.env["DATABASE_URL"] = originalDbUrl;
|
|
126
|
+
else delete process.env["DATABASE_URL"];
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function pollFor<T>(probe: () => T | undefined, timeoutMs = 8000): Promise<T> {
|
|
131
|
+
const deadline = Date.now() + timeoutMs;
|
|
132
|
+
for (;;) {
|
|
133
|
+
const result = probe();
|
|
134
|
+
if (result !== undefined) return result;
|
|
135
|
+
if (Date.now() > deadline) throw new Error("pollFor: timeout");
|
|
136
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
describe("runWorkerApp", () => {
|
|
141
|
+
test("boots against real Postgres/Redis — mode is worker, dispatcher available", async () => {
|
|
142
|
+
const handle = await boot();
|
|
143
|
+
expect(handle.entrypoint.mode).toBe("worker");
|
|
144
|
+
expect(handle.entrypoint.dispatcher).toBeDefined();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("event-triggered job runs end-to-end with Temporal already defined (fw#1725 regression)", async () => {
|
|
148
|
+
const handle = await boot();
|
|
149
|
+
const dispatchSystemWrite = makeDispatchSystemWrite(handle.entrypoint.dispatcher);
|
|
150
|
+
|
|
151
|
+
const result = await dispatchSystemWrite({
|
|
152
|
+
handlerQn: "worker-probe:write:ping",
|
|
153
|
+
payload: { note: "hello-from-worker" },
|
|
154
|
+
tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
|
|
155
|
+
});
|
|
156
|
+
expect(result.isSuccess).toBe(true);
|
|
157
|
+
|
|
158
|
+
const run = await pollFor(() => jobRuns.find((r) => r.note === "hello-from-worker"));
|
|
159
|
+
expect(run.temporalWasDefined).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("wireComponents receives db/redis/registry/dispatchSystemWrite/lifecycle and can register a shutdown hook", async () => {
|
|
163
|
+
let seenDeps: {
|
|
164
|
+
db: boolean;
|
|
165
|
+
redis: boolean;
|
|
166
|
+
registry: boolean;
|
|
167
|
+
dispatchSystemWrite: boolean;
|
|
168
|
+
} | null = null;
|
|
169
|
+
let shutdownHookRan = false;
|
|
170
|
+
|
|
171
|
+
const handle = await boot({
|
|
172
|
+
wireComponents: async (deps) => {
|
|
173
|
+
seenDeps = {
|
|
174
|
+
db: deps.db !== undefined,
|
|
175
|
+
redis: deps.redis !== undefined,
|
|
176
|
+
registry: deps.registry.features.has("worker-probe"),
|
|
177
|
+
dispatchSystemWrite: typeof deps.dispatchSystemWrite === "function",
|
|
178
|
+
};
|
|
179
|
+
deps.lifecycle.registerShutdownHook("test-component", async () => {
|
|
180
|
+
shutdownHookRan = true;
|
|
181
|
+
});
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(seenDeps!).toEqual({
|
|
186
|
+
db: true,
|
|
187
|
+
redis: true,
|
|
188
|
+
registry: true,
|
|
189
|
+
dispatchSystemWrite: true,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await handle.stop();
|
|
193
|
+
handles = handles.filter((h) => h !== handle);
|
|
194
|
+
expect(shutdownHookRan).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("Schema-Drift-Gate: pending migration aborts the boot before anything else initializes", async () => {
|
|
198
|
+
const driftDir = await mkdtemp(join(tmpdir(), "kumiko-worker-drift-boot-"));
|
|
199
|
+
tempDirs.push(driftDir);
|
|
200
|
+
await writeFile(
|
|
201
|
+
join(driftDir, "0001_pending.sql"),
|
|
202
|
+
`CREATE TABLE "worker_never_created_table" ("id" uuid PRIMARY KEY);`,
|
|
203
|
+
);
|
|
204
|
+
await writeFile(
|
|
205
|
+
join(driftDir, ".snapshot.json"),
|
|
206
|
+
JSON.stringify({
|
|
207
|
+
version: 1,
|
|
208
|
+
tables: [{ tableName: "worker_never_created_table", columns: [] }],
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
await expect(boot({ migrations: { dir: driftDir } })).rejects.toThrow(/Schema drift detected/);
|
|
213
|
+
});
|
|
214
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -15,4 +15,10 @@ export type {
|
|
|
15
15
|
SignupSetup,
|
|
16
16
|
} from "./run-prod-app";
|
|
17
17
|
export { runProdApp } from "./run-prod-app";
|
|
18
|
+
export type {
|
|
19
|
+
RunWorkerAppOptions,
|
|
20
|
+
WorkerAppHandle,
|
|
21
|
+
WorkerWireDeps,
|
|
22
|
+
} from "./run-worker-app";
|
|
23
|
+
export { runWorkerApp } from "./run-worker-app";
|
|
18
24
|
export type { SecurityHeadersOption } from "./security-headers";
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// runWorkerApp — production-grade bootstrap wrapper for a dedicated
|
|
2
|
+
// Kumiko worker process. Symmetric to runProdApp, but without HTTP: no
|
|
3
|
+
// Hono app, no auth routes, no SSE broker, no seeds. Shares the boot core
|
|
4
|
+
// with runProdApp (env fail-fast, Temporal polyfill, composeFeatures/
|
|
5
|
+
// registry, PII invariants, KMS health gate, connections, schema-drift
|
|
6
|
+
// gate, boot-crypto, extraContext) — see fw#1725: before this function,
|
|
7
|
+
// every app deploying a worker rebuilt this boot by hand (solon#42), and
|
|
8
|
+
// every deviation was silent: without `ensureTemporalPolyfill`, every job
|
|
9
|
+
// in the worker fails with "Temporal is not defined" in a retry loop,
|
|
10
|
+
// with no boot-time signal that anything is wrong.
|
|
11
|
+
//
|
|
12
|
+
// App-author writes:
|
|
13
|
+
// await runWorkerApp({ features, wireComponents: async (deps) => {...} });
|
|
14
|
+
//
|
|
15
|
+
// Container/Coolify sets the same env vars as runProdApp:
|
|
16
|
+
// DATABASE_URL, REDIS_URL, JWT_SECRET — PORT is not needed.
|
|
17
|
+
|
|
18
|
+
import { loadJwtSecretOrKeyring, type ServerOptions } from "@cosmicdrift/kumiko-framework/api";
|
|
19
|
+
import {
|
|
20
|
+
configureBlindIndexKey,
|
|
21
|
+
configurePiiSubjectKms,
|
|
22
|
+
type KmsAdapter,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
24
|
+
import {
|
|
25
|
+
configureEntityFieldEncryption,
|
|
26
|
+
createDbConnection,
|
|
27
|
+
type DbConnection,
|
|
28
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
29
|
+
import {
|
|
30
|
+
createRegistry,
|
|
31
|
+
type EffectiveFeaturesResolver,
|
|
32
|
+
type FeatureDefinition,
|
|
33
|
+
findTierResolverUsage,
|
|
34
|
+
type Registry,
|
|
35
|
+
type TierResolverPlugin,
|
|
36
|
+
validateBoot,
|
|
37
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
38
|
+
import {
|
|
39
|
+
createWorkerEntrypoint,
|
|
40
|
+
type WorkerEntrypoint,
|
|
41
|
+
} from "@cosmicdrift/kumiko-framework/entrypoint";
|
|
42
|
+
import {
|
|
43
|
+
assertKumikoSchemaCurrent,
|
|
44
|
+
SchemaDriftError,
|
|
45
|
+
} from "@cosmicdrift/kumiko-framework/migrations";
|
|
46
|
+
import type {
|
|
47
|
+
ObservabilityOptions,
|
|
48
|
+
ObservabilityProvider,
|
|
49
|
+
} from "@cosmicdrift/kumiko-framework/observability";
|
|
50
|
+
import {
|
|
51
|
+
createEntityCache,
|
|
52
|
+
createEventDedup,
|
|
53
|
+
createIdempotencyGuard,
|
|
54
|
+
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
55
|
+
import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
56
|
+
import { warnIfNonUtcServerTimeZone } from "@cosmicdrift/kumiko-framework/time";
|
|
57
|
+
import Redis from "ioredis";
|
|
58
|
+
import { resolveBootCrypto } from "./boot/boot-crypto";
|
|
59
|
+
import { jobRunLoggerCallbacks } from "./boot/job-run-logger";
|
|
60
|
+
import { composeFeatures } from "./compose-features";
|
|
61
|
+
import { type ExtraRoutesSystemDeps, makeDispatchSystemWrite } from "./extra-routes-deps";
|
|
62
|
+
import { assertPiiBootInvariants } from "./pii-boot-gate";
|
|
63
|
+
import { requireEnv } from "./run-prod-app";
|
|
64
|
+
import { addConfigAccessorFactory, buildBootExtraContext } from "./run-prod-app-boot-context";
|
|
65
|
+
|
|
66
|
+
export type WorkerContextOption =
|
|
67
|
+
| Record<string, unknown>
|
|
68
|
+
| ((deps: WorkerDeps) => Record<string, unknown>);
|
|
69
|
+
|
|
70
|
+
export type WorkerDeps = {
|
|
71
|
+
readonly db: DbConnection;
|
|
72
|
+
readonly redis: Redis;
|
|
73
|
+
readonly registry: Registry;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Deps for the `wireComponents` hook — app-wired co-running components
|
|
77
|
+
* (an analysis runner, an IMAP supervisor, ...) that need the system-
|
|
78
|
+
* write dispatcher and register their own shutdown hooks on the worker
|
|
79
|
+
* lifecycle. Same deps shape as extraRoutes, plus `lifecycle` for
|
|
80
|
+
* `registerShutdownHook`. */
|
|
81
|
+
export type WorkerWireDeps = ExtraRoutesSystemDeps & {
|
|
82
|
+
readonly lifecycle: WorkerEntrypoint["lifecycle"];
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type RunWorkerAppOptions = {
|
|
86
|
+
/** App-specific features — same array as in the API/all-in-one process,
|
|
87
|
+
* so the registry + schema stay identical across processes. */
|
|
88
|
+
readonly features: readonly FeatureDefinition[];
|
|
89
|
+
/** Mount the auto-mixed config/user/tenant/auth-email-password features —
|
|
90
|
+
* MUST match the API process's `includeBundled` value, otherwise API
|
|
91
|
+
* and worker run with a diverging registry topology. Also controls
|
|
92
|
+
* whether buildBootExtraContext auto-wires `configResolver` (same
|
|
93
|
+
* auth-mode gate as runProdApp). */
|
|
94
|
+
readonly includeBundled?: boolean;
|
|
95
|
+
/** Path to kumiko/migrations for the boot gate. See RunProdAppOptions
|
|
96
|
+
* ["migrations"] — identical semantics. */
|
|
97
|
+
readonly migrations?: { readonly dir: string } | false;
|
|
98
|
+
/** Extra AppContext keys — same factory-union pattern as
|
|
99
|
+
* RunProdAppOptions["extraContext"], without sseBroker (the worker
|
|
100
|
+
* has none — see entrypoint/index.ts's documented SSE limitation). */
|
|
101
|
+
readonly extraContext?: WorkerContextOption;
|
|
102
|
+
/** MasterKeyProvider for ctx.secrets. Default: env-KEK (see
|
|
103
|
+
* RunProdAppOptions["masterKey"]). */
|
|
104
|
+
readonly masterKey?: MasterKeyProvider;
|
|
105
|
+
/** Subject-key adapter for crypto-shredding — boot checks health()
|
|
106
|
+
* before any connection (see RunProdAppOptions["kms"]). */
|
|
107
|
+
readonly kms?: KmsAdapter;
|
|
108
|
+
/** Blind-index key for lookupable fields (see
|
|
109
|
+
* RunProdAppOptions["blindIndexKey"]). */
|
|
110
|
+
readonly blindIndexKey?: string;
|
|
111
|
+
/** Explicit opt-out from the PII boot gate (see
|
|
112
|
+
* RunProdAppOptions["allowPlaintextPii"]). */
|
|
113
|
+
readonly allowPlaintextPii?: string;
|
|
114
|
+
readonly jobs?: {
|
|
115
|
+
readonly queueNamePrefix?: string;
|
|
116
|
+
};
|
|
117
|
+
/** Tuning knobs for the event-dispatcher loop (pollIntervalMs, pgClient
|
|
118
|
+
* for LISTEN/NOTIFY). */
|
|
119
|
+
readonly eventDispatcher?: ServerOptions["eventDispatcher"];
|
|
120
|
+
/** Hook for app-wired co-running components that need the system-write
|
|
121
|
+
* dispatcher (e.g. an analysis runner, an IMAP supervisor). Runs AFTER
|
|
122
|
+
* `entrypoint.start()` — the hook itself is responsible for starting
|
|
123
|
+
* its component and registering a shutdown hook on `lifecycle`. */
|
|
124
|
+
readonly wireComponents?: (deps: WorkerWireDeps) => Promise<void> | void;
|
|
125
|
+
/** Feature-toggle resolver (see RunProdAppOptions["effectiveFeatures"]). */
|
|
126
|
+
readonly effectiveFeatures?: EffectiveFeaturesResolver;
|
|
127
|
+
/** Override `process.env` for env-validation (see
|
|
128
|
+
* RunProdAppOptions["envSource"]). */
|
|
129
|
+
readonly envSource?: Record<string, string | undefined>;
|
|
130
|
+
readonly observability?: ObservabilityProvider;
|
|
131
|
+
readonly observabilityOptions?: ObservabilityOptions;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export type WorkerAppHandle = {
|
|
135
|
+
/** In KUMIKO_DRY_RUN_ENV=boot mode with an injected envSource (test
|
|
136
|
+
* path), no boot ran — this slot is an undefined-cast, do not access. */
|
|
137
|
+
readonly entrypoint: WorkerEntrypoint;
|
|
138
|
+
readonly stop: () => Promise<void>;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
function makeBootModeHandle(): WorkerAppHandle {
|
|
142
|
+
return {
|
|
143
|
+
// @cast-boundary boot-mode: no entrypoint exists because no boot ran —
|
|
144
|
+
// callers on this path never read this slot.
|
|
145
|
+
entrypoint: undefined as unknown as WorkerEntrypoint,
|
|
146
|
+
stop: async () => {},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function runWorkerApp(options: RunWorkerAppOptions): Promise<WorkerAppHandle> {
|
|
151
|
+
const envSource = options.envSource ?? process.env;
|
|
152
|
+
|
|
153
|
+
// 1. Polyfill before anything else — exactly the bug fw#1725 reports:
|
|
154
|
+
// without it, every job in the worker fails with "Temporal is not
|
|
155
|
+
// defined" in a retry loop, with no boot-time signal.
|
|
156
|
+
const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
|
|
157
|
+
await ensureTemporalPolyfill();
|
|
158
|
+
|
|
159
|
+
// 2. Env vars: fail-fast, same requireEnv as runProdApp. JWT_SECRET is
|
|
160
|
+
// validated by loadJwtSecretOrKeyring itself (throws when neither
|
|
161
|
+
// JWT_SECRET nor JWT_SECRET_V<n> is set).
|
|
162
|
+
const databaseUrl = requireEnv("DATABASE_URL", envSource, "runWorkerApp");
|
|
163
|
+
const redisUrl = requireEnv("REDIS_URL", envSource, "runWorkerApp");
|
|
164
|
+
const jwtSecretOrKeyring = loadJwtSecretOrKeyring(envSource);
|
|
165
|
+
|
|
166
|
+
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
167
|
+
console.log("[runWorkerApp] booting Kumiko worker…");
|
|
168
|
+
|
|
169
|
+
// 3. Feature registry — identical to runProdApp, MUST be built with the
|
|
170
|
+
// same `includeBundled` as the API process.
|
|
171
|
+
const features = composeFeatures(options.features, {
|
|
172
|
+
includeBundled: !!options.includeBundled,
|
|
173
|
+
});
|
|
174
|
+
validateBoot(features);
|
|
175
|
+
warnIfNonUtcServerTimeZone();
|
|
176
|
+
assertPiiBootInvariants(features, {
|
|
177
|
+
kms: options.kms,
|
|
178
|
+
blindIndexKey: options.blindIndexKey,
|
|
179
|
+
allowPlaintextPii: options.allowPlaintextPii,
|
|
180
|
+
mode: "prod",
|
|
181
|
+
});
|
|
182
|
+
const registry = createRegistry(features);
|
|
183
|
+
|
|
184
|
+
// Boot-mode exit (parity with runProdApp's C1): validators ran and the
|
|
185
|
+
// registry is built, no DB/Redis client was constructed. Makes the boot
|
|
186
|
+
// testable without real Postgres/Redis infra.
|
|
187
|
+
if (envSource["KUMIKO_DRY_RUN_ENV"] === "boot") {
|
|
188
|
+
// biome-ignore lint/suspicious/noConsole: boot-mode output IS the deliverable
|
|
189
|
+
console.log(
|
|
190
|
+
`[runWorkerApp] boot validation OK (${features.length} features, ${registry.features.size} registry entries)`,
|
|
191
|
+
);
|
|
192
|
+
if (options.envSource === undefined) {
|
|
193
|
+
process.exit(0);
|
|
194
|
+
}
|
|
195
|
+
return makeBootModeHandle();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 4. KMS health gate — runs BEFORE the connections, so an abort leaks
|
|
199
|
+
// nothing (identical to runProdApp).
|
|
200
|
+
if (options.kms) {
|
|
201
|
+
const kmsHealth = await options.kms.health();
|
|
202
|
+
if (!kmsHealth.ok) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
`[runWorkerApp] BOOT ABORTED — KMS health check failed (latency ${kmsHealth.latencyMs}ms)`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const { db, close: closeDb } = createDbConnection(databaseUrl);
|
|
210
|
+
const redis = new Redis(redisUrl, { maxRetriesPerRequest: null });
|
|
211
|
+
|
|
212
|
+
let resolvedEffectiveFeatures: EffectiveFeaturesResolver | undefined = options.effectiveFeatures;
|
|
213
|
+
if (resolvedEffectiveFeatures === undefined) {
|
|
214
|
+
const tierResolverUsage = findTierResolverUsage(features);
|
|
215
|
+
if (tierResolverUsage) {
|
|
216
|
+
const plugin = tierResolverUsage.options as TierResolverPlugin;
|
|
217
|
+
resolvedEffectiveFeatures = await plugin.build({ db, registry });
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 5. Schema-drift gate — identical to runProdApp.
|
|
222
|
+
if (options.migrations !== false) {
|
|
223
|
+
const migrationsDir = options.migrations?.dir ?? "./kumiko/migrations";
|
|
224
|
+
// biome-ignore lint/suspicious/noConsole: boot-time progress hint
|
|
225
|
+
console.log(`[runWorkerApp] checking schema drift (${migrationsDir})…`);
|
|
226
|
+
try {
|
|
227
|
+
await assertKumikoSchemaCurrent(db, migrationsDir);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
if (err instanceof SchemaDriftError) {
|
|
230
|
+
// biome-ignore lint/suspicious/noConsole: terminal error message
|
|
231
|
+
console.error(`\n[runWorkerApp] BOOT ABORTED — ${err.message}\n`);
|
|
232
|
+
}
|
|
233
|
+
throw err;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 6. Pipeline pieces — same defaults as runProdApp.
|
|
238
|
+
const idempotency = createIdempotencyGuard(redis, { ttlSeconds: 60 });
|
|
239
|
+
const eventDedup = createEventDedup(redis, { ttlSeconds: 60 });
|
|
240
|
+
const entityCache = createEntityCache(redis, { ttlSeconds: 60 });
|
|
241
|
+
|
|
242
|
+
// 7. Boot-crypto + extraContext — the core of fw#1725: KMS wiring,
|
|
243
|
+
// blind-index, config-encryption must be wired in the worker exactly
|
|
244
|
+
// like in the API, otherwise the worker writes rows the API can no
|
|
245
|
+
// longer read (different cipher, missing blind index).
|
|
246
|
+
const deps: WorkerDeps = { db, redis, registry };
|
|
247
|
+
const resolvedExtraContext =
|
|
248
|
+
typeof options.extraContext === "function"
|
|
249
|
+
? options.extraContext(deps)
|
|
250
|
+
: (options.extraContext ?? {});
|
|
251
|
+
|
|
252
|
+
const bootCrypto = resolveBootCrypto(envSource, options.masterKey);
|
|
253
|
+
configureEntityFieldEncryption(bootCrypto.entityFieldCipher);
|
|
254
|
+
configurePiiSubjectKms(options.kms);
|
|
255
|
+
configureBlindIndexKey(options.blindIndexKey);
|
|
256
|
+
const autoExtraContext = buildBootExtraContext({
|
|
257
|
+
db,
|
|
258
|
+
features,
|
|
259
|
+
envSource,
|
|
260
|
+
registry,
|
|
261
|
+
hasAuth: !!options.includeBundled,
|
|
262
|
+
crypto: bootCrypto,
|
|
263
|
+
...(options.kms && { kms: options.kms }),
|
|
264
|
+
});
|
|
265
|
+
const extraContext = addConfigAccessorFactory(
|
|
266
|
+
{ ...autoExtraContext, ...resolvedExtraContext },
|
|
267
|
+
registry,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const jobLogger = jobRunLoggerCallbacks(registry, db);
|
|
271
|
+
const entrypoint = createWorkerEntrypoint({
|
|
272
|
+
registry,
|
|
273
|
+
context: { db, redis, entityCache, registry, ...extraContext },
|
|
274
|
+
jwtSecret: jwtSecretOrKeyring,
|
|
275
|
+
dispatcherOptions: {
|
|
276
|
+
idempotency,
|
|
277
|
+
...(resolvedEffectiveFeatures && { effectiveFeatures: resolvedEffectiveFeatures }),
|
|
278
|
+
},
|
|
279
|
+
eventDedup,
|
|
280
|
+
...(options.observability && { observability: options.observability }),
|
|
281
|
+
...(options.observabilityOptions && { observabilityOptions: options.observabilityOptions }),
|
|
282
|
+
redisUrl,
|
|
283
|
+
...jobLogger,
|
|
284
|
+
...(options.jobs?.queueNamePrefix !== undefined && {
|
|
285
|
+
queueNamePrefix: options.jobs.queueNamePrefix,
|
|
286
|
+
}),
|
|
287
|
+
...(options.eventDispatcher && { eventDispatcher: options.eventDispatcher }),
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
const handle: WorkerAppHandle = {
|
|
291
|
+
entrypoint,
|
|
292
|
+
stop: async () => {
|
|
293
|
+
await entrypoint.stop();
|
|
294
|
+
await closeDb();
|
|
295
|
+
redis.disconnect();
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
await entrypoint.start();
|
|
300
|
+
|
|
301
|
+
if (options.wireComponents) {
|
|
302
|
+
await options.wireComponents({
|
|
303
|
+
db,
|
|
304
|
+
redis,
|
|
305
|
+
registry,
|
|
306
|
+
dispatchSystemWrite: makeDispatchSystemWrite(entrypoint.dispatcher),
|
|
307
|
+
lifecycle: entrypoint.lifecycle,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
let shuttingDown = false;
|
|
312
|
+
const shutdown = async (signal: string) => {
|
|
313
|
+
// skip: shutdown already in progress, avoid double-drain
|
|
314
|
+
if (shuttingDown) return;
|
|
315
|
+
shuttingDown = true;
|
|
316
|
+
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
317
|
+
console.log(`[runWorkerApp] ${signal} received — draining…`);
|
|
318
|
+
try {
|
|
319
|
+
await handle.stop();
|
|
320
|
+
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
321
|
+
console.log("[runWorkerApp] graceful shutdown complete.");
|
|
322
|
+
} catch (e) {
|
|
323
|
+
// biome-ignore lint/suspicious/noConsole: shutdown-time error, only path is stderr
|
|
324
|
+
console.error("[runWorkerApp] error during shutdown:", e);
|
|
325
|
+
} finally {
|
|
326
|
+
process.exit(0);
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
330
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
331
|
+
|
|
332
|
+
// biome-ignore lint/suspicious/noConsole: boot-time progress hint, no logger configured this early
|
|
333
|
+
console.log("[runWorkerApp] ready — worker running.");
|
|
334
|
+
|
|
335
|
+
return handle;
|
|
336
|
+
}
|