@cosmicdrift/kumiko-framework 0.75.0 → 0.76.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.75.0",
3
+ "version": "0.76.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.75.0",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.76.0",
185
185
  "bun-types": "^1.3.13",
186
186
  "pino-pretty": "^13.1.3"
187
187
  },
@@ -53,7 +53,6 @@ let dbA: ReturnType<typeof createTenantDb>;
53
53
  beforeAll(async () => {
54
54
  stack = await setupTestStack({ features: [] });
55
55
  await unsafeCreateEntityTable(stack.db, authorEntity);
56
- await unsafeCreateEntityTable(stack.db, postEntity);
57
56
  dbA = createTenantDb(stack.db, tenantA, "tenant");
58
57
 
59
58
  await insertMany(stack.db, authorTable, [
@@ -0,0 +1,96 @@
1
+ // #497 guard: an entity declared `systemStream: true` (tenant-independent, e.g.
2
+ // user) gets its event stream on SYSTEM_TENANT_ID for EVERY op; a normal entity
3
+ // stays on the caller's tenant (byte-identical to the old hardcoded user.tenantId).
4
+ // Routing is per-entity (createEntity flag), NOT inherited from r.systemScope().
5
+
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { createEntity, createTextField } from "../../engine";
8
+ import { SYSTEM_TENANT_ID } from "../../engine/types/identifiers";
9
+ import { eventsTable } from "../../event-store";
10
+ import {
11
+ createTestDb,
12
+ type TestDb,
13
+ TestUsers,
14
+ testTenantId,
15
+ unsafeCreateEntityTable,
16
+ } from "../../stack";
17
+ import { createEventStoreExecutor } from "../event-store-executor";
18
+ import { selectMany } from "../query";
19
+ import { buildEntityTable } from "../table-builder";
20
+ import { createTenantDb } from "../tenant-db";
21
+
22
+ const systemEntity = createEntity({
23
+ table: "sstream_sys",
24
+ systemStream: true,
25
+ fields: { name: createTextField({ required: true }) },
26
+ });
27
+ const tenantOwnedEntity = createEntity({
28
+ table: "sstream_tn",
29
+ fields: { name: createTextField({ required: true }) },
30
+ });
31
+
32
+ const systemTable = buildEntityTable("sstreamSys", systemEntity);
33
+ const tenantTable = buildEntityTable("sstreamTn", tenantOwnedEntity);
34
+ const systemExec = createEventStoreExecutor(systemTable, systemEntity, {
35
+ entityName: "sstreamSys",
36
+ });
37
+ const tenantExec = createEventStoreExecutor(tenantTable, tenantOwnedEntity, {
38
+ entityName: "sstreamTn",
39
+ });
40
+
41
+ const user = TestUsers.admin; // tenantId === testTenantId(1)
42
+
43
+ let testDb: TestDb;
44
+
45
+ beforeAll(async () => {
46
+ testDb = await createTestDb();
47
+ await unsafeCreateEntityTable(testDb.db, systemEntity, "sstreamSys");
48
+ await unsafeCreateEntityTable(testDb.db, tenantOwnedEntity, "sstreamTn");
49
+ });
50
+
51
+ afterAll(async () => {
52
+ await testDb.cleanup();
53
+ });
54
+
55
+ async function eventTenantIds(aggregateId: string): Promise<string[]> {
56
+ const rows = await selectMany(testDb.db, eventsTable, { aggregateId });
57
+ return rows.map((r) => r!["tenantId"] as string);
58
+ }
59
+
60
+ describe("systemStream stream-tenant routing (#497)", () => {
61
+ test("systemStream entity: created event lands on SYSTEM_TENANT_ID", async () => {
62
+ const db = createTenantDb(testDb.db, user.tenantId);
63
+ const res = await systemExec.create({ name: "a" }, user, db);
64
+ expect(res.isSuccess).toBe(true);
65
+ if (!res.isSuccess) throw new Error("create failed");
66
+
67
+ expect(await eventTenantIds(String(res.data.id))).toEqual([SYSTEM_TENANT_ID]);
68
+ });
69
+
70
+ test("systemStream entity: update addresses the SYSTEM stream (every op routes)", async () => {
71
+ const db = createTenantDb(testDb.db, user.tenantId);
72
+ const created = await systemExec.create({ name: "a" }, user, db);
73
+ if (!created.isSuccess) throw new Error("create failed");
74
+ const id = String(created.data.id);
75
+
76
+ // If update addressed user.tenantId instead of SYSTEM, getStreamVersion
77
+ // would read 0 and the append would version-conflict. Success proves the
78
+ // whole addressing path (not just create) routes to SYSTEM.
79
+ const updated = await systemExec.update({ id, changes: { name: "b" } }, user, db, {
80
+ skipOptimisticLock: true,
81
+ });
82
+ expect(updated.isSuccess).toBe(true);
83
+ expect(await eventTenantIds(id)).toEqual([SYSTEM_TENANT_ID, SYSTEM_TENANT_ID]);
84
+ });
85
+
86
+ test("normal entity: created event lands on the caller's tenant (unchanged)", async () => {
87
+ const db = createTenantDb(testDb.db, user.tenantId);
88
+ const res = await tenantExec.create({ name: "a" }, user, db);
89
+ expect(res.isSuccess).toBe(true);
90
+ if (!res.isSuccess) throw new Error("create failed");
91
+
92
+ const ids = await eventTenantIds(String(res.data.id));
93
+ expect(ids).toEqual([testTenantId(1)]);
94
+ expect(ids).not.toContain(SYSTEM_TENANT_ID);
95
+ });
96
+ });
@@ -243,6 +243,13 @@ export function createEventStoreExecutor(
243
243
  const { searchAdapter, entityName, entityCache } = options;
244
244
  const softDelete = entity.softDelete ?? false;
245
245
 
246
+ // Stream-tenant choke-point. A systemStream entity (tenant-independent, e.g.
247
+ // user) lives on SYSTEM_TENANT_ID deterministically — every op addresses it
248
+ // there. Everything else stays on the caller's tenant (byte-identical to the
249
+ // old hardcoded user.tenantId). Single source of truth for the stream key.
250
+ const streamTenantFor = (user: SessionUser): TenantId =>
251
+ entity.systemStream ? SYSTEM_TENANT_ID : user.tenantId;
252
+
246
253
  // idType default (undefined) is now "uuid" — the ES-pivot made UUID the
247
254
  // only valid aggregate-id type. Explicit `idType: "serial"` is the only
248
255
  // shape that's incompatible with the event-store and still rejected.
@@ -433,7 +440,7 @@ export function createEventStoreExecutor(
433
440
  event = await append(db.raw, {
434
441
  aggregateId,
435
442
  aggregateType: entityName,
436
- tenantId: user.tenantId,
443
+ tenantId: streamTenantFor(user),
437
444
  expectedVersion: 0,
438
445
  type: entityEventName(entityName, "created"),
439
446
  payload: stripSensitive(flatData),
@@ -451,7 +458,7 @@ export function createEventStoreExecutor(
451
458
  // is recoverable client-side via a fresh detail-query if needed.
452
459
  let currentVersion = -1;
453
460
  try {
454
- currentVersion = await getStreamVersion(db.raw, aggregateId, user.tenantId);
461
+ currentVersion = await getStreamVersion(db.raw, aggregateId, streamTenantFor(user));
455
462
  } catch {
456
463
  // Aborted TX or any lookup failure — keep the sentinel.
457
464
  }
@@ -560,14 +567,18 @@ export function createEventStoreExecutor(
560
567
  );
561
568
  }
562
569
 
563
- await assertStreamWritable(db, payload.id, user.tenantId);
570
+ await assertStreamWritable(db, payload.id, streamTenantFor(user));
564
571
 
565
572
  // Stream-version is authoritative, not row.version. `ctx.appendEvent`
566
573
  // can bump the stream between CRUD writes (domain event on the same
567
574
  // aggregate); a stale row.version here would make the next CRUD write
568
575
  // trip `events_aggregate_version_uq` (tenant_id, aggregate_id, version)
569
576
  // with version_conflict.
570
- const currentVersion = await getStreamVersion(db.raw, String(payload.id), user.tenantId);
577
+ const currentVersion = await getStreamVersion(
578
+ db.raw,
579
+ String(payload.id),
580
+ streamTenantFor(user),
581
+ );
571
582
  if (!updateOptions?.skipOptimisticLock) {
572
583
  if (payload.version === undefined) {
573
584
  return writeFailure(
@@ -603,7 +614,7 @@ export function createEventStoreExecutor(
603
614
  const event = await append(db.raw, {
604
615
  aggregateId: String(payload.id),
605
616
  aggregateType: entityName,
606
- tenantId: user.tenantId,
617
+ tenantId: streamTenantFor(user),
607
618
  expectedVersion: currentVersion,
608
619
  type: entityEventName(entityName, "updated"),
609
620
  payload: {
@@ -696,10 +707,14 @@ export function createEventStoreExecutor(
696
707
  );
697
708
  }
698
709
 
699
- await assertStreamWritable(db, payload.id, user.tenantId);
710
+ await assertStreamWritable(db, payload.id, streamTenantFor(user));
700
711
 
701
712
  // Stream-version authoritative (see update() for rationale).
702
- const currentVersion = await getStreamVersion(db.raw, String(payload.id), user.tenantId);
713
+ const currentVersion = await getStreamVersion(
714
+ db.raw,
715
+ String(payload.id),
716
+ streamTenantFor(user),
717
+ );
703
718
 
704
719
  // Deletes carry the full pre-delete row as `previous`. That's what
705
720
  // projections and downstream consumers need to reverse any aggregates —
@@ -708,7 +723,7 @@ export function createEventStoreExecutor(
708
723
  const event = await append(db.raw, {
709
724
  aggregateId: String(payload.id),
710
725
  aggregateType: entityName,
711
- tenantId: user.tenantId,
726
+ tenantId: streamTenantFor(user),
712
727
  expectedVersion: currentVersion,
713
728
  type: entityEventName(entityName, "deleted"),
714
729
  payload: { previous: stripSensitive(existing) },
@@ -773,10 +788,14 @@ export function createEventStoreExecutor(
773
788
  );
774
789
  }
775
790
 
776
- await assertStreamWritable(db, payload.id, user.tenantId);
791
+ await assertStreamWritable(db, payload.id, streamTenantFor(user));
777
792
 
778
793
  // Stream-version authoritative (see update() for rationale).
779
- const currentVersion = await getStreamVersion(db.raw, String(payload.id), user.tenantId);
794
+ const currentVersion = await getStreamVersion(
795
+ db.raw,
796
+ String(payload.id),
797
+ streamTenantFor(user),
798
+ );
780
799
  // Restore carries the soft-deleted snapshot as `previous` — mirror of
781
800
  // delete for symmetry. Projections that decremented on delete use
782
801
  // `previous` to re-increment on restore without re-querying the entity
@@ -784,7 +803,7 @@ export function createEventStoreExecutor(
784
803
  const event = await append(db.raw, {
785
804
  aggregateId: String(payload.id),
786
805
  aggregateType: entityName,
787
- tenantId: user.tenantId,
806
+ tenantId: streamTenantFor(user),
788
807
  expectedVersion: currentVersion,
789
808
  type: entityEventName(entityName, "restored"),
790
809
  payload: { previous: stripSensitive(data) },
@@ -1000,7 +1019,11 @@ export function createEventStoreExecutor(
1000
1019
  const withStreamVersion = async (
1001
1020
  row: Record<string, unknown>,
1002
1021
  ): Promise<Record<string, unknown>> => {
1003
- const streamVersion = await getStreamVersion(db.raw, String(payload.id), user.tenantId);
1022
+ const streamVersion = await getStreamVersion(
1023
+ db.raw,
1024
+ String(payload.id),
1025
+ streamTenantFor(user),
1026
+ );
1004
1027
  return streamVersion > 0 ? { ...row, version: streamVersion } : row;
1005
1028
  };
1006
1029
 
@@ -236,8 +236,8 @@ describe("buildAppSchema — dangling audience-ref dev-warning (#408/3)", () =>
236
236
  });
237
237
 
238
238
  test("authoring warnings are suppressed under NODE_ENV=test — no CI-log noise (#408/1)", () => {
239
- // bun:test setzt NODE_ENV=test; eine un-platzierte Audience (tenant bleibt
240
- // bei placingShell("system") übrig) darf KEIN console.warn ins Log spülen.
239
+ // bun:test sets NODE_ENV=test; an unplaced audience (tenant left over by
240
+ // placingShell("system")) must NOT emit a console.warn into CI logs.
241
241
  const warn = spyOn(console, "warn").mockImplementation(() => {});
242
242
  try {
243
243
  buildAppSchema(createRegistry([placingShell("system"), billing]));
@@ -202,10 +202,10 @@ describe("buildConfigFeatureSchema — per-role cascade (system > tenant > user)
202
202
  });
203
203
 
204
204
  describe("buildConfigFeatureSchema — machine-role does not leak into screen access (#406/2)", () => {
205
- // Ein gemischter write-Set (machine "system" + human "SystemAdmin") entsteht
206
- // nur über ein explizites write:-Override (kein Preset erzeugt den Mix). Der
207
- // Screen darf trotzdem NUR die Human-Rolle gaten — "system" trägt kein User,
208
- // ein Leak macht das Gate inkonsistent zum Hardening-Ziel.
205
+ // A mixed write-set (machine "system" + human "SystemAdmin") only arises via
206
+ // an explicit write:-override (no preset produces the mix). The screen must
207
+ // gate on the human role only — "system" is not carried by a real user and
208
+ // leaking it makes the gate inconsistent with the hardening goal.
209
209
  const mixedWrite = defineFeature("mixed", (r) => {
210
210
  r.config({
211
211
  keys: {
@@ -363,6 +363,9 @@ export function createEntity<F>(def: {
363
363
  readonly table?: string;
364
364
  readonly fields: F;
365
365
  readonly softDelete?: boolean;
366
+ /** Event stream lives on SYSTEM_TENANT_ID (tenant-independent aggregate, e.g.
367
+ * user) instead of the creator's tenant. See EntityDefinition.systemStream. */
368
+ readonly systemStream?: boolean;
366
369
  readonly searchWeight?: number;
367
370
  readonly defaultCurrency?: string;
368
371
  readonly transitions?: Readonly<Record<string, Readonly<Record<string, readonly string[]>>>>;
@@ -561,6 +561,11 @@ export type EntityDefinition<F extends FieldsMap = FieldsMap> = {
561
561
  readonly table?: string;
562
562
  readonly fields: F;
563
563
  readonly softDelete?: boolean;
564
+ /** This aggregate's event stream lives on SYSTEM_TENANT_ID rather than the
565
+ * creator's tenant. Opt-in per entity (NOT inherited from r.systemScope()):
566
+ * only for genuinely tenant-independent aggregates like `user`. The first
567
+ * event (create) is what's routed; updates resolve the stream tenant upstream. */
568
+ readonly systemStream?: boolean;
564
569
  readonly searchWeight?: number;
565
570
  readonly defaultCurrency?: string;
566
571
  /** Allowed state transitions per field. Boot validates against select options. */