@cosmicdrift/kumiko-dev-server 0.77.0 → 0.78.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.77.0",
3
+ "version": "0.78.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.77.0",
54
- "@cosmicdrift/kumiko-framework": "0.77.0",
53
+ "@cosmicdrift/kumiko-bundled-features": "0.78.0",
54
+ "@cosmicdrift/kumiko-framework": "0.78.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
+ });
@@ -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
- type ApiEntrypointOptions,
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
- /** Job-Block. Wenn das Feature `r.job(...)` registriert, MUSS dieser
420
- * Block gesetzt sein sonst wirft createApiEntrypoint mit dem
421
- * expliziten "registry declares N job(s)..."-Fehler. Default-Pattern
422
- * für Single-Container-Deployments (publicstatus, kleine SaaS):
423
- * `{ runLocalJobs: true }` der API-Process consumiert auch die
424
- * Worker-Lane, kein separates worker-Image nötig. Für skalierende
425
- * Setups (mehrere API-Replicas + dezidierte Worker): runLocalJobs
426
- * weglassen + workers via separatem `runWorkerApp` (kommt Phase 4). */
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 ApiEntrypoint. In KUMIKO_DRY_RUN_ENV mode WITH
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 entrypoint = createApiEntrypoint({
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
- // Auto-Pass-Through für r.job-Wiring: wenn das Registry Jobs
784
- // deklariert, MUSS der jobs-Block gesetzt sein — sonst stoppt
785
- // createApiEntrypoint mit explizitem Fehler. Default für Single-
786
- // Container-Deployments: runLocalJobs=true (API-Process consumiert
787
- // auch worker-Lane). Caller kann override'n via options.jobs.
788
- ...(registry.getAllJobs().size > 0 && {
789
- jobs: {
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
- runLocalJobs: options.jobs?.runLocalJobs ?? true,
792
- ...(options.jobs?.queueNamePrefix !== undefined && {
793
- queueNamePrefix: options.jobs.queueNamePrefix,
794
- }),
795
- },
796
- }),
797
- // Single-Container: MSPs laufen im API-Process (Default an, analog
798
- // runLocalJobs). Ohne lokalen Dispatcher würden r.multiStreamProjection-
799
- // Projektionen nie anwenden — es gibt hier keinen Worker-Process.
800
- ...(!options.eventDispatcher?.disabled && {
801
- eventDispatcher: {
802
- runLocal: true,
803
- ...(options.eventDispatcher?.pollIntervalMs !== undefined && {
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