@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 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.6.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.6.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",
@@ -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.tenantId, // ← tenantIdOverride: Aggregate lebt im Tenant-Stream, NICHT SYSTEM
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-Stream | ✅ `m.tenantId` |
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 testDb.db.execute(sql`
113
- INSERT INTO read_tenant_memberships (user_id, tenant_id, roles) VALUES
114
- (${userId}, ${tenantId1}::uuid, '["Admin", "TenantAdmin"]'),
115
- (${userId}, ${tenantId2}::uuid, '["User"]')
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
- await testDb.db.execute(sql`
137
- INSERT INTO read_tenant_memberships (user_id, tenant_id, roles) VALUES
138
- (${userId}, '00000000-0000-4000-8000-000000000003'::uuid, 'not-json')
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)", () => {
@@ -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, tenant_id::text AS tenant_id, roles
83
- FROM read_tenant_memberships
84
- WHERE user_id = ${userId}`,
85
- )) as unknown as readonly { user_id: string; tenant_id: string; roles: string }[];
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
  );
@@ -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