@cosmicdrift/kumiko-framework 0.40.0 → 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-framework",
3
- "version": "0.40.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.38.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
- readonly payloadJson: string;
12
- readonly metadataJson: string;
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.payloadJson,
35
- params.metadataJson,
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.payloadJson,
71
- params.metadataJson,
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
- readonly payloadJson: string;
17
- readonly metadataJson: string;
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.payloadJson,
55
- params.metadataJson,
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
- readonly stateJson: string;
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.stateJson],
148
+ [params.aggregateId, params.tenantId, params.aggregateType, params.version, params.state],
143
149
  );
144
150
  }
145
151
 
@@ -1890,6 +1890,56 @@ describe("boot-validator", () => {
1890
1890
  });
1891
1891
  });
1892
1892
 
1893
+ // --- rowAction payload-Extractor Feld-Referenzen (Tier 2.7e-3) ---
1894
+ // Row-Meta (id, version) ist auf jeder Entity-Row vorhanden ohne ein
1895
+ // Entity-Field zu sein — pick ["id", "version"] ist das Standard-Payload
1896
+ // für optimistic-lock-Lifecycle-Writes und darf den Boot nicht killen
1897
+ // (Prod-Incident publicstatus 2026-06-11: 0.40-Validator lehnte
1898
+ // maintenance-start/cancel/complete ab, CrashLoopBackOff).
1899
+ describe("entityList rowAction payload pick (Tier 2.7e-3)", () => {
1900
+ function makeFeature(pick: readonly string[]) {
1901
+ return defineFeature("shop", (r) => {
1902
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
1903
+ r.screen({
1904
+ id: "product-list",
1905
+ type: "entityList",
1906
+ entity: "product",
1907
+ columns: ["name"],
1908
+ rowActions: [
1909
+ {
1910
+ id: "archive",
1911
+ label: "actions.archive",
1912
+ handler: "shop:write:archive",
1913
+ payload: { pick: [...pick] },
1914
+ },
1915
+ ],
1916
+ });
1917
+ r.writeHandler(
1918
+ "archive",
1919
+ z.object({}),
1920
+ async () => ({ isSuccess: true as const, data: null }),
1921
+ {
1922
+ access: { roles: ["Admin"] },
1923
+ },
1924
+ );
1925
+ });
1926
+ }
1927
+
1928
+ test("pick mit Row-Meta id + version → kein Throw (optimistic-lock-Standard)", () => {
1929
+ expect(() => validateBoot([makeFeature(["id", "version"])])).not.toThrow();
1930
+ });
1931
+
1932
+ test("pick mit Entity-Field → kein Throw", () => {
1933
+ expect(() => validateBoot([makeFeature(["id", "name"])])).not.toThrow();
1934
+ });
1935
+
1936
+ test("pick mit unknown Field → Throw mit klarer Message", () => {
1937
+ expect(() => validateBoot([makeFeature(["id", "ghost"])])).toThrow(
1938
+ /rowAction "archive" payload references unknown field "ghost"/,
1939
+ );
1940
+ });
1941
+ });
1942
+
1893
1943
  // --- toolbarAction navigate + writeHandler Validierung (Tier 2.7e-2) ---
1894
1944
  describe("entityList toolbarAction navigate (Tier 2.7e-2)", () => {
1895
1945
  function makeFeature(targetScreen: string, withTarget: boolean) {
@@ -48,7 +48,9 @@ function validateActionFieldRefs(
48
48
  }
49
49
  const sources = "pick" in extractor ? extractor.pick : Object.values(extractor.map);
50
50
  for (const source of sources) {
51
- if (source === "id") continue; // row.id ist immer da (Aggregat-Id)
51
+ // Row-Meta ist immer da, ohne Entity-Field zu sein: id (Aggregat-Id)
52
+ // und version (optimistic lock — Standard-pick für Lifecycle-Writes).
53
+ if (source === "id" || source === "version") continue;
52
54
  if (!fieldNames.has(source)) {
53
55
  throw new Error(
54
56
  `[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
@@ -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
  };
@@ -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
- // No-op on API mode event-dispatcher isn't built, job-runner doesn't
122
- // exist. Kept for a uniform call-site so `main.ts` doesn't branch on mode.
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
- // `{disabled:true}` skips dispatcher creation entirely — an API process
326
- // doesn't hold an idle poller.
327
- const server = buildApiServer(options, lifecycle, { disabled: true }, apiJobRunner, "api");
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
- options.eventDispatcher,
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
- payloadJson: stringifyJson(event.payload),
73
- metadataJson: stringifyJson(event.metadata),
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
- stringifyJson(e.payload),
134
- stringifyJson(e.metadata),
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
- payloadJson: stringifyJson(event.payload),
180
- metadataJson: stringifyJson(event.metadata),
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
- stateJson: stringifyJson(args.state),
110
+ state: args.state,
112
111
  });
113
112
  }
114
113