@cosmicdrift/kumiko-bundled-features 0.21.1 → 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.
Files changed (31) hide show
  1. package/package.json +2 -1
  2. package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +4 -4
  3. package/src/auth-email-password/__tests__/seed-admin.integration.test.ts +3 -3
  4. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -1
  5. package/src/auth-email-password/seeding.ts +9 -6
  6. package/src/compliance-profiles/seeding.ts +4 -1
  7. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +58 -0
  8. package/src/custom-fields/constants.ts +23 -0
  9. package/src/custom-fields/handlers/set-custom-field.write.ts +8 -4
  10. package/src/custom-fields/lib/value-schema.ts +51 -17
  11. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +144 -0
  12. package/src/custom-fields/web/client-plugin.tsx +25 -0
  13. package/src/custom-fields/web/custom-fields-form-section.tsx +181 -0
  14. package/src/custom-fields/web/index.ts +8 -0
  15. package/src/files/__tests__/files.integration.test.ts +3 -23
  16. package/src/files/feature.ts +7 -34
  17. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +27 -0
  18. package/src/files-provider-s3/s3-provider.ts +8 -13
  19. package/src/tenant/__tests__/seed-testing.integration.test.ts +1 -1
  20. package/src/tenant/seeding.ts +35 -15
  21. package/src/text-content/seeding.ts +4 -1
  22. package/src/text-content/table.ts +1 -1
  23. package/src/user/__tests__/seed-testing.integration.test.ts +5 -5
  24. package/src/user/seeding.ts +13 -8
  25. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -15
  26. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +231 -0
  27. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +4 -15
  28. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +4 -15
  29. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +6 -18
  30. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +3 -0
  31. package/src/files/schema/file-ref.ts +0 -58
@@ -0,0 +1,181 @@
1
+ // @runtime client
2
+ // CustomFieldsFormSection — extension-section component für entityEdit-
3
+ // Screens. Lädt die fieldDefinition-Liste des Tenants, filtert auf die
4
+ // host-Entity, rendert pro Definition einen typed Input, dispatched
5
+ // `custom-fields:write:set-custom-field` pro non-empty Value beim Save.
6
+ //
7
+ // Mount via createKumikoApp({ clientFeatures: [customFieldsClient()] })
8
+ // — der clientFeature-Factory registriert diese Component unter dem
9
+ // Namen `CustomFieldsFormSection`, den die App im Screen-Schema via
10
+ // `component: { react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME } }`
11
+ // referenziert.
12
+
13
+ import { useDispatcher, usePrimitives, useQuery } from "@cosmicdrift/kumiko-renderer";
14
+ import { type ReactNode, useState } from "react";
15
+ import { CustomFieldsHandlers, CustomFieldsQueries } from "../constants";
16
+
17
+ type FieldDefinitionRow = {
18
+ readonly id: string;
19
+ readonly entityName: string;
20
+ readonly fieldKey: string;
21
+ readonly type: string;
22
+ readonly required: boolean;
23
+ readonly displayOrder: number;
24
+ };
25
+
26
+ type FieldDefinitionListResponse = {
27
+ readonly rows: readonly FieldDefinitionRow[];
28
+ };
29
+
30
+ export function CustomFieldsFormSection({
31
+ entityName,
32
+ entityId,
33
+ }: {
34
+ readonly entityName: string;
35
+ readonly entityId: string | null;
36
+ }): ReactNode {
37
+ const { Banner, Button, Field, Input, Text } = usePrimitives();
38
+ const dispatcher = useDispatcher();
39
+ const query = useQuery<FieldDefinitionListResponse>(CustomFieldsQueries.fieldDefinitionList, {});
40
+ const [pending, setPending] = useState<Readonly<Record<string, string>>>({});
41
+ const [saving, setSaving] = useState(false);
42
+ const [errorKey, setErrorKey] = useState<string | null>(null);
43
+
44
+ if (entityId === null) {
45
+ return (
46
+ <Banner variant="info" testId="custom-fields-form-create-mode">
47
+ <Text>Save the entity first to add custom field values.</Text>
48
+ </Banner>
49
+ );
50
+ }
51
+ if (query.loading && query.data === null) {
52
+ return (
53
+ <Banner variant="loading" testId="custom-fields-form-loading">
54
+ <Text>Loading…</Text>
55
+ </Banner>
56
+ );
57
+ }
58
+ if (query.error) {
59
+ return (
60
+ <Banner variant="error" testId="custom-fields-form-error">
61
+ <Text>{query.error.i18nKey}</Text>
62
+ </Banner>
63
+ );
64
+ }
65
+
66
+ const matchingFields = (query.data?.rows ?? [])
67
+ .filter((f) => f.entityName === entityName)
68
+ .slice()
69
+ .sort((a, b) => a.displayOrder - b.displayOrder);
70
+
71
+ if (matchingFields.length === 0) {
72
+ return (
73
+ <Banner variant="info" testId="custom-fields-form-empty">
74
+ <Text>No custom fields defined for "{entityName}".</Text>
75
+ </Banner>
76
+ );
77
+ }
78
+
79
+ const handleSave = async (): Promise<void> => {
80
+ setSaving(true);
81
+ setErrorKey(null);
82
+ try {
83
+ for (const field of matchingFields) {
84
+ const raw = pending[field.fieldKey];
85
+ if (raw === undefined || raw === "") continue;
86
+ const value = coerceValue(field.type, raw);
87
+ const result = await dispatcher.write(CustomFieldsHandlers.setCustomField, {
88
+ entityName,
89
+ entityId,
90
+ fieldKey: field.fieldKey,
91
+ value,
92
+ });
93
+ if (!result.isSuccess) {
94
+ setErrorKey(result.error?.i18nKey ?? "custom-fields:save-failed");
95
+ return;
96
+ }
97
+ }
98
+ setPending({});
99
+ } finally {
100
+ setSaving(false);
101
+ }
102
+ };
103
+
104
+ const dirty = Object.values(pending).some((v) => v !== "");
105
+
106
+ return (
107
+ <div data-testid="custom-fields-form-section">
108
+ {matchingFields.map((field) => (
109
+ <Field
110
+ key={field.id}
111
+ id={`custom-field-${field.fieldKey}`}
112
+ label={field.fieldKey}
113
+ required={field.required}
114
+ >
115
+ {renderInputFor(field, pending[field.fieldKey] ?? "", (v) =>
116
+ setPending((p) => ({ ...p, [field.fieldKey]: v })),
117
+ )}
118
+ </Field>
119
+ ))}
120
+ <Button
121
+ variant="primary"
122
+ onClick={() => void handleSave()}
123
+ disabled={saving || !dirty}
124
+ testId="custom-fields-form-save"
125
+ >
126
+ {saving ? "Saving…" : "Save custom fields"}
127
+ </Button>
128
+ {errorKey !== null && (
129
+ <Banner variant="error" testId="custom-fields-form-save-error">
130
+ <Text>{errorKey}</Text>
131
+ </Banner>
132
+ )}
133
+ </div>
134
+ );
135
+
136
+ function renderInputFor(
137
+ field: FieldDefinitionRow,
138
+ raw: string,
139
+ onChange: (v: string) => void,
140
+ ): ReactNode {
141
+ const id = `custom-field-${field.fieldKey}`;
142
+ const name = field.fieldKey;
143
+ if (field.type === "number") {
144
+ return (
145
+ <Input
146
+ kind="number"
147
+ id={id}
148
+ name={name}
149
+ value={raw === "" ? "" : Number(raw)}
150
+ onChange={(v) => onChange(v === undefined ? "" : String(v))}
151
+ />
152
+ );
153
+ }
154
+ if (field.type === "boolean") {
155
+ return (
156
+ <Input
157
+ kind="boolean"
158
+ id={id}
159
+ name={name}
160
+ value={raw === "true"}
161
+ onChange={(v) => onChange(v ? "true" : "false")}
162
+ />
163
+ );
164
+ }
165
+ if (field.type === "date") {
166
+ return (
167
+ <Input kind="date" id={id} name={name} value={raw} onChange={(v) => onChange(v ?? "")} />
168
+ );
169
+ }
170
+ return <Input kind="text" id={id} name={name} value={raw} onChange={onChange} />;
171
+ }
172
+ }
173
+
174
+ function coerceValue(type: string, raw: string): unknown {
175
+ if (type === "number") {
176
+ const n = Number(raw);
177
+ return Number.isNaN(n) ? raw : n;
178
+ }
179
+ if (type === "boolean") return raw === "true";
180
+ return raw;
181
+ }
@@ -0,0 +1,8 @@
1
+ // @runtime client
2
+ export {
3
+ CUSTOM_FIELDS_FORM_EXTENSION_NAME,
4
+ CustomFieldsHandlers,
5
+ CustomFieldsQueries,
6
+ } from "../constants";
7
+ export { customFieldsClient } from "./client-plugin";
8
+ export { CustomFieldsFormSection } from "./custom-fields-form-section";
@@ -4,13 +4,11 @@
4
4
  // 1. Feature-Definition Smoke (Boot-Validation passes)
5
5
  // 2. Cross-Feature-Behavior: fileRef-Entity ist als Hook-Anker für
6
6
  // Sprint-2-userData-Extension nutzbar
7
- // 3. DDL-Konsistenz: Framework-pgTable + Feature-Entity zeigen auf
8
- // dieselbe Postgres-Struktur (Drift-Guard)
9
- // 4. Event-QN-Match: r.defineEvent + framework's fileUploadedEvent
10
- // resolven zum selben QN
7
+ // 3. DDL-Konsistenz: fileRefsTable (buildEntityTable) + fileRefEntity
8
+ // zeigen auf dieselbe Postgres-Struktur (Drift-Guard)
11
9
 
12
10
  import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
13
- import { FILE_UPLOADED_EVENT_TYPE, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
11
+ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
14
12
  import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
15
13
 
16
14
  // Native dialect exposes column metadata on the `columns` array (EntityTableMeta)
@@ -148,21 +146,3 @@ describe("files :: DDL-Konsistenz (M3, S1.7)", () => {
148
146
  expect(pgColumns.has("size")).toBe(true);
149
147
  });
150
148
  });
151
-
152
- describe("files :: event-QN-match (M4, S1.7)", () => {
153
- test("framework's fileUploadedEvent.name === 'files:event:uploaded'", () => {
154
- // Wenn das Framework den Event-Namen aendert, fliegt dieser Test
155
- // sofort an — und der QN aus r.defineEvent("uploaded") im feature
156
- // wuerde nicht mehr matchen. Drift-Guard.
157
- expect(FILE_UPLOADED_EVENT_TYPE).toBe("files:event:uploaded");
158
- });
159
-
160
- test("Feature-Name 'files' + Event-Short 'uploaded' = QN 'files:event:uploaded'", () => {
161
- // r.defineEvent("uploaded") in defineFeature("files", ...) resolved
162
- // zu QN "files:event:uploaded" via Framework-Convention. Match
163
- // garantiert dass framework's appendEvent + EventDef-Schema-
164
- // Validation auf demselben QN landen.
165
- const expected = `${feature.name}:event:uploaded`;
166
- expect(expected).toBe(FILE_UPLOADED_EVENT_TYPE);
167
- });
168
- });
@@ -1,34 +1,7 @@
1
- import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
- import { fileUploadedPayloadSchema } from "@cosmicdrift/kumiko-framework/files";
3
- import { fileRefEntity } from "./schema/file-ref";
4
-
5
- export { fileRefEntity } from "./schema/file-ref";
6
-
7
- // files Schema-Sicht der framework-internen file_refs-Tabelle als
8
- // bundled-feature, damit Cross-Feature-Hooks (userData, tenantData) sich
9
- // an die "fileRef"-Entity hängen können.
10
- //
11
- // Sprint 1.5 (this commit):
12
- // - r.entity("fileRef", fileRefEntity) — Schema-Surface
13
- // - r.defineEvent("uploaded", schema) — Event-Marker
14
- //
15
- // Sprint 2 (kommt):
16
- // - r.useExtension(EXT_USER_DATA, "fileRef", { export, delete })
17
- //
18
- // Sprint 5 (kommt):
19
- // - r.useExtension(EXT_TENANT_DATA, "fileRef", { destroy })
20
- //
21
- // Routes bleiben framework-internal (multipart-Upload + binary-Streaming
22
- // passen nicht in das Handler-Pattern; siehe schema/file-ref.ts für
23
- // Architektur-Note).
24
- //
25
- // Sprint-1.5-Plan-Roadmap-Wille: "fileRefsTable bleibt in framework
26
- // (kein Daten-Move), aber r.entity('fileRef') deklariert sie für das
27
- // Feature." — diese Datei IST die Umsetzung.
28
- export function createFilesFeature(): FeatureDefinition {
29
- return defineFeature("files", (r) => {
30
- r.entity("fileRef", fileRefEntity);
31
-
32
- r.defineEvent("uploaded", fileUploadedPayloadSchema);
33
- });
34
- }
1
+ // files full Event-Sourcing für File-Metadata. Die Implementierung (Entity,
2
+ // files:event:*-Events, Inline-Projektion) lebt seit dem ES-Umbau im
3
+ // Framework neben file-routes + fileRefsTable, weil file-routes hart davon
4
+ // abhängt (appendDomainEventCore verlangt registrierte Events + Projektion).
5
+ // Dieses Modul re-exportiert nur, damit der App-Import-Pfad
6
+ // `@cosmicdrift/kumiko-bundled-features/files` stabil bleibt.
7
+ export { createFilesFeature, fileRefEntity } from "@cosmicdrift/kumiko-framework/files";
@@ -110,6 +110,33 @@ describe("s3-provider (Minio)", () => {
110
110
  const key = uniqueKey("never-existed.bin");
111
111
  await expect(provider.read(key)).rejects.toThrow();
112
112
  });
113
+
114
+ test("writeStream round-trip via multipart writer preserves bytes", async () => {
115
+ // Pinst die idiomatic Bun-S3-writer-Form (write + end, kein manual
116
+ // flush). Chunks summieren absichtlich auf > 5 MiB (partSize) UND auf
117
+ // einen krummen Rest, damit der multipart-finalizer auch dann greift,
118
+ // wenn die Source-Chunks nicht auf die Part-Boundary aufgehen.
119
+ const key = uniqueKey("stream-multipart.bin");
120
+ const partSize = 5 * 1024 * 1024;
121
+ const chunk = new Uint8Array(1024 * 1024);
122
+ for (let i = 0; i < chunk.length; i++) chunk[i] = i % 251;
123
+ const chunks: Uint8Array[] = [];
124
+ for (let i = 0; i < 7; i++) chunks.push(chunk);
125
+
126
+ if (!provider.writeStream) throw new Error("s3 provider should implement writeStream");
127
+ await provider.writeStream(
128
+ key,
129
+ (async function* () {
130
+ for (const c of chunks) yield c;
131
+ })(),
132
+ );
133
+
134
+ const readBack = await provider.read(key);
135
+ expect(readBack.byteLength).toBe(chunks.length * chunk.length);
136
+ expect(readBack.byteLength).toBeGreaterThan(partSize);
137
+ expect(readBack[0]).toBe(0);
138
+ expect(readBack[readBack.byteLength - 1]).toBe(chunk[chunk.length - 1]);
139
+ });
113
140
  });
114
141
 
115
142
  describe("createS3ProviderFromEnv", () => {
@@ -72,26 +72,21 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
72
72
  },
73
73
 
74
74
  async writeStream(key, source, options): Promise<void> {
75
- // Echtes multipart-streaming via Bun's S3-Writer — der Source-
76
- // AsyncIterable wird chunk-weise hochgeladen, niemals alles im Memory.
77
- // Wir flushen sobald ein Part voll ist, damit der Heap-Footprint auf
78
- // ~ein Part begrenzt bleibt, unabhaengig von der Total-Bundle-Size —
79
- // macht 1GB+ Exports ohne OOM moeglich.
75
+ // Echtes multipart-streaming via Bun's S3-Writer — partSize steuert die
76
+ // Part-Boundary intern (AWS/R2 verlangen non-final Parts >= 5 MiB,
77
+ // sonst EntityTooSmall beim CompleteMultipartUpload). Manuelles flush()
78
+ // hier wuerde genau diese Garantie brechen, sobald die Source-Chunks
79
+ // nicht auf partSize aufgehen.
80
80
  const writer = client.file(key).writer({
81
81
  ...(options?.mimeType !== undefined && { type: options.mimeType }),
82
82
  retry: 3,
83
83
  queueSize: 4,
84
84
  partSize: STREAM_PART_SIZE,
85
85
  });
86
- let buffered = 0;
87
86
  for await (const chunk of source) {
88
- // write() returns a Promise when the writer applies backpressure —
89
- // awaiting it bounds the in-flight queue instead of buffering ahead.
90
- buffered += await writer.write(chunk);
91
- if (buffered >= STREAM_PART_SIZE) {
92
- await writer.flush();
93
- buffered = 0;
94
- }
87
+ // Await applies Backpressure und bounded die in-flight Queue auf
88
+ // queueSize, statt unbegrenzt zu puffern.
89
+ await writer.write(chunk);
95
90
  }
96
91
  await writer.end();
97
92
  },
@@ -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
@@ -25,6 +25,7 @@ import {
25
25
  setupTestStack,
26
26
  type TestStack,
27
27
  unsafeCreateEntityTable,
28
+ unsafePushTables,
28
29
  } from "@cosmicdrift/kumiko-framework/stack";
29
30
  import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
30
31
  import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
@@ -145,21 +146,9 @@ beforeAll(async () => {
145
146
  UNIQUE(user_id, tenant_id)
146
147
  )
147
148
  `);
148
- await asRawClient(stack.db).unsafe(`
149
- CREATE TABLE IF NOT EXISTS file_refs (
150
- id UUID PRIMARY KEY,
151
- tenant_id UUID NOT NULL,
152
- storage_key TEXT NOT NULL,
153
- file_name TEXT NOT NULL,
154
- mime_type TEXT NOT NULL,
155
- size INTEGER NOT NULL,
156
- entity_type TEXT,
157
- entity_id TEXT,
158
- field_name TEXT,
159
- inserted_at TIMESTAMPTZ DEFAULT now() NOT NULL,
160
- inserted_by_id TEXT
161
- )
162
- `);
149
+ // fileRef ist buildEntityTable-getrieben (softDelete) — echte Entity-Tabelle
150
+ // pushen statt hand-CREATE, damit is_deleted/deleted_at/deleted_by_id da sind.
151
+ await unsafePushTables(stack.db, { fileRefsTable });
163
152
  await asRawClient(stack.db).unsafe(`
164
153
  CREATE TABLE IF NOT EXISTS test_notes (
165
154
  id UUID PRIMARY KEY,