@cosmicdrift/kumiko-bundled-features 0.19.0 → 0.20.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-bundled-features",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
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>",
@@ -0,0 +1,119 @@
1
+ // Pins the shared boot-seed contract (DEFAULT_SEED_IF_EXISTS="skip") across
2
+ // event-sourced seed helpers. Feature-specific behaviour stays in each
3
+ // helper's own test file; here we assert cross-cutting invariants only.
4
+
5
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
+ import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
7
+ import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
8
+ import {
9
+ setupTestStack,
10
+ type TestStack,
11
+ testTenantId,
12
+ unsafeCreateEntityTable,
13
+ } from "@cosmicdrift/kumiko-framework/stack";
14
+ import { createComplianceProfilesFeature } from "../compliance-profiles/feature";
15
+ import {
16
+ tenantComplianceProfileEntity,
17
+ tenantComplianceProfileTable,
18
+ } from "../compliance-profiles/schema/profile-selection";
19
+ import { seedComplianceProfile } from "../compliance-profiles/seeding";
20
+ import { createTextContentFeature } from "../text-content/feature";
21
+ import { seedTextBlock } from "../text-content/seeding";
22
+ import { type TextBlockRow, textBlockEntity, textBlocksTable } from "../text-content/table";
23
+
24
+ let stack: TestStack;
25
+
26
+ beforeAll(async () => {
27
+ stack = await setupTestStack({
28
+ features: [createTextContentFeature(), createComplianceProfilesFeature()],
29
+ });
30
+ await unsafeCreateEntityTable(stack.db, textBlockEntity);
31
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
32
+ await createEventsTable(stack.db);
33
+ });
34
+
35
+ afterAll(async () => {
36
+ await stack.cleanup();
37
+ });
38
+
39
+ describe("boot-seed contract", () => {
40
+ test("seedTextBlock: re-boot skip preserves user edit + event count", async () => {
41
+ const tenantId = testTenantId(301);
42
+
43
+ await seedTextBlock(stack.db, {
44
+ tenantId,
45
+ slug: "imprint",
46
+ lang: "de",
47
+ title: "Impressum",
48
+ body: "seed body",
49
+ });
50
+ await seedTextBlock(stack.db, {
51
+ tenantId,
52
+ slug: "imprint",
53
+ lang: "de",
54
+ title: "Impressum (edited)",
55
+ body: "admin body",
56
+ ifExists: "update",
57
+ });
58
+ await seedTextBlock(stack.db, {
59
+ tenantId,
60
+ slug: "imprint",
61
+ lang: "de",
62
+ title: "Impressum",
63
+ body: "seed body",
64
+ });
65
+
66
+ const row = await fetchOne<TextBlockRow>(stack.db, textBlocksTable, {
67
+ tenantId,
68
+ slug: "imprint",
69
+ lang: "de",
70
+ });
71
+ expect(row).toMatchObject({ title: "Impressum (edited)", body: "admin body", version: 2 });
72
+
73
+ const events = await selectMany(stack.db, eventsTable, { aggregateId: String(row!.id) });
74
+ expect(events).toHaveLength(2);
75
+ });
76
+
77
+ test("seedComplianceProfile: re-boot skip preserves profile + event count", async () => {
78
+ const tenantId = testTenantId(302);
79
+
80
+ await seedComplianceProfile(stack.db, { tenantId, profileKey: "eu-dsgvo" });
81
+ await seedComplianceProfile(stack.db, {
82
+ tenantId,
83
+ profileKey: "swiss-dsg",
84
+ ifExists: "update",
85
+ });
86
+ await seedComplianceProfile(stack.db, { tenantId, profileKey: "eu-dsgvo" });
87
+
88
+ const profileRow = (await fetchOne(stack.db, tenantComplianceProfileTable, {
89
+ tenantId,
90
+ })) as { id: string; profileKey: string; version: number };
91
+ expect(profileRow.profileKey).toBe("swiss-dsg");
92
+ expect(profileRow.version).toBe(2);
93
+
94
+ const events = await selectMany(stack.db, eventsTable, {
95
+ aggregateId: profileRow.id,
96
+ });
97
+ expect(events).toHaveLength(2);
98
+ expect(events.map((e) => e.type)).toEqual([
99
+ "tenant-compliance-profile.created",
100
+ "tenant-compliance-profile.updated",
101
+ ]);
102
+ });
103
+
104
+ test('seedComplianceProfile ifExists="update" overwrites existing profile', async () => {
105
+ const tenantId = testTenantId(303);
106
+
107
+ await seedComplianceProfile(stack.db, { tenantId, profileKey: "eu-dsgvo" });
108
+ await seedComplianceProfile(stack.db, {
109
+ tenantId,
110
+ profileKey: "swiss-dsg",
111
+ ifExists: "update",
112
+ });
113
+
114
+ const row = (await fetchOne(stack.db, tenantComplianceProfileTable, {
115
+ tenantId,
116
+ })) as { profileKey: string };
117
+ expect(row.profileKey).toBe("swiss-dsg");
118
+ });
119
+ });
@@ -3,13 +3,13 @@
3
3
  // Beweist:
4
4
  // 1. Helper umgeht set-profile-Zod-Engung (kann minimal-no-region
5
5
  // setzen für Migration-Edge-Case-Tests in Sprint 2+)
6
- // 2. Idempotent: zweiter Call mit gleichem tenantId updated den
7
- // bestehenden Eintrag
6
+ // 2. Default skip: zweiter Boot-Call ohne ifExists="update" ändert nichts
8
7
  // 3. Override wird als JSON-String persistiert + via for-tenant
9
8
  // korrekt zurueckgelesen
10
9
 
11
10
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
12
- import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
11
+ import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
12
+ import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
13
13
  import {
14
14
  createTestUser,
15
15
  setupTestStack,
@@ -17,7 +17,11 @@ import {
17
17
  testTenantId,
18
18
  unsafeCreateEntityTable,
19
19
  } from "@cosmicdrift/kumiko-framework/stack";
20
- import { createComplianceProfilesFeature, tenantComplianceProfileEntity } from "../feature";
20
+ import { createComplianceProfilesFeature } from "../feature";
21
+ import {
22
+ tenantComplianceProfileEntity,
23
+ tenantComplianceProfileTable,
24
+ } from "../schema/profile-selection";
21
25
  import { seedComplianceProfile } from "../seeding";
22
26
 
23
27
  const FOR_TENANT = "compliance-profiles:query:for-tenant";
@@ -47,17 +51,42 @@ describe("seedComplianceProfile", () => {
47
51
  expect(result.profile.key).toBe("eu-dsgvo");
48
52
  });
49
53
 
50
- test("idempotent: zweiter Call updated den bestehenden Eintrag", async () => {
54
+ test('ifExists="update" overwrites bestehenden Eintrag', async () => {
51
55
  const tenantId = testTenantId(201);
52
56
  const user = createTestUser({ id: 201, tenantId, roles: ["TenantAdmin"] });
53
57
 
54
58
  await seedComplianceProfile(stack.db, { tenantId, profileKey: "eu-dsgvo" });
55
- await seedComplianceProfile(stack.db, { tenantId, profileKey: "swiss-dsg" });
59
+ await seedComplianceProfile(stack.db, {
60
+ tenantId,
61
+ profileKey: "swiss-dsg",
62
+ ifExists: "update",
63
+ });
56
64
 
57
65
  const result = await stack.http.queryOk<{ profile: { key: string } }>(FOR_TENANT, {}, user);
58
66
  expect(result.profile.key).toBe("swiss-dsg");
59
67
  });
60
68
 
69
+ test("default skip: zweiter Boot-Call ohne update überschreibt nicht", async () => {
70
+ const tenantId = testTenantId(204);
71
+
72
+ await seedComplianceProfile(stack.db, { tenantId, profileKey: "eu-dsgvo" });
73
+ await seedComplianceProfile(stack.db, {
74
+ tenantId,
75
+ profileKey: "swiss-dsg",
76
+ ifExists: "update",
77
+ });
78
+ await seedComplianceProfile(stack.db, { tenantId, profileKey: "de-hr-dsgvo-hgb" });
79
+
80
+ const row = (await fetchOne(stack.db, tenantComplianceProfileTable, {
81
+ tenantId,
82
+ })) as { id: string; profileKey: string; version: number };
83
+ expect(row.profileKey).toBe("swiss-dsg");
84
+ expect(row.version).toBe(2);
85
+
86
+ const events = await selectMany(stack.db, eventsTable, { aggregateId: row.id });
87
+ expect(events).toHaveLength(2);
88
+ });
89
+
61
90
  test("kann minimal-no-region direkt seeden (Migration-Edge-Case, ohne set-profile-Zod-Engung)", async () => {
62
91
  const tenantId = testTenantId(202);
63
92
  const user = createTestUser({ id: 202, tenantId, roles: ["TenantAdmin"] });
@@ -2,7 +2,7 @@
2
2
  // über den Event-Store-Executor an — gleicher Pfad wie der echte
3
3
  // set-profile-Handler, aber ohne Zod-Schema-Engung (akzeptiert
4
4
  // minimal-no-region für Migration-Edge-Case-Tests) und ohne Access-
5
- // Check. Idempotent: zweiter Call mit gleichem tenantId updated.
5
+ // Check. Default ifExists="skip": nur fehlende Profile anlegen.
6
6
  //
7
7
  // Sprint 2 user-data-rights nutzt das fuer Test-Setup ("user kann
8
8
  // Daten exportieren mit profile X" — pro Test ein frischer Tenant +
@@ -21,6 +21,7 @@ import {
21
21
  type DbConnection,
22
22
  } from "@cosmicdrift/kumiko-framework/db";
23
23
  import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
24
+ import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
24
25
  import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
25
26
  import {
26
27
  tenantComplianceProfileEntity,
@@ -38,6 +39,7 @@ export type SeedComplianceProfileOptions = {
38
39
  readonly profileKey: ComplianceProfileKey;
39
40
  readonly override?: ComplianceProfileOverride;
40
41
  readonly by?: SessionUser;
42
+ readonly ifExists?: SeedIfExists;
41
43
  };
42
44
 
43
45
  export async function seedComplianceProfile(
@@ -55,39 +57,42 @@ export async function seedComplianceProfile(
55
57
  tenantId: opts.tenantId,
56
58
  })) as { id: string; version: number } | null; // @cast-boundary db-runner
57
59
 
58
- if (existing) {
59
- const result = await executor.update(
60
- {
61
- id: existing.id,
62
- version: existing.version,
63
- changes: { profileKey: opts.profileKey, override: overrideJson },
64
- },
65
- by,
66
- tdb,
67
- );
68
- if (!result.isSuccess) {
69
- throw new Error(`seedComplianceProfile update failed: ${JSON.stringify(result)}`);
70
- }
71
- return { id: existing.id };
72
- }
73
-
74
- const result = await executor.create(
75
- {
76
- profileKey: opts.profileKey,
77
- override: overrideJson,
78
- tenantId: opts.tenantId,
60
+ return runEventStoreSeed({
61
+ existing,
62
+ ifExists: opts.ifExists,
63
+ create: async () => {
64
+ const result = await executor.create(
65
+ {
66
+ profileKey: opts.profileKey,
67
+ override: overrideJson,
68
+ tenantId: opts.tenantId,
69
+ },
70
+ by,
71
+ tdb,
72
+ );
73
+ if (!result.isSuccess) {
74
+ throw new Error(`seedComplianceProfile create failed: ${JSON.stringify(result)}`);
75
+ }
76
+ const data = result.data as { id?: string };
77
+ if (data.id === undefined) {
78
+ throw new Error("seedComplianceProfile: executor.create did not return an id");
79
+ }
80
+ return { id: data.id };
81
+ },
82
+ update: async (row) => {
83
+ const result = await executor.update(
84
+ {
85
+ id: row.id,
86
+ version: row.version,
87
+ changes: { profileKey: opts.profileKey, override: overrideJson },
88
+ },
89
+ by,
90
+ tdb,
91
+ );
92
+ if (!result.isSuccess) {
93
+ throw new Error(`seedComplianceProfile update failed: ${JSON.stringify(result)}`);
94
+ }
95
+ return { id: row.id };
79
96
  },
80
- by,
81
- tdb,
82
- );
83
- if (!result.isSuccess) {
84
- throw new Error(`seedComplianceProfile create failed: ${JSON.stringify(result)}`);
85
- }
86
- // @cast-boundary db-row: executor.create-result enthält die inserted
87
- // Row als Record<string, unknown>; id ist nach INSERT garantiert.
88
- const data = result.data as { id?: string };
89
- if (data.id === undefined) {
90
- throw new Error("seedComplianceProfile: executor.create did not return an id");
91
- }
92
- return { id: data.id };
97
+ });
93
98
  }
@@ -48,8 +48,8 @@ legal-pages doesn't have its own table — it uses text-content's
48
48
  `read_text_blocks`. Table setup therefore goes through text-content:
49
49
 
50
50
  ```bash
51
- yarn kumiko migrate generate # text-block entity is detected
52
- yarn kumiko migrate apply
51
+ bun kumiko migrate generate # text-block entity is detected
52
+ bun kumiko migrate apply
53
53
  ```
54
54
 
55
55
  See [text-content/README.md](../text-content/README.md#production-table-setup).
@@ -153,6 +153,7 @@ describe("legal-pages :: edge-cases", () => {
153
153
  lang: "de",
154
154
  title: "Impressum",
155
155
  body: "## XSS-Test\n\n<script>window.x=1</script>\n\nDanach.",
156
+ ifExists: "update",
156
157
  });
157
158
  const res = await stack.app.request("/legal/impressum");
158
159
  expect(res.status).toBe(200);
@@ -25,11 +25,7 @@
25
25
  // correct event + projection.
26
26
  //
27
27
  // Idempotent: calling twice for the same (userId, tenantId) is a no-op on
28
- // the second call. Test fixtures that seed the same membership across
29
- // `beforeEach` runs don't need explicit cleanup. A real `addMember` handler
30
- // returns ConflictError on duplicates — that's the user-facing contract.
31
- // Fixture-seeding prioritises "make the state exist" over "detect duplicate
32
- // seeding", which is usually a test-author bug we don't need to surface.
28
+ // the second call (ifExists="skip", siehe @cosmicdrift/kumiko-framework/seeding).
33
29
 
34
30
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
35
31
  import {
@@ -77,8 +73,8 @@ export type SeedTenantOptions = {
77
73
  };
78
74
 
79
75
  /**
80
- * Seed a tenant through the event-store executor. Idempotent: a second
81
- * call for the same `id` is a no-op. Same TX-semantics as the real
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
82
78
  * `TenantHandlers.create`, minus the SystemAdmin-access-check and minus
83
79
  * ConflictError-on-duplicate.
84
80
  */
@@ -22,20 +22,23 @@ runProdApp({
22
22
 
23
23
  ### Production table setup
24
24
 
25
- Each app creates the `read_text_blocks` table via a Drizzle migration:
25
+ Each app creates the `read_text_blocks` table via a schema migration:
26
26
 
27
27
  ```bash
28
- # In the app workspace (e.g. samples/showcases/myapp):
29
- yarn kumiko migrate generate # detects the new r.entity("text-block")
30
- # drizzle migration in the drizzle/ folder
31
- yarn kumiko migrate apply # apply (pre-deploy step in prod)
28
+ # In the app workspace (legacy drizzle.config.ts apps):
29
+ bun kumiko migrate generate # detects the new r.entity("text-block")
30
+ bun kumiko migrate apply # pre-deploy step in prod
31
+
32
+ # New apps (kumiko/schema.ts):
33
+ bun kumiko schema generate text-content
34
+ bun kumiko schema apply
32
35
  ```
33
36
 
34
37
  The boot gate (`runProdApp`) checks hard: missing table = `SchemaDriftError`,
35
38
  container exits. No auto-heal in production. See
36
39
  [docs/plans/architecture/migrations.md](../../../../docs/plans/architecture/migrations.md).
37
40
 
38
- In integration tests (vitest) it's enough to do:
41
+ In integration tests (`bun test`) it's enough to do:
39
42
 
40
43
  ```typescript
41
44
  import { unsafeCreateEntityTable } from "@cosmicdrift/kumiko-framework/stack";
@@ -1,6 +1,6 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
- import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
2
+ import { type DbConnection, fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
3
+ import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
4
4
  import {
5
5
  createTestUser,
6
6
  setupTestStack,
@@ -12,7 +12,7 @@ import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
12
12
  import { TextContentHandlers, TextContentQueries } from "../constants";
13
13
  import { createTextContentFeature } from "../feature";
14
14
  import { seedTextBlock } from "../seeding";
15
- import { textBlockEntity } from "../table";
15
+ import { type TextBlockRow, textBlockEntity, textBlocksTable } from "../table";
16
16
 
17
17
  let stack: TestStack;
18
18
  let db: DbConnection;
@@ -395,7 +395,7 @@ describe("text-content :: edge-cases", () => {
395
395
  });
396
396
 
397
397
  describe("text-content :: seedTextBlock", () => {
398
- test("seedTextBlock is idempotent", async () => {
398
+ test('ifExists="update" overwrites existing row (same aggregate id)', async () => {
399
399
  const a = await seedTextBlock(db, {
400
400
  tenantId: tenantAdmin.tenantId,
401
401
  slug: "seed-test",
@@ -409,10 +409,44 @@ describe("text-content :: seedTextBlock", () => {
409
409
  lang: "de",
410
410
  title: "v2",
411
411
  body: "neu",
412
+ ifExists: "update",
412
413
  });
413
414
  expect(a.id).toBe(b.id);
414
415
  });
415
416
 
417
+ test('default ifExists="skip" does not overwrite on re-boot', async () => {
418
+ const base = {
419
+ tenantId: tenantAdmin.tenantId,
420
+ slug: "seed-skip",
421
+ lang: "de",
422
+ };
423
+ await seedTextBlock(db, {
424
+ ...base,
425
+ title: "Initial",
426
+ body: "from seed",
427
+ });
428
+ await seedTextBlock(db, {
429
+ ...base,
430
+ title: "User edit",
431
+ body: "from admin",
432
+ ifExists: "update",
433
+ });
434
+ await seedTextBlock(db, {
435
+ ...base,
436
+ title: "Seed again",
437
+ body: "would overwrite",
438
+ });
439
+
440
+ const row = await fetchOne<TextBlockRow>(db, textBlocksTable, base);
441
+ expect(row).toMatchObject({ title: "User edit", body: "from admin", version: 2 });
442
+
443
+ const events = await selectMany(db, eventsTable, {
444
+ aggregateId: String(row!.id),
445
+ });
446
+ expect(events).toHaveLength(2);
447
+ expect(events.map((e) => e.type)).toEqual(["text-block.created", "text-block.updated"]);
448
+ });
449
+
416
450
  // Drift-Documentation: seedTextBlock geht direkt durch den Executor
417
451
  // OHNE slugSchema-Validation, set.write läuft DURCH die Validation.
418
452
  // Folge: seedTextBlock akzeptiert Slugs mit ":" oder "/" (legal-pages
@@ -1,7 +1,7 @@
1
1
  // Test-Helper für text-content. Legt einen TextBlock direkt über den
2
2
  // Event-Store-Executor an — gleicher Pfad wie der echte set-Handler,
3
- // aber ohne Access-Check. Idempotent: zweiter Call mit gleichem
4
- // (tenantId, slug, lang) updated den existing Block.
3
+ // aber ohne Access-Check. Default ifExists="skip": nur fehlende Blocks
4
+ // anlegen; opt-in update für Demo-Fixtures.
5
5
 
6
6
  import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
7
7
  import {
@@ -10,6 +10,7 @@ import {
10
10
  type DbConnection,
11
11
  } from "@cosmicdrift/kumiko-framework/db";
12
12
  import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
13
+ import { runEventStoreSeed, type SeedIfExists } from "@cosmicdrift/kumiko-framework/seeding";
13
14
  import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
14
15
  import { type TextBlockRow, textBlockEntity, textBlocksTable } from "./table";
15
16
 
@@ -29,6 +30,7 @@ export type SeedTextBlockOptions = {
29
30
  * set.write die geseedete Row später überschreiben kann. */
30
31
  readonly folder?: string | null;
31
32
  readonly by?: SessionUser;
33
+ readonly ifExists?: SeedIfExists;
32
34
  };
33
35
 
34
36
  export async function seedTextBlock(
@@ -54,43 +56,45 @@ export async function seedTextBlock(
54
56
 
55
57
  const folder = opts.folder ?? null;
56
58
 
57
- if (existing) {
58
- const result = await executor.update(
59
- {
60
- id: existing.id,
61
- version: existing.version,
62
- changes: { title: opts.title, body: opts.body ?? null, folder },
63
- },
64
- by,
65
- tdb,
66
- );
67
- if (!result.isSuccess) {
68
- throw new Error(`seedTextBlock update failed: ${JSON.stringify(result)}`);
69
- }
70
- return { id: existing.id };
71
- }
72
-
73
- const result = await executor.create(
74
- {
75
- slug: opts.slug,
76
- lang: opts.lang,
77
- title: opts.title,
78
- body: opts.body ?? null,
79
- folder,
80
- tenantId: opts.tenantId,
59
+ return runEventStoreSeed({
60
+ existing,
61
+ ifExists: opts.ifExists,
62
+ create: async () => {
63
+ const result = await executor.create(
64
+ {
65
+ slug: opts.slug,
66
+ lang: opts.lang,
67
+ title: opts.title,
68
+ body: opts.body ?? null,
69
+ folder,
70
+ tenantId: opts.tenantId,
71
+ },
72
+ by,
73
+ tdb,
74
+ );
75
+ if (!result.isSuccess) {
76
+ throw new Error(`seedTextBlock create failed: ${JSON.stringify(result)}`);
77
+ }
78
+ const data = result.data as Partial<TextBlockRow>;
79
+ if (data.id === undefined) {
80
+ throw new Error("seedTextBlock: executor.create did not return an id");
81
+ }
82
+ return { id: data.id };
83
+ },
84
+ update: async (row) => {
85
+ const result = await executor.update(
86
+ {
87
+ id: row.id,
88
+ version: row.version,
89
+ changes: { title: opts.title, body: opts.body ?? null, folder },
90
+ },
91
+ by,
92
+ tdb,
93
+ );
94
+ if (!result.isSuccess) {
95
+ throw new Error(`seedTextBlock update failed: ${JSON.stringify(result)}`);
96
+ }
97
+ return { id: row.id };
81
98
  },
82
- by,
83
- tdb,
84
- );
85
- if (!result.isSuccess) {
86
- throw new Error(`seedTextBlock create failed: ${JSON.stringify(result)}`);
87
- }
88
- // @cast-boundary db-row executor.create result.data ist Drizzle-row
89
- // (Record<string, unknown>), projected nach INSERT/RETURNING auf
90
- // TextBlockRow. Runtime-narrowing in der nächsten Zeile.
91
- const data = result.data as Partial<TextBlockRow>;
92
- if (data.id === undefined) {
93
- throw new Error("seedTextBlock: executor.create did not return an id");
94
- }
95
- return { id: data.id };
99
+ });
96
100
  }
@@ -1,8 +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 (idempotent: zweiter Aufruf für dieselbe Email
5
- // liefert die existierende userId zurück).
4
+ // bei Duplikaten. Verhält sich wie ifExists="skip" (siehe
5
+ // @cosmicdrift/kumiko-framework/seeding): existierende Email → return
6
+ // ohne Event.
6
7
  //
7
8
  // Warum nicht direkt `db.insert(userTable)`: das würde den Event-Store
8
9
  // umgehen, also kein `user.created`-Event und keine MSP-Konsumenten