@cosmicdrift/kumiko-framework 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 +2 -2
- package/src/db/queries/event-store-admin.ts +9 -6
- package/src/db/queries/event-store.ts +12 -6
- package/src/engine/types/screen.ts +11 -0
- package/src/entrypoint/index.ts +37 -7
- package/src/event-store/__tests__/event-store.integration.test.ts +56 -0
- package/src/event-store/admin-api.ts +4 -5
- package/src/event-store/event-store.ts +2 -3
- package/src/event-store/snapshot.ts +1 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.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>",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.40.1",
|
|
185
185
|
"@types/uuid": "^11.0.0",
|
|
186
186
|
"bun-types": "^1.3.13",
|
|
187
187
|
"pino-pretty": "^13.1.3"
|
|
@@ -8,8 +8,11 @@ export type RawFirstEventInsertParams = {
|
|
|
8
8
|
readonly newVersion: number;
|
|
9
9
|
readonly type: string;
|
|
10
10
|
readonly eventVersion: number;
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
// Plain objects — a JS string bound to ::jsonb double-encodes under
|
|
12
|
+
// Bun.SQL (jsonb string scalar instead of object), see
|
|
13
|
+
// db/queries/event-store.ts SubsequentEventInsertParams.
|
|
14
|
+
readonly payload: Record<string, unknown>;
|
|
15
|
+
readonly metadata: Record<string, unknown>;
|
|
13
16
|
readonly createdAt: string;
|
|
14
17
|
readonly createdBy: string;
|
|
15
18
|
};
|
|
@@ -31,8 +34,8 @@ export async function insertRawFirstEvent(
|
|
|
31
34
|
params.newVersion,
|
|
32
35
|
params.type,
|
|
33
36
|
params.eventVersion,
|
|
34
|
-
params.
|
|
35
|
-
params.
|
|
37
|
+
params.payload,
|
|
38
|
+
params.metadata,
|
|
36
39
|
params.createdAt,
|
|
37
40
|
params.createdBy,
|
|
38
41
|
],
|
|
@@ -67,8 +70,8 @@ export async function insertRawSubsequentEvent(
|
|
|
67
70
|
params.newVersion,
|
|
68
71
|
params.type,
|
|
69
72
|
params.eventVersion,
|
|
70
|
-
params.
|
|
71
|
-
params.
|
|
73
|
+
params.payload,
|
|
74
|
+
params.metadata,
|
|
72
75
|
params.createdAt,
|
|
73
76
|
params.createdBy,
|
|
74
77
|
params.expectedVersion,
|
|
@@ -13,8 +13,12 @@ export type SubsequentEventInsertParams = {
|
|
|
13
13
|
readonly newVersion: number;
|
|
14
14
|
readonly type: string;
|
|
15
15
|
readonly eventVersion: number;
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
// Plain objects, NOT pre-stringified JSON: Bun.SQL encodes a JS string
|
|
17
|
+
// bound to ::jsonb as a JSON string scalar (double-encoding) — every
|
|
18
|
+
// version>1 event written between 2026-05-25 and this fix carried
|
|
19
|
+
// payload/metadata as jsonb strings instead of objects.
|
|
20
|
+
readonly payload: Record<string, unknown>;
|
|
21
|
+
readonly metadata: Record<string, unknown>;
|
|
18
22
|
readonly createdBy: string;
|
|
19
23
|
readonly expectedVersion: number;
|
|
20
24
|
};
|
|
@@ -51,8 +55,8 @@ export async function insertSubsequentEventRow(
|
|
|
51
55
|
params.newVersion,
|
|
52
56
|
params.type,
|
|
53
57
|
params.eventVersion,
|
|
54
|
-
params.
|
|
55
|
-
params.
|
|
58
|
+
params.payload,
|
|
59
|
+
params.metadata,
|
|
56
60
|
params.createdBy,
|
|
57
61
|
params.expectedVersion,
|
|
58
62
|
],
|
|
@@ -127,7 +131,9 @@ export type SaveSnapshotParams = {
|
|
|
127
131
|
readonly tenantId: string;
|
|
128
132
|
readonly aggregateType: string;
|
|
129
133
|
readonly version: number;
|
|
130
|
-
|
|
134
|
+
// Plain object — see SubsequentEventInsertParams on why pre-stringified
|
|
135
|
+
// JSON double-encodes under Bun.SQL's ::jsonb binding.
|
|
136
|
+
readonly state: Record<string, unknown>;
|
|
131
137
|
};
|
|
132
138
|
|
|
133
139
|
export async function upsertSnapshot(db: AnyDb, params: SaveSnapshotParams): Promise<void> {
|
|
@@ -139,7 +145,7 @@ export async function upsertSnapshot(db: AnyDb, params: SaveSnapshotParams): Pro
|
|
|
139
145
|
"state" = $5::jsonb,
|
|
140
146
|
"aggregate_type" = $3,
|
|
141
147
|
"created_at" = now()`,
|
|
142
|
-
[params.aggregateId, params.tenantId, params.aggregateType, params.version, params.
|
|
148
|
+
[params.aggregateId, params.tenantId, params.aggregateType, params.version, params.state],
|
|
143
149
|
);
|
|
144
150
|
}
|
|
145
151
|
|
|
@@ -333,6 +333,17 @@ export type EntityEditScreenDefinition = {
|
|
|
333
333
|
readonly type: "entityEdit";
|
|
334
334
|
readonly entity: string;
|
|
335
335
|
readonly layout: EditLayout;
|
|
336
|
+
/** Default true. `false` für Entities deren Create über einen eigenen
|
|
337
|
+
* Lifecycle-Write läuft (z.B. incident:open mit Event-Stream + Joins)
|
|
338
|
+
* statt über `<entity>:create`: unterdrückt den automatischen
|
|
339
|
+
* „+ Neu"-Button auf entityList-Screens dieser Entity und rendert den
|
|
340
|
+
* Create-Branch (Aufruf ohne entityId) als Fehler statt eines Forms,
|
|
341
|
+
* dessen Submit gegen einen nicht registrierten Handler liefe. */
|
|
342
|
+
readonly allowCreate?: boolean;
|
|
343
|
+
/** Default true. `false` wenn kein `<entity>:delete`-Handler existiert
|
|
344
|
+
* (History-/Audit-Erhalt): unterdrückt den Löschen-Button im
|
|
345
|
+
* Update-Form. */
|
|
346
|
+
readonly allowDelete?: boolean;
|
|
336
347
|
readonly slots?: ScreenSlots;
|
|
337
348
|
readonly access?: AccessRule;
|
|
338
349
|
};
|
package/src/entrypoint/index.ts
CHANGED
|
@@ -96,6 +96,15 @@ export type ApiEntrypointOptions = BaseEntrypointOptions & {
|
|
|
96
96
|
readonly jobs?: JobsBlock & {
|
|
97
97
|
readonly runLocalJobs?: boolean;
|
|
98
98
|
};
|
|
99
|
+
// Event-dispatcher inside the API process — the MSP analog to
|
|
100
|
+
// `jobs.runLocalJobs`. Single-container deployments (runProdApp) have no
|
|
101
|
+
// worker process; without `runLocal: true` their multiStreamProjections
|
|
102
|
+
// NEVER apply (the read-side silently stays empty — 2026-06-11 incident).
|
|
103
|
+
// processLane becomes "both" so worker-lane MSPs run here too.
|
|
104
|
+
// Deployments with a dedicated worker keep this unset.
|
|
105
|
+
readonly eventDispatcher?: ServerOptions["eventDispatcher"] & {
|
|
106
|
+
readonly runLocal?: boolean;
|
|
107
|
+
};
|
|
99
108
|
};
|
|
100
109
|
|
|
101
110
|
export type WorkerEntrypointOptions = BaseEntrypointOptions &
|
|
@@ -117,9 +126,13 @@ export type ApiEntrypoint = {
|
|
|
117
126
|
// Command-dispatcher behind /api/* — for writes outside the HTTP
|
|
118
127
|
// pipeline (provider-webhook routes, see KumikoServer.dispatcher).
|
|
119
128
|
readonly dispatcher: Dispatcher;
|
|
129
|
+
// Set when `eventDispatcher.runLocal: true` built a local poller —
|
|
130
|
+
// single-container mode. Undefined for API-only processes.
|
|
131
|
+
readonly eventDispatcher?: EventDispatcher;
|
|
120
132
|
readonly mode: "api";
|
|
121
|
-
//
|
|
122
|
-
//
|
|
133
|
+
// Starts the local BullMQ worker (runLocalJobs) and the local
|
|
134
|
+
// event-dispatcher (eventDispatcher.runLocal) when configured;
|
|
135
|
+
// no-op otherwise. Uniform call-site so `main.ts` doesn't branch on mode.
|
|
123
136
|
start(): Promise<void>;
|
|
124
137
|
stop(): Promise<void>;
|
|
125
138
|
};
|
|
@@ -322,9 +335,19 @@ export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoin
|
|
|
322
335
|
)
|
|
323
336
|
: undefined;
|
|
324
337
|
|
|
325
|
-
// `
|
|
326
|
-
// doesn't hold an idle poller.
|
|
327
|
-
|
|
338
|
+
// Without `runLocal` the dispatcher is skipped entirely (`{disabled:true}`)
|
|
339
|
+
// — an API process behind a dedicated worker doesn't hold an idle poller.
|
|
340
|
+
// WITH `runLocal` this process fills every role (single-container), so
|
|
341
|
+
// processLane is "both": worker-lane MSPs must run here or they'd never
|
|
342
|
+
// apply anywhere.
|
|
343
|
+
const { runLocal: runLocalDispatcher, ...dispatcherTunables } = options.eventDispatcher ?? {};
|
|
344
|
+
const server = buildApiServer(
|
|
345
|
+
options,
|
|
346
|
+
lifecycle,
|
|
347
|
+
runLocalDispatcher ? dispatcherTunables : { disabled: true },
|
|
348
|
+
apiJobRunner,
|
|
349
|
+
runLocalDispatcher ? "both" : "api",
|
|
350
|
+
);
|
|
328
351
|
|
|
329
352
|
return {
|
|
330
353
|
app: server.app,
|
|
@@ -333,12 +356,17 @@ export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoin
|
|
|
333
356
|
lifecycle,
|
|
334
357
|
observability: server.observability,
|
|
335
358
|
dispatcher: server.dispatcher,
|
|
359
|
+
...(server.eventDispatcher && { eventDispatcher: server.eventDispatcher }),
|
|
336
360
|
mode: "api",
|
|
337
361
|
async start() {
|
|
338
362
|
// Start the local BullMQ worker when runLocalJobs=true; enqueuer-only
|
|
339
363
|
// runners have a no-op .start() by design (JobRunner skips worker
|
|
340
364
|
// creation when consumerLane is undefined).
|
|
341
365
|
if (apiJobRunner) await apiJobRunner.start();
|
|
366
|
+
// Local event-dispatcher (runLocal): begins the poll loop so MSPs
|
|
367
|
+
// apply in-process. stop() is already lifecycle-registered by
|
|
368
|
+
// buildServer.
|
|
369
|
+
if (server.eventDispatcher) await server.eventDispatcher.start();
|
|
342
370
|
},
|
|
343
371
|
async stop() {
|
|
344
372
|
await lifecycle.drain();
|
|
@@ -410,11 +438,13 @@ export function createAllInOneEntrypoint(options: AllInOneEntrypointOptions): Al
|
|
|
410
438
|
// config instead of `{disabled:true}`, so buildServer wires the poller
|
|
411
439
|
// alongside the HTTP app. processLane "both" disables MSP lane-filter
|
|
412
440
|
// entirely: all-in-one is a single process that fills every role, so
|
|
413
|
-
// every MSP (api-only, worker-only, both) must run here.
|
|
441
|
+
// every MSP (api-only, worker-only, both) must run here. `runLocal` is
|
|
442
|
+
// the API-mode flag — all-in-one is always local, strip it.
|
|
443
|
+
const { runLocal: _runLocal, ...allInOneDispatcherTunables } = options.eventDispatcher ?? {};
|
|
414
444
|
const server = buildApiServer(
|
|
415
445
|
options,
|
|
416
446
|
lifecycle,
|
|
417
|
-
|
|
447
|
+
allInOneDispatcherTunables,
|
|
418
448
|
workerJobRunner,
|
|
419
449
|
"both",
|
|
420
450
|
);
|
|
@@ -584,3 +584,59 @@ describe("event-store: streamAllEventsByType (memory-bounded iteration)", () =>
|
|
|
584
584
|
expect((thrown as Error).name).toBe("AbortError");
|
|
585
585
|
});
|
|
586
586
|
});
|
|
587
|
+
|
|
588
|
+
describe("event-store: jsonb encoding of payload/metadata", () => {
|
|
589
|
+
// Regression für die Bun.SQL-Doppelkodierung (Prod-Incident 2026-06-11):
|
|
590
|
+
// insertSubsequentEventRow band stringifyJson(payload) an ::jsonb — Bun
|
|
591
|
+
// kodiert einen JS-String für jsonb erneut, das Ergebnis ist ein jsonb-
|
|
592
|
+
// STRING-Skalar statt einem Objekt. Der typed Read-Pfad parsed Strings
|
|
593
|
+
// beim Laden zurück (bun-db/query.ts), deshalb blieb das in allen
|
|
594
|
+
// loadAggregate-Tests unsichtbar — nur SQL-Konsumenten (payload->>'x',
|
|
595
|
+
// GDPR-Pipeline, MSP-Replays über raw rows) sahen kaputte Daten. Darum
|
|
596
|
+
// prüft dieser Test das Spalten-Encoding direkt in SQL.
|
|
597
|
+
test("first AND subsequent append store payload/metadata as jsonb objects", async () => {
|
|
598
|
+
const aggregateId = uuid();
|
|
599
|
+
const base = {
|
|
600
|
+
aggregateId,
|
|
601
|
+
aggregateType: "task",
|
|
602
|
+
tenantId: tenantA,
|
|
603
|
+
metadata: { userId: userA },
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
await append(testDb.db, {
|
|
607
|
+
...base,
|
|
608
|
+
expectedVersion: 0,
|
|
609
|
+
type: "task.created",
|
|
610
|
+
payload: { title: "T" },
|
|
611
|
+
});
|
|
612
|
+
await append(testDb.db, {
|
|
613
|
+
...base,
|
|
614
|
+
expectedVersion: 1,
|
|
615
|
+
type: "task.updated",
|
|
616
|
+
payload: { title: "T2", nested: { deep: true } },
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
const rows = (await asRawClient(testDb.db).unsafe(
|
|
620
|
+
`SELECT version,
|
|
621
|
+
jsonb_typeof(payload) AS payload_type,
|
|
622
|
+
jsonb_typeof(metadata) AS metadata_type,
|
|
623
|
+
payload->>'title' AS title
|
|
624
|
+
FROM kumiko_events WHERE aggregate_id = $1 ORDER BY version`,
|
|
625
|
+
[aggregateId],
|
|
626
|
+
)) as ReadonlyArray<{
|
|
627
|
+
version: number;
|
|
628
|
+
payload_type: string;
|
|
629
|
+
metadata_type: string;
|
|
630
|
+
title: string | null;
|
|
631
|
+
}>;
|
|
632
|
+
|
|
633
|
+
expect(rows).toHaveLength(2);
|
|
634
|
+
for (const row of rows) {
|
|
635
|
+
expect(row.payload_type).toBe("object");
|
|
636
|
+
expect(row.metadata_type).toBe("object");
|
|
637
|
+
}
|
|
638
|
+
// SQL-seitiger Feldzugriff funktioniert nur auf echten Objekten —
|
|
639
|
+
// genau der Pfad, der mit String-Skalaren null lieferte.
|
|
640
|
+
expect(rows[1]?.title).toBe("T2");
|
|
641
|
+
});
|
|
642
|
+
});
|
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
insertRawSubsequentEvent,
|
|
18
18
|
} from "../db/queries/event-store-admin";
|
|
19
19
|
import type { TenantId } from "../engine/types";
|
|
20
|
-
import { stringifyJson } from "../utils/safe-json";
|
|
21
20
|
import { VersionConflictError } from "./errors";
|
|
22
21
|
import type { EventMetadata } from "./event-store";
|
|
23
22
|
|
|
@@ -69,8 +68,8 @@ function rawEventParams(event: RawEventToAppend, newVersion: number, eventVersio
|
|
|
69
68
|
newVersion,
|
|
70
69
|
type: event.type,
|
|
71
70
|
eventVersion,
|
|
72
|
-
|
|
73
|
-
|
|
71
|
+
payload: event.payload,
|
|
72
|
+
metadata: event.metadata,
|
|
74
73
|
createdAt: event.createdAt.toString(),
|
|
75
74
|
createdBy: event.createdBy,
|
|
76
75
|
};
|
|
@@ -130,8 +129,8 @@ export async function appendRawBatch(
|
|
|
130
129
|
newVersion,
|
|
131
130
|
e.type,
|
|
132
131
|
eventVersion,
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
e.payload,
|
|
133
|
+
e.metadata,
|
|
135
134
|
e.createdAt.toString(),
|
|
136
135
|
e.createdBy,
|
|
137
136
|
);
|
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
} from "../db/queries/event-store";
|
|
11
11
|
import { insertOne, selectMany } from "../db/query";
|
|
12
12
|
import type { TenantId } from "../engine/types";
|
|
13
|
-
import { stringifyJson } from "../utils/safe-json";
|
|
14
13
|
import { isStreamArchived } from "./archive";
|
|
15
14
|
import { VersionConflictError } from "./errors";
|
|
16
15
|
import { eventsTable } from "./events-schema";
|
|
@@ -176,8 +175,8 @@ async function insertSubsequentEvent(
|
|
|
176
175
|
newVersion,
|
|
177
176
|
type: event.type,
|
|
178
177
|
eventVersion,
|
|
179
|
-
|
|
180
|
-
|
|
178
|
+
payload: event.payload,
|
|
179
|
+
metadata: event.metadata,
|
|
181
180
|
createdBy: event.metadata.userId,
|
|
182
181
|
expectedVersion: event.expectedVersion,
|
|
183
182
|
});
|
|
@@ -17,7 +17,6 @@ import { selectMany } from "../db/query";
|
|
|
17
17
|
import { tableExists } from "../db/schema-inspection";
|
|
18
18
|
import type { TenantId } from "../engine/types";
|
|
19
19
|
import { unsafePushTables } from "../stack";
|
|
20
|
-
import { stringifyJson } from "../utils/safe-json";
|
|
21
20
|
import { isStreamArchived } from "./archive";
|
|
22
21
|
import { loadEventsAfterVersion, type StoredEvent } from "./event-store";
|
|
23
22
|
|
|
@@ -108,7 +107,7 @@ export async function saveSnapshot(db: DbRunner, args: SaveSnapshotArgs): Promis
|
|
|
108
107
|
tenantId: args.tenantId,
|
|
109
108
|
aggregateType: args.aggregateType,
|
|
110
109
|
version: args.version,
|
|
111
|
-
|
|
110
|
+
state: args.state,
|
|
112
111
|
});
|
|
113
112
|
}
|
|
114
113
|
|