@cosmicdrift/kumiko-dev-server 0.77.1 → 0.79.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-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.79.0",
|
|
4
4
|
"description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"kumiko-schema-check": "./bin/kumiko-schema-check.ts"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
54
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
53
|
+
"@cosmicdrift/kumiko-bundled-features": "0.79.0",
|
|
54
|
+
"@cosmicdrift/kumiko-framework": "0.79.0",
|
|
55
55
|
"ts-morph": "^28.0.0"
|
|
56
56
|
},
|
|
57
57
|
"publishConfig": {
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
createProjectionStateTable,
|
|
31
31
|
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
32
32
|
import { unsafeEnsureEntityTable } from "@cosmicdrift/kumiko-framework/stack";
|
|
33
|
+
import { Queue } from "bullmq";
|
|
33
34
|
import postgres from "postgres";
|
|
34
35
|
import { z } from "zod";
|
|
35
36
|
import { type ProdAppHandle, runProdApp } from "../run-prod-app";
|
|
@@ -118,6 +119,29 @@ const widgetFeature = defineFeature("prod-probe", (r) => {
|
|
|
118
119
|
});
|
|
119
120
|
});
|
|
120
121
|
|
|
122
|
+
// Worker-lane cron — the lane the data-export job (run-export-jobs) lives on.
|
|
123
|
+
// runProdApp must schedule it (single-instance runs both lanes); on the old
|
|
124
|
+
// createApiEntrypoint path it was silently never registered → exports hung.
|
|
125
|
+
const cronProbeFeature = defineFeature("cron-probe", (r) => {
|
|
126
|
+
r.job("worker-lane-cron", { trigger: { cron: "0 0 1 1 *" }, runIn: "worker" }, async () => {});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
async function workerLaneSchedulers(prefix: string): Promise<{ name?: string; key?: string }[]> {
|
|
130
|
+
const url = new URL(process.env["REDIS_URL"] ?? "redis://localhost:16379");
|
|
131
|
+
const queue = new Queue(`${prefix}-worker`, {
|
|
132
|
+
connection: { host: url.hostname, port: Number(url.port) },
|
|
133
|
+
});
|
|
134
|
+
// Read-only: do NOT obliterate — the running all-in-one worker consumes this
|
|
135
|
+
// same queue, and deleting its keys mid-flight aborts the worker's blocking
|
|
136
|
+
// Redis read with "Connection is closed". The unique prefix isolates the
|
|
137
|
+
// leftover scheduler; the test Redis is ephemeral.
|
|
138
|
+
try {
|
|
139
|
+
return await queue.getJobSchedulers();
|
|
140
|
+
} finally {
|
|
141
|
+
await queue.close();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
121
145
|
const TENANT_ID = "00000000-0000-4000-8000-000000000001";
|
|
122
146
|
|
|
123
147
|
// Per-suite DB so reboots can be tested without conflicting with other
|
|
@@ -747,3 +771,33 @@ describe("runProdApp — auth allowedOrigins forwarding", () => {
|
|
|
747
771
|
}
|
|
748
772
|
});
|
|
749
773
|
});
|
|
774
|
+
|
|
775
|
+
describe("runProdApp job-lane wiring (runSingleInstance)", () => {
|
|
776
|
+
// Red-then-green for the export bug: on createApiEntrypoint (old default) the
|
|
777
|
+
// worker-lane cron was never registered. createAllInOneEntrypoint (new
|
|
778
|
+
// single-instance default) runs two runners, so it IS registered.
|
|
779
|
+
test("default single-instance schedules the WORKER-lane cron", async () => {
|
|
780
|
+
const prefix = `test-rsi-${Date.now().toString(36)}`;
|
|
781
|
+
const handle = await boot(undefined, {
|
|
782
|
+
features: [cronProbeFeature],
|
|
783
|
+
jobs: { queueNamePrefix: prefix },
|
|
784
|
+
});
|
|
785
|
+
expect(handle.entrypoint.mode).toBe("all-in-one");
|
|
786
|
+
const schedulers = await workerLaneSchedulers(prefix);
|
|
787
|
+
expect(schedulers.some((s) => (s.name ?? s.key ?? "").includes("worker-lane-cron"))).toBe(true);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
test("runSingleInstance:false → api-only, worker lane left to a dedicated worker", async () => {
|
|
791
|
+
const prefix = `test-rsi-api-${Date.now().toString(36)}`;
|
|
792
|
+
const handle = await boot(undefined, {
|
|
793
|
+
features: [cronProbeFeature],
|
|
794
|
+
jobs: { queueNamePrefix: prefix },
|
|
795
|
+
runSingleInstance: false,
|
|
796
|
+
});
|
|
797
|
+
expect(handle.entrypoint.mode).toBe("api");
|
|
798
|
+
const schedulers = await workerLaneSchedulers(prefix);
|
|
799
|
+
expect(schedulers.some((s) => (s.name ?? s.key ?? "").includes("worker-lane-cron"))).toBe(
|
|
800
|
+
false,
|
|
801
|
+
);
|
|
802
|
+
});
|
|
803
|
+
});
|
package/src/run-prod-app.ts
CHANGED
|
@@ -58,8 +58,9 @@ import {
|
|
|
58
58
|
validateBoot,
|
|
59
59
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
60
60
|
import {
|
|
61
|
+
type AllInOneEntrypoint,
|
|
61
62
|
type ApiEntrypoint,
|
|
62
|
-
|
|
63
|
+
createAllInOneEntrypoint,
|
|
63
64
|
createApiEntrypoint,
|
|
64
65
|
} from "@cosmicdrift/kumiko-framework/entrypoint";
|
|
65
66
|
import {
|
|
@@ -416,17 +417,21 @@ export type RunProdAppOptions = {
|
|
|
416
417
|
* weiterhin automatisch ergänzt; Factory-Result + auto-resolver
|
|
417
418
|
* werden gemerged (Factory-Werte überschreiben). */
|
|
418
419
|
readonly extraContext?: ExtraContextOption;
|
|
419
|
-
/**
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
* `
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
420
|
+
/** Deploy-Topologie. Default `true` (Single-Container): dieser Prozess
|
|
421
|
+
* fährt HTTP + BEIDE Job-Lanes (api + worker) + den Event-Dispatcher
|
|
422
|
+
* (MSP-Anwendung) inline — via `createAllInOneEntrypoint`. Damit laufen
|
|
423
|
+
* worker-Lane-Crons (z.B. der Daten-Export `run-export-jobs`, default
|
|
424
|
+
* `runIn:"worker"`) und r.multiStreamProjection ohne separaten Worker.
|
|
425
|
+
*
|
|
426
|
+
* `false` NUR mit einem dezidierten Worker-Deployment setzen: dann fährt
|
|
427
|
+
* dieser Prozess API-only (`createApiEntrypoint`), und worker-Lane-Jobs
|
|
428
|
+
* + MSPs werden NICHT mehr lokal angewandt — der Worker muss sie
|
|
429
|
+
* übernehmen, sonst bleiben Export-Jobs pending und die Read-Side leer
|
|
430
|
+
* (2026-06-11-Incident-Klasse). */
|
|
431
|
+
readonly runSingleInstance?: boolean;
|
|
432
|
+
/** Job-Block. Wenn das Feature `r.job(...)` registriert, wird er
|
|
433
|
+
* automatisch verdrahtet (siehe runSingleInstance). */
|
|
427
434
|
readonly jobs?: {
|
|
428
|
-
/** Default true (Single-Container). */
|
|
429
|
-
readonly runLocalJobs?: boolean;
|
|
430
435
|
/** BullMQ-Queue-Prefix (default "kumiko"). */
|
|
431
436
|
readonly queueNamePrefix?: string;
|
|
432
437
|
};
|
|
@@ -495,11 +500,12 @@ export type RunProdAppOptions = {
|
|
|
495
500
|
};
|
|
496
501
|
|
|
497
502
|
export type ProdAppHandle = {
|
|
498
|
-
/** The composed
|
|
503
|
+
/** The composed entrypoint — AllInOne (runSingleInstance, default) or
|
|
504
|
+
* Api-only (runSingleInstance:false). In KUMIKO_DRY_RUN_ENV mode WITH
|
|
499
505
|
* `envSource` injected (test path), no boot ran and this slot is an
|
|
500
506
|
* undefined-cast — do not access. Production dry-run hits
|
|
501
507
|
* `process.exit(0)` before returning a handle. */
|
|
502
|
-
readonly entrypoint: ApiEntrypoint;
|
|
508
|
+
readonly entrypoint: ApiEntrypoint | AllInOneEntrypoint;
|
|
503
509
|
/** The fetch-handler — wired into Bun.serve in production, called
|
|
504
510
|
* directly in tests. Composes Hono + static-fallback. */
|
|
505
511
|
readonly fetch: (req: Request) => Promise<Response> | Response;
|
|
@@ -707,7 +713,7 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
707
713
|
? buildProdSessionAuth(db, options.auth.sessions)
|
|
708
714
|
: undefined;
|
|
709
715
|
|
|
710
|
-
const
|
|
716
|
+
const baseEntrypointOptions = {
|
|
711
717
|
registry,
|
|
712
718
|
context: {
|
|
713
719
|
db,
|
|
@@ -780,32 +786,48 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
780
786
|
},
|
|
781
787
|
}),
|
|
782
788
|
...(resolvedAnonymousAccess && { anonymousAccess: resolvedAnonymousAccess }),
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
789
|
+
};
|
|
790
|
+
|
|
791
|
+
// Deploy-Topologie. Default (Single-Container): createAllInOneEntrypoint
|
|
792
|
+
// fährt HTTP + BEIDE Job-Lanes (zwei Runner, jeder schedult seine eigene
|
|
793
|
+
// Lane-Crons → kein Double-Fire) + Event-Dispatcher inline. So laufen
|
|
794
|
+
// worker-Lane-Crons (run-export-jobs default runIn:"worker") UND MSPs ohne
|
|
795
|
+
// separaten Worker-Process — die Asymmetrie, an der der Daten-Export hing.
|
|
796
|
+
// runSingleInstance:false → API-only; ein dezidierter Worker MUSS dann die
|
|
797
|
+
// worker-Lane + MSPs übernehmen (api-Lane-Jobs laufen weiter lokal).
|
|
798
|
+
// Default single-instance. eventDispatcher.disabled ist die Alt-Art, MSPs
|
|
799
|
+
// diesem Prozess wegzunehmen (dezidierter Worker) — als runSingleInstance:
|
|
800
|
+
// false honorieren (api-only, kein lokaler Dispatcher), damit der explizite
|
|
801
|
+
// Flag Vorrang behält aber Bestands-Caller nicht brechen.
|
|
802
|
+
const runSingleInstance = options.runSingleInstance ?? options.eventDispatcher?.disabled !== true;
|
|
803
|
+
const hasJobs = registry.getAllJobs().size > 0;
|
|
804
|
+
const queueNamePrefix = options.jobs?.queueNamePrefix;
|
|
805
|
+
const dispatcherTunables =
|
|
806
|
+
options.eventDispatcher?.pollIntervalMs !== undefined
|
|
807
|
+
? { pollIntervalMs: options.eventDispatcher.pollIntervalMs }
|
|
808
|
+
: {};
|
|
809
|
+
|
|
810
|
+
const entrypoint: ApiEntrypoint | AllInOneEntrypoint = runSingleInstance
|
|
811
|
+
? createAllInOneEntrypoint({
|
|
812
|
+
...baseEntrypointOptions,
|
|
813
|
+
// Worker-Seite liest die JobsBlock TOP-LEVEL (nicht nested `jobs` wie
|
|
814
|
+
// die api-Seite); beide Lane-Runner ziehen redisUrl/prefix von hier.
|
|
790
815
|
redisUrl,
|
|
791
|
-
|
|
792
|
-
...(options.
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
pollIntervalMs: options.eventDispatcher.pollIntervalMs,
|
|
816
|
+
...(queueNamePrefix !== undefined && { queueNamePrefix }),
|
|
817
|
+
...(!options.eventDispatcher?.disabled && { eventDispatcher: dispatcherTunables }),
|
|
818
|
+
})
|
|
819
|
+
: createApiEntrypoint({
|
|
820
|
+
...baseEntrypointOptions,
|
|
821
|
+
// API-only: api-Lane-Jobs laufen lokal, ein dezidierter Worker fährt
|
|
822
|
+
// worker-Lane + MSPs. createApiEntrypoint liest den nested `jobs`-Block.
|
|
823
|
+
...(hasJobs && {
|
|
824
|
+
jobs: {
|
|
825
|
+
redisUrl,
|
|
826
|
+
runLocalJobs: true,
|
|
827
|
+
...(queueNamePrefix !== undefined && { queueNamePrefix }),
|
|
828
|
+
},
|
|
805
829
|
}),
|
|
806
|
-
}
|
|
807
|
-
}),
|
|
808
|
-
} satisfies ApiEntrypointOptions);
|
|
830
|
+
});
|
|
809
831
|
|
|
810
832
|
// 8. Build the AppSchema once + serialize. Wird beim Static-Fallback
|
|
811
833
|
// in die index.html injiziert damit createKumikoApp() im Browser
|