@cosmicdrift/kumiko-dev-server 0.40.1 → 0.41.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.41.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>",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"kumiko-schema-check": "./bin/kumiko-schema-check.ts"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
50
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
49
|
+
"@cosmicdrift/kumiko-bundled-features": "0.40.1",
|
|
50
|
+
"@cosmicdrift/kumiko-framework": "0.40.1",
|
|
51
51
|
"ts-morph": "^28.0.0"
|
|
52
52
|
},
|
|
53
53
|
"publishConfig": {
|
|
@@ -81,6 +81,41 @@ const widgetFeature = defineFeature("prod-probe", (r) => {
|
|
|
81
81
|
data: { tenantSeen: event.user.tenantId, roles: event.user.roles },
|
|
82
82
|
}),
|
|
83
83
|
});
|
|
84
|
+
// Event + MSP-Paar für den lokalen Event-Dispatcher (2026-06-11):
|
|
85
|
+
// runProdApp ist Single-Container — ohne lokalen Dispatcher wendet KEINE
|
|
86
|
+
// multiStreamProjection jemals an (Prod hatte deshalb leere Projektionen
|
|
87
|
+
// + leere kumiko_event_consumers). Der Write appended das Event; die MSP
|
|
88
|
+
// schreibt async in prod_probe_pings — der Test pollt darauf.
|
|
89
|
+
const pingedEvent = r.defineEvent("probe-pinged", z.object({ note: z.string() }));
|
|
90
|
+
r.writeHandler({
|
|
91
|
+
name: "probe-append",
|
|
92
|
+
schema: z.object({ aggregateId: z.string(), note: z.string() }),
|
|
93
|
+
access: { roles: ["SystemAdmin"] },
|
|
94
|
+
handler: async (event, ctx) => {
|
|
95
|
+
const payload = event.payload as { aggregateId: string; note: string }; // @cast-boundary engine-payload
|
|
96
|
+
// unsafeAppendEvent: das Test-Feature augmentiert keine Event-Type-Map,
|
|
97
|
+
// der strict-typed appendEvent narrowt hier auf never.
|
|
98
|
+
await ctx.unsafeAppendEvent({
|
|
99
|
+
aggregateId: payload.aggregateId,
|
|
100
|
+
aggregateType: "probe",
|
|
101
|
+
type: pingedEvent.name,
|
|
102
|
+
payload: { note: payload.note },
|
|
103
|
+
});
|
|
104
|
+
return { isSuccess: true as const, data: { ok: true as const } };
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
r.multiStreamProjection({
|
|
108
|
+
name: "probe-ping-projection",
|
|
109
|
+
apply: {
|
|
110
|
+
[pingedEvent.name]: async (event, tx) => {
|
|
111
|
+
const payload = event.payload as { note: string }; // @cast-boundary engine-payload
|
|
112
|
+
await asRawClient(tx).unsafe(
|
|
113
|
+
`INSERT INTO prod_probe_pings (aggregate_id, note) VALUES ($1, $2)`,
|
|
114
|
+
[event.aggregateId, payload.note],
|
|
115
|
+
);
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
});
|
|
84
119
|
});
|
|
85
120
|
|
|
86
121
|
const TENANT_ID = "00000000-0000-4000-8000-000000000001";
|
|
@@ -130,6 +165,13 @@ async function migrateTestDb(): Promise<void> {
|
|
|
130
165
|
await createProjectionStateTable(db);
|
|
131
166
|
await createEventConsumerStateTable(db);
|
|
132
167
|
await unsafeEnsureEntityTable(db, widgetEntity, "widget");
|
|
168
|
+
await asRawClient(db).unsafe(
|
|
169
|
+
`CREATE TABLE IF NOT EXISTS prod_probe_pings (
|
|
170
|
+
id BIGSERIAL PRIMARY KEY,
|
|
171
|
+
aggregate_id UUID NOT NULL,
|
|
172
|
+
note TEXT NOT NULL
|
|
173
|
+
)`,
|
|
174
|
+
);
|
|
133
175
|
} finally {
|
|
134
176
|
await close();
|
|
135
177
|
}
|
|
@@ -594,3 +636,73 @@ describe("runProdApp", () => {
|
|
|
594
636
|
);
|
|
595
637
|
});
|
|
596
638
|
});
|
|
639
|
+
|
|
640
|
+
describe("runProdApp: lokaler Event-Dispatcher (MSP-Anwendung im Single-Container)", () => {
|
|
641
|
+
// Regression für den 2026-06-11-Incident: runProdApp baute den
|
|
642
|
+
// Event-Dispatcher nie ({disabled:true} im API-Entrypoint) — jede
|
|
643
|
+
// multiStreamProjection blieb in Prod unangewendet, kumiko_event_consumers
|
|
644
|
+
// blieb leer. Der Test schreibt über den ECHTEN Boot-Pfad und pollt auf
|
|
645
|
+
// die async projizierte Row.
|
|
646
|
+
async function pollFor<T>(probe: () => Promise<T | undefined>, timeoutMs = 8000): Promise<T> {
|
|
647
|
+
const deadline = Date.now() + timeoutMs;
|
|
648
|
+
for (;;) {
|
|
649
|
+
const result = await probe();
|
|
650
|
+
if (result !== undefined) return result;
|
|
651
|
+
if (Date.now() > deadline) throw new Error("pollFor: timeout");
|
|
652
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
test("Write → appendEvent → MSP wendet async an; Consumer-Cursor wandert", async () => {
|
|
657
|
+
let dispatchSystemWrite: import("../extra-routes-deps").ExtraRoutesSystemDeps["dispatchSystemWrite"];
|
|
658
|
+
const handle = await boot(undefined, {
|
|
659
|
+
eventDispatcher: { pollIntervalMs: 50 },
|
|
660
|
+
extraRoutes: (_app, deps) => {
|
|
661
|
+
dispatchSystemWrite = deps.dispatchSystemWrite;
|
|
662
|
+
},
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
// Default-Boot baut den lokalen Dispatcher und start() hat ihn gestartet.
|
|
666
|
+
expect(handle.entrypoint.eventDispatcher).toBeDefined();
|
|
667
|
+
|
|
668
|
+
const aggregateId = crypto.randomUUID();
|
|
669
|
+
const result = await dispatchSystemWrite!({
|
|
670
|
+
handlerQn: "prod-probe:write:probe-append",
|
|
671
|
+
payload: { aggregateId, note: "dispatched" },
|
|
672
|
+
tenantId: TENANT_ID as import("@cosmicdrift/kumiko-framework/engine").TenantId,
|
|
673
|
+
});
|
|
674
|
+
expect(result.isSuccess).toBe(true);
|
|
675
|
+
|
|
676
|
+
const url = ADMIN_URL.replace(/\/[^/]+$/, `/${TEST_DB}`);
|
|
677
|
+
const { db, close } = createDbConnection(url);
|
|
678
|
+
try {
|
|
679
|
+
const row = await pollFor(async () => {
|
|
680
|
+
const rows = (await asRawClient(db).unsafe(
|
|
681
|
+
`SELECT note FROM prod_probe_pings WHERE aggregate_id = $1`,
|
|
682
|
+
[aggregateId],
|
|
683
|
+
)) as Array<{ note: string }>;
|
|
684
|
+
return rows[0];
|
|
685
|
+
});
|
|
686
|
+
expect(row.note).toBe("dispatched");
|
|
687
|
+
|
|
688
|
+
// Consumer-Registrierung + Cursor-Fortschritt — in Prod war diese
|
|
689
|
+
// Tabelle komplett leer, DER Beweis dass nie ein Dispatcher lief.
|
|
690
|
+
const consumers = (await asRawClient(db).unsafe(
|
|
691
|
+
`SELECT name, last_processed_event_id FROM kumiko_event_consumers
|
|
692
|
+
WHERE name = 'prod-probe:projection:probe-ping-projection'
|
|
693
|
+
OR name LIKE '%probe-ping-projection%'`,
|
|
694
|
+
)) as Array<{ name: string; last_processed_event_id: string | number }>;
|
|
695
|
+
expect(consumers.length).toBeGreaterThan(0);
|
|
696
|
+
expect(Number(consumers[0]?.last_processed_event_id)).toBeGreaterThan(0);
|
|
697
|
+
} finally {
|
|
698
|
+
await close();
|
|
699
|
+
}
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
test("eventDispatcher.disabled: kein lokaler Dispatcher gebaut", async () => {
|
|
703
|
+
const handle = await boot(undefined, {
|
|
704
|
+
eventDispatcher: { disabled: true },
|
|
705
|
+
});
|
|
706
|
+
expect(handle.entrypoint.eventDispatcher).toBeUndefined();
|
|
707
|
+
});
|
|
708
|
+
});
|
package/src/run-prod-app.ts
CHANGED
|
@@ -416,6 +416,20 @@ export type RunProdAppOptions = {
|
|
|
416
416
|
/** BullMQ-Queue-Prefix (default "kumiko"). */
|
|
417
417
|
readonly queueNamePrefix?: string;
|
|
418
418
|
};
|
|
419
|
+
/** Event-Dispatcher (MSP-Anwendung) im API-Process. Default AN —
|
|
420
|
+
* runProdApp ist das Single-Container-Deployment, es gibt keinen
|
|
421
|
+
* Worker-Process der multiStreamProjections anwenden könnte. Bis
|
|
422
|
+
* 2026-06-11 fehlte der Dispatcher hier komplett: jede MSP-basierte
|
|
423
|
+
* Read-Projektion (z.B. custom-fields jsonb) blieb in Prod leer,
|
|
424
|
+
* kumiko_event_consumers blieb ohne Rows. `disabled: true` nur für
|
|
425
|
+
* Setups mit dezidiertem Worker-Process. */
|
|
426
|
+
readonly eventDispatcher?: {
|
|
427
|
+
readonly disabled?: boolean;
|
|
428
|
+
/** Poll-Intervall des Dispatcher-Loops (default siehe
|
|
429
|
+
* createEventDispatcher). LISTEN/NOTIFY-Wiring kommt mit einem
|
|
430
|
+
* späteren pgClient-Pass-through. */
|
|
431
|
+
readonly pollIntervalMs?: number;
|
|
432
|
+
};
|
|
419
433
|
/** Mount-Point für app-eigene HTTP-Routes außerhalb des Dispatcher-
|
|
420
434
|
* Systems. Aufgerufen NACH /api/* + /health, VOR der static-fallback —
|
|
421
435
|
* perfekt für GET-Endpoints die kein JSON liefern: /feed.xml,
|
|
@@ -754,6 +768,17 @@ export async function runProdApp(options: RunProdAppOptions): Promise<ProdAppHan
|
|
|
754
768
|
}),
|
|
755
769
|
},
|
|
756
770
|
}),
|
|
771
|
+
// Single-Container: MSPs laufen im API-Process (Default an, analog
|
|
772
|
+
// runLocalJobs). Ohne lokalen Dispatcher würden r.multiStreamProjection-
|
|
773
|
+
// Projektionen nie anwenden — es gibt hier keinen Worker-Process.
|
|
774
|
+
...(!options.eventDispatcher?.disabled && {
|
|
775
|
+
eventDispatcher: {
|
|
776
|
+
runLocal: true,
|
|
777
|
+
...(options.eventDispatcher?.pollIntervalMs !== undefined && {
|
|
778
|
+
pollIntervalMs: options.eventDispatcher.pollIntervalMs,
|
|
779
|
+
}),
|
|
780
|
+
},
|
|
781
|
+
}),
|
|
757
782
|
} satisfies ApiEntrypointOptions);
|
|
758
783
|
|
|
759
784
|
// 8. Build the AppSchema once + serialize. Wird beim Static-Fallback
|