@cosmicdrift/kumiko-framework 0.63.0 → 0.65.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 +2 -2
- package/src/__tests__/reference-data.integration.test.ts +35 -3
- package/src/__tests__/schema-cli-rebuild.integration.test.ts +176 -0
- package/src/__tests__/schema-cli.integration.test.ts +59 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +4 -0
- package/src/db/__tests__/assert-exists-in.integration.test.ts +104 -0
- package/src/db/__tests__/eagerload.integration.test.ts +148 -0
- package/src/db/event-store-executor.ts +2 -6
- package/src/engine/__tests__/build-app-schema-settings-hub.test.ts +49 -1
- package/src/engine/__tests__/build-config-feature-schema.test.ts +46 -0
- package/src/engine/__tests__/define-feature-entity-mapping.test.ts +47 -0
- package/src/engine/__tests__/define-workflow.test.ts +57 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +51 -0
- package/src/engine/build-app-schema.ts +37 -5
- package/src/engine/build-config-feature-schema.ts +5 -1
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +1 -1
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/extractors/round5.ts +15 -0
- package/src/engine/feature-ast/parse.ts +3 -0
- package/src/event-store/__tests__/row-to-stored-event.test.ts +68 -0
- package/src/pipeline/projection-rebuild.ts +2 -2
- package/src/schema-cli.ts +133 -1
- package/src/utils/__tests__/compare.test.ts +20 -0
- package/src/utils/__tests__/serialization.test.ts +24 -0
|
@@ -201,6 +201,33 @@ describe("buildConfigFeatureSchema — per-role cascade (system > tenant > user)
|
|
|
201
201
|
});
|
|
202
202
|
});
|
|
203
203
|
|
|
204
|
+
describe("buildConfigFeatureSchema — machine-role does not leak into screen access (#406/2)", () => {
|
|
205
|
+
// Ein gemischter write-Set (machine "system" + human "SystemAdmin") entsteht
|
|
206
|
+
// nur über ein explizites write:-Override (kein Preset erzeugt den Mix). Der
|
|
207
|
+
// Screen darf trotzdem NUR die Human-Rolle gaten — "system" trägt kein User,
|
|
208
|
+
// ein Leak macht das Gate inkonsistent zum Hardening-Ziel.
|
|
209
|
+
const mixedWrite = defineFeature("mixed", (r) => {
|
|
210
|
+
r.config({
|
|
211
|
+
keys: {
|
|
212
|
+
apiKey: createSystemConfig("text", {
|
|
213
|
+
write: [...access.system, ...access.systemAdmin],
|
|
214
|
+
mask: { title: "mixed.api-key" },
|
|
215
|
+
}),
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("a key with write ['system','SystemAdmin'] yields a SystemAdmin-only screen (no 'system')", () => {
|
|
221
|
+
const s = buildConfigFeatureSchema(createRegistry([mixedWrite]));
|
|
222
|
+
const screen = s.screens.find((x) => x.id === "mixed-system");
|
|
223
|
+
if (!screen || screen.type !== "configEdit")
|
|
224
|
+
throw new Error("no mixed-system configEdit screen");
|
|
225
|
+
expect(screen.access).toEqual({ roles: ["SystemAdmin"] });
|
|
226
|
+
const roles = screen.access && "roles" in screen.access ? screen.access.roles : [];
|
|
227
|
+
expect(roles).not.toContain("system");
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
204
231
|
describe("buildConfigFeatureSchema — access + workspace", () => {
|
|
205
232
|
test("home-scope screens union write (edit) roles; an `all`-writable group collapses to openToAll", () => {
|
|
206
233
|
// billing tenant keys are createTenantConfig → write = admin roles
|
|
@@ -211,6 +238,25 @@ describe("buildConfigFeatureSchema — access + workspace", () => {
|
|
|
211
238
|
expect(configScreen("notify-user").access).toEqual({ openToAll: true });
|
|
212
239
|
});
|
|
213
240
|
|
|
241
|
+
test("a screen mixing an openToAll key with role-restricted keys collapses to openToAll", () => {
|
|
242
|
+
// unionAccessRules short-circuits: one openToAll writer opens the whole gate,
|
|
243
|
+
// even next to role-restricted keys — the mixed case the all-or-nothing tests miss.
|
|
244
|
+
const mixedWrite = defineFeature("mixedwrite", (r) => {
|
|
245
|
+
r.config({
|
|
246
|
+
keys: {
|
|
247
|
+
open: createTenantConfig("text", { write: access.all, mask: { title: "mw.open" } }),
|
|
248
|
+
gated: createTenantConfig("text", { mask: { title: "mw.gated" } }), // default admin write
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
const out = buildConfigFeatureSchema(createRegistry([mixedWrite]));
|
|
253
|
+
const screen = out.screens.find((s) => s.id === "mixedwrite-tenant");
|
|
254
|
+
if (screen?.type !== "configEdit")
|
|
255
|
+
throw new Error('expected configEdit screen "mixedwrite-tenant"');
|
|
256
|
+
expect(screen.access).toEqual({ openToAll: true });
|
|
257
|
+
expect(out.navs.find((n) => n.id === "audience-tenant")?.access).toEqual({ openToAll: true });
|
|
258
|
+
});
|
|
259
|
+
|
|
214
260
|
test("returns empty (no workspace) when no key opts into the hub via mask", () => {
|
|
215
261
|
const plain = defineFeature("plain", (r) => {
|
|
216
262
|
r.config({ keys: { secret: createSystemConfig("text", {}) } });
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { defineFeature } from "../define-feature";
|
|
4
|
+
import { createEntity, createTextField } from "../factories";
|
|
5
|
+
|
|
6
|
+
const noteEntity = createEntity({
|
|
7
|
+
table: "dfm_notes",
|
|
8
|
+
fields: { title: createTextField({ required: true }) },
|
|
9
|
+
});
|
|
10
|
+
const tagEntity = createEntity({
|
|
11
|
+
table: "dfm_tags",
|
|
12
|
+
fields: { label: createTextField({ required: true }) },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const bareCreate = {
|
|
16
|
+
name: "create",
|
|
17
|
+
schema: z.object({ title: z.string() }),
|
|
18
|
+
access: { roles: ["User"] },
|
|
19
|
+
handler: async () => ({ isSuccess: true as const, data: {} }),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe("defineFeature — bare CRUD verb → entity mapping", () => {
|
|
23
|
+
test("single-entity fallback maps when the feature name differs from the entity name", () => {
|
|
24
|
+
const f = defineFeature("mynotes", (r) => {
|
|
25
|
+
r.entity("note", noteEntity);
|
|
26
|
+
r.writeHandler(bareCreate);
|
|
27
|
+
});
|
|
28
|
+
expect(f.handlerEntityMappings?.["create"]).toBe("note");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("feature-name match wins (the fallback is the secondary path)", () => {
|
|
32
|
+
const f = defineFeature("note", (r) => {
|
|
33
|
+
r.entity("note", noteEntity);
|
|
34
|
+
r.writeHandler(bareCreate);
|
|
35
|
+
});
|
|
36
|
+
expect(f.handlerEntityMappings?.["create"]).toBe("note");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("no fallback when the feature owns more than one entity", () => {
|
|
40
|
+
const f = defineFeature("mixed", (r) => {
|
|
41
|
+
r.entity("note", noteEntity);
|
|
42
|
+
r.entity("tag", tagEntity);
|
|
43
|
+
r.writeHandler(bareCreate);
|
|
44
|
+
});
|
|
45
|
+
expect(f.handlerEntityMappings?.["create"]).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
computeDefinitionFingerprint,
|
|
4
|
+
defineWorkflow,
|
|
5
|
+
type WorkflowTrigger,
|
|
6
|
+
} from "../define-workflow";
|
|
7
|
+
import type { PipelineDef } from "../types/step";
|
|
8
|
+
|
|
9
|
+
const pipe = (build: PipelineDef["build"]): PipelineDef => ({ __kind: "pipeline", build });
|
|
10
|
+
const eventTrigger: WorkflowTrigger = { kind: "event", eventType: "user.signed-up" };
|
|
11
|
+
|
|
12
|
+
describe("defineWorkflow", () => {
|
|
13
|
+
test("maps input into a WorkflowDefinition", () => {
|
|
14
|
+
const steps = pipe(() => []);
|
|
15
|
+
const wf = defineWorkflow({ name: "onboard", trigger: eventTrigger, steps });
|
|
16
|
+
|
|
17
|
+
expect(wf.__kind).toBe("workflow");
|
|
18
|
+
expect(wf.name).toBe("onboard");
|
|
19
|
+
expect(wf.trigger).toEqual(eventTrigger);
|
|
20
|
+
expect(wf.pipelineDef).toBe(steps);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("computeDefinitionFingerprint", () => {
|
|
25
|
+
const base = { name: "wf", trigger: eventTrigger, pipelineDef: pipe(() => []) };
|
|
26
|
+
|
|
27
|
+
test("is deterministic and a sha256 hex digest", () => {
|
|
28
|
+
const a = computeDefinitionFingerprint(base);
|
|
29
|
+
const b = computeDefinitionFingerprint({ ...base, pipelineDef: pipe(() => []) });
|
|
30
|
+
|
|
31
|
+
expect(a).toMatch(/^[0-9a-f]{64}$/);
|
|
32
|
+
expect(a).toBe(b);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("changes when the closure source changes — the in-flight drift detector", () => {
|
|
36
|
+
const before = computeDefinitionFingerprint(base);
|
|
37
|
+
// runtime-distinct body (`as never` + comments are stripped by the
|
|
38
|
+
// transpiler, so the difference must survive into emitted source).
|
|
39
|
+
const after = computeDefinitionFingerprint({
|
|
40
|
+
...base,
|
|
41
|
+
pipelineDef: pipe(() => [{} as never]),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(after).not.toBe(before);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("changes when name or trigger changes", () => {
|
|
48
|
+
const fp = computeDefinitionFingerprint(base);
|
|
49
|
+
expect(computeDefinitionFingerprint({ ...base, name: "other" })).not.toBe(fp);
|
|
50
|
+
expect(
|
|
51
|
+
computeDefinitionFingerprint({
|
|
52
|
+
...base,
|
|
53
|
+
trigger: { kind: "cron", schedule: "0 0 * * *" },
|
|
54
|
+
}),
|
|
55
|
+
).not.toBe(fp);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Coverage-Lücke (0% unit + 0% integration): findTierResolverUsage ist der
|
|
2
|
+
// geteilte Pickup-Helper für runDevApp + runProdApp. Findet er die Usage NICHT,
|
|
3
|
+
// bleibt effectiveFeatures undefined → tier-gating still aus (alle Features on).
|
|
4
|
+
// Genau dieser found-Pfad war in keinem Test exekutiert.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { createEntity, defineFeature } from "../index";
|
|
8
|
+
import { findTierResolverUsage, TENANT_TIER_RESOLVER_EXT } from "../tier-resolver-extension";
|
|
9
|
+
|
|
10
|
+
function tierResolverFeature(name: string) {
|
|
11
|
+
return defineFeature(name, (r) => {
|
|
12
|
+
r.extendsRegistrar(TENANT_TIER_RESOLVER_EXT, { onRegister: () => {} });
|
|
13
|
+
r.entity("dummy", createEntity({ table: "Dummies", fields: {} }));
|
|
14
|
+
r.useExtension(TENANT_TIER_RESOLVER_EXT, "dummy");
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function plainFeature(name: string) {
|
|
19
|
+
return defineFeature(name, (r) => {
|
|
20
|
+
r.entity("thing", createEntity({ table: "Things", fields: {} }));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("findTierResolverUsage", () => {
|
|
25
|
+
test("findet die tenantTierResolver-Usage", () => {
|
|
26
|
+
const usage = findTierResolverUsage([tierResolverFeature("tier-stub")]);
|
|
27
|
+
expect(usage?.extensionName).toBe(TENANT_TIER_RESOLVER_EXT);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("findet die Usage auch wenn sie nicht im ersten Feature liegt", () => {
|
|
31
|
+
const usage = findTierResolverUsage([plainFeature("a"), tierResolverFeature("tier-stub")]);
|
|
32
|
+
expect(usage?.extensionName).toBe(TENANT_TIER_RESOLVER_EXT);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("returnt undefined wenn keine Feature die Extension nutzt", () => {
|
|
36
|
+
expect(findTierResolverUsage([plainFeature("a"), plainFeature("b")])).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("ignoriert andere Extension-Usages", () => {
|
|
40
|
+
const other = defineFeature("other", (r) => {
|
|
41
|
+
r.extendsRegistrar("somethingElse", { onRegister: () => {} });
|
|
42
|
+
r.entity("d", createEntity({ table: "Ds", fields: {} }));
|
|
43
|
+
r.useExtension("somethingElse", "d");
|
|
44
|
+
});
|
|
45
|
+
expect(findTierResolverUsage([other])).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("leere Feature-Liste → undefined", () => {
|
|
49
|
+
expect(findTierResolverUsage([])).toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -79,6 +79,7 @@ export function buildAppSchema(registry: Registry): AppSchema {
|
|
|
79
79
|
workspaces = placed.workspaces;
|
|
80
80
|
if (placed.standalone !== undefined) workspaces.push(placed.standalone);
|
|
81
81
|
warnUnplacedAudiences(placed.unplaced);
|
|
82
|
+
warnDanglingAudienceRefs(placed.danglingRefs);
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
|
|
@@ -133,7 +134,12 @@ function mergeSettingsHubIntoConfigFeature(
|
|
|
133
134
|
function placeSettingsHub(
|
|
134
135
|
appWorkspaces: readonly WorkspaceSchema[],
|
|
135
136
|
generated: ConfigFeatureSchema,
|
|
136
|
-
): {
|
|
137
|
+
): {
|
|
138
|
+
workspaces: WorkspaceSchema[];
|
|
139
|
+
standalone: WorkspaceSchema | undefined;
|
|
140
|
+
unplaced: string[];
|
|
141
|
+
danglingRefs: string[];
|
|
142
|
+
} {
|
|
137
143
|
const prefix = `${SETTINGS_HUB_FEATURE}:nav:`;
|
|
138
144
|
const audienceShortIds = new Set<string>();
|
|
139
145
|
const childParent = new Map<string, string>();
|
|
@@ -150,12 +156,20 @@ function placeSettingsHub(
|
|
|
150
156
|
}
|
|
151
157
|
|
|
152
158
|
const placedAudiences = new Set<string>();
|
|
159
|
+
// Config-Hub-Navs, die eine Workspace referenziert, die aber nie generiert
|
|
160
|
+
// wurden (weder Audience noch bekanntes Kind) — z.B. `config:nav:audience-user`
|
|
161
|
+
// ohne registrierte User-Scope-Config-Keys. Sonst verschwindet die Referenz
|
|
162
|
+
// lautlos (silent-skip).
|
|
163
|
+
const danglingRefs = new Set<string>();
|
|
153
164
|
const workspaces = appWorkspaces.map((ws) => {
|
|
154
165
|
const additions: string[] = [];
|
|
155
166
|
for (const member of ws.navMembers) {
|
|
156
167
|
if (!member.startsWith(prefix)) continue;
|
|
157
168
|
const shortId = member.slice(prefix.length);
|
|
158
|
-
if (!audienceShortIds.has(shortId))
|
|
169
|
+
if (!audienceShortIds.has(shortId)) {
|
|
170
|
+
if (!childParent.has(shortId)) danglingRefs.add(shortId);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
159
173
|
placedAudiences.add(shortId);
|
|
160
174
|
for (const child of childrenByAudience.get(shortId) ?? []) {
|
|
161
175
|
const childQn = `${prefix}${child}`;
|
|
@@ -180,14 +194,16 @@ function placeSettingsHub(
|
|
|
180
194
|
|
|
181
195
|
const unplaced =
|
|
182
196
|
placedAudiences.size > 0 ? [...audienceShortIds].filter((id) => !placedAudiences.has(id)) : [];
|
|
183
|
-
return { workspaces, standalone, unplaced };
|
|
197
|
+
return { workspaces, standalone, unplaced, danglingRefs: [...danglingRefs] };
|
|
184
198
|
}
|
|
185
199
|
|
|
186
200
|
function warnUnplacedAudiences(unplaced: readonly string[]): void {
|
|
187
201
|
// skip: every audience placed — nothing to warn about
|
|
188
202
|
if (unplaced.length === 0) return;
|
|
189
|
-
|
|
190
|
-
|
|
203
|
+
const env = typeof process !== "undefined" ? process.env.NODE_ENV : undefined;
|
|
204
|
+
// skip: dev-only authoring hint — silent in production and in tests
|
|
205
|
+
// (bun:test sets NODE_ENV=test) where it would only noise up CI logs.
|
|
206
|
+
if (env === "production" || env === "test") return;
|
|
191
207
|
// biome-ignore lint/suspicious/noConsole: dev-only authoring hint
|
|
192
208
|
console.warn(
|
|
193
209
|
`[kumiko] Settings-Hub: ${unplaced.join(", ")} nicht in einer App-Workspace platziert — ` +
|
|
@@ -197,6 +213,22 @@ function warnUnplacedAudiences(unplaced: readonly string[]): void {
|
|
|
197
213
|
);
|
|
198
214
|
}
|
|
199
215
|
|
|
216
|
+
function warnDanglingAudienceRefs(dangling: readonly string[]): void {
|
|
217
|
+
// skip: no dangling refs — nothing to warn about
|
|
218
|
+
if (dangling.length === 0) return;
|
|
219
|
+
const env = typeof process !== "undefined" ? process.env.NODE_ENV : undefined;
|
|
220
|
+
// skip: dev-only authoring hint — silent in production and in tests
|
|
221
|
+
if (env === "production" || env === "test") return;
|
|
222
|
+
// biome-ignore lint/suspicious/noConsole: dev-only authoring hint
|
|
223
|
+
console.warn(
|
|
224
|
+
`[kumiko] Settings-Hub: ${dangling
|
|
225
|
+
.map((id) => `${SETTINGS_HUB_FEATURE}:nav:${id}`)
|
|
226
|
+
.join(", ")} in einer Workspace referenziert, aber nie generiert — ` +
|
|
227
|
+
`keine Config-Keys für diesen Scope registriert. Tippfehler oder ` +
|
|
228
|
+
`vorzeitige Referenz? Der Eintrag rendert sonst unsichtbar.`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
200
232
|
// PlatformComponent slots ({ react, native }) legitimately hold component
|
|
201
233
|
// functions — JSON.stringify drops them at inject-time and the client
|
|
202
234
|
// re-resolves by name, so the walker treats them as opaque.
|
|
@@ -141,7 +141,11 @@ function scopedKeysAt(masked: readonly MaskedKey[], scope: ConfigScope): ScopedK
|
|
|
141
141
|
const out: ScopedKey[] = [];
|
|
142
142
|
for (const key of masked) {
|
|
143
143
|
const roles = effectiveWriteRoles(key.def, scope);
|
|
144
|
-
|
|
144
|
+
// MACHINE_WRITE_ROLE aus den Screen-Rollen strippen: ein gemischter
|
|
145
|
+
// write-Set (z.B. ["system", "SystemAdmin"]) darf "system" nicht ins
|
|
146
|
+
// Screen-Access-Gate leaken — analog zum machine-gefilterten Workspace-Gate.
|
|
147
|
+
const humanRoles = roles.filter((r) => r !== MACHINE_WRITE_ROLE);
|
|
148
|
+
if (humanRoles.length > 0) out.push({ key, roles: humanRoles });
|
|
145
149
|
}
|
|
146
150
|
return out;
|
|
147
151
|
}
|
|
@@ -59,7 +59,7 @@ const FEATURES: readonly RealFeature[] = [
|
|
|
59
59
|
path: "packages/bundled-features/src/sessions/feature.ts",
|
|
60
60
|
expectedFeatureName: "sessions",
|
|
61
61
|
recognisedKinds: ["entityHook"],
|
|
62
|
-
errorMethodNames: ["
|
|
62
|
+
errorMethodNames: ["unmanagedTable", "writeHandler", "queryHandler", "job"],
|
|
63
63
|
},
|
|
64
64
|
{
|
|
65
65
|
path: "packages/bundled-features/src/auth-email-password/feature.ts",
|
|
@@ -94,3 +94,18 @@ export function extractExposesApi(
|
|
|
94
94
|
apiName: arg.getLiteralValue(),
|
|
95
95
|
});
|
|
96
96
|
}
|
|
97
|
+
|
|
98
|
+
export function extractUnmanagedTable(
|
|
99
|
+
call: CallExpression,
|
|
100
|
+
sourceFile: SourceFile,
|
|
101
|
+
): ExtractOutput<never> {
|
|
102
|
+
// The meta argument is always a factory call (defineUnmanagedTable /
|
|
103
|
+
// buildEntityTableMeta) or a captured identifier — never an inline literal,
|
|
104
|
+
// so there is nothing to extract statically. A clean ParseError (not
|
|
105
|
+
// UnknownPattern) marks it design-time-unreadable, like entity-by-identifier.
|
|
106
|
+
return fail(
|
|
107
|
+
"unmanagedTable",
|
|
108
|
+
sourceLocationFromNode(call, sourceFile),
|
|
109
|
+
"unmanagedTable meta is a factory/identifier argument, not a static literal",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
extractTranslations,
|
|
59
59
|
extractTree,
|
|
60
60
|
extractTreeActions,
|
|
61
|
+
extractUnmanagedTable,
|
|
61
62
|
extractUseExtension,
|
|
62
63
|
extractUsesApi,
|
|
63
64
|
extractWorkspace,
|
|
@@ -358,6 +359,8 @@ function dispatchExtractor(
|
|
|
358
359
|
return extractUsesApi(call, sourceFile);
|
|
359
360
|
case "exposesApi":
|
|
360
361
|
return extractExposesApi(call, sourceFile);
|
|
362
|
+
case "unmanagedTable":
|
|
363
|
+
return extractUnmanagedTable(call, sourceFile);
|
|
361
364
|
// Round 6 — Visual-Tree patterns
|
|
362
365
|
case "treeActions":
|
|
363
366
|
return extractTreeActions(call, sourceFile);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Coverage-Lücke (unit + integration): toStoredEvent wird auf dem Event-Load-
|
|
2
|
+
// Pfad ausgeführt, aber kein Test asserted das Mapping. Regression-Guard: ein
|
|
3
|
+
// fallengelassenes/falsches Feld wäre stiller Datenverlust beim Replay.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import type { TenantId } from "../../engine/types";
|
|
7
|
+
import type { EventMetadata, StoredEvent } from "../event-store";
|
|
8
|
+
import { toStoredEvent } from "../row-to-stored-event";
|
|
9
|
+
|
|
10
|
+
const metadata: EventMetadata = {
|
|
11
|
+
userId: "user-1",
|
|
12
|
+
requestId: "req-1",
|
|
13
|
+
correlationId: "corr-1",
|
|
14
|
+
causationId: "cause-1",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const row = {
|
|
18
|
+
id: 42n,
|
|
19
|
+
aggregateId: "agg-1",
|
|
20
|
+
aggregateType: "credit",
|
|
21
|
+
tenantId: "tenant-1" as TenantId,
|
|
22
|
+
version: 3,
|
|
23
|
+
type: "credit.created",
|
|
24
|
+
eventVersion: 2,
|
|
25
|
+
payload: { amount: 100 },
|
|
26
|
+
metadata,
|
|
27
|
+
createdAt: Temporal.Instant.from("2026-01-01T00:00:00Z"),
|
|
28
|
+
createdBy: "user-1",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe("toStoredEvent", () => {
|
|
32
|
+
test("stringifiziert die bigint-id", () => {
|
|
33
|
+
expect(toStoredEvent(row).id).toBe("42");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("mappt jedes Feld werttreu durch", () => {
|
|
37
|
+
const ev = toStoredEvent(row);
|
|
38
|
+
expect(ev.aggregateId).toBe("agg-1");
|
|
39
|
+
expect(ev.aggregateType).toBe("credit");
|
|
40
|
+
expect(ev.tenantId).toBe("tenant-1" as TenantId);
|
|
41
|
+
expect(ev.version).toBe(3);
|
|
42
|
+
expect(ev.type).toBe("credit.created");
|
|
43
|
+
expect(ev.eventVersion).toBe(2);
|
|
44
|
+
expect(ev.payload).toEqual({ amount: 100 });
|
|
45
|
+
expect(ev.metadata).toBe(metadata);
|
|
46
|
+
expect(ev.createdAt).toBe(row.createdAt);
|
|
47
|
+
expect(ev.createdBy).toBe("user-1");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("Feld-Vollständigkeit: das Mapping deckt genau die StoredEvent-Keys ab", () => {
|
|
51
|
+
// Pin gegen versehentliches Weglassen eines Feldes im Mapping. Muss mit
|
|
52
|
+
// der StoredEvent-Definition (event-store.ts) synchron bleiben.
|
|
53
|
+
const expectedKeys: ReadonlyArray<keyof StoredEvent> = [
|
|
54
|
+
"id",
|
|
55
|
+
"aggregateId",
|
|
56
|
+
"aggregateType",
|
|
57
|
+
"tenantId",
|
|
58
|
+
"version",
|
|
59
|
+
"type",
|
|
60
|
+
"eventVersion",
|
|
61
|
+
"payload",
|
|
62
|
+
"metadata",
|
|
63
|
+
"createdAt",
|
|
64
|
+
"createdBy",
|
|
65
|
+
];
|
|
66
|
+
expect(Object.keys(toStoredEvent(row)).sort()).toEqual([...expectedKeys].sort());
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -146,8 +146,8 @@ type RebuildDeps = {
|
|
|
146
146
|
// Test-only seam: fires once after the unlocked bulk drain and before the
|
|
147
147
|
// cutover fence. Lets a concurrency test inject a committed write into the
|
|
148
148
|
// replay window deterministically. The `__test_` prefix marks it test-only:
|
|
149
|
-
// a
|
|
150
|
-
//
|
|
149
|
+
// a slow callback here delays the cutover fence acquisition, widening the
|
|
150
|
+
// window in which concurrent writers can still commit below the cursor.
|
|
151
151
|
readonly __test_onBeforeFence?: () => void | Promise<void>;
|
|
152
152
|
};
|
|
153
153
|
|
package/src/schema-cli.ts
CHANGED
|
@@ -13,10 +13,12 @@ import { join, resolve as resolvePath } from "node:path";
|
|
|
13
13
|
import {
|
|
14
14
|
baselineMigrations,
|
|
15
15
|
createDbConnection,
|
|
16
|
+
type DbConnection,
|
|
16
17
|
fetchAppliedMigrations,
|
|
17
18
|
generateMigration,
|
|
18
19
|
loadMigrationsFromDir,
|
|
19
20
|
loadSnapshotJson,
|
|
21
|
+
readRebuildMarker,
|
|
20
22
|
rebuildTablesFromDiff,
|
|
21
23
|
type renderTablesDdl,
|
|
22
24
|
runMigrationsFromDir,
|
|
@@ -24,8 +26,16 @@ import {
|
|
|
24
26
|
writeRebuildMarker,
|
|
25
27
|
writeSnapshotJson,
|
|
26
28
|
} from "./db";
|
|
29
|
+
import { validateBoot } from "./engine/boot-validator";
|
|
30
|
+
import { createRegistry } from "./engine/registry";
|
|
31
|
+
import type { FeatureDefinition } from "./engine/types/feature";
|
|
27
32
|
import { createEventsTable } from "./event-store";
|
|
28
|
-
import {
|
|
33
|
+
import { buildProjectionTableIndex } from "./migrations";
|
|
34
|
+
import {
|
|
35
|
+
createEventConsumerStateTable,
|
|
36
|
+
createProjectionStateTable,
|
|
37
|
+
rebuildProjection,
|
|
38
|
+
} from "./pipeline";
|
|
29
39
|
|
|
30
40
|
export type SchemaCliOut = {
|
|
31
41
|
readonly log: (line: string) => void;
|
|
@@ -61,6 +71,40 @@ function nextSequenceNumber(migrationsDir: string): number {
|
|
|
61
71
|
return max + 1;
|
|
62
72
|
}
|
|
63
73
|
|
|
74
|
+
// Maps changed tables to their projections (via the app registry) and replays
|
|
75
|
+
// the events. Tables without a registered projection are skipped.
|
|
76
|
+
async function rebuildAffectedProjections(
|
|
77
|
+
db: DbConnection,
|
|
78
|
+
changedTables: readonly string[],
|
|
79
|
+
features: readonly FeatureDefinition[],
|
|
80
|
+
out: SchemaCliOut,
|
|
81
|
+
): Promise<void> {
|
|
82
|
+
const registry = createRegistry(features);
|
|
83
|
+
const tableToProjection = buildProjectionTableIndex(registry);
|
|
84
|
+
|
|
85
|
+
const projections = new Set<string>();
|
|
86
|
+
for (const table of changedTables) {
|
|
87
|
+
const name = tableToProjection.get(table);
|
|
88
|
+
if (name) projections.add(name);
|
|
89
|
+
}
|
|
90
|
+
// skip: no changed table maps to a registered projection — nothing to rebuild.
|
|
91
|
+
if (projections.size === 0) return;
|
|
92
|
+
|
|
93
|
+
out.log(` Rebuild ${projections.size} Projection(s)…`);
|
|
94
|
+
for (const name of projections) {
|
|
95
|
+
const r = await rebuildProjection(name, { db, registry });
|
|
96
|
+
out.log(` ↻ ${name} (${r.eventsProcessed} events, ${r.durationMs}ms)`);
|
|
97
|
+
}
|
|
98
|
+
out.log("");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type RunSchemaCliOptions = {
|
|
102
|
+
/** Composed app features. When given, `apply` rebuilds the projections whose
|
|
103
|
+
* tables a freshly applied migration changed (via its `.rebuild.json`
|
|
104
|
+
* marker). Omitted (dev `kumiko schema`) → no rebuild, migrations only. */
|
|
105
|
+
readonly features?: readonly FeatureDefinition[];
|
|
106
|
+
};
|
|
107
|
+
|
|
64
108
|
/**
|
|
65
109
|
* Runs a schema-CLI subcommand. `appCwd` is the app workspace root (where
|
|
66
110
|
* `kumiko/schema.ts` + `kumiko/migrations/` live). Returns a process exit code.
|
|
@@ -69,6 +113,7 @@ export async function runSchemaCli(
|
|
|
69
113
|
argv: readonly string[],
|
|
70
114
|
appCwd: string,
|
|
71
115
|
out: SchemaCliOut,
|
|
116
|
+
options: RunSchemaCliOptions = {},
|
|
72
117
|
): Promise<number> {
|
|
73
118
|
const sub = argv[0];
|
|
74
119
|
const schemaFile = resolvePath(appCwd, "kumiko/schema.ts");
|
|
@@ -132,6 +177,77 @@ export async function runSchemaCli(
|
|
|
132
177
|
return 0;
|
|
133
178
|
}
|
|
134
179
|
|
|
180
|
+
case "validate": {
|
|
181
|
+
// Static, DB-free boot-blocking checks for CI — catches "this won't boot"
|
|
182
|
+
// before deploy. Two layers, no database:
|
|
183
|
+
// 1. schema drift: would `generate` write a migration? (= an entity was
|
|
184
|
+
// added/changed but never generated → missing table → prod 500)
|
|
185
|
+
// 2. boot validity: validateBoot over the composed FEATURES (QN/screen/
|
|
186
|
+
// nav/role refs). Runs only if kumiko/schema.ts exports FEATURES.
|
|
187
|
+
// The DB-level gate (assertKumikoSchemaCurrent) stays at boot/deploy.
|
|
188
|
+
if (!existsSync(schemaFile)) {
|
|
189
|
+
out.err(` ${schemaFile} fehlt.`);
|
|
190
|
+
out.err(" App-Convention: kumiko/schema.ts mit");
|
|
191
|
+
out.err(" export const ENTITY_METAS: EntityTableMeta[] = [...]");
|
|
192
|
+
return 1;
|
|
193
|
+
}
|
|
194
|
+
const mod = (await import(schemaFile)) as {
|
|
195
|
+
ENTITY_METAS?: unknown;
|
|
196
|
+
FEATURES?: unknown;
|
|
197
|
+
};
|
|
198
|
+
if (!Array.isArray(mod.ENTITY_METAS)) {
|
|
199
|
+
out.err(
|
|
200
|
+
` Schema file ${schemaFile} muss \`export const ENTITY_METAS: EntityTableMeta[]\` haben.`,
|
|
201
|
+
);
|
|
202
|
+
return 1;
|
|
203
|
+
}
|
|
204
|
+
let ok = true;
|
|
205
|
+
|
|
206
|
+
// 1. Schema drift — compute the diff, never write.
|
|
207
|
+
const metas = mod.ENTITY_METAS as Parameters<typeof renderTablesDdl>[0];
|
|
208
|
+
const snapshotPath = join(migrationsDir, SNAPSHOT_FILENAME);
|
|
209
|
+
const prevSnapshot = existsSync(snapshotPath) ? loadSnapshotJson(snapshotPath) : null;
|
|
210
|
+
const drift = generateMigration({
|
|
211
|
+
metas,
|
|
212
|
+
prevSnapshot,
|
|
213
|
+
name: "validate",
|
|
214
|
+
sequenceNumber: nextSequenceNumber(migrationsDir),
|
|
215
|
+
});
|
|
216
|
+
const pendingTables = [
|
|
217
|
+
...drift.diff.newTables.map((t) => t.tableName),
|
|
218
|
+
...drift.diff.changedTables.map((t) => t.tableName),
|
|
219
|
+
...drift.diff.droppedTables,
|
|
220
|
+
];
|
|
221
|
+
if (pendingTables.length === 0) {
|
|
222
|
+
out.log(" ✓ schema: migrations match the entity definitions");
|
|
223
|
+
} else {
|
|
224
|
+
ok = false;
|
|
225
|
+
out.err(" ✗ schema drift: entity definitions are ahead of kumiko/migrations.");
|
|
226
|
+
out.err(
|
|
227
|
+
` pending — new: ${drift.diff.newTables.length}, changed: ${drift.diff.changedTables.length}, dropped: ${drift.diff.droppedTables.length}`,
|
|
228
|
+
);
|
|
229
|
+
out.err(` tables: ${pendingTables.join(", ")}`);
|
|
230
|
+
out.err(" Fix: `kumiko-schema generate <name>`, then commit the migration.");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 2. Boot validity — needs the composed feature set.
|
|
234
|
+
if (Array.isArray(mod.FEATURES)) {
|
|
235
|
+
try {
|
|
236
|
+
validateBoot(mod.FEATURES as readonly FeatureDefinition[]);
|
|
237
|
+
out.log(" ✓ boot: feature configuration is valid");
|
|
238
|
+
} catch (e) {
|
|
239
|
+
ok = false;
|
|
240
|
+
out.err(` ✗ boot: ${e instanceof Error ? e.message : String(e)}`);
|
|
241
|
+
}
|
|
242
|
+
} else {
|
|
243
|
+
out.log(
|
|
244
|
+
" · boot: skipped — add `export const FEATURES = composeFeatures(APP_FEATURES, { includeBundled: true })` to kumiko/schema.ts to enable validateBoot.",
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return ok ? 0 : 1;
|
|
249
|
+
}
|
|
250
|
+
|
|
135
251
|
case "apply": {
|
|
136
252
|
const dbUrl = process.env["DATABASE_URL"];
|
|
137
253
|
if (!dbUrl) {
|
|
@@ -162,6 +278,21 @@ export async function runSchemaCli(
|
|
|
162
278
|
if (result.skipped.length > 0) out.log(` (${result.skipped.length} already applied)`);
|
|
163
279
|
}
|
|
164
280
|
out.log("");
|
|
281
|
+
|
|
282
|
+
// Projection-rebuild for tables a freshly applied migration changed
|
|
283
|
+
// (marker NNNN_<name>.rebuild.json from `generate`). Without it read_*
|
|
284
|
+
// projections stay stale after a schema change. Needs the composed
|
|
285
|
+
// feature set → only when the caller passed `features` (the app bin);
|
|
286
|
+
// the dev CLI omits it and applies migrations only.
|
|
287
|
+
if (options.features && result.applied.length > 0) {
|
|
288
|
+
const changedTables = new Set<string>();
|
|
289
|
+
for (const id of result.applied) {
|
|
290
|
+
for (const table of readRebuildMarker(migrationsDir, id)) changedTables.add(table);
|
|
291
|
+
}
|
|
292
|
+
if (changedTables.size > 0) {
|
|
293
|
+
await rebuildAffectedProjections(db, [...changedTables], options.features, out);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
165
296
|
return 0;
|
|
166
297
|
} catch (e) {
|
|
167
298
|
out.err("");
|
|
@@ -243,6 +374,7 @@ export async function runSchemaCli(
|
|
|
243
374
|
out.log("");
|
|
244
375
|
out.log(" Subcommands:");
|
|
245
376
|
out.log(" generate <name> Schreibe neue Migration aus EntityTableMeta-Diff");
|
|
377
|
+
out.log(" validate Static CI-Gate (kein DB): schema-drift + validateBoot");
|
|
246
378
|
out.log(" apply Applied pending checked-in SQL-Files");
|
|
247
379
|
out.log(" baseline Markiere checked-in Migrations als applied (kein SQL-Run)");
|
|
248
380
|
out.log(" status Liste applied vs pending");
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { compareByCodepoint } from "../compare";
|
|
3
|
+
|
|
4
|
+
describe("compareByCodepoint", () => {
|
|
5
|
+
test("returns -1 / 1 / 0 for less / greater / equal", () => {
|
|
6
|
+
expect(compareByCodepoint("a", "b")).toBe(-1);
|
|
7
|
+
expect(compareByCodepoint("b", "a")).toBe(1);
|
|
8
|
+
expect(compareByCodepoint("x", "x")).toBe(0);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test("orders by UTF-16 code unit — uppercase sorts before lowercase", () => {
|
|
12
|
+
// 'Z' (90) < 'a' (97): the opposite of many locale collations, which is
|
|
13
|
+
// the whole point (#330 — stable across macOS dev box and Linux CI).
|
|
14
|
+
expect(compareByCodepoint("Z", "a")).toBe(-1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("is a usable Array.sort comparator yielding codepoint order", () => {
|
|
18
|
+
expect(["b", "A", "a", "B"].sort(compareByCodepoint)).toEqual(["A", "B", "a", "b"]);
|
|
19
|
+
});
|
|
20
|
+
});
|