@cosmicdrift/kumiko-framework 0.6.0 → 0.7.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/CHANGELOG.md +6 -0
- package/package.json +2 -2
- package/src/es-ops/README.md +4 -2
- package/src/es-ops/__tests__/context.integration.ts +100 -10
- package/src/es-ops/context.ts +21 -4
- package/src/es-ops/types.ts +14 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @cosmicdrift/kumiko-framework
|
|
2
2
|
|
|
3
|
+
## 0.7.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- bcf43b6: es-ops: `SeedMembershipRow` exposes `streamTenantId` (stream-tenant aus `kumiko_events.v1`) neben dem payload-`tenantId`. Seed-Authors müssen den `kumiko_events`-JOIN nicht mehr selbst bauen — `m.streamTenantId` ist der korrekte Wert für `systemWriteAs`'s `tenantIdOverride` wenn das Aggregate von einem fremden Executor angelegt wurde (typisches `seedTenantMembership(by=systemAdmin)`-Pattern).
|
|
8
|
+
|
|
3
9
|
## 0.6.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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>",
|
|
@@ -163,7 +163,7 @@
|
|
|
163
163
|
"zod": "^4.4.3"
|
|
164
164
|
},
|
|
165
165
|
"devDependencies": {
|
|
166
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
166
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.7.0",
|
|
167
167
|
"@types/uuid": "^11.0.0",
|
|
168
168
|
"bun-types": "^1.3.13",
|
|
169
169
|
"drizzle-kit": "^0.31.10",
|
package/src/es-ops/README.md
CHANGED
|
@@ -32,7 +32,7 @@ export default {
|
|
|
32
32
|
await ctx.systemWriteAs(
|
|
33
33
|
"tenant:write:update-member-roles",
|
|
34
34
|
{ userId: admin.id, tenantId: m.tenantId, roles: [...m.roles, "TenantAdmin"] },
|
|
35
|
-
m.
|
|
35
|
+
m.streamTenantId, // ← tenantIdOverride aus dem JOIN auf kumiko_events.v1
|
|
36
36
|
);
|
|
37
37
|
}
|
|
38
38
|
},
|
|
@@ -47,9 +47,11 @@ Faustregel: **wenn das Ziel-Aggregate via Tenant-User erstellt wurde, brauchst D
|
|
|
47
47
|
|---|---|---|
|
|
48
48
|
| config-values (system-scope) | SYSTEM_TENANT | weglassen |
|
|
49
49
|
| system text-content | SYSTEM_TENANT | weglassen |
|
|
50
|
-
| tenant-membership | jeweiliger Tenant
|
|
50
|
+
| tenant-membership | jeweiliger Stream-Tenant aus events.v1 | ✅ `m.streamTenantId` (NICHT `m.tenantId` — die beiden können divergieren!) |
|
|
51
51
|
| App-Entity (orders, tasks, …) | Tenant-Stream | ✅ Tenant-Id aus dem Lookup |
|
|
52
52
|
|
|
53
|
+
**Warum nicht `m.tenantId`?** read_tenant_memberships.tenant_id ist der payload-tenant (logisches Mitgliedschafts-Ziel), kumiko_events.tenant_id der v1-Row ist der stream-tenant (wo das Aggregate physisch lebt). seedTenantMembership mit `by=systemAdmin` lässt die zwei auseinanderlaufen — der Helper `findMembershipsOfUser` liefert beide getrennt, damit Seeds den richtigen wählen können.
|
|
54
|
+
|
|
53
55
|
Ohne `tenantIdOverride` sucht der Executor den Stream gegen SYSTEM_TENANT → `version_conflict`. Memory: `feedback_event_store_tenant_consistency.md`.
|
|
54
56
|
|
|
55
57
|
## Deployment-Anforderungen
|
|
@@ -55,10 +55,34 @@ afterAll(async () => {
|
|
|
55
55
|
|
|
56
56
|
beforeEach(async () => {
|
|
57
57
|
await testDb.db.execute(sql`
|
|
58
|
-
TRUNCATE kumiko_es_operations, read_users, read_tenant_memberships, read_tenants
|
|
58
|
+
TRUNCATE kumiko_es_operations, kumiko_events, read_users, read_tenant_memberships, read_tenants
|
|
59
|
+
RESTART IDENTITY CASCADE
|
|
59
60
|
`);
|
|
60
61
|
});
|
|
61
62
|
|
|
63
|
+
// Helper: simulate `seedTenantMembership` writing both the read-row and
|
|
64
|
+
// its v1-event with a custom stream-tenant. Tests use this to construct
|
|
65
|
+
// the stream-vs-payload-tenant scenarios that drive the JOIN-helper.
|
|
66
|
+
async function insertMembershipWithEvent(args: {
|
|
67
|
+
readonly id: string;
|
|
68
|
+
readonly userId: string;
|
|
69
|
+
readonly payloadTenantId: string;
|
|
70
|
+
readonly streamTenantId: string;
|
|
71
|
+
readonly roles: string;
|
|
72
|
+
}): Promise<void> {
|
|
73
|
+
await testDb.db.execute(sql`
|
|
74
|
+
INSERT INTO read_tenant_memberships (id, user_id, tenant_id, roles)
|
|
75
|
+
VALUES (${args.id}::uuid, ${args.userId}, ${args.payloadTenantId}::uuid, ${args.roles})
|
|
76
|
+
`);
|
|
77
|
+
await testDb.db.execute(sql`
|
|
78
|
+
INSERT INTO kumiko_events
|
|
79
|
+
(aggregate_id, aggregate_type, tenant_id, version, type, payload, metadata, created_by)
|
|
80
|
+
VALUES
|
|
81
|
+
(${args.id}::uuid, 'tenant-membership', ${args.streamTenantId}::uuid, 1,
|
|
82
|
+
'tenant-membership.created', '{}'::jsonb, '{"userId":"system"}'::jsonb, 'system')
|
|
83
|
+
`);
|
|
84
|
+
}
|
|
85
|
+
|
|
62
86
|
function makeMockDispatcher() {
|
|
63
87
|
return {
|
|
64
88
|
write: vi.fn(async () => ({ isSuccess: true as const, data: {} })),
|
|
@@ -107,13 +131,24 @@ describe("SeedMigrationContext.findUserByEmail (integration)", () => {
|
|
|
107
131
|
describe("SeedMigrationContext.findMembershipsOfUser (integration)", () => {
|
|
108
132
|
test("parst JSON-encoded roles-Spalte zu string[]", async () => {
|
|
109
133
|
const userId = "01900000-0000-7000-8000-000000000001";
|
|
134
|
+
const aggId1 = "00000000-0000-4000-8000-0000000000a1";
|
|
135
|
+
const aggId2 = "00000000-0000-4000-8000-0000000000a2";
|
|
110
136
|
const tenantId1 = "00000000-0000-4000-8000-000000000001";
|
|
111
137
|
const tenantId2 = "00000000-0000-4000-8000-000000000002";
|
|
112
|
-
await
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
138
|
+
await insertMembershipWithEvent({
|
|
139
|
+
id: aggId1,
|
|
140
|
+
userId,
|
|
141
|
+
payloadTenantId: tenantId1,
|
|
142
|
+
streamTenantId: tenantId1,
|
|
143
|
+
roles: '["Admin", "TenantAdmin"]',
|
|
144
|
+
});
|
|
145
|
+
await insertMembershipWithEvent({
|
|
146
|
+
id: aggId2,
|
|
147
|
+
userId,
|
|
148
|
+
payloadTenantId: tenantId2,
|
|
149
|
+
streamTenantId: tenantId2,
|
|
150
|
+
roles: '["User"]',
|
|
151
|
+
});
|
|
117
152
|
|
|
118
153
|
const ctx = createSeedMigrationContext({
|
|
119
154
|
dispatcher: makeMockDispatcher() as never,
|
|
@@ -129,14 +164,49 @@ describe("SeedMigrationContext.findMembershipsOfUser (integration)", () => {
|
|
|
129
164
|
expect(m2?.roles).toEqual(["User"]);
|
|
130
165
|
});
|
|
131
166
|
|
|
167
|
+
test("stream-tenant != payload-tenant wird korrekt ausgewiesen (Driver-Bug)", async () => {
|
|
168
|
+
// Reproduziert den publicstatus-Driver-Fall: seedTenantMembership
|
|
169
|
+
// wurde mit by=systemAdmin aufgerufen → executor.tenantId=
|
|
170
|
+
// SYSTEM_TENANT_ID landet als events.tenant_id, während payload.
|
|
171
|
+
// tenantId der target-Tenant ist. Die beiden divergieren.
|
|
172
|
+
const userId = "01900000-0000-7000-8000-000000000001";
|
|
173
|
+
const aggId = "00000000-0000-4000-8000-0000000000b1";
|
|
174
|
+
const payloadTenant = "00000000-0000-4000-8000-000000000042";
|
|
175
|
+
const streamTenant = "00000000-0000-4000-8000-000000000001"; // SYSTEM_TENANT-Stil
|
|
176
|
+
await insertMembershipWithEvent({
|
|
177
|
+
id: aggId,
|
|
178
|
+
userId,
|
|
179
|
+
payloadTenantId: payloadTenant,
|
|
180
|
+
streamTenantId: streamTenant,
|
|
181
|
+
roles: '["Admin"]',
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const ctx = createSeedMigrationContext({
|
|
185
|
+
dispatcher: makeMockDispatcher() as never,
|
|
186
|
+
dbRunner: testDb.db,
|
|
187
|
+
});
|
|
188
|
+
const [m] = await ctx.findMembershipsOfUser(userId);
|
|
189
|
+
expect(m).toEqual({
|
|
190
|
+
userId,
|
|
191
|
+
tenantId: payloadTenant,
|
|
192
|
+
streamTenantId: streamTenant,
|
|
193
|
+
roles: ["Admin"],
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
132
197
|
test("malformed roles-JSON → leeres Array (defensive, no throw)", async () => {
|
|
133
198
|
// Defensive: wenn ein corrupted row kommt, soll der Seed nicht
|
|
134
199
|
// explodieren — kann selbst entscheiden was zu tun ist.
|
|
135
200
|
const userId = "01900000-0000-7000-8000-000000000002";
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
201
|
+
const aggId = "00000000-0000-4000-8000-0000000000c1";
|
|
202
|
+
const tenantId = "00000000-0000-4000-8000-000000000003";
|
|
203
|
+
await insertMembershipWithEvent({
|
|
204
|
+
id: aggId,
|
|
205
|
+
userId,
|
|
206
|
+
payloadTenantId: tenantId,
|
|
207
|
+
streamTenantId: tenantId,
|
|
208
|
+
roles: "not-json",
|
|
209
|
+
});
|
|
140
210
|
const ctx = createSeedMigrationContext({
|
|
141
211
|
dispatcher: makeMockDispatcher() as never,
|
|
142
212
|
dbRunner: testDb.db,
|
|
@@ -153,6 +223,26 @@ describe("SeedMigrationContext.findMembershipsOfUser (integration)", () => {
|
|
|
153
223
|
const memberships = await ctx.findMembershipsOfUser("01900000-0000-7000-8000-000000000099");
|
|
154
224
|
expect(memberships).toEqual([]);
|
|
155
225
|
});
|
|
226
|
+
|
|
227
|
+
test("membership ohne v1-Event wird vom INNER JOIN ausgefiltert (Drift-Detection)", async () => {
|
|
228
|
+
// Schutz vor Data-Drift: read-row ohne event-row ist kein legitimer
|
|
229
|
+
// Zustand für ein ES-Aggregate. Statt einer Half-Row zurückzugeben
|
|
230
|
+
// verschwindet die Row aus dem Result — Seed-Author sieht "0 memberships"
|
|
231
|
+
// statt einer mit fehlendem stream-tenant zu arbeiten und schwer
|
|
232
|
+
// diagnostizierbare version_conflict-Errors zu produzieren.
|
|
233
|
+
const userId = "01900000-0000-7000-8000-000000000003";
|
|
234
|
+
await testDb.db.execute(sql`
|
|
235
|
+
INSERT INTO read_tenant_memberships (id, user_id, tenant_id, roles) VALUES
|
|
236
|
+
('00000000-0000-4000-8000-0000000000d1'::uuid, ${userId},
|
|
237
|
+
'00000000-0000-4000-8000-000000000005'::uuid, '["Admin"]')
|
|
238
|
+
`);
|
|
239
|
+
const ctx = createSeedMigrationContext({
|
|
240
|
+
dispatcher: makeMockDispatcher() as never,
|
|
241
|
+
dbRunner: testDb.db,
|
|
242
|
+
});
|
|
243
|
+
const memberships = await ctx.findMembershipsOfUser(userId);
|
|
244
|
+
expect(memberships).toEqual([]);
|
|
245
|
+
});
|
|
156
246
|
});
|
|
157
247
|
|
|
158
248
|
describe("SeedMigrationContext.findTenants (integration)", () => {
|
package/src/es-ops/context.ts
CHANGED
|
@@ -76,17 +76,34 @@ export function createSeedMigrationContext(
|
|
|
76
76
|
},
|
|
77
77
|
|
|
78
78
|
findMembershipsOfUser: async (userId) => {
|
|
79
|
+
// INNER JOIN auf kumiko_events um den stream-tenant (events.tenant_id
|
|
80
|
+
// der v1-Row) neben dem payload-tenant (memberships.tenant_id) zu
|
|
81
|
+
// liefern. Die beiden divergieren wenn das Aggregate von einem
|
|
82
|
+
// Executor mit fremder tenantId angelegt wurde (seedTenantMembership
|
|
83
|
+
// by=systemAdmin) — typischer publicstatus-Driver-Use-Case.
|
|
84
|
+
// INNER (nicht LEFT): kein v1-Event bei vorhandener Read-Row wäre
|
|
85
|
+
// Data-Drift, kein legitimer Zustand für Seed-Migrations.
|
|
79
86
|
// @cast-boundary db-row — roles ist JSON-string in der text-Spalte
|
|
80
87
|
// (Memory: tenant-membership.created payload "[\"User\"]"), wird unten geparst
|
|
81
88
|
const rows = (await args.dbRunner.execute(
|
|
82
|
-
sql`SELECT user_id::text AS user_id,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
89
|
+
sql`SELECT m.user_id::text AS user_id,
|
|
90
|
+
m.tenant_id::text AS tenant_id,
|
|
91
|
+
e.tenant_id::text AS stream_tenant_id,
|
|
92
|
+
m.roles
|
|
93
|
+
FROM read_tenant_memberships m
|
|
94
|
+
JOIN kumiko_events e ON e.aggregate_id = m.id AND e.version = 1
|
|
95
|
+
WHERE m.user_id = ${userId}`,
|
|
96
|
+
)) as unknown as readonly {
|
|
97
|
+
user_id: string;
|
|
98
|
+
tenant_id: string;
|
|
99
|
+
stream_tenant_id: string;
|
|
100
|
+
roles: string;
|
|
101
|
+
}[];
|
|
86
102
|
return rows.map(
|
|
87
103
|
(r): SeedMembershipRow => ({
|
|
88
104
|
userId: r.user_id,
|
|
89
105
|
tenantId: r.tenant_id,
|
|
106
|
+
streamTenantId: r.stream_tenant_id,
|
|
90
107
|
roles: safeParseRolesJson(r.roles),
|
|
91
108
|
}),
|
|
92
109
|
);
|
package/src/es-ops/types.ts
CHANGED
|
@@ -46,10 +46,23 @@ export type SeedUserRow = {
|
|
|
46
46
|
readonly tenantId: string;
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
/** Read-shape eines Membership-Eintrags wie an Seed-Helpers exposed.
|
|
49
|
+
/** Read-shape eines Membership-Eintrags wie an Seed-Helpers exposed.
|
|
50
|
+
* Unterscheidet zwei tenantIds: die "logische" aus dem Read-Projektion
|
|
51
|
+
* (`tenantId`) und die "physische" aus dem Aggregate-Stream
|
|
52
|
+
* (`streamTenantId`). Die beiden weichen voneinander ab wenn das
|
|
53
|
+
* Aggregate von einem Executor mit anderer tenantId angelegt wurde
|
|
54
|
+
* (z.B. seedTenantMembership-by=systemAdmin) — typischer
|
|
55
|
+
* publicstatus-Driver-Use-Case. */
|
|
50
56
|
export type SeedMembershipRow = {
|
|
51
57
|
readonly userId: string;
|
|
58
|
+
/** Payload-tenant aus `read_tenant_memberships.tenant_id`. Geht ins
|
|
59
|
+
* write-payload als `tenantId`. */
|
|
52
60
|
readonly tenantId: string;
|
|
61
|
+
/** Stream-tenant aus `kumiko_events.tenant_id` der v1-Row. MUSS als
|
|
62
|
+
* `tenantIdOverride` an `systemWriteAs` durchgereicht werden, sonst
|
|
63
|
+
* sucht der Event-Store-Executor den Stream im falschen Tenant und
|
|
64
|
+
* liefert `version_conflict`. */
|
|
65
|
+
readonly streamTenantId: string;
|
|
53
66
|
readonly roles: readonly string[];
|
|
54
67
|
};
|
|
55
68
|
|