@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.
- package/package.json +2 -1
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +4 -4
- package/src/auth-email-password/__tests__/seed-admin.integration.test.ts +3 -3
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -1
- package/src/auth-email-password/seeding.ts +9 -6
- package/src/compliance-profiles/seeding.ts +4 -1
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +58 -0
- package/src/custom-fields/constants.ts +23 -0
- package/src/custom-fields/handlers/set-custom-field.write.ts +8 -4
- package/src/custom-fields/lib/value-schema.ts +51 -17
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +144 -0
- package/src/custom-fields/web/client-plugin.tsx +25 -0
- package/src/custom-fields/web/custom-fields-form-section.tsx +181 -0
- package/src/custom-fields/web/index.ts +8 -0
- package/src/files/__tests__/files.integration.test.ts +3 -23
- package/src/files/feature.ts +7 -34
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +27 -0
- package/src/files-provider-s3/s3-provider.ts +8 -13
- package/src/tenant/__tests__/seed-testing.integration.test.ts +1 -1
- package/src/tenant/seeding.ts +35 -15
- package/src/text-content/seeding.ts +4 -1
- package/src/text-content/table.ts +1 -1
- package/src/user/__tests__/seed-testing.integration.test.ts +5 -5
- package/src/user/seeding.ts +13 -8
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -15
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +231 -0
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +4 -15
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +4 -15
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +6 -18
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +3 -0
- 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:
|
|
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 {
|
|
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
|
-
});
|
package/src/files/feature.ts
CHANGED
|
@@ -1,34 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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 —
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
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
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
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",
|
package/src/tenant/seeding.ts
CHANGED
|
@@ -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
|
|
28
|
-
// the second call
|
|
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
|
|
77
|
-
* a second call for the same `id` is a no-op
|
|
78
|
-
* `TenantHandlers.create`, minus the SystemAdmin-
|
|
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(
|
|
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<
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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
|
|
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
|
});
|
package/src/user/seeding.ts
CHANGED
|
@@ -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.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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,
|