@cosmicdrift/kumiko-framework 0.40.1 → 0.41.1

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.1",
3
+ "version": "0.41.1",
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"
@@ -1,7 +1,7 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { asRawClient } from "../../db/query";
3
3
  import { createBooleanField, createEntity, createTextField } from "../../engine";
4
- import { createEventsTable } from "../../event-store";
4
+ import { append, createEventsTable } from "../../event-store";
5
5
  import {
6
6
  createTestDb,
7
7
  type TestDb,
@@ -246,3 +246,42 @@ describe("event-store-executor — sensitive fields", () => {
246
246
  expect(event.payload.previous.apiToken).toBeUndefined();
247
247
  });
248
248
  });
249
+
250
+ describe("event-store-executor — detail liefert die Stream-Version", () => {
251
+ const crud = createEventStoreExecutor(table, entity, { entityName: "esExecUser" });
252
+
253
+ // Lifecycle-Writes (ctx.appendEvent) bumpen den Stream, ohne row.version
254
+ // anzufassen — gäbe detail die stale Row-Version heraus, liefe jedes
255
+ // darauf gebaute CRUD-Update (entityEdit nutzt detail.version als
256
+ // optimistic-lock-Basis) in ein garantiertes version_conflict.
257
+ // Prod-Repro: incident:open appended das Eröffnungs-Update → Stream v2,
258
+ // Row v1 → incident-edit konnte nie speichern.
259
+ test("nach ctx.appendEvent-artigem Stream-Bump: detail.version == Stream, Update damit erfolgreich", async () => {
260
+ const created = await crud.create({ email: "stream@test.de" }, adminUser, tdb);
261
+ expect(created.isSuccess).toBe(true);
262
+ if (!created.isSuccess) return;
263
+ const id = created.data.id;
264
+
265
+ // Hand-emittiertes Event auf demselben Aggregat (wie incident:post-update).
266
+ await append(testDb.db, {
267
+ aggregateId: String(id),
268
+ aggregateType: "esExecUser",
269
+ tenantId: adminUser.tenantId,
270
+ expectedVersion: 1,
271
+ type: "esExecUser.lifecycle-bumped",
272
+ payload: { note: "stream moved past the row" },
273
+ metadata: { userId: String(adminUser.id) },
274
+ });
275
+
276
+ const detail = await crud.detail({ id }, adminUser, tdb);
277
+ expect(detail).not.toBeNull();
278
+ expect(detail?.["version"]).toBe(2);
279
+
280
+ const updated = await crud.update(
281
+ { id, version: 2, changes: { firstName: "After" } },
282
+ adminUser,
283
+ tdb,
284
+ );
285
+ expect(updated.isSuccess).toBe(true);
286
+ });
287
+ });
@@ -969,6 +969,19 @@ export function createEventStoreExecutor(
969
969
 
970
970
  const idWhere = idFilter(payload.id);
971
971
 
972
+ // Stream-version authoritative (same policy as update/Block 0):
973
+ // ctx.appendEvent (lifecycle-writes like incident:post-update) bumps
974
+ // the stream WITHOUT touching row.version — a detail-read that hands
975
+ // out the stale row.version dooms the next CRUD update built on it
976
+ // (entityEdit loads detail.version as its optimistic-lock base) to a
977
+ // guaranteed version_conflict.
978
+ const withStreamVersion = async (
979
+ row: Record<string, unknown>,
980
+ ): Promise<Record<string, unknown>> => {
981
+ const streamVersion = await getStreamVersion(db.raw, String(payload.id), user.tenantId);
982
+ return streamVersion > 0 ? { ...row, version: streamVersion } : row;
983
+ };
984
+
972
985
  if (entityCache && entityName) {
973
986
  const cached = await entityCache.get(user.tenantId, entityName, payload.id);
974
987
  if (cached) {
@@ -978,7 +991,7 @@ export function createEventStoreExecutor(
978
991
  const checkRows = await loadWithOwnership(db, idWhere, ownership);
979
992
  if (checkRows.length === 0) return null;
980
993
  }
981
- return cached;
994
+ return withStreamVersion(cached);
982
995
  }
983
996
  }
984
997
 
@@ -993,7 +1006,7 @@ export function createEventStoreExecutor(
993
1006
  await entityCache.set(user.tenantId, entityName, payload.id, coerced);
994
1007
  }
995
1008
 
996
- return coerced;
1009
+ return withStreamVersion(coerced);
997
1010
  },
998
1011
  };
999
1012
  }
@@ -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
 
@@ -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