@cosmicdrift/kumiko-framework 0.74.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 +2 -2
- package/src/db/__tests__/eagerload.integration.test.ts +0 -1
- package/src/db/__tests__/event-store-system-stream.integration.test.ts +96 -0
- package/src/db/event-store-executor.ts +35 -12
- package/src/engine/__tests__/build-app-schema-settings-hub.test.ts +2 -2
- package/src/engine/__tests__/build-config-feature-schema.test.ts +4 -4
- package/src/engine/factories.ts +3 -0
- package/src/engine/types/fields.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "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.
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
710
|
+
await assertStreamWritable(db, payload.id, streamTenantFor(user));
|
|
700
711
|
|
|
701
712
|
// Stream-version authoritative (see update() for rationale).
|
|
702
|
-
const currentVersion = await getStreamVersion(
|
|
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
|
|
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
|
|
791
|
+
await assertStreamWritable(db, payload.id, streamTenantFor(user));
|
|
777
792
|
|
|
778
793
|
// Stream-version authoritative (see update() for rationale).
|
|
779
|
-
const currentVersion = await getStreamVersion(
|
|
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
|
|
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(
|
|
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
|
|
240
|
-
//
|
|
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
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
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: {
|
package/src/engine/factories.ts
CHANGED
|
@@ -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. */
|