@cosmicdrift/kumiko-bundled-features 0.22.0 → 0.23.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-bundled-features",
3
- "version": "0.22.0",
3
+ "version": "0.23.1",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -138,12 +138,12 @@ beforeEach(async () => {
138
138
  });
139
139
 
140
140
  // Alice = Admin von Tenant-A
141
- aliceId = await seedUser(stack.db, {
141
+ ({ id: aliceId } = await seedUser(stack.db, {
142
142
  email: ALICE_EMAIL,
143
143
  displayName: "Alice",
144
144
  passwordHash: await hashPassword("alice-pw-1234"),
145
145
  emailVerified: true,
146
- });
146
+ }));
147
147
  await seedTenantMembership(stack.db, {
148
148
  userId: aliceId,
149
149
  tenantId: TENANT_A_ID,
@@ -151,12 +151,12 @@ beforeEach(async () => {
151
151
  });
152
152
 
153
153
  // Bob = Member von Tenant-B (für Branch 1 + 2 tests)
154
- bobId = await seedUser(stack.db, {
154
+ ({ id: bobId } = await seedUser(stack.db, {
155
155
  email: BOB_EMAIL,
156
156
  displayName: "Bob",
157
157
  passwordHash: await hashPassword(BOB_PASSWORD),
158
158
  emailVerified: true,
159
- });
159
+ }));
160
160
  await seedTenantMembership(stack.db, {
161
161
  userId: bobId,
162
162
  tenantId: TENANT_B_ID,
@@ -61,7 +61,7 @@ beforeEach(async () => {
61
61
 
62
62
  describe("seedAdmin", () => {
63
63
  test("legt Tenants, User mit gehashtem Password und Memberships an — Login-Roundtrip funktioniert", async () => {
64
- const userId = await seedAdmin(stack.db, {
64
+ const { id: userId } = await seedAdmin(stack.db, {
65
65
  email: "admin@example.com",
66
66
  password: "secret-pw",
67
67
  displayName: "Admin",
@@ -113,7 +113,7 @@ describe("seedAdmin", () => {
113
113
 
114
114
  test("idempotent: zweiter Aufruf no-op (kein Crash, Stand bleibt)", async () => {
115
115
  // Erstaufruf
116
- const userId1 = await seedAdmin(stack.db, {
116
+ const { id: userId1 } = await seedAdmin(stack.db, {
117
117
  email: "admin@example.com",
118
118
  password: "pw1",
119
119
  displayName: "Admin",
@@ -124,7 +124,7 @@ describe("seedAdmin", () => {
124
124
  // Zweiter Aufruf — gleicher Email, anderes Password (würde theoretisch
125
125
  // einen neuen Hash erzeugen und neu schreiben, der idempotent-Check
126
126
  // greift VOR dem Insert).
127
- const userId2 = await seedAdmin(stack.db, {
127
+ const { id: userId2 } = await seedAdmin(stack.db, {
128
128
  email: "admin@example.com",
129
129
  password: "pw2",
130
130
  displayName: "Admin",
@@ -138,7 +138,7 @@ export function createInviteSignupCompleteHandler() {
138
138
  // @cast-boundary db-runner — TenantDb.raw is DbRunner; seed-helpers
139
139
  // operate on plain drizzle-API which both shapes expose identically.
140
140
  const dbConn = ctx.db.raw as DbConnection;
141
- const userId = await seedUserWithPassword(dbConn, {
141
+ const { id: userId } = await seedUserWithPassword(dbConn, {
142
142
  email: invitationEmail,
143
143
  password: event.payload.password,
144
144
  displayName: invitationEmail.split("@")[0] ?? invitationEmail,
@@ -46,12 +46,12 @@ export type SeedUserWithPasswordOptions = {
46
46
 
47
47
  /**
48
48
  * Seed a user mit Plain-Password (wird vor dem Insert mit argon2
49
- * gehasht). Liefert userId, idempotent über email.
49
+ * gehasht). Liefert die userId, idempotent über email.
50
50
  */
51
51
  export async function seedUserWithPassword(
52
52
  db: DbConnection,
53
53
  options: SeedUserWithPasswordOptions,
54
- ): Promise<string> {
54
+ ): Promise<{ id: string }> {
55
55
  const passwordHash = await hashPassword(options.password);
56
56
  return seedUser(db, {
57
57
  email: options.email,
@@ -114,7 +114,7 @@ export async function provisionSignupAccount(
114
114
  key: options.tenantKey,
115
115
  name: options.tenantName,
116
116
  });
117
- const userId = await seedUserWithPassword(db, {
117
+ const { id: userId } = await seedUserWithPassword(db, {
118
118
  email: options.email,
119
119
  password: options.password,
120
120
  displayName: options.displayName,
@@ -155,14 +155,17 @@ export type SeedAdminOptions = {
155
155
  * Password + N Tenants + N Memberships. Alles idempotent (Re-Run im
156
156
  * persistent-DB-Modus läuft durch). Liefert die userId zurück.
157
157
  */
158
- export async function seedAdmin(db: DbConnection, options: SeedAdminOptions): Promise<string> {
158
+ export async function seedAdmin(
159
+ db: DbConnection,
160
+ options: SeedAdminOptions,
161
+ ): Promise<{ id: string }> {
159
162
  const by = options.by ?? TestUsers.systemAdmin;
160
163
 
161
164
  for (const m of options.memberships) {
162
165
  await seedTenant(db, { id: m.tenantId, key: m.tenantKey, name: m.tenantName, by });
163
166
  }
164
167
 
165
- const userId = await seedUserWithPassword(db, {
168
+ const { id: userId } = await seedUserWithPassword(db, {
166
169
  email: options.email,
167
170
  password: options.password,
168
171
  displayName: options.displayName,
@@ -179,5 +182,5 @@ export async function seedAdmin(db: DbConnection, options: SeedAdminOptions): Pr
179
182
  });
180
183
  }
181
184
 
182
- return userId;
185
+ return { id: userId };
183
186
  }
@@ -45,7 +45,7 @@ export type SeedComplianceProfileOptions = {
45
45
  export async function seedComplianceProfile(
46
46
  db: DbConnection,
47
47
  opts: SeedComplianceProfileOptions,
48
- ): Promise<{ id: string | number }> {
48
+ ): Promise<{ id: string }> {
49
49
  // user.tenantId muss === opts.tenantId sein damit Event-Store-Stream
50
50
  // + Projection im selben Tenant-Bucket landen (Memory:
51
51
  // feedback_event_store_tenant_consistency).
@@ -73,6 +73,9 @@ export async function seedComplianceProfile(
73
73
  if (!result.isSuccess) {
74
74
  throw new Error(`seedComplianceProfile create failed: ${JSON.stringify(result)}`);
75
75
  }
76
+ // @cast-boundary db-row: executor.create result.data ist die
77
+ // inserted Projection-Row (Record<string, unknown>); id ist nach
78
+ // INSERT garantiert (Runtime-Check direkt darunter).
76
79
  const data = result.data as { id?: string };
77
80
  if (data.id === undefined) {
78
81
  throw new Error("seedComplianceProfile: executor.create did not return an id");
@@ -62,7 +62,7 @@ beforeEach(async () => {
62
62
 
63
63
  describe("seedTenant", () => {
64
64
  test("schreibt Projection-Row mit id/key/name", async () => {
65
- const id = await seedTenant(stack.db, {
65
+ const { id } = await seedTenant(stack.db, {
66
66
  id: TENANT_A,
67
67
  key: "tenant-a",
68
68
  name: "Tenant A",
@@ -24,8 +24,9 @@
24
24
  // IS a test fixture, not a user request) while still producing the
25
25
  // correct event + projection.
26
26
  //
27
- // Idempotent: calling twice for the same (userId, tenantId) is a no-op on
28
- // the second call (ifExists="skip", siehe @cosmicdrift/kumiko-framework/seeding).
27
+ // Idempotent (add-only): calling twice for the same (userId, tenantId) is
28
+ // a no-op on the second call. Memberships have no update-semantic — to
29
+ // change roles, write a new event via the regular handler path.
29
30
 
30
31
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
31
32
  import {
@@ -73,12 +74,15 @@ export type SeedTenantOptions = {
73
74
  };
74
75
 
75
76
  /**
76
- * Seed a tenant through the event-store executor. Idempotent (ifExists="skip"):
77
- * a second call for the same `id` is a no-op. Same TX-semantics as the real
78
- * `TenantHandlers.create`, minus the SystemAdmin-access-check and minus
79
- * ConflictError-on-duplicate.
77
+ * Seed a tenant through the event-store executor. Idempotent add-only:
78
+ * a second call for the same `id` is a no-op (no update path). Same
79
+ * TX-semantics as the real `TenantHandlers.create`, minus the SystemAdmin-
80
+ * access-check and minus ConflictError-on-duplicate.
80
81
  */
81
- export async function seedTenant(db: DbRunner, options: SeedTenantOptions): Promise<TenantId> {
82
+ export async function seedTenant(
83
+ db: DbRunner,
84
+ options: SeedTenantOptions,
85
+ ): Promise<{ id: TenantId }> {
82
86
  const by = options.by ?? TestUsers.systemAdmin;
83
87
  // executor.create erwartet eine TenantDb (mit .insert()-API), nicht
84
88
  // die rohe DbConnection. Auch wenn das Tenant-Aggregat selbst NICHT
@@ -88,14 +92,14 @@ export async function seedTenant(db: DbRunner, options: SeedTenantOptions): Prom
88
92
  const tdb = createTenantDb(db, by.tenantId, "system");
89
93
 
90
94
  const existing = await fetchOne(db, tenantTable, { id: options.id });
91
- if (existing) return options.id;
95
+ if (existing) return { id: options.id };
92
96
 
93
97
  // Idempotenz: Aggregate kann im Event-Store existieren ohne Projection-Row
94
98
  // (Projection-Drift nach rebuild, manuellem DELETE, oder async-lag). Wenn
95
99
  // Stream-Version > 0 → kein create() — wäre version_conflict. Caller
96
100
  // bekommt die ID, Projection wird beim nächsten Dispatcher-Cycle aufgebaut.
97
101
  const streamVersion = await getAggregateStreamMaxVersion(db, options.id);
98
- if (streamVersion > 0) return options.id;
102
+ if (streamVersion > 0) return { id: options.id };
99
103
 
100
104
  const result = await tenantExecutor.create(
101
105
  { id: options.id, key: options.key, name: options.name },
@@ -107,7 +111,7 @@ export async function seedTenant(db: DbRunner, options: SeedTenantOptions): Prom
107
111
  `seedTenant failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
108
112
  );
109
113
  }
110
- return options.id;
114
+ return { id: options.id };
111
115
  }
112
116
 
113
117
  /**
@@ -116,11 +120,13 @@ export async function seedTenant(db: DbRunner, options: SeedTenantOptions): Prom
116
120
  * projection row in one transaction — identical effect to
117
121
  * `TenantHandlers.addMember`, minus the access-check and minus the
118
122
  * ConflictError on duplicates (duplicate calls no-op).
123
+ *
124
+ * Returns the membership-row id (existing on no-op, freshly minted on create).
119
125
  */
120
126
  export async function seedTenantMembership(
121
127
  db: DbRunner,
122
128
  options: SeedTenantMembershipOptions,
123
- ): Promise<void> {
129
+ ): Promise<{ id: string }> {
124
130
  const by = options.by ?? TestUsers.systemAdmin;
125
131
  // Wrap into a system-scoped TenantDb so the insert respects the tenant-
126
132
  // override (we write into options.tenantId, which may differ from by.tenantId).
@@ -134,10 +140,11 @@ export async function seedTenantMembership(
134
140
  userId: options.userId,
135
141
  tenantId: options.tenantId,
136
142
  });
137
- // skip: idempotent no-op — duplicate seed is expected across beforeEach-
138
- // resets that don't truncate this table. Cheaper than try/catch on the
139
- // unique-index, and documented in the function JSDoc above.
140
- if (existing) return;
143
+ if (existing) {
144
+ // @cast-boundary db-row: membership-row id is uuid (string) per
145
+ // entity definition; fetchOne returns the raw projection row.
146
+ return { id: existing["id"] as string };
147
+ }
141
148
 
142
149
  const result = await executor.create(
143
150
  {
@@ -153,4 +160,17 @@ export async function seedTenantMembership(
153
160
  `seedTenantMembership failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
154
161
  );
155
162
  }
163
+ return { id: extractMembershipId(result.data) };
164
+ }
165
+
166
+ function extractMembershipId(data: unknown): string {
167
+ if (typeof data === "object" && data !== null && "id" in data) {
168
+ // @cast-boundary engine-bridge: executor.create returns the projection
169
+ // row as Record<string, unknown>; id is uuid per entity definition.
170
+ const id = (data as { id: unknown }).id;
171
+ if (typeof id === "string") return id;
172
+ }
173
+ throw new Error(
174
+ `seedTenantMembership: executor.create returned no string id (got ${JSON.stringify(data)})`,
175
+ );
156
176
  }
@@ -36,7 +36,7 @@ export type SeedTextBlockOptions = {
36
36
  export async function seedTextBlock(
37
37
  db: DbConnection,
38
38
  opts: SeedTextBlockOptions,
39
- ): Promise<{ id: string | number }> {
39
+ ): Promise<{ id: string }> {
40
40
  // Default-user muss user.tenantId === opts.tenantId haben, sonst
41
41
  // landet der event-store-stream im user.tenantId-bucket aber die
42
42
  // projection-row im opts.tenantId-bucket. Spätere echte writes via
@@ -75,6 +75,9 @@ export async function seedTextBlock(
75
75
  if (!result.isSuccess) {
76
76
  throw new Error(`seedTextBlock create failed: ${JSON.stringify(result)}`);
77
77
  }
78
+ // @cast-boundary db-row: executor.create result.data ist die
79
+ // inserted Drizzle-Row (Record<string, unknown>), projected
80
+ // nach INSERT/RETURNING auf TextBlockRow. Runtime-Check unten.
78
81
  const data = result.data as Partial<TextBlockRow>;
79
82
  if (data.id === undefined) {
80
83
  throw new Error("seedTextBlock: executor.create did not return an id");
@@ -38,7 +38,7 @@ export const textBlocksTable = buildEntityTable("text-block", textBlockEntity);
38
38
  // createdAt, updatedAt, createdBy, updatedBy) die buildBaseColumns
39
39
  // erzwingt.
40
40
  export type TextBlockRow = {
41
- readonly id: string | number;
41
+ readonly id: string;
42
42
  readonly version: number;
43
43
  readonly tenantId: string;
44
44
  readonly slug: string;
@@ -47,7 +47,7 @@ beforeEach(async () => {
47
47
 
48
48
  describe("seedUser", () => {
49
49
  test("schreibt Projection-Row mit email/displayName/passwordHash", async () => {
50
- const userId = await seedUser(stack.db, {
50
+ const { id: userId } = await seedUser(stack.db, {
51
51
  email: "alice@example.com",
52
52
  displayName: "Alice",
53
53
  passwordHash: "$argon2id$test-hash",
@@ -62,7 +62,7 @@ describe("seedUser", () => {
62
62
  });
63
63
 
64
64
  test("emittiert user.created-Event auf den Aggregate-Stream", async () => {
65
- const userId = await seedUser(stack.db, {
65
+ const { id: userId } = await seedUser(stack.db, {
66
66
  email: "bob@example.com",
67
67
  displayName: "Bob",
68
68
  });
@@ -84,7 +84,7 @@ describe("seedUser", () => {
84
84
  email: "carol@example.com",
85
85
  displayName: "Carol Updated",
86
86
  });
87
- expect(second).toBe(first);
87
+ expect(second.id).toBe(first.id);
88
88
 
89
89
  const rows = await selectMany(stack.db, userTable, { email: "carol@example.com" });
90
90
  expect(rows).toHaveLength(1);
@@ -96,7 +96,7 @@ describe("seedUser", () => {
96
96
  });
97
97
 
98
98
  test("passwordHash optional — User ohne Hash anlegbar (z.B. SSO-Federation)", async () => {
99
- const userId = await seedUser(stack.db, {
99
+ const { id: userId } = await seedUser(stack.db, {
100
100
  email: "dave@example.com",
101
101
  displayName: "Dave",
102
102
  });
@@ -105,7 +105,7 @@ describe("seedUser", () => {
105
105
  });
106
106
 
107
107
  test("default `by` ist TestUsers.systemAdmin (für audit-trail)", async () => {
108
- const userId = await seedUser(stack.db, {
108
+ const { id: userId } = await seedUser(stack.db, {
109
109
  email: "eve@example.com",
110
110
  displayName: "Eve",
111
111
  });
@@ -1,9 +1,9 @@
1
1
  // Testing-Helper fürs user-Feature. `seedUser` legt einen User direkt
2
2
  // über den Event-Store-Executor an — gleicher Pfad wie der echte
3
3
  // `UserHandlers.create`, aber ohne Access-Check und ohne ConflictError
4
- // bei Duplikaten. Verhält sich wie ifExists="skip" (siehe
5
- // @cosmicdrift/kumiko-framework/seeding): existierende Email → return
6
- // ohne Event.
4
+ // bei Duplikaten. Idempotent add-only über die `email`-Spalte: ein
5
+ // existierender User → return ohne Event. Kein update-Pfad — Profilfelder
6
+ // ändern läuft über den regulären Handler.
7
7
  //
8
8
  // Warum nicht direkt `db.insert(userTable)`: das würde den Event-Store
9
9
  // umgehen, also kein `user.created`-Event und keine MSP-Konsumenten
@@ -46,10 +46,13 @@ export type SeedUserOptions = {
46
46
 
47
47
  /**
48
48
  * Seed a user. Returns the userId (existing oder neu angelegt).
49
- * Idempotent über die `email`-Spalte: wenn ein User mit dieser Email
50
- * existiert, kommt seine ID zurück ohne neuen Insert.
49
+ * Idempotent add-only über die `email`-Spalte: wenn ein User mit dieser
50
+ * Email existiert, kommt seine ID zurück ohne neuen Insert.
51
51
  */
52
- export async function seedUser(db: DbConnection, options: SeedUserOptions): Promise<string> {
52
+ export async function seedUser(
53
+ db: DbConnection,
54
+ options: SeedUserOptions,
55
+ ): Promise<{ id: string }> {
53
56
  const by = options.by ?? TestUsers.systemAdmin;
54
57
  // executor.create erwartet eine TenantDb (mit .insert()-API). User
55
58
  // ist zwar tenant-agnostic (kein tenant_id-Spalte), aber das runtime-
@@ -57,7 +60,9 @@ export async function seedUser(db: DbConnection, options: SeedUserOptions): Prom
57
60
  const tdb = createTenantDb(db, by.tenantId, "system");
58
61
 
59
62
  const existing = await fetchOne(db, userTable, { email: options.email });
60
- if (existing) return existing["id"] as string; // @cast-boundary db-row
63
+ // @cast-boundary db-row: users.id ist uuid-Spalte (string), fetchOne
64
+ // liefert die Projection-Row als Record<string, unknown>.
65
+ if (existing) return { id: existing["id"] as string };
61
66
 
62
67
  const result = await userExecutor.create(
63
68
  {
@@ -76,7 +81,7 @@ export async function seedUser(db: DbConnection, options: SeedUserOptions): Prom
76
81
  `seedUser failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
77
82
  );
78
83
  }
79
- return extractId(result.data, "seedUser");
84
+ return { id: extractId(result.data, "seedUser") };
80
85
  }
81
86
 
82
87
  // Extrahiert die `id`-Spalte aus dem executor.create-Result. Der