@cosmicdrift/kumiko-framework 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +37 -0
- package/package.json +10 -2
- package/src/engine/define-feature.ts +13 -0
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/extractors/round5.ts +25 -1
- package/src/engine/feature-ast/parse.ts +4 -0
- package/src/engine/feature-ast/patterns.ts +11 -0
- package/src/engine/feature-ast/render.ts +7 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +3 -0
- package/src/engine/pattern-library/library.ts +19 -0
- package/src/engine/types/feature.ts +19 -0
- package/src/env/__tests__/compose-env-schema.test.ts +283 -0
- package/src/env/__tests__/dry-run.test.ts +121 -0
- package/src/env/_zod-introspect.ts +51 -0
- package/src/env/dry-run.ts +191 -0
- package/src/env/index.ts +349 -0
- package/src/es-ops/README.md +4 -2
- package/src/es-ops/__tests__/context.integration.ts +100 -10
- package/src/es-ops/context.ts +21 -4
- package/src/es-ops/types.ts +14 -1
package/src/env/index.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// Env-Schema composition for Kumiko apps.
|
|
2
|
+
//
|
|
3
|
+
// Each feature declares its required env-vars via `r.envSchema(z.object({...}))`.
|
|
4
|
+
// `composeEnvSchema({ features, extend })` merges those into one app-wide
|
|
5
|
+
// Zod-object that `runProdApp` validates `process.env` against at boot.
|
|
6
|
+
//
|
|
7
|
+
// On invalid/missing vars `parseEnv` throws `KumikoBootError` with ALL
|
|
8
|
+
// problems aggregated (Zod's safeParse traverses every field before
|
|
9
|
+
// short-circuiting). `runProdApp` catches at its top level and renders
|
|
10
|
+
// via `KumikoBootError.format()` — so apps don't repeat the try/catch.
|
|
11
|
+
//
|
|
12
|
+
// Per-var metadata for deploy-time tooling (Pulumi-config-key override,
|
|
13
|
+
// generator command, k8s hints) lives in Zod's `.meta({ kumiko: {...} })`.
|
|
14
|
+
// Without meta, defaults: `camelCase(envVarName)` for the Pulumi key,
|
|
15
|
+
// `<set-me>` placeholder for the value, no `--secret` flag.
|
|
16
|
+
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import type { FeatureDefinition } from "../engine/types/feature";
|
|
19
|
+
import { zodDef, zodDescription, zodMeta, zodShape, zodShapeField } from "./_zod-introspect";
|
|
20
|
+
|
|
21
|
+
// --- Per-env-var metadata (attach via Zod's `.meta()`) ---
|
|
22
|
+
|
|
23
|
+
export type KumikoEnvMeta = {
|
|
24
|
+
readonly pulumi?: {
|
|
25
|
+
/** Override the auto-derived camelCase Pulumi-config-key name. Apps
|
|
26
|
+
* setting `pulumiPrefix: "studio"` and authoring `STUDIO_ADMIN_EMAIL`
|
|
27
|
+
* would otherwise get `studioStudioAdminEmail` — set
|
|
28
|
+
* `.meta({ kumiko: { pulumi: { name: "adminEmail" } } })` to drop the
|
|
29
|
+
* duplicated prefix. */
|
|
30
|
+
readonly name?: string;
|
|
31
|
+
/** Shell expression that generates a value, e.g. `openssl rand -base64 32`.
|
|
32
|
+
* Without this, dry-run-pulumi emits `<set-me>` as the placeholder. */
|
|
33
|
+
readonly generator?: string;
|
|
34
|
+
/** Force the `--secret` flag in `pulumi config set`. Default false. */
|
|
35
|
+
readonly secret?: boolean;
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function isKumikoMeta(value: unknown): value is KumikoEnvMeta {
|
|
40
|
+
if (value === null || typeof value !== "object") return false;
|
|
41
|
+
// @cast-boundary schema-walk — runtime-shape narrowing of zod-meta payload
|
|
42
|
+
const v = value as { pulumi?: unknown };
|
|
43
|
+
if (v.pulumi === undefined) return true;
|
|
44
|
+
if (v.pulumi === null || typeof v.pulumi !== "object") return false;
|
|
45
|
+
// @cast-boundary schema-walk
|
|
46
|
+
const p = v.pulumi as { name?: unknown; generator?: unknown; secret?: unknown };
|
|
47
|
+
if (p.name !== undefined && typeof p.name !== "string") return false;
|
|
48
|
+
if (p.generator !== undefined && typeof p.generator !== "string") return false;
|
|
49
|
+
if (p.secret !== undefined && typeof p.secret !== "boolean") return false;
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function readKumikoMeta(field: z.ZodType): KumikoEnvMeta {
|
|
54
|
+
const meta = zodMeta(field);
|
|
55
|
+
if (meta && typeof meta === "object" && meta !== null && "kumiko" in meta) {
|
|
56
|
+
// @cast-boundary schema-walk
|
|
57
|
+
const k = (meta as { kumiko?: unknown }).kumiko;
|
|
58
|
+
if (isKumikoMeta(k)) return k;
|
|
59
|
+
}
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- Field-classification helpers (Zod v4 introspection) ---
|
|
64
|
+
|
|
65
|
+
export type EnvFieldClass = "required" | "optional" | "withDefault";
|
|
66
|
+
|
|
67
|
+
export function classifyField(field: z.ZodType): EnvFieldClass {
|
|
68
|
+
// Drill through ZodEffects/ZodPipe wrappers to find the inner kind.
|
|
69
|
+
let current: z.ZodType = field;
|
|
70
|
+
for (let i = 0; i < 8; i++) {
|
|
71
|
+
if (current instanceof z.ZodDefault) return "withDefault";
|
|
72
|
+
if (current instanceof z.ZodOptional || current instanceof z.ZodNullable) {
|
|
73
|
+
return "optional";
|
|
74
|
+
}
|
|
75
|
+
const inner = zodDef(current);
|
|
76
|
+
if (inner?.innerType) {
|
|
77
|
+
current = inner.innerType;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (inner?.in) {
|
|
81
|
+
current = inner.in;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
return "required";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function getDefaultValue(field: z.ZodType): unknown {
|
|
90
|
+
let current: z.ZodType = field;
|
|
91
|
+
for (let i = 0; i < 8; i++) {
|
|
92
|
+
if (current instanceof z.ZodDefault) {
|
|
93
|
+
// Zod v4: defaultValue is the raw value (v3 was a factory function).
|
|
94
|
+
// Support both shapes for forward/backward safety.
|
|
95
|
+
const raw = zodDef(current)?.defaultValue;
|
|
96
|
+
// @cast-boundary schema-walk — Zod v3 stored a thunk, v4 a raw value
|
|
97
|
+
return typeof raw === "function" ? (raw as () => unknown)() : raw;
|
|
98
|
+
}
|
|
99
|
+
const inner = zodDef(current);
|
|
100
|
+
if (inner?.innerType) {
|
|
101
|
+
current = inner.innerType;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function getFieldDescription(field: z.ZodType): string | undefined {
|
|
110
|
+
return zodDescription(field);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- Compose ---
|
|
114
|
+
|
|
115
|
+
export type ComposeEnvSchemaOptions = {
|
|
116
|
+
/** All features whose envSchemas should be merged. */
|
|
117
|
+
readonly features: readonly FeatureDefinition[];
|
|
118
|
+
/** App-specific env-vars (e.g. `STUDIO_ADMIN_EMAIL`). Keys here are
|
|
119
|
+
* tagged as source "app" in the resulting sources map. */
|
|
120
|
+
readonly extend?: z.ZodObject<z.ZodRawShape>;
|
|
121
|
+
/** Feature-names whose env-vars should be auto-`.optional()`-wrapped.
|
|
122
|
+
* Lets an app opt out of e.g. `channel-email-smtp`'s vars without
|
|
123
|
+
* manually `.partial()`-ing each shape at the call-site. */
|
|
124
|
+
readonly optionalFeatures?: readonly string[];
|
|
125
|
+
/** Framework-core env-vars (PORT, DATABASE_URL, REDIS_URL, …) from
|
|
126
|
+
* `@cosmicdrift/kumiko-dev-server`. Keys here are tagged as source
|
|
127
|
+
* "framework-core" in error output. Conflict-detection runs across
|
|
128
|
+
* core/features/extend as one merged pool — same key declared in two
|
|
129
|
+
* layers throws KumikoBootError at compose-time. */
|
|
130
|
+
readonly core?: z.ZodObject<z.ZodRawShape>;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export type ComposedEnvSchema = {
|
|
134
|
+
/** The merged Zod schema. Type-erased to `ZodObject<ZodRawShape>` — TS
|
|
135
|
+
* can't variadic-merge N generic feature schemas without tuple gymnastics
|
|
136
|
+
* that hurt readability. For type-safe `z.infer` in app code, build a
|
|
137
|
+
* parallel typed schema manually (see Plan-Doc
|
|
138
|
+
* `kumiko-studio/docs/plans/sprint-9-env-schemas.md` → API-Design)
|
|
139
|
+
* and only pass `composed.schema` to `runProdApp` for validation. */
|
|
140
|
+
readonly schema: z.ZodObject<z.ZodRawShape>;
|
|
141
|
+
/** env-var-name → declaring source-name: feature-name from `r.envSchema()`,
|
|
142
|
+
* "app" from `extend`, or "framework-core" from `core`. */
|
|
143
|
+
readonly sources: Readonly<Record<string, string>>;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export function composeEnvSchema(options: ComposeEnvSchemaOptions): ComposedEnvSchema {
|
|
147
|
+
const optionalSet = new Set(options.optionalFeatures ?? []);
|
|
148
|
+
const merged: Record<string, z.ZodType> = {};
|
|
149
|
+
const sources: Record<string, string> = {};
|
|
150
|
+
|
|
151
|
+
// Framework-core first so a feature that accidentally declares the same
|
|
152
|
+
// var (e.g. PORT) gets a clear conflict error citing "framework-core"
|
|
153
|
+
// rather than silently overwriting a deploy-critical default. The
|
|
154
|
+
// conflict-detection makes the order safe in both directions — both
|
|
155
|
+
// throw — but downstream tools that consume `sources` (e.g. the
|
|
156
|
+
// upcoming Phase-5 lint-guard) may rely on this insertion order.
|
|
157
|
+
if (options.core) {
|
|
158
|
+
for (const [key, field] of Object.entries(zodShape(options.core))) {
|
|
159
|
+
merged[key] = field;
|
|
160
|
+
sources[key] = "framework-core";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
for (const feature of options.features) {
|
|
165
|
+
if (!feature.envSchema) continue;
|
|
166
|
+
const shape = zodShape(feature.envSchema);
|
|
167
|
+
const wrap = optionalSet.has(feature.name);
|
|
168
|
+
for (const [key, field] of Object.entries(shape)) {
|
|
169
|
+
if (merged[key] !== undefined) {
|
|
170
|
+
throw new KumikoBootError([
|
|
171
|
+
{
|
|
172
|
+
name: key,
|
|
173
|
+
kind: "invalid",
|
|
174
|
+
message:
|
|
175
|
+
`env-var conflict: "${key}" declared by both ` +
|
|
176
|
+
`"${sources[key]}" and "${feature.name}" — pick one owner.`,
|
|
177
|
+
},
|
|
178
|
+
]);
|
|
179
|
+
}
|
|
180
|
+
merged[key] = wrap ? field.optional() : field;
|
|
181
|
+
sources[key] = feature.name;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (options.extend) {
|
|
186
|
+
for (const [key, field] of Object.entries(zodShape(options.extend))) {
|
|
187
|
+
if (merged[key] !== undefined) {
|
|
188
|
+
throw new KumikoBootError([
|
|
189
|
+
{
|
|
190
|
+
name: key,
|
|
191
|
+
kind: "invalid",
|
|
192
|
+
message:
|
|
193
|
+
`env-var conflict: "${key}" declared by both "${sources[key]}" ` +
|
|
194
|
+
`and the app's extend block — rename one.`,
|
|
195
|
+
},
|
|
196
|
+
]);
|
|
197
|
+
}
|
|
198
|
+
merged[key] = field;
|
|
199
|
+
sources[key] = "app";
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
schema: z.object(merged),
|
|
205
|
+
sources,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- Errors ---
|
|
210
|
+
|
|
211
|
+
export type EnvErrorKind = "missing" | "invalid";
|
|
212
|
+
|
|
213
|
+
export type EnvError = {
|
|
214
|
+
readonly name: string;
|
|
215
|
+
readonly kind: EnvErrorKind;
|
|
216
|
+
readonly message: string;
|
|
217
|
+
/** Declaring source-name from `composeEnvSchema`'s sources map:
|
|
218
|
+
* feature-name, "app" from `extend`, or "framework-core" from `core`.
|
|
219
|
+
* Populated when parseEnv received `options.sources`. Surfaced by
|
|
220
|
+
* `KumikoBootError.format()` so operators see WHICH source wants the
|
|
221
|
+
* missing var, not just the var name. */
|
|
222
|
+
readonly source?: string;
|
|
223
|
+
/** "Set via: pulumi config set ..." line. Computed when pulumiPrefix is
|
|
224
|
+
* passed to parseEnv. */
|
|
225
|
+
readonly suggestion?: string;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
export class KumikoBootError extends Error {
|
|
229
|
+
readonly errors: readonly EnvError[];
|
|
230
|
+
|
|
231
|
+
constructor(errors: readonly EnvError[]) {
|
|
232
|
+
super(`Boot failed: ${errors.length} env-var problem${errors.length === 1 ? "" : "s"}`);
|
|
233
|
+
this.name = "KumikoBootError";
|
|
234
|
+
this.errors = errors;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
format(): string {
|
|
238
|
+
const lines: string[] = [
|
|
239
|
+
`Boot failed: ${this.errors.length} env-var problem${this.errors.length === 1 ? "" : "s"}`,
|
|
240
|
+
"",
|
|
241
|
+
];
|
|
242
|
+
for (const err of this.errors) {
|
|
243
|
+
const tag = err.kind === "missing" ? "required, missing" : "invalid";
|
|
244
|
+
const sourceTag = err.source ? `${err.source}, ${tag}` : tag;
|
|
245
|
+
lines.push(` ✗ ${err.name} (${sourceTag})`);
|
|
246
|
+
lines.push(` ${err.message}`);
|
|
247
|
+
if (err.suggestion) {
|
|
248
|
+
lines.push(` ${err.suggestion}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
lines.push("");
|
|
252
|
+
lines.push(
|
|
253
|
+
"See: kumiko-platform/docs/runbooks/standard-deploy-app.md#step-1-boot-dry-run-lokal",
|
|
254
|
+
);
|
|
255
|
+
return lines.join("\n");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// --- Parse ---
|
|
260
|
+
|
|
261
|
+
export type ParseEnvOptions = {
|
|
262
|
+
/** From composeEnvSchema's return — enables per-var feature-source
|
|
263
|
+
* attribution in suggestions. */
|
|
264
|
+
readonly sources?: Readonly<Record<string, string>>;
|
|
265
|
+
/** When set, error suggestions include `pulumi config set <prefix>...`. */
|
|
266
|
+
readonly pulumiPrefix?: string;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
export function parseEnv<S extends z.ZodObject<z.ZodRawShape>>(
|
|
270
|
+
schema: S,
|
|
271
|
+
env: Record<string, string | undefined>,
|
|
272
|
+
options: ParseEnvOptions = {},
|
|
273
|
+
): z.infer<S> {
|
|
274
|
+
// Filter undefined values to keep Zod's required-vs-invalid signal clean.
|
|
275
|
+
// (process.env returns string|undefined; passing undefined would parse
|
|
276
|
+
// as "field is set to undefined" which clouds the missing-key heuristic.)
|
|
277
|
+
const cleaned: Record<string, string> = {};
|
|
278
|
+
for (const [k, v] of Object.entries(env)) {
|
|
279
|
+
if (v !== undefined) cleaned[k] = v;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const result = schema.safeParse(cleaned);
|
|
283
|
+
if (result.success) {
|
|
284
|
+
// @cast-boundary schema-walk — z.infer<S> erasure across safeParse result
|
|
285
|
+
return result.data as z.infer<S>;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Map Zod-issues → EnvError[], augmenting with suggestions when meta+prefix.
|
|
289
|
+
const errors: EnvError[] = result.error.issues.map((issue) => {
|
|
290
|
+
const name = String(issue.path[0] ?? "<unknown>");
|
|
291
|
+
const field = zodShapeField(schema, name);
|
|
292
|
+
// Zod v4 dropped the `received` property on invalid_type issues; the
|
|
293
|
+
// canonical signal for "value was missing" is now "the key isn't in
|
|
294
|
+
// the input object". Use input-presence as the missing/invalid switch.
|
|
295
|
+
const isMissing = issue.code === "invalid_type" && !(name in cleaned);
|
|
296
|
+
const kind: EnvErrorKind = isMissing ? "missing" : "invalid";
|
|
297
|
+
const desc = field ? getFieldDescription(field) : undefined;
|
|
298
|
+
const message = desc ? `${issue.message} — ${desc}` : issue.message;
|
|
299
|
+
const suggestion = field ? buildPulumiSuggestion(name, field, options.pulumiPrefix) : undefined;
|
|
300
|
+
const source = options.sources?.[name];
|
|
301
|
+
return {
|
|
302
|
+
name,
|
|
303
|
+
kind,
|
|
304
|
+
message,
|
|
305
|
+
...(source ? { source } : {}),
|
|
306
|
+
...(suggestion ? { suggestion } : {}),
|
|
307
|
+
};
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
throw new KumikoBootError(errors);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// --- Pulumi-suggestion ---
|
|
314
|
+
|
|
315
|
+
function ucfirst(s: string): string {
|
|
316
|
+
return s.length === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function camelCase(snakeShout: string): string {
|
|
320
|
+
const parts = snakeShout.toLowerCase().split("_").filter(Boolean);
|
|
321
|
+
if (parts.length === 0) return snakeShout.toLowerCase();
|
|
322
|
+
return parts[0] + parts.slice(1).map(ucfirst).join("");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function pulumiConfigKey(
|
|
326
|
+
envName: string,
|
|
327
|
+
field: z.ZodType | undefined,
|
|
328
|
+
prefix: string | undefined,
|
|
329
|
+
): string {
|
|
330
|
+
const meta = field ? readKumikoMeta(field) : {};
|
|
331
|
+
if (meta.pulumi?.name) {
|
|
332
|
+
return prefix ? prefix + ucfirst(meta.pulumi.name) : meta.pulumi.name;
|
|
333
|
+
}
|
|
334
|
+
const camel = camelCase(envName);
|
|
335
|
+
return prefix ? prefix + ucfirst(camel) : camel;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function buildPulumiSuggestion(
|
|
339
|
+
envName: string,
|
|
340
|
+
field: z.ZodType,
|
|
341
|
+
prefix: string | undefined,
|
|
342
|
+
): string | undefined {
|
|
343
|
+
if (!prefix) return undefined;
|
|
344
|
+
const meta = readKumikoMeta(field);
|
|
345
|
+
const key = pulumiConfigKey(envName, field, prefix);
|
|
346
|
+
const secretFlag = meta.pulumi?.secret ? " --secret" : "";
|
|
347
|
+
const value = meta.pulumi?.generator ? `"$(${meta.pulumi.generator})"` : `"<set-me>"`;
|
|
348
|
+
return `Set via: pulumi config set${secretFlag} ${key} ${value}`;
|
|
349
|
+
}
|
package/src/es-ops/README.md
CHANGED
|
@@ -32,7 +32,7 @@ export default {
|
|
|
32
32
|
await ctx.systemWriteAs(
|
|
33
33
|
"tenant:write:update-member-roles",
|
|
34
34
|
{ userId: admin.id, tenantId: m.tenantId, roles: [...m.roles, "TenantAdmin"] },
|
|
35
|
-
m.
|
|
35
|
+
m.streamTenantId, // ← tenantIdOverride aus dem JOIN auf kumiko_events.v1
|
|
36
36
|
);
|
|
37
37
|
}
|
|
38
38
|
},
|
|
@@ -47,9 +47,11 @@ Faustregel: **wenn das Ziel-Aggregate via Tenant-User erstellt wurde, brauchst D
|
|
|
47
47
|
|---|---|---|
|
|
48
48
|
| config-values (system-scope) | SYSTEM_TENANT | weglassen |
|
|
49
49
|
| system text-content | SYSTEM_TENANT | weglassen |
|
|
50
|
-
| tenant-membership | jeweiliger Tenant
|
|
50
|
+
| tenant-membership | jeweiliger Stream-Tenant aus events.v1 | ✅ `m.streamTenantId` (NICHT `m.tenantId` — die beiden können divergieren!) |
|
|
51
51
|
| App-Entity (orders, tasks, …) | Tenant-Stream | ✅ Tenant-Id aus dem Lookup |
|
|
52
52
|
|
|
53
|
+
**Warum nicht `m.tenantId`?** read_tenant_memberships.tenant_id ist der payload-tenant (logisches Mitgliedschafts-Ziel), kumiko_events.tenant_id der v1-Row ist der stream-tenant (wo das Aggregate physisch lebt). seedTenantMembership mit `by=systemAdmin` lässt die zwei auseinanderlaufen — der Helper `findMembershipsOfUser` liefert beide getrennt, damit Seeds den richtigen wählen können.
|
|
54
|
+
|
|
53
55
|
Ohne `tenantIdOverride` sucht der Executor den Stream gegen SYSTEM_TENANT → `version_conflict`. Memory: `feedback_event_store_tenant_consistency.md`.
|
|
54
56
|
|
|
55
57
|
## Deployment-Anforderungen
|
|
@@ -55,10 +55,34 @@ afterAll(async () => {
|
|
|
55
55
|
|
|
56
56
|
beforeEach(async () => {
|
|
57
57
|
await testDb.db.execute(sql`
|
|
58
|
-
TRUNCATE kumiko_es_operations, read_users, read_tenant_memberships, read_tenants
|
|
58
|
+
TRUNCATE kumiko_es_operations, kumiko_events, read_users, read_tenant_memberships, read_tenants
|
|
59
|
+
RESTART IDENTITY CASCADE
|
|
59
60
|
`);
|
|
60
61
|
});
|
|
61
62
|
|
|
63
|
+
// Helper: simulate `seedTenantMembership` writing both the read-row and
|
|
64
|
+
// its v1-event with a custom stream-tenant. Tests use this to construct
|
|
65
|
+
// the stream-vs-payload-tenant scenarios that drive the JOIN-helper.
|
|
66
|
+
async function insertMembershipWithEvent(args: {
|
|
67
|
+
readonly id: string;
|
|
68
|
+
readonly userId: string;
|
|
69
|
+
readonly payloadTenantId: string;
|
|
70
|
+
readonly streamTenantId: string;
|
|
71
|
+
readonly roles: string;
|
|
72
|
+
}): Promise<void> {
|
|
73
|
+
await testDb.db.execute(sql`
|
|
74
|
+
INSERT INTO read_tenant_memberships (id, user_id, tenant_id, roles)
|
|
75
|
+
VALUES (${args.id}::uuid, ${args.userId}, ${args.payloadTenantId}::uuid, ${args.roles})
|
|
76
|
+
`);
|
|
77
|
+
await testDb.db.execute(sql`
|
|
78
|
+
INSERT INTO kumiko_events
|
|
79
|
+
(aggregate_id, aggregate_type, tenant_id, version, type, payload, metadata, created_by)
|
|
80
|
+
VALUES
|
|
81
|
+
(${args.id}::uuid, 'tenant-membership', ${args.streamTenantId}::uuid, 1,
|
|
82
|
+
'tenant-membership.created', '{}'::jsonb, '{"userId":"system"}'::jsonb, 'system')
|
|
83
|
+
`);
|
|
84
|
+
}
|
|
85
|
+
|
|
62
86
|
function makeMockDispatcher() {
|
|
63
87
|
return {
|
|
64
88
|
write: vi.fn(async () => ({ isSuccess: true as const, data: {} })),
|
|
@@ -107,13 +131,24 @@ describe("SeedMigrationContext.findUserByEmail (integration)", () => {
|
|
|
107
131
|
describe("SeedMigrationContext.findMembershipsOfUser (integration)", () => {
|
|
108
132
|
test("parst JSON-encoded roles-Spalte zu string[]", async () => {
|
|
109
133
|
const userId = "01900000-0000-7000-8000-000000000001";
|
|
134
|
+
const aggId1 = "00000000-0000-4000-8000-0000000000a1";
|
|
135
|
+
const aggId2 = "00000000-0000-4000-8000-0000000000a2";
|
|
110
136
|
const tenantId1 = "00000000-0000-4000-8000-000000000001";
|
|
111
137
|
const tenantId2 = "00000000-0000-4000-8000-000000000002";
|
|
112
|
-
await
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
138
|
+
await insertMembershipWithEvent({
|
|
139
|
+
id: aggId1,
|
|
140
|
+
userId,
|
|
141
|
+
payloadTenantId: tenantId1,
|
|
142
|
+
streamTenantId: tenantId1,
|
|
143
|
+
roles: '["Admin", "TenantAdmin"]',
|
|
144
|
+
});
|
|
145
|
+
await insertMembershipWithEvent({
|
|
146
|
+
id: aggId2,
|
|
147
|
+
userId,
|
|
148
|
+
payloadTenantId: tenantId2,
|
|
149
|
+
streamTenantId: tenantId2,
|
|
150
|
+
roles: '["User"]',
|
|
151
|
+
});
|
|
117
152
|
|
|
118
153
|
const ctx = createSeedMigrationContext({
|
|
119
154
|
dispatcher: makeMockDispatcher() as never,
|
|
@@ -129,14 +164,49 @@ describe("SeedMigrationContext.findMembershipsOfUser (integration)", () => {
|
|
|
129
164
|
expect(m2?.roles).toEqual(["User"]);
|
|
130
165
|
});
|
|
131
166
|
|
|
167
|
+
test("stream-tenant != payload-tenant wird korrekt ausgewiesen (Driver-Bug)", async () => {
|
|
168
|
+
// Reproduziert den publicstatus-Driver-Fall: seedTenantMembership
|
|
169
|
+
// wurde mit by=systemAdmin aufgerufen → executor.tenantId=
|
|
170
|
+
// SYSTEM_TENANT_ID landet als events.tenant_id, während payload.
|
|
171
|
+
// tenantId der target-Tenant ist. Die beiden divergieren.
|
|
172
|
+
const userId = "01900000-0000-7000-8000-000000000001";
|
|
173
|
+
const aggId = "00000000-0000-4000-8000-0000000000b1";
|
|
174
|
+
const payloadTenant = "00000000-0000-4000-8000-000000000042";
|
|
175
|
+
const streamTenant = "00000000-0000-4000-8000-000000000001"; // SYSTEM_TENANT-Stil
|
|
176
|
+
await insertMembershipWithEvent({
|
|
177
|
+
id: aggId,
|
|
178
|
+
userId,
|
|
179
|
+
payloadTenantId: payloadTenant,
|
|
180
|
+
streamTenantId: streamTenant,
|
|
181
|
+
roles: '["Admin"]',
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const ctx = createSeedMigrationContext({
|
|
185
|
+
dispatcher: makeMockDispatcher() as never,
|
|
186
|
+
dbRunner: testDb.db,
|
|
187
|
+
});
|
|
188
|
+
const [m] = await ctx.findMembershipsOfUser(userId);
|
|
189
|
+
expect(m).toEqual({
|
|
190
|
+
userId,
|
|
191
|
+
tenantId: payloadTenant,
|
|
192
|
+
streamTenantId: streamTenant,
|
|
193
|
+
roles: ["Admin"],
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
132
197
|
test("malformed roles-JSON → leeres Array (defensive, no throw)", async () => {
|
|
133
198
|
// Defensive: wenn ein corrupted row kommt, soll der Seed nicht
|
|
134
199
|
// explodieren — kann selbst entscheiden was zu tun ist.
|
|
135
200
|
const userId = "01900000-0000-7000-8000-000000000002";
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
201
|
+
const aggId = "00000000-0000-4000-8000-0000000000c1";
|
|
202
|
+
const tenantId = "00000000-0000-4000-8000-000000000003";
|
|
203
|
+
await insertMembershipWithEvent({
|
|
204
|
+
id: aggId,
|
|
205
|
+
userId,
|
|
206
|
+
payloadTenantId: tenantId,
|
|
207
|
+
streamTenantId: tenantId,
|
|
208
|
+
roles: "not-json",
|
|
209
|
+
});
|
|
140
210
|
const ctx = createSeedMigrationContext({
|
|
141
211
|
dispatcher: makeMockDispatcher() as never,
|
|
142
212
|
dbRunner: testDb.db,
|
|
@@ -153,6 +223,26 @@ describe("SeedMigrationContext.findMembershipsOfUser (integration)", () => {
|
|
|
153
223
|
const memberships = await ctx.findMembershipsOfUser("01900000-0000-7000-8000-000000000099");
|
|
154
224
|
expect(memberships).toEqual([]);
|
|
155
225
|
});
|
|
226
|
+
|
|
227
|
+
test("membership ohne v1-Event wird vom INNER JOIN ausgefiltert (Drift-Detection)", async () => {
|
|
228
|
+
// Schutz vor Data-Drift: read-row ohne event-row ist kein legitimer
|
|
229
|
+
// Zustand für ein ES-Aggregate. Statt einer Half-Row zurückzugeben
|
|
230
|
+
// verschwindet die Row aus dem Result — Seed-Author sieht "0 memberships"
|
|
231
|
+
// statt einer mit fehlendem stream-tenant zu arbeiten und schwer
|
|
232
|
+
// diagnostizierbare version_conflict-Errors zu produzieren.
|
|
233
|
+
const userId = "01900000-0000-7000-8000-000000000003";
|
|
234
|
+
await testDb.db.execute(sql`
|
|
235
|
+
INSERT INTO read_tenant_memberships (id, user_id, tenant_id, roles) VALUES
|
|
236
|
+
('00000000-0000-4000-8000-0000000000d1'::uuid, ${userId},
|
|
237
|
+
'00000000-0000-4000-8000-000000000005'::uuid, '["Admin"]')
|
|
238
|
+
`);
|
|
239
|
+
const ctx = createSeedMigrationContext({
|
|
240
|
+
dispatcher: makeMockDispatcher() as never,
|
|
241
|
+
dbRunner: testDb.db,
|
|
242
|
+
});
|
|
243
|
+
const memberships = await ctx.findMembershipsOfUser(userId);
|
|
244
|
+
expect(memberships).toEqual([]);
|
|
245
|
+
});
|
|
156
246
|
});
|
|
157
247
|
|
|
158
248
|
describe("SeedMigrationContext.findTenants (integration)", () => {
|
package/src/es-ops/context.ts
CHANGED
|
@@ -76,17 +76,34 @@ export function createSeedMigrationContext(
|
|
|
76
76
|
},
|
|
77
77
|
|
|
78
78
|
findMembershipsOfUser: async (userId) => {
|
|
79
|
+
// INNER JOIN auf kumiko_events um den stream-tenant (events.tenant_id
|
|
80
|
+
// der v1-Row) neben dem payload-tenant (memberships.tenant_id) zu
|
|
81
|
+
// liefern. Die beiden divergieren wenn das Aggregate von einem
|
|
82
|
+
// Executor mit fremder tenantId angelegt wurde (seedTenantMembership
|
|
83
|
+
// by=systemAdmin) — typischer publicstatus-Driver-Use-Case.
|
|
84
|
+
// INNER (nicht LEFT): kein v1-Event bei vorhandener Read-Row wäre
|
|
85
|
+
// Data-Drift, kein legitimer Zustand für Seed-Migrations.
|
|
79
86
|
// @cast-boundary db-row — roles ist JSON-string in der text-Spalte
|
|
80
87
|
// (Memory: tenant-membership.created payload "[\"User\"]"), wird unten geparst
|
|
81
88
|
const rows = (await args.dbRunner.execute(
|
|
82
|
-
sql`SELECT user_id::text AS user_id,
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
89
|
+
sql`SELECT m.user_id::text AS user_id,
|
|
90
|
+
m.tenant_id::text AS tenant_id,
|
|
91
|
+
e.tenant_id::text AS stream_tenant_id,
|
|
92
|
+
m.roles
|
|
93
|
+
FROM read_tenant_memberships m
|
|
94
|
+
JOIN kumiko_events e ON e.aggregate_id = m.id AND e.version = 1
|
|
95
|
+
WHERE m.user_id = ${userId}`,
|
|
96
|
+
)) as unknown as readonly {
|
|
97
|
+
user_id: string;
|
|
98
|
+
tenant_id: string;
|
|
99
|
+
stream_tenant_id: string;
|
|
100
|
+
roles: string;
|
|
101
|
+
}[];
|
|
86
102
|
return rows.map(
|
|
87
103
|
(r): SeedMembershipRow => ({
|
|
88
104
|
userId: r.user_id,
|
|
89
105
|
tenantId: r.tenant_id,
|
|
106
|
+
streamTenantId: r.stream_tenant_id,
|
|
90
107
|
roles: safeParseRolesJson(r.roles),
|
|
91
108
|
}),
|
|
92
109
|
);
|
package/src/es-ops/types.ts
CHANGED
|
@@ -46,10 +46,23 @@ export type SeedUserRow = {
|
|
|
46
46
|
readonly tenantId: string;
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
/** Read-shape eines Membership-Eintrags wie an Seed-Helpers exposed.
|
|
49
|
+
/** Read-shape eines Membership-Eintrags wie an Seed-Helpers exposed.
|
|
50
|
+
* Unterscheidet zwei tenantIds: die "logische" aus dem Read-Projektion
|
|
51
|
+
* (`tenantId`) und die "physische" aus dem Aggregate-Stream
|
|
52
|
+
* (`streamTenantId`). Die beiden weichen voneinander ab wenn das
|
|
53
|
+
* Aggregate von einem Executor mit anderer tenantId angelegt wurde
|
|
54
|
+
* (z.B. seedTenantMembership-by=systemAdmin) — typischer
|
|
55
|
+
* publicstatus-Driver-Use-Case. */
|
|
50
56
|
export type SeedMembershipRow = {
|
|
51
57
|
readonly userId: string;
|
|
58
|
+
/** Payload-tenant aus `read_tenant_memberships.tenant_id`. Geht ins
|
|
59
|
+
* write-payload als `tenantId`. */
|
|
52
60
|
readonly tenantId: string;
|
|
61
|
+
/** Stream-tenant aus `kumiko_events.tenant_id` der v1-Row. MUSS als
|
|
62
|
+
* `tenantIdOverride` an `systemWriteAs` durchgereicht werden, sonst
|
|
63
|
+
* sucht der Event-Store-Executor den Stream im falschen Tenant und
|
|
64
|
+
* liefert `version_conflict`. */
|
|
65
|
+
readonly streamTenantId: string;
|
|
53
66
|
readonly roles: readonly string[];
|
|
54
67
|
};
|
|
55
68
|
|