@cosmicdrift/kumiko-framework 0.41.0 → 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.41.0",
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>",
@@ -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
  }