@cosmicdrift/kumiko-framework 0.38.0 → 0.40.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/api/__tests__/auth-routes-cookie.test.ts +79 -0
- package/src/api/auth-routes.ts +36 -8
- package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +122 -0
- package/src/engine/__tests__/config-helpers.test.ts +3 -3
- package/src/engine/boot-validator/config-deps.ts +23 -0
- package/src/engine/boot-validator/index.ts +2 -0
- package/src/engine/boot-validator/screens-nav.ts +73 -0
- package/src/engine/config-helpers.ts +4 -1
- package/src/engine/extension-names.ts +10 -0
- package/src/engine/feature-ast/extractors/round1.ts +8 -1
- package/src/engine/feature-ast/patterns.ts +6 -0
- package/src/engine/feature-manifest.ts +148 -0
- package/src/engine/index.ts +11 -0
- package/src/engine/registry.ts +10 -0
- package/src/engine/types/screen.ts +12 -0
- package/src/env/__tests__/parse-env-dry-run.test.ts +40 -0
- package/src/env/index.ts +22 -0
- package/src/event-store/__tests__/perf.integration.test.ts +1 -1
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +167 -0
- package/src/migrations/index.ts +9 -0
- package/src/migrations/pending-rebuilds.ts +126 -0
- package/src/stack/test-stack.ts +8 -3
- package/src/ui-types/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
"zod": "^4.4.3"
|
|
182
182
|
},
|
|
183
183
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
184
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.38.0",
|
|
185
185
|
"@types/uuid": "^11.0.0",
|
|
186
186
|
"bun-types": "^1.3.13",
|
|
187
187
|
"pino-pretty": "^13.1.3"
|
|
@@ -177,3 +177,82 @@ describe("auth-routes cookie behaviour on /auth/switch-tenant", () => {
|
|
|
177
177
|
expect(newAuth?.value).not.toBe(validToken); // new jwt
|
|
178
178
|
});
|
|
179
179
|
});
|
|
180
|
+
|
|
181
|
+
// cookieDomain — Bug-Bash-2 Wave I (Auth auf Marketing-Host): Login auf
|
|
182
|
+
// dem Apex muss eine Session setzen die admin.<domain> mitliest. Ohne
|
|
183
|
+
// Option bleibt das Cookie host-only (kein Domain-Attribut).
|
|
184
|
+
describe("auth-routes cookieDomain", () => {
|
|
185
|
+
async function login(app: Hono): Promise<Response> {
|
|
186
|
+
return app.request("/api/auth/login", {
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers: { "Content-Type": "application/json" },
|
|
189
|
+
body: JSON.stringify({ email: "a@b.c", password: "pw" }),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
test("ohne cookieDomain: kein Domain-Attribut auf den Cookies", async () => {
|
|
194
|
+
const { app } = await buildApp();
|
|
195
|
+
const res = await login(app);
|
|
196
|
+
expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).not.toMatch(/Domain=/i);
|
|
197
|
+
expect(getSetCookieRaw(res, CSRF_COOKIE_NAME)).not.toMatch(/Domain=/i);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("login: cookieDomain setzt Domain auf BEIDEN Cookies", async () => {
|
|
201
|
+
const { app } = await buildApp({ cookieDomain: "example.eu" });
|
|
202
|
+
const res = await login(app);
|
|
203
|
+
expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(/Domain=example\.eu/i);
|
|
204
|
+
expect(getSetCookieRaw(res, CSRF_COOKIE_NAME)).toMatch(/Domain=example\.eu/i);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("switch-tenant: rotierte Cookies tragen das Domain-Attribut", async () => {
|
|
208
|
+
const otherTenant = TestUsers.otherTenant;
|
|
209
|
+
const dispatcher = createStubDispatcher({
|
|
210
|
+
async query(type: string, _payload: unknown, _user: SessionUser): Promise<unknown> {
|
|
211
|
+
if (type === "tenant:query:memberships") {
|
|
212
|
+
return [
|
|
213
|
+
{
|
|
214
|
+
userId: TestUsers.user.id,
|
|
215
|
+
tenantId: otherTenant.tenantId,
|
|
216
|
+
roles: otherTenant.roles,
|
|
217
|
+
},
|
|
218
|
+
];
|
|
219
|
+
}
|
|
220
|
+
return [];
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
const { app, validToken } = await buildApp({ cookieDomain: "example.eu" }, dispatcher);
|
|
224
|
+
const res = await app.request("/api/auth/switch-tenant", {
|
|
225
|
+
method: "POST",
|
|
226
|
+
headers: {
|
|
227
|
+
"Content-Type": "application/json",
|
|
228
|
+
Authorization: `Bearer ${validToken}`,
|
|
229
|
+
},
|
|
230
|
+
body: JSON.stringify({ tenantId: otherTenant.tenantId }),
|
|
231
|
+
});
|
|
232
|
+
expect(res.status).toBe(200);
|
|
233
|
+
expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(/Domain=example\.eu/i);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("logout löscht beide Varianten: mit Domain UND host-only", async () => {
|
|
237
|
+
const { app, validToken } = await buildApp({ cookieDomain: "example.eu" });
|
|
238
|
+
const res = await app.request("/api/auth/logout", {
|
|
239
|
+
method: "POST",
|
|
240
|
+
headers: { Authorization: `Bearer ${validToken}` },
|
|
241
|
+
});
|
|
242
|
+
expect(res.status).toBe(200);
|
|
243
|
+
const raw = res.headers.getSetCookie();
|
|
244
|
+
const authDeletes = raw.filter(
|
|
245
|
+
(c) => c.startsWith(`${AUTH_COOKIE_NAME}=`) && /Max-Age=0/i.test(c),
|
|
246
|
+
);
|
|
247
|
+
const csrfDeletes = raw.filter(
|
|
248
|
+
(c) => c.startsWith(`${CSRF_COOKIE_NAME}=`) && /Max-Age=0/i.test(c),
|
|
249
|
+
);
|
|
250
|
+
// Host-only-Bestand (vor cookieDomain gesetzt) + aktuelle Domain-
|
|
251
|
+
// Variante — beide müssen invalidiert werden, sonst wirkt der Logout
|
|
252
|
+
// auf dem alten Cookie nicht.
|
|
253
|
+
expect(authDeletes.some((c) => /Domain=example\.eu/i.test(c))).toBe(true);
|
|
254
|
+
expect(authDeletes.some((c) => !/Domain=/i.test(c))).toBe(true);
|
|
255
|
+
expect(csrfDeletes.some((c) => /Domain=example\.eu/i.test(c))).toBe(true);
|
|
256
|
+
expect(csrfDeletes.some((c) => !/Domain=/i.test(c))).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
});
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -40,7 +40,12 @@ function cookieSecure(): boolean {
|
|
|
40
40
|
// state that would trip the csrf-middleware on every retry.
|
|
41
41
|
function setAuthCookies(
|
|
42
42
|
c: Context,
|
|
43
|
-
opts: {
|
|
43
|
+
opts: {
|
|
44
|
+
token: string;
|
|
45
|
+
csrfToken: string;
|
|
46
|
+
sameSite: "lax" | "strict";
|
|
47
|
+
domain?: string | undefined;
|
|
48
|
+
},
|
|
44
49
|
): void {
|
|
45
50
|
const sameSite = opts.sameSite === "strict" ? "Strict" : "Lax";
|
|
46
51
|
const common = {
|
|
@@ -48,6 +53,7 @@ function setAuthCookies(
|
|
|
48
53
|
sameSite,
|
|
49
54
|
path: "/",
|
|
50
55
|
maxAge: JWT_TTL_SECONDS,
|
|
56
|
+
...(opts.domain !== undefined && { domain: opts.domain }),
|
|
51
57
|
} as const;
|
|
52
58
|
|
|
53
59
|
setCookie(c, AUTH_COOKIE_NAME, opts.token, { ...common, httpOnly: true });
|
|
@@ -56,9 +62,17 @@ function setAuthCookies(
|
|
|
56
62
|
setCookie(c, CSRF_COOKIE_NAME, opts.csrfToken, { ...common, httpOnly: false });
|
|
57
63
|
}
|
|
58
64
|
|
|
59
|
-
function clearAuthCookies(c: Context): void {
|
|
65
|
+
function clearAuthCookies(c: Context, domain?: string): void {
|
|
66
|
+
// Beide Varianten löschen: mit Domain (aktuelle Cookies) UND host-only
|
|
67
|
+
// (Bestand aus der Zeit vor cookieDomain) — sonst bleibt nach einem
|
|
68
|
+
// Deploy mit neu gesetztem cookieDomain der alte Cookie liegen und der
|
|
69
|
+
// Logout wirkt nur scheinbar.
|
|
60
70
|
deleteCookie(c, AUTH_COOKIE_NAME, { path: "/" });
|
|
61
71
|
deleteCookie(c, CSRF_COOKIE_NAME, { path: "/" });
|
|
72
|
+
if (domain !== undefined) {
|
|
73
|
+
deleteCookie(c, AUTH_COOKIE_NAME, { path: "/", domain });
|
|
74
|
+
deleteCookie(c, CSRF_COOKIE_NAME, { path: "/", domain });
|
|
75
|
+
}
|
|
62
76
|
}
|
|
63
77
|
|
|
64
78
|
// Body schema for POST /auth/login. Enforced BEFORE rate-limit so that a
|
|
@@ -237,6 +251,14 @@ export type AuthRoutesConfig = {
|
|
|
237
251
|
// The framework always pairs the cookie with a Double-Submit CSRF token
|
|
238
252
|
// (see csrf-middleware), so "lax" is defense-in-depth, not defense-alone.
|
|
239
253
|
cookieSameSite?: "lax" | "strict";
|
|
254
|
+
// Domain attribute for both auth cookies. Unset (default) = host-only
|
|
255
|
+
// cookie, scoped to the exact host that served the response. Set it to
|
|
256
|
+
// the registrable parent domain (e.g. "example.eu") when login and app
|
|
257
|
+
// live on DIFFERENT subdomains (login on apex, app on admin.) — the
|
|
258
|
+
// browser then sends the session to every subdomain. Careful: that
|
|
259
|
+
// includes ALL subdomains (tenant pages, previews); widen the scope
|
|
260
|
+
// only when the cross-subdomain session is actually required.
|
|
261
|
+
cookieDomain?: string;
|
|
240
262
|
};
|
|
241
263
|
|
|
242
264
|
export type PasswordResetConfig = {
|
|
@@ -402,6 +424,7 @@ export function createAuthRoutes(
|
|
|
402
424
|
// "lax" keeps email deep-links (invite, magic-link, notification click)
|
|
403
425
|
// working. High-security apps can opt into "strict" — see AuthRoutesConfig.
|
|
404
426
|
const cookieSameSite = config.cookieSameSite ?? "lax";
|
|
427
|
+
const cookieDomain = config.cookieDomain;
|
|
405
428
|
|
|
406
429
|
// POST /auth/login — public endpoint (bypasses auth middleware via PUBLIC_API_PATHS).
|
|
407
430
|
// The configured login handler authenticates and returns a SessionUser;
|
|
@@ -481,7 +504,7 @@ export function createAuthRoutes(
|
|
|
481
504
|
// ignores Set-Cookie keeps working without any server-side knowledge
|
|
482
505
|
// of which transport this client will use next.
|
|
483
506
|
const csrfToken = generateToken();
|
|
484
|
-
setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite });
|
|
507
|
+
setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
|
|
485
508
|
|
|
486
509
|
return c.json({
|
|
487
510
|
isSuccess: true,
|
|
@@ -588,7 +611,7 @@ export function createAuthRoutes(
|
|
|
588
611
|
|
|
589
612
|
const token = await jwt.sign(sessionForJwt);
|
|
590
613
|
const csrfToken = generateToken();
|
|
591
|
-
setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite });
|
|
614
|
+
setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
|
|
592
615
|
|
|
593
616
|
return c.json({
|
|
594
617
|
isSuccess: true,
|
|
@@ -671,7 +694,7 @@ export function createAuthRoutes(
|
|
|
671
694
|
}
|
|
672
695
|
const token = await jwt.sign(sessionForJwt);
|
|
673
696
|
const csrfToken = generateToken();
|
|
674
|
-
setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite });
|
|
697
|
+
setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
|
|
675
698
|
return c.json({
|
|
676
699
|
isSuccess: true,
|
|
677
700
|
token,
|
|
@@ -711,7 +734,7 @@ export function createAuthRoutes(
|
|
|
711
734
|
}
|
|
712
735
|
const token = await jwt.sign(sessionForJwt);
|
|
713
736
|
const csrfToken = generateToken();
|
|
714
|
-
setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite });
|
|
737
|
+
setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
|
|
715
738
|
return c.json({
|
|
716
739
|
isSuccess: true,
|
|
717
740
|
token,
|
|
@@ -737,7 +760,7 @@ export function createAuthRoutes(
|
|
|
737
760
|
}
|
|
738
761
|
// Clear cookies on the cookie-transport path. Idempotent — clearing a
|
|
739
762
|
// missing cookie is a no-op, so bearer-only clients aren't affected.
|
|
740
|
-
clearAuthCookies(c);
|
|
763
|
+
clearAuthCookies(c, cookieDomain);
|
|
741
764
|
return c.json({ isSuccess: true });
|
|
742
765
|
});
|
|
743
766
|
|
|
@@ -876,7 +899,12 @@ export function createAuthRoutes(
|
|
|
876
899
|
// the new token in the body below — their Set-Cookie is a no-op
|
|
877
900
|
// because the browser never sent cookies.
|
|
878
901
|
const csrfToken = generateToken();
|
|
879
|
-
setAuthCookies(c, {
|
|
902
|
+
setAuthCookies(c, {
|
|
903
|
+
token: newToken,
|
|
904
|
+
csrfToken,
|
|
905
|
+
sameSite: cookieSameSite,
|
|
906
|
+
domain: cookieDomain,
|
|
907
|
+
});
|
|
880
908
|
|
|
881
909
|
return c.json({ token: newToken, tenantId: targetTenantId, roles: mergedRoles });
|
|
882
910
|
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// 225/2: der sicherheitskritische WHERE-Merge (caller-`where.tenantId`
|
|
2
|
+
// darf den Tenant-Scope nur NARROWEN, nie erweitern) — hier gegen echtes
|
|
3
|
+
// Postgres über den vollen HTTP-Pfad (setupTestStack), nicht nur als
|
|
4
|
+
// SQL-String-Pin gegen den recording-Fake (tenant-db-where-merge.test.ts
|
|
5
|
+
// bleibt als Schnell-Pin bestehen).
|
|
6
|
+
|
|
7
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { selectMany, updateMany } from "../../bun-db";
|
|
10
|
+
import {
|
|
11
|
+
createEntity,
|
|
12
|
+
createTextField,
|
|
13
|
+
defineEntityCreateHandler,
|
|
14
|
+
defineFeature,
|
|
15
|
+
} from "../../engine";
|
|
16
|
+
import {
|
|
17
|
+
createTestUser,
|
|
18
|
+
setupTestStack,
|
|
19
|
+
type TestStack,
|
|
20
|
+
testTenantId,
|
|
21
|
+
unsafeCreateEntityTable,
|
|
22
|
+
} from "../../stack";
|
|
23
|
+
import { buildEntityTable } from "../table-builder";
|
|
24
|
+
|
|
25
|
+
const noteEntity = createEntity({
|
|
26
|
+
fields: {
|
|
27
|
+
title: createTextField({ required: true }),
|
|
28
|
+
},
|
|
29
|
+
table: "where_merge_notes",
|
|
30
|
+
});
|
|
31
|
+
const noteTable = buildEntityTable("note", noteEntity);
|
|
32
|
+
|
|
33
|
+
// Die Handler reichen eine CALLER-KONTROLLIERTE where.tenantId in die
|
|
34
|
+
// TenantDb — genau der Angriffsvektor, den der Merge neutralisieren muss.
|
|
35
|
+
const probeFeature = defineFeature("where-merge-probe", (r) => {
|
|
36
|
+
r.entity("note", noteEntity);
|
|
37
|
+
r.writeHandler(defineEntityCreateHandler("note", noteEntity, { access: { roles: ["User"] } }));
|
|
38
|
+
|
|
39
|
+
r.queryHandler({
|
|
40
|
+
name: "list-for-tenant",
|
|
41
|
+
schema: z.object({ tenantId: z.string() }),
|
|
42
|
+
access: { roles: ["User"] },
|
|
43
|
+
handler: async (query, ctx) => {
|
|
44
|
+
const rows = await selectMany(ctx.db, noteTable, {
|
|
45
|
+
tenantId: query.payload.tenantId,
|
|
46
|
+
});
|
|
47
|
+
return rows.map((row) => ({ title: row["title"], tenantId: row["tenantId"] }));
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
r.writeHandler({
|
|
52
|
+
name: "retitle-for-tenant",
|
|
53
|
+
schema: z.object({ tenantId: z.string(), title: z.string() }),
|
|
54
|
+
access: { roles: ["User"] },
|
|
55
|
+
handler: async (event, ctx) => {
|
|
56
|
+
const count = await updateMany(
|
|
57
|
+
ctx.db,
|
|
58
|
+
noteTable,
|
|
59
|
+
{ tenantId: event.payload.tenantId },
|
|
60
|
+
{ title: event.payload.title },
|
|
61
|
+
);
|
|
62
|
+
return { isSuccess: true as const, data: { count } };
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
let stack: TestStack;
|
|
68
|
+
|
|
69
|
+
const tenantA = testTenantId(81);
|
|
70
|
+
const tenantB = testTenantId(82);
|
|
71
|
+
const userA = createTestUser({ id: 81, tenantId: tenantA, roles: ["User"] });
|
|
72
|
+
const userB = createTestUser({ id: 82, tenantId: tenantB, roles: ["User"] });
|
|
73
|
+
|
|
74
|
+
beforeAll(async () => {
|
|
75
|
+
stack = await setupTestStack({ features: [probeFeature] });
|
|
76
|
+
await unsafeCreateEntityTable(stack.db, noteEntity);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterAll(async () => {
|
|
80
|
+
await stack.cleanup();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("tenant-db WHERE merge — full stack", () => {
|
|
84
|
+
test("foreign where.tenantId in a query never returns the other tenant's rows", async () => {
|
|
85
|
+
await stack.http.writeOk("where-merge-probe:write:note:create", { title: "a-note" }, userA);
|
|
86
|
+
await stack.http.writeOk("where-merge-probe:write:note:create", { title: "b-note" }, userB);
|
|
87
|
+
|
|
88
|
+
// userA fragt EXPLIZIT nach tenantB — der Merge IGNORIERT die fremde
|
|
89
|
+
// tenantId (fällt auf den eigenen Scope zurück): nie b-note, die
|
|
90
|
+
// fremde Row bleibt unsichtbar.
|
|
91
|
+
const crossRead = (await stack.http.queryOk(
|
|
92
|
+
"where-merge-probe:query:list-for-tenant",
|
|
93
|
+
{ tenantId: tenantB },
|
|
94
|
+
userA,
|
|
95
|
+
)) as Array<{ title: string; tenantId: string }>;
|
|
96
|
+
expect(crossRead.map((r) => r.title)).not.toContain("b-note");
|
|
97
|
+
expect(crossRead.every((r) => r.tenantId === tenantA)).toBe(true);
|
|
98
|
+
|
|
99
|
+
// Kontrolle: der eigene Tenant ist über denselben Pfad lesbar.
|
|
100
|
+
const ownRead = (await stack.http.queryOk(
|
|
101
|
+
"where-merge-probe:query:list-for-tenant",
|
|
102
|
+
{ tenantId: tenantA },
|
|
103
|
+
userA,
|
|
104
|
+
)) as Array<{ title: string }>;
|
|
105
|
+
expect(ownRead.map((r) => r.title)).toEqual(["a-note"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("foreign where.tenantId in an update never touches the other tenant's rows", async () => {
|
|
109
|
+
await stack.http.writeOk(
|
|
110
|
+
"where-merge-probe:write:retitle-for-tenant",
|
|
111
|
+
{ tenantId: tenantB, title: "HACKED" },
|
|
112
|
+
userA,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const bRows = (await stack.http.queryOk(
|
|
116
|
+
"where-merge-probe:query:list-for-tenant",
|
|
117
|
+
{ tenantId: tenantB },
|
|
118
|
+
userB,
|
|
119
|
+
)) as Array<{ title: string }>;
|
|
120
|
+
expect(bRows.map((r) => r.title)).toEqual(["b-note"]);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -13,7 +13,7 @@ describe("access presets", () => {
|
|
|
13
13
|
});
|
|
14
14
|
|
|
15
15
|
test("access.admin", () => {
|
|
16
|
-
expect(access.admin).toEqual(["Admin", "SystemAdmin"]);
|
|
16
|
+
expect(access.admin).toEqual(["TenantAdmin", "Admin", "SystemAdmin"]);
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
test("access.systemAdmin", () => {
|
|
@@ -41,7 +41,7 @@ describe("createTenantConfig", () => {
|
|
|
41
41
|
test("defaults: admin writes, all reads", () => {
|
|
42
42
|
const key = createTenantConfig("text");
|
|
43
43
|
expect(key.scope).toBe("tenant");
|
|
44
|
-
expect(key.access.write).toEqual(["Admin", "SystemAdmin"]);
|
|
44
|
+
expect(key.access.write).toEqual(["TenantAdmin", "Admin", "SystemAdmin"]);
|
|
45
45
|
expect(key.access.read).toEqual(["all"]);
|
|
46
46
|
});
|
|
47
47
|
|
|
@@ -76,7 +76,7 @@ describe("createSystemConfig", () => {
|
|
|
76
76
|
const key = createSystemConfig("number");
|
|
77
77
|
expect(key.scope).toBe("system");
|
|
78
78
|
expect(key.access.write).toEqual(["system"]);
|
|
79
|
-
expect(key.access.read).toEqual(["Admin", "SystemAdmin"]);
|
|
79
|
+
expect(key.access.read).toEqual(["TenantAdmin", "Admin", "SystemAdmin"]);
|
|
80
80
|
});
|
|
81
81
|
|
|
82
82
|
test("with default value", () => {
|
|
@@ -87,6 +87,29 @@ export function validateConfigKeyComputed(feature: FeatureDefinition): void {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
// --- Config key required/default compatibility ---
|
|
91
|
+
|
|
92
|
+
export function validateConfigKeyRequired(feature: FeatureDefinition): void {
|
|
93
|
+
for (const [keyName, keyDef] of Object.entries(feature.configKeys ?? {})) {
|
|
94
|
+
if (keyDef.required !== true) continue;
|
|
95
|
+
// required heißt "Tenant MUSS konfigurieren" — ein non-empty default
|
|
96
|
+
// oder ein computed-Resolver macht den Key nie unset, readiness könnte
|
|
97
|
+
// die Lücke nie melden: der required-Flag wäre eine stille Lüge.
|
|
98
|
+
if (keyDef.computed !== undefined) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`[Feature ${feature.name}] Config key "${keyName}" has required=true AND a computed resolver — a computed key can never be missing; drop one of the two`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const d = keyDef.default;
|
|
104
|
+
const nonEmptyDefault = d !== undefined && !(typeof d === "string" && d.trim().length === 0);
|
|
105
|
+
if (nonEmptyDefault) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`[Feature ${feature.name}] Config key "${keyName}" has required=true AND a non-empty default (${JSON.stringify(d)}) — the key can never be unset, readiness would never flag it; use default "" or drop required`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
90
113
|
// --- Config key allowPerRequest compatibility ---
|
|
91
114
|
|
|
92
115
|
export function validateConfigKeyAllowPerRequest(feature: FeatureDefinition): void {
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
validateConfigKeyAllowPerRequest,
|
|
6
6
|
validateConfigKeyBounds,
|
|
7
7
|
validateConfigKeyComputed,
|
|
8
|
+
validateConfigKeyRequired,
|
|
8
9
|
validateConfigReads,
|
|
9
10
|
warnOnToggleableDependencies,
|
|
10
11
|
} from "./config-deps";
|
|
@@ -132,6 +133,7 @@ export function validateBoot(features: readonly FeatureDefinition[]): void {
|
|
|
132
133
|
validateLocatedTimestamps(feature);
|
|
133
134
|
validateEntityIndexes(feature);
|
|
134
135
|
validateConfigKeyBounds(feature);
|
|
136
|
+
validateConfigKeyRequired(feature);
|
|
135
137
|
validateConfigKeyComputed(feature);
|
|
136
138
|
validateConfigKeyAllowPerRequest(feature);
|
|
137
139
|
validateOwnershipRules(feature, allClaimKeys, knownRoles);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { qualifyEntityName } from "../qualified-name";
|
|
2
2
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
|
|
3
3
|
import type { FeatureDefinition, NavDefinition, WorkspaceDefinition } from "../types";
|
|
4
|
+
import type { FieldCondition, RowAction, RowFieldExtractor, ToolbarAction } from "../types/screen";
|
|
4
5
|
import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../types/screen";
|
|
5
6
|
|
|
6
7
|
// --- Screen validation ---
|
|
@@ -16,6 +17,62 @@ import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from
|
|
|
16
17
|
// Field-level renderer QN strings (cross-feature `component:` references)
|
|
17
18
|
// are NOT validated here — the r.uiComponent registry that would resolve
|
|
18
19
|
// them ships in M4/M5. Until then those are kept opaque on purpose.
|
|
20
|
+
|
|
21
|
+
// Tier 2.7e-3: deklarative Feld-Referenzen einer Action gegen die Entity-
|
|
22
|
+
// Felder pinnen — ein Tippfehler in pick/map-Quellfeldern oder
|
|
23
|
+
// visible.field erzeugte sonst still `undefined` im Payload bzw. dauerhaft
|
|
24
|
+
// falsche Sichtbarkeit (gleiche "Typo fällt erst beim Klick"-Klasse wie
|
|
25
|
+
// navigate/handler).
|
|
26
|
+
function validateActionFieldRefs(
|
|
27
|
+
featureName: string,
|
|
28
|
+
screenId: string,
|
|
29
|
+
actionKind: "rowAction" | "toolbarAction",
|
|
30
|
+
actionId: string,
|
|
31
|
+
action: RowAction | ToolbarAction,
|
|
32
|
+
fieldNames: ReadonlySet<string>,
|
|
33
|
+
): void {
|
|
34
|
+
// ToolbarAction.payload ist ein STATISCHER Record (kein Row-Context) —
|
|
35
|
+
// nur echte pick/map-Extractoren werden gegen die Feldnamen geprüft.
|
|
36
|
+
const isExtractor = (v: unknown): v is RowFieldExtractor =>
|
|
37
|
+
typeof v === "object" && v !== null && ("pick" in v || "map" in v);
|
|
38
|
+
const payload = "payload" in action && isExtractor(action.payload) ? action.payload : undefined;
|
|
39
|
+
const params = "params" in action && isExtractor(action.params) ? action.params : undefined;
|
|
40
|
+
const visible: FieldCondition | undefined = "visible" in action ? action.visible : undefined;
|
|
41
|
+
const entityId: string | undefined = "entityId" in action ? action.entityId : undefined;
|
|
42
|
+
const known = () => [...fieldNames].sort().join(", ") || "(none)";
|
|
43
|
+
const checkExtractor = (label: string, extractor: RowFieldExtractor | undefined): void => {
|
|
44
|
+
// skip: extractor ist ein optionaler Action-Slot — ohne ihn gibt es
|
|
45
|
+
// keine Feld-Referenzen zu validieren.
|
|
46
|
+
if (extractor === undefined) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const sources = "pick" in extractor ? extractor.pick : Object.values(extractor.map);
|
|
50
|
+
for (const source of sources) {
|
|
51
|
+
if (source === "id") continue; // row.id ist immer da (Aggregat-Id)
|
|
52
|
+
if (!fieldNames.has(source)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
|
|
55
|
+
`${label} references unknown field "${source}". Known fields: ${known()}.`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
checkExtractor("payload", payload);
|
|
61
|
+
checkExtractor("params", params);
|
|
62
|
+
if (visible !== undefined && typeof visible !== "boolean" && !fieldNames.has(visible.field)) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
|
|
65
|
+
`visible.field references unknown field "${visible.field}". Known fields: ${known()}.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (entityId !== undefined && entityId !== "id" && !fieldNames.has(entityId)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
|
|
71
|
+
`entityId references unknown field "${entityId}". Known fields: ${known()}.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
19
76
|
export function validateScreens(
|
|
20
77
|
feature: FeatureDefinition,
|
|
21
78
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
@@ -370,6 +427,14 @@ export function validateScreens(
|
|
|
370
427
|
);
|
|
371
428
|
}
|
|
372
429
|
}
|
|
430
|
+
validateActionFieldRefs(
|
|
431
|
+
feature.name,
|
|
432
|
+
screenId,
|
|
433
|
+
"rowAction",
|
|
434
|
+
action.id,
|
|
435
|
+
action,
|
|
436
|
+
fieldNames,
|
|
437
|
+
);
|
|
373
438
|
}
|
|
374
439
|
}
|
|
375
440
|
// Tier 2.7e-2: toolbarActions — analog zu rowActions, aber bisher
|
|
@@ -394,6 +459,14 @@ export function validateScreens(
|
|
|
394
459
|
);
|
|
395
460
|
}
|
|
396
461
|
}
|
|
462
|
+
validateActionFieldRefs(
|
|
463
|
+
feature.name,
|
|
464
|
+
screenId,
|
|
465
|
+
"toolbarAction",
|
|
466
|
+
action.id,
|
|
467
|
+
action,
|
|
468
|
+
fieldNames,
|
|
469
|
+
);
|
|
397
470
|
}
|
|
398
471
|
}
|
|
399
472
|
} else {
|
|
@@ -15,7 +15,10 @@ import type {
|
|
|
15
15
|
|
|
16
16
|
export const access = {
|
|
17
17
|
all: ["all"] as readonly string[], // @cast-boundary schema-walk
|
|
18
|
-
|
|
18
|
+
// TenantAdmin zusätzlich: bundled-features vergeben "TenantAdmin",
|
|
19
|
+
// App-Repos historisch "Admin" — das Preset deckt beide ab, sonst
|
|
20
|
+
// driftet die writeRole-Spalte der Feature-Reference (Manifest 243/2).
|
|
21
|
+
admin: ["TenantAdmin", "Admin", "SystemAdmin"] as readonly string[], // @cast-boundary schema-walk
|
|
19
22
|
systemAdmin: ["SystemAdmin"] as readonly string[], // @cast-boundary schema-walk
|
|
20
23
|
system: ["system"] as readonly string[], // @cast-boundary schema-walk
|
|
21
24
|
privileged: ["system", "SystemAdmin"] as readonly string[], // @cast-boundary schema-walk
|
|
@@ -33,6 +33,16 @@
|
|
|
33
33
|
*/
|
|
34
34
|
export const EXT_USER_DATA = "userData" as const;
|
|
35
35
|
|
|
36
|
+
// Order-Bänder für EXT_USER_DATA-Hooks (forget-Pipeline). Der Kontrakt war
|
|
37
|
+
// implizit über zwei Packages verteilt (-100 in custom-fields, 0 in
|
|
38
|
+
// user-data-rights) — ein Host-Hook mit order < REDACT_BEFORE_OWNER liefe
|
|
39
|
+
// VOR den Redaktoren und brächte den Strip-nach-owner-null-Bug zurück
|
|
40
|
+
// (DSGVO-Art.-17-Regression). Regel: Redaktoren < 0 <= owner-mutierende Hooks.
|
|
41
|
+
export const EXT_USER_DATA_ORDER = {
|
|
42
|
+
REDACT_BEFORE_OWNER: -100,
|
|
43
|
+
DEFAULT: 0,
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
36
46
|
/**
|
|
37
47
|
* `tenantData` — Tenant-Destroy-Hooks pro Entity (DSGVO + AVV-Beendigung).
|
|
38
48
|
*
|
|
@@ -97,10 +97,17 @@ export function extractDescribe(
|
|
|
97
97
|
"expected a single string literal",
|
|
98
98
|
);
|
|
99
99
|
}
|
|
100
|
+
// Mirrors the define-feature boot guard: whitespace-only describes throw
|
|
101
|
+
// at boot — the AST path must reject them too, and store the TRIMMED
|
|
102
|
+
// text so render output matches the runtime/manifest value.
|
|
103
|
+
const trimmed = text.trim();
|
|
104
|
+
if (trimmed.length === 0) {
|
|
105
|
+
return fail("describe", sourceLocationFromNode(call, sourceFile), "must be a non-empty string");
|
|
106
|
+
}
|
|
100
107
|
return ok({
|
|
101
108
|
kind: "describe",
|
|
102
109
|
source: sourceLocationFromNode(call, sourceFile),
|
|
103
|
-
text,
|
|
110
|
+
text: trimmed,
|
|
104
111
|
});
|
|
105
112
|
}
|
|
106
113
|
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
// `defineFeature(name, (r) => { ... })` setup callback and yields one
|
|
4
4
|
// FeaturePattern per recognised call.
|
|
5
5
|
//
|
|
6
|
+
// **Doc single-source:** THIS file is the canonical pattern reference
|
|
7
|
+
// (mirrored into docs/corpus). The FeatureRegistrar JSDoc in
|
|
8
|
+
// types/feature.ts stays a short pointer — duplicating semantics there
|
|
9
|
+
// has already drifted once; verify any "checks at boot" claim against
|
|
10
|
+
// engine/boot-validator/* before writing it down.
|
|
11
|
+
//
|
|
6
12
|
// **Design principle:**
|
|
7
13
|
//
|
|
8
14
|
// - Whatever the Designer/AI can edit declaratively → typed static
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Runtime-introspected feature-manifest — die geteilte Extraktionslogik
|
|
2
|
+
// hinter `gen-feature-manifest.ts` (use-all-bundled) und dem enterprise-
|
|
3
|
+
// Generator. Vorher waren das zwei fast wortgleiche Forks mit divergentem
|
|
4
|
+
// Schema (enterprise#95): erweitert das Framework die Introspektion, driftete
|
|
5
|
+
// die Kopie still. Quelle der Wahrheit ist die GEBOOTETE Registry — der
|
|
6
|
+
// AST-Parser kann die imperativen Factory-Helper der bundled features nicht
|
|
7
|
+
// lesen.
|
|
8
|
+
|
|
9
|
+
import type { Registry } from "./types/feature";
|
|
10
|
+
|
|
11
|
+
export type ManifestConfigKey = {
|
|
12
|
+
readonly key: string;
|
|
13
|
+
readonly qualifiedName: string;
|
|
14
|
+
readonly type: "text" | "number" | "boolean" | "select";
|
|
15
|
+
readonly scope: string;
|
|
16
|
+
readonly default: string | number | boolean | null;
|
|
17
|
+
readonly encrypted: boolean;
|
|
18
|
+
readonly computed: boolean;
|
|
19
|
+
readonly options: readonly string[] | null;
|
|
20
|
+
readonly bounds: { readonly min?: number; readonly max?: number } | null;
|
|
21
|
+
readonly writeRoles: readonly string[];
|
|
22
|
+
readonly readRoles: readonly string[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ManifestSecret = {
|
|
26
|
+
readonly qualifiedName: string;
|
|
27
|
+
readonly scope: string;
|
|
28
|
+
readonly label: string | null;
|
|
29
|
+
readonly hint: string | null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ManifestExtension = {
|
|
33
|
+
readonly extensionName: string;
|
|
34
|
+
readonly entityName: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type ManifestFeature = {
|
|
38
|
+
readonly name: string;
|
|
39
|
+
readonly description: string | null;
|
|
40
|
+
readonly toggleableDefault: boolean | null;
|
|
41
|
+
readonly requires: readonly string[];
|
|
42
|
+
readonly optionalRequires: readonly string[];
|
|
43
|
+
readonly configReads: readonly string[];
|
|
44
|
+
readonly exposesApis: readonly string[];
|
|
45
|
+
readonly usesApis: readonly string[];
|
|
46
|
+
readonly extensionsUsed: readonly ManifestExtension[];
|
|
47
|
+
readonly configKeys: readonly ManifestConfigKey[];
|
|
48
|
+
readonly secrets: readonly ManifestSecret[];
|
|
49
|
+
/** Optionaler Herkunfts-Tag (z.B. "enterprise") — gesetzt via Options. */
|
|
50
|
+
readonly tier?: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type FeatureManifest = {
|
|
54
|
+
readonly source: string;
|
|
55
|
+
readonly featureCount: number;
|
|
56
|
+
readonly features: readonly ManifestFeature[];
|
|
57
|
+
readonly tier?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const CONFIG_SEGMENT = ":config:";
|
|
61
|
+
|
|
62
|
+
export type BuildManifestOptions = {
|
|
63
|
+
/** Herkunfts-Beschreibung fürs Manifest (landet 1:1 im JSON). */
|
|
64
|
+
readonly source: string;
|
|
65
|
+
/** Nur diese Features emittieren (z.B. die 16 enterprise-Features einer
|
|
66
|
+
* Registry, die auch deren bundled-requires gemountet hat). Default:
|
|
67
|
+
* alle Features der Registry. */
|
|
68
|
+
readonly featureNames?: ReadonlySet<string>;
|
|
69
|
+
/** Taggt jedes Feature + das Manifest top-level (z.B. "enterprise"). */
|
|
70
|
+
readonly tier?: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export function buildManifestFromRegistry(
|
|
74
|
+
registry: Registry,
|
|
75
|
+
options: BuildManifestOptions,
|
|
76
|
+
): FeatureManifest {
|
|
77
|
+
const allConfigKeys = registry.getAllConfigKeys();
|
|
78
|
+
const allSecretKeys = registry.getAllSecretKeys();
|
|
79
|
+
|
|
80
|
+
const manifestFeatures: ManifestFeature[] = [];
|
|
81
|
+
for (const feature of registry.features.values()) {
|
|
82
|
+
if (options.featureNames !== undefined && !options.featureNames.has(feature.name)) continue;
|
|
83
|
+
|
|
84
|
+
const configKeys: ManifestConfigKey[] = [];
|
|
85
|
+
for (const [qualifiedName, def] of allConfigKeys) {
|
|
86
|
+
const prefix = `${feature.name}${CONFIG_SEGMENT}`;
|
|
87
|
+
if (!qualifiedName.startsWith(prefix)) continue;
|
|
88
|
+
configKeys.push({
|
|
89
|
+
key: qualifiedName.slice(prefix.length),
|
|
90
|
+
qualifiedName,
|
|
91
|
+
type: def.type,
|
|
92
|
+
scope: def.scope,
|
|
93
|
+
default: def.default ?? null,
|
|
94
|
+
encrypted: def.encrypted ?? false,
|
|
95
|
+
computed: def.computed !== undefined,
|
|
96
|
+
options: def.options ?? null,
|
|
97
|
+
bounds: def.bounds ?? null,
|
|
98
|
+
writeRoles: def.access.write,
|
|
99
|
+
readRoles: def.access.read,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const secrets: ManifestSecret[] = [];
|
|
104
|
+
for (const secret of allSecretKeys.values()) {
|
|
105
|
+
if (!secret.qualifiedName.startsWith(`${feature.name}:`)) continue;
|
|
106
|
+
secrets.push({
|
|
107
|
+
qualifiedName: secret.qualifiedName,
|
|
108
|
+
scope: secret.scope,
|
|
109
|
+
label: secret.label["en"] ?? secret.label["de"] ?? null,
|
|
110
|
+
hint: secret.hint?.["en"] ?? secret.hint?.["de"] ?? null,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
configKeys.sort((a, b) => a.qualifiedName.localeCompare(b.qualifiedName));
|
|
115
|
+
secrets.sort((a, b) => a.qualifiedName.localeCompare(b.qualifiedName));
|
|
116
|
+
|
|
117
|
+
manifestFeatures.push({
|
|
118
|
+
name: feature.name,
|
|
119
|
+
description: feature.description ?? null,
|
|
120
|
+
toggleableDefault: feature.toggleableDefault ?? null,
|
|
121
|
+
requires: [...feature.requires],
|
|
122
|
+
optionalRequires: [...feature.optionalRequires],
|
|
123
|
+
configReads: [...feature.configReads],
|
|
124
|
+
exposesApis: [...feature.exposedApis],
|
|
125
|
+
usesApis: [...feature.usedApis],
|
|
126
|
+
extensionsUsed: feature.extensionUsages.map((usage) => ({
|
|
127
|
+
extensionName: usage.extensionName,
|
|
128
|
+
entityName: usage.entityName,
|
|
129
|
+
})),
|
|
130
|
+
configKeys,
|
|
131
|
+
secrets,
|
|
132
|
+
...(options.tier !== undefined && { tier: options.tier }),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
manifestFeatures.sort((a, b) => a.name.localeCompare(b.name));
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
source: options.source,
|
|
140
|
+
featureCount: manifestFeatures.length,
|
|
141
|
+
features: manifestFeatures,
|
|
142
|
+
...(options.tier !== undefined && { tier: options.tier }),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function serializeManifest(manifest: FeatureManifest): string {
|
|
147
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
148
|
+
}
|
package/src/engine/index.ts
CHANGED
|
@@ -66,6 +66,7 @@ export {
|
|
|
66
66
|
EXT_STORAGE_PROVIDER,
|
|
67
67
|
EXT_TENANT_DATA,
|
|
68
68
|
EXT_USER_DATA,
|
|
69
|
+
EXT_USER_DATA_ORDER,
|
|
69
70
|
} from "./extension-names";
|
|
70
71
|
export type {
|
|
71
72
|
UserDataDeleteHook,
|
|
@@ -136,6 +137,16 @@ export {
|
|
|
136
137
|
replacePattern,
|
|
137
138
|
VERSION_HEADER,
|
|
138
139
|
} from "./feature-ast";
|
|
140
|
+
export {
|
|
141
|
+
type BuildManifestOptions,
|
|
142
|
+
buildManifestFromRegistry,
|
|
143
|
+
type FeatureManifest,
|
|
144
|
+
type ManifestConfigKey,
|
|
145
|
+
type ManifestExtension,
|
|
146
|
+
type ManifestFeature,
|
|
147
|
+
type ManifestSecret,
|
|
148
|
+
serializeManifest,
|
|
149
|
+
} from "./feature-manifest";
|
|
139
150
|
export {
|
|
140
151
|
checkWriteFieldOwnership,
|
|
141
152
|
checkWriteFieldRoles,
|
package/src/engine/registry.ts
CHANGED
|
@@ -340,6 +340,16 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
340
340
|
`Pick a different tableName — both would emit CREATE TABLE "${physical}".`,
|
|
341
341
|
);
|
|
342
342
|
}
|
|
343
|
+
// Entity-vs-entity ist genauso fatal: zwei Entities mit explizitem,
|
|
344
|
+
// identischem tableName überschrieben sich hier vorher still —
|
|
345
|
+
// doppeltes CREATE TABLE bzw. eine Projektion frisst die andere.
|
|
346
|
+
if (clash?.kind === "entity") {
|
|
347
|
+
throw new Error(
|
|
348
|
+
`Entity "${name}" (feature "${feature.name}") has physical table "${physical}" which ` +
|
|
349
|
+
`collides with entity "${clash.owner}" (feature "${clash.featureName}"). ` +
|
|
350
|
+
`Pick a different tableName — both would project into "${physical}".`,
|
|
351
|
+
);
|
|
352
|
+
}
|
|
343
353
|
physicalTableOwners.set(physical, { kind: "entity", owner: name, featureName: feature.name });
|
|
344
354
|
}
|
|
345
355
|
|
|
@@ -511,6 +511,18 @@ export function normalizeListColumn(c: ListColumnSpec): Exclude<ListColumnSpec,
|
|
|
511
511
|
return col;
|
|
512
512
|
}
|
|
513
513
|
|
|
514
|
+
/** Evaluates a declarative FieldCondition against the current row/form
|
|
515
|
+
* values. THE single implementation — renderer (row-action visibility),
|
|
516
|
+
* headless view-model (visible/readOnly/required) and render-edit
|
|
517
|
+
* (form-condition closures) reuse it; three hand-rolled copies had
|
|
518
|
+
* already drifted in shape. */
|
|
519
|
+
export function evalFieldCondition(cond: FieldCondition, values: Record<string, unknown>): boolean {
|
|
520
|
+
if (typeof cond === "boolean") return cond;
|
|
521
|
+
const val = values[cond.field];
|
|
522
|
+
if ("eq" in cond) return val === cond.eq;
|
|
523
|
+
return val !== cond.ne;
|
|
524
|
+
}
|
|
525
|
+
|
|
514
526
|
export function normalizeEditField(f: EditFieldSpec): Exclude<EditFieldSpec, string> {
|
|
515
527
|
return typeof f === "string" ? { field: f } : f;
|
|
516
528
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// st15/7: der Dry-Run-Pfad lieferte `({} as Shape)` — jeder Zugriff silent
|
|
2
|
+
// undefined. parseEnvDryRun liefert stattdessen ein ehrliches Partial.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { parseEnvDryRun } from "../index";
|
|
7
|
+
|
|
8
|
+
const schema = z.object({
|
|
9
|
+
DATABASE_URL: z.string().min(1),
|
|
10
|
+
PORT: z.coerce.number().int().default(3000),
|
|
11
|
+
DEBUG: z
|
|
12
|
+
.string()
|
|
13
|
+
.optional()
|
|
14
|
+
.transform((v) => v === "1"),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("parseEnvDryRun", () => {
|
|
18
|
+
test("missing required fields are simply absent — no throw", () => {
|
|
19
|
+
const out = parseEnvDryRun(schema, {});
|
|
20
|
+
expect(out.DATABASE_URL).toBeUndefined();
|
|
21
|
+
expect("DATABASE_URL" in out).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("present fields come through parsed/coerced", () => {
|
|
25
|
+
const out = parseEnvDryRun(schema, {
|
|
26
|
+
DATABASE_URL: "postgres://x",
|
|
27
|
+
PORT: "8123",
|
|
28
|
+
DEBUG: "1",
|
|
29
|
+
});
|
|
30
|
+
expect(out.DATABASE_URL).toBe("postgres://x");
|
|
31
|
+
expect(out.PORT).toBe(8123);
|
|
32
|
+
expect(out.DEBUG).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("invalid values are dropped instead of throwing (inventory must render)", () => {
|
|
36
|
+
const out = parseEnvDryRun(schema, { PORT: "not-a-number", DATABASE_URL: "postgres://x" });
|
|
37
|
+
expect(out.PORT).toBeUndefined();
|
|
38
|
+
expect(out.DATABASE_URL).toBe("postgres://x");
|
|
39
|
+
});
|
|
40
|
+
});
|
package/src/env/index.ts
CHANGED
|
@@ -316,6 +316,28 @@ function ucfirst(s: string): string {
|
|
|
316
316
|
return s.length === 0 ? s : s.charAt(0).toUpperCase() + s.slice(1);
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
+
/** Dry-Run-Gegenstück zu parseEnv (KUMIKO_DRY_RUN_ENV-Pfad): parst jedes
|
|
320
|
+
* Feld einzeln und liefert ein ehrliches Partial — vorhandene Werte kommen
|
|
321
|
+
* typisiert (gecoerct) durch, fehlende/invalide fehlen einfach. Wirft nie:
|
|
322
|
+
* der Dry-Run soll das Inventory rendern, nicht an required-Feldern
|
|
323
|
+
* scheitern. Ersetzt das `({} as Shape)`-Muster, bei dem JEDER Zugriff
|
|
324
|
+
* silent undefined war und das Compile-Level nichts davon wusste. */
|
|
325
|
+
export function parseEnvDryRun<S extends z.ZodObject<z.ZodRawShape>>(
|
|
326
|
+
schema: S,
|
|
327
|
+
env: Record<string, string | undefined>,
|
|
328
|
+
): Partial<z.infer<S>> {
|
|
329
|
+
const out: Record<string, unknown> = {};
|
|
330
|
+
for (const [name, field] of Object.entries(zodShape(schema))) {
|
|
331
|
+
const raw = env[name];
|
|
332
|
+
if (raw === undefined) continue;
|
|
333
|
+
const result = field.safeParse(raw);
|
|
334
|
+
if (result.success) out[name] = result.data;
|
|
335
|
+
}
|
|
336
|
+
// @cast-boundary schema-walk — Partial<z.infer<S>> erasure über den
|
|
337
|
+
// per-Feld-safeParse; jeder enthaltene Wert hat seinen Feld-Parse bestanden.
|
|
338
|
+
return out as Partial<z.infer<S>>;
|
|
339
|
+
}
|
|
340
|
+
|
|
319
341
|
export function camelCase(snakeShout: string): string {
|
|
320
342
|
const parts = snakeShout.toLowerCase().split("_").filter(Boolean);
|
|
321
343
|
if (parts.length === 0) return snakeShout.toLowerCase();
|
|
@@ -135,7 +135,7 @@ describe("event-store performance — Gate A", () => {
|
|
|
135
135
|
|
|
136
136
|
// 25ms statt der 10ms aus dem Spike-Doc: der shared cdgs-runner failt
|
|
137
137
|
// lastabhängig (real gemessen 13.7ms p99) — als CI-Gate zählt die
|
|
138
|
-
// Größenordnung, nicht der Idle-Bestwert.
|
|
138
|
+
// Größenordnung, nicht der Idle-Bestwert. Tracking: #325.
|
|
139
139
|
expect(p99).toBeLessThan(25);
|
|
140
140
|
});
|
|
141
141
|
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// studio#36/#46: ein fehlgeschlagener Projection-Rebuild nach `schema apply`
|
|
2
|
+
// durfte nicht verloren gehen — die Queue persistiert die betroffenen
|
|
3
|
+
// Tabellen, ein erneuter Lauf holt offene Rebuilds nach.
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { integer, table as pgTable, uuid } from "../../db/dialect";
|
|
10
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
11
|
+
import { asRawClient, selectMany } from "../../db/query";
|
|
12
|
+
import { writeRebuildMarker } from "../../db/rebuild-marker";
|
|
13
|
+
import { buildEntityTable } from "../../db/table-builder";
|
|
14
|
+
import { createTenantDb, type TenantDb } from "../../db/tenant-db";
|
|
15
|
+
import {
|
|
16
|
+
createEntity,
|
|
17
|
+
createRegistry,
|
|
18
|
+
createTextField,
|
|
19
|
+
defineApply,
|
|
20
|
+
defineFeature,
|
|
21
|
+
type ProjectionDefinition,
|
|
22
|
+
} from "../../engine";
|
|
23
|
+
import { createEventsTable } from "../../event-store";
|
|
24
|
+
import { createProjectionStateTable } from "../../pipeline";
|
|
25
|
+
import {
|
|
26
|
+
createTestDb,
|
|
27
|
+
type TestDb,
|
|
28
|
+
TestUsers,
|
|
29
|
+
unsafeCreateEntityTable,
|
|
30
|
+
unsafePushTables,
|
|
31
|
+
} from "../../stack";
|
|
32
|
+
import {
|
|
33
|
+
listPendingRebuilds,
|
|
34
|
+
queueRebuildsFromMarkers,
|
|
35
|
+
runPendingRebuilds,
|
|
36
|
+
} from "../pending-rebuilds";
|
|
37
|
+
|
|
38
|
+
const itemEntity = createEntity({
|
|
39
|
+
table: "read_pending_items",
|
|
40
|
+
fields: {
|
|
41
|
+
groupId: createTextField({ required: true }),
|
|
42
|
+
name: createTextField({ required: true }),
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
const itemTable = buildEntityTable("pending-item", itemEntity);
|
|
46
|
+
|
|
47
|
+
const countsTable = pgTable("read_pending_counts", {
|
|
48
|
+
groupId: uuid("group_id").primaryKey(),
|
|
49
|
+
tenantId: uuid("tenant_id").notNull(),
|
|
50
|
+
itemCount: integer("item_count").notNull().default(0),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Steuerbarer Fail: simuliert einen transienten Rebuild-Fehler.
|
|
54
|
+
let failApply = false;
|
|
55
|
+
|
|
56
|
+
const countsProjection: ProjectionDefinition = {
|
|
57
|
+
name: "pending-counts",
|
|
58
|
+
source: "pending-item",
|
|
59
|
+
table: countsTable,
|
|
60
|
+
apply: {
|
|
61
|
+
"pending-item.created": defineApply<{ groupId: string }>(async (event, tx) => {
|
|
62
|
+
if (failApply) throw new Error("transient rebuild failure (test)");
|
|
63
|
+
await asRawClient(tx).unsafe(
|
|
64
|
+
`INSERT INTO "read_pending_counts" (group_id, tenant_id, item_count) VALUES ($1::uuid, $2::uuid, 1)
|
|
65
|
+
ON CONFLICT (group_id) DO UPDATE SET item_count = read_pending_counts.item_count + 1`,
|
|
66
|
+
[event.payload.groupId, event.tenantId],
|
|
67
|
+
);
|
|
68
|
+
}),
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const feature = defineFeature("pendingtest", (r) => {
|
|
73
|
+
r.entity("pending-item", itemEntity);
|
|
74
|
+
r.projection(countsProjection);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const admin = TestUsers.admin;
|
|
78
|
+
const registry = createRegistry([feature]);
|
|
79
|
+
const executor = createEventStoreExecutor(itemTable, itemEntity, { entityName: "pending-item" });
|
|
80
|
+
|
|
81
|
+
let testDb: TestDb;
|
|
82
|
+
let tdb: TenantDb;
|
|
83
|
+
let markerDir: string;
|
|
84
|
+
|
|
85
|
+
beforeAll(async () => {
|
|
86
|
+
testDb = await createTestDb();
|
|
87
|
+
await unsafeCreateEntityTable(testDb.db, itemEntity, "pending-item");
|
|
88
|
+
await createEventsTable(testDb.db);
|
|
89
|
+
await createProjectionStateTable(testDb.db);
|
|
90
|
+
await unsafePushTables(testDb.db, { readPendingCounts: countsTable });
|
|
91
|
+
tdb = createTenantDb(testDb.db, admin.tenantId);
|
|
92
|
+
markerDir = mkdtempSync(join(tmpdir(), "pending-rebuilds-"));
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
afterAll(async () => {
|
|
96
|
+
rmSync(markerDir, { recursive: true, force: true });
|
|
97
|
+
await testDb.cleanup();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
beforeEach(async () => {
|
|
101
|
+
failApply = false;
|
|
102
|
+
await asRawClient(testDb.db).unsafe(
|
|
103
|
+
`TRUNCATE kumiko_events, read_pending_items, read_pending_counts, kumiko_projections RESTART IDENTITY CASCADE`,
|
|
104
|
+
);
|
|
105
|
+
await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS kumiko_pending_rebuilds`);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const GROUP = "00000000-0000-4000-8000-000000000001";
|
|
109
|
+
|
|
110
|
+
async function getCount(): Promise<number | undefined> {
|
|
111
|
+
const [row] = await selectMany(testDb.db, countsTable, { groupId: GROUP });
|
|
112
|
+
return row?.itemCount;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
describe("pending-rebuilds queue", () => {
|
|
116
|
+
test("failed rebuild stays queued — a later run without new migrations catches up", async () => {
|
|
117
|
+
await executor.create({ groupId: GROUP, name: "a" }, admin, tdb);
|
|
118
|
+
await executor.create({ groupId: GROUP, name: "b" }, admin, tdb);
|
|
119
|
+
|
|
120
|
+
writeRebuildMarker(markerDir, "0001_add_counts.sql", ["read_pending_counts"]);
|
|
121
|
+
const queued = await queueRebuildsFromMarkers(testDb.db, {
|
|
122
|
+
migrationsDir: markerDir,
|
|
123
|
+
appliedIds: ["0001_add_counts"],
|
|
124
|
+
});
|
|
125
|
+
expect(queued).toEqual(["read_pending_counts"]);
|
|
126
|
+
|
|
127
|
+
failApply = true;
|
|
128
|
+
const firstRun = await runPendingRebuilds(testDb.db, registry);
|
|
129
|
+
expect(firstRun.failed).toEqual([
|
|
130
|
+
{
|
|
131
|
+
projection: "pendingtest:projection:pending-counts",
|
|
132
|
+
error: expect.stringContaining("transient rebuild failure"),
|
|
133
|
+
},
|
|
134
|
+
]);
|
|
135
|
+
// Der Kern von studio#36: die Tabelle bleibt pending.
|
|
136
|
+
expect(await listPendingRebuilds(testDb.db)).toEqual(["read_pending_counts"]);
|
|
137
|
+
|
|
138
|
+
// Re-Run OHNE neue applied-Migrations (appliedIds leer = "alles war
|
|
139
|
+
// schon applied") — die Queue alleine treibt den Nachhol-Rebuild.
|
|
140
|
+
failApply = false;
|
|
141
|
+
const secondRun = await runPendingRebuilds(testDb.db, registry);
|
|
142
|
+
expect(secondRun.failed).toEqual([]);
|
|
143
|
+
expect(secondRun.rebuilt).toEqual([
|
|
144
|
+
{ projection: "pendingtest:projection:pending-counts", eventsProcessed: 2 },
|
|
145
|
+
]);
|
|
146
|
+
expect(await listPendingRebuilds(testDb.db)).toEqual([]);
|
|
147
|
+
expect(await getCount()).toBe(2);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("tables without a registered projection are drained, not stuck forever", async () => {
|
|
151
|
+
writeRebuildMarker(markerDir, "0002_unmapped.sql", ["read_some_plain_table"]);
|
|
152
|
+
await queueRebuildsFromMarkers(testDb.db, {
|
|
153
|
+
migrationsDir: markerDir,
|
|
154
|
+
appliedIds: ["0002_unmapped"],
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const run = await runPendingRebuilds(testDb.db, registry);
|
|
158
|
+
expect(run.unmapped).toEqual(["read_some_plain_table"]);
|
|
159
|
+
expect(run.failed).toEqual([]);
|
|
160
|
+
expect(await listPendingRebuilds(testDb.db)).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("no markers, no queue → noop", async () => {
|
|
164
|
+
const run = await runPendingRebuilds(testDb.db, registry);
|
|
165
|
+
expect(run).toEqual({ rebuilt: [], failed: [], unmapped: [] });
|
|
166
|
+
});
|
|
167
|
+
});
|
package/src/migrations/index.ts
CHANGED
|
@@ -7,5 +7,14 @@ export {
|
|
|
7
7
|
type KumikoDriftReport,
|
|
8
8
|
SchemaDriftError,
|
|
9
9
|
} from "./kumiko-drift";
|
|
10
|
+
// Persistente Pending-Rebuild-Queue (survives Rebuild-Failures + Crashes).
|
|
11
|
+
export {
|
|
12
|
+
createPendingRebuildsTable,
|
|
13
|
+
listPendingRebuilds,
|
|
14
|
+
type PendingRebuildRun,
|
|
15
|
+
pendingRebuildsTable,
|
|
16
|
+
queueRebuildsFromMarkers,
|
|
17
|
+
runPendingRebuilds,
|
|
18
|
+
} from "./pending-rebuilds";
|
|
10
19
|
// tableName → projection-name, für den app-seitigen Projection-Rebuild.
|
|
11
20
|
export { buildProjectionTableIndex } from "./projection-table-index";
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Persistente Pending-Rebuild-Queue für den `kumiko schema apply`-Pfad.
|
|
2
|
+
//
|
|
3
|
+
// Problem (studio#36/#46): Apps lasen die rebuild-Marker nur für
|
|
4
|
+
// `result.applied` der AKTUELLEN apply-Runde. Schlug der Projection-Rebuild
|
|
5
|
+
// fehl (oder crashte der Prozess dazwischen), war der Marker-Bezug beim
|
|
6
|
+
// nächsten apply weg — `applied` ist dann leer — und die Projektion blieb
|
|
7
|
+
// stillschweigend unfertig, ohne Self-Service-Retry-Pfad.
|
|
8
|
+
//
|
|
9
|
+
// Lösung: die betroffenen Tabellen werden VOR dem Rebuild in
|
|
10
|
+
// `kumiko_pending_rebuilds` persistiert und erst nach erfolgreichem Rebuild
|
|
11
|
+
// der zugehörigen Projektion gelöscht. Ein erneuter apply (auch ohne neue
|
|
12
|
+
// Migrations) holt offene Rebuilds über `runPendingRebuilds` nach.
|
|
13
|
+
|
|
14
|
+
import type { DbConnection } from "../db/connection";
|
|
15
|
+
import { instant, table as pgTable, sql, text } from "../db/dialect";
|
|
16
|
+
import { deleteMany, selectMany, upsertOnConflict } from "../db/query";
|
|
17
|
+
import { readRebuildMarker } from "../db/rebuild-marker";
|
|
18
|
+
import { tableExists } from "../db/schema-inspection";
|
|
19
|
+
import type { Registry } from "../engine/types";
|
|
20
|
+
import { rebuildProjection } from "../pipeline";
|
|
21
|
+
import { unsafePushTables } from "../stack";
|
|
22
|
+
import { buildProjectionTableIndex } from "./projection-table-index";
|
|
23
|
+
|
|
24
|
+
export const pendingRebuildsTable = pgTable("kumiko_pending_rebuilds", {
|
|
25
|
+
tableName: text("table_name").primaryKey(),
|
|
26
|
+
migrationId: text("migration_id").notNull(),
|
|
27
|
+
queuedAt: instant("queued_at", { precision: 3 }).notNull().default(sql`now()`),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export async function createPendingRebuildsTable(db: DbConnection): Promise<void> {
|
|
31
|
+
// skip: table already exists — bootstrap läuft aus mehreren Pfaden
|
|
32
|
+
if (await tableExists(db, "public.kumiko_pending_rebuilds")) return;
|
|
33
|
+
await unsafePushTables(db, { kumikoPendingRebuilds: pendingRebuildsTable });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Liest die rebuild-Marker der frisch applizierten Migrations und queued
|
|
37
|
+
* die betroffenen Tabellen. Upsert: ein bereits pending-er Tisch behält
|
|
38
|
+
* seinen Queue-Slot (queued_at bleibt, damit die Reihenfolge stabil ist) —
|
|
39
|
+
* migration_id zeigt auf die zuletzt flaggende Migration (Debug-Bezug). */
|
|
40
|
+
export async function queueRebuildsFromMarkers(
|
|
41
|
+
db: DbConnection,
|
|
42
|
+
options: { readonly migrationsDir: string; readonly appliedIds: readonly string[] },
|
|
43
|
+
): Promise<readonly string[]> {
|
|
44
|
+
await createPendingRebuildsTable(db);
|
|
45
|
+
const queued: string[] = [];
|
|
46
|
+
for (const migrationId of options.appliedIds) {
|
|
47
|
+
for (const tableName of readRebuildMarker(options.migrationsDir, migrationId)) {
|
|
48
|
+
await upsertOnConflict(
|
|
49
|
+
db,
|
|
50
|
+
pendingRebuildsTable,
|
|
51
|
+
{ tableName, migrationId },
|
|
52
|
+
{ conflictKeys: ["tableName"], update: { migrationId } },
|
|
53
|
+
);
|
|
54
|
+
queued.push(tableName);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return queued;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type PendingRebuildRow = { readonly tableName: string };
|
|
61
|
+
|
|
62
|
+
export async function listPendingRebuilds(db: DbConnection): Promise<readonly string[]> {
|
|
63
|
+
await createPendingRebuildsTable(db);
|
|
64
|
+
const rows = await selectMany<PendingRebuildRow>(db, pendingRebuildsTable, undefined, {
|
|
65
|
+
orderBy: [{ col: "queuedAt" }, { col: "tableName" }],
|
|
66
|
+
});
|
|
67
|
+
return rows.map((row) => row.tableName);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function clearPendingRebuilds(db: DbConnection, tables: readonly string[]): Promise<void> {
|
|
71
|
+
for (const tableName of tables) {
|
|
72
|
+
await deleteMany(db, pendingRebuildsTable, { tableName });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type PendingRebuildRun = {
|
|
77
|
+
/** Erfolgreich rebuildte Projektionen (Queue-Einträge geräumt). */
|
|
78
|
+
readonly rebuilt: readonly { readonly projection: string; readonly eventsProcessed: number }[];
|
|
79
|
+
/** Fehlgeschlagene Projektionen — ihre Tabellen BLEIBEN pending. */
|
|
80
|
+
readonly failed: readonly { readonly projection: string; readonly error: string }[];
|
|
81
|
+
/** Pending-Tabellen ohne registrierte Projektion — geräumt (kein Rebuild-Sinn). */
|
|
82
|
+
readonly unmapped: readonly string[];
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Arbeitet die persistierte Queue ab: mappt Tabellen auf Projektionen,
|
|
86
|
+
* rebuildet jede betroffene Projektion und räumt ihre Tabellen erst nach
|
|
87
|
+
* ERFOLG aus der Queue. Fehlgeschlagene bleiben pending — der nächste
|
|
88
|
+
* apply (oder ein direkter Re-Call) holt sie nach. */
|
|
89
|
+
export async function runPendingRebuilds(
|
|
90
|
+
db: DbConnection,
|
|
91
|
+
registry: Registry,
|
|
92
|
+
): Promise<PendingRebuildRun> {
|
|
93
|
+
const pending = await listPendingRebuilds(db);
|
|
94
|
+
if (pending.length === 0) return { rebuilt: [], failed: [], unmapped: [] };
|
|
95
|
+
|
|
96
|
+
const tableToProjection = buildProjectionTableIndex(registry);
|
|
97
|
+
const byProjection = new Map<string, string[]>();
|
|
98
|
+
const unmapped: string[] = [];
|
|
99
|
+
for (const tableName of pending) {
|
|
100
|
+
const projection = tableToProjection.get(tableName);
|
|
101
|
+
if (projection === undefined) {
|
|
102
|
+
unmapped.push(tableName);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
byProjection.set(projection, [...(byProjection.get(projection) ?? []), tableName]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Tabellen ohne Projektion: gleiche Semantik wie der bisherige Skip beim
|
|
109
|
+
// Apply — aber explizit geräumt, damit die Queue nicht ewig wächst.
|
|
110
|
+
if (unmapped.length > 0) {
|
|
111
|
+
await clearPendingRebuilds(db, unmapped);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const rebuilt: { projection: string; eventsProcessed: number }[] = [];
|
|
115
|
+
const failed: { projection: string; error: string }[] = [];
|
|
116
|
+
for (const [projection, tables] of byProjection) {
|
|
117
|
+
try {
|
|
118
|
+
const result = await rebuildProjection(projection, { db, registry });
|
|
119
|
+
await clearPendingRebuilds(db, tables);
|
|
120
|
+
rebuilt.push({ projection, eventsProcessed: result.eventsProcessed });
|
|
121
|
+
} catch (e) {
|
|
122
|
+
failed.push({ projection, error: e instanceof Error ? e.message : String(e) });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { rebuilt, failed, unmapped };
|
|
126
|
+
}
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -179,11 +179,16 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
179
179
|
// emit only one CREATE TABLE per physical table.
|
|
180
180
|
const { enumerateFeatureTableSources } = await import("../db/feature-table-sources");
|
|
181
181
|
const projectionTables: Record<string, unknown> = {};
|
|
182
|
-
|
|
182
|
+
// Dedup by NAME, matching collectTableMetas — by-reference alone let two
|
|
183
|
+
// distinct table objects with the same name slip through as a double
|
|
184
|
+
// CREATE TABLE while schema-generate emitted only one meta (silent
|
|
185
|
+
// test-vs-schema divergence).
|
|
186
|
+
const seenTableNames = new Set<string>();
|
|
183
187
|
for (const feature of options.features) {
|
|
184
188
|
for (const { table, origin } of enumerateFeatureTableSources(feature)) {
|
|
185
|
-
|
|
186
|
-
|
|
189
|
+
const name = extractTableInfo(table).name;
|
|
190
|
+
if (seenTableNames.has(name)) continue;
|
|
191
|
+
seenTableNames.add(name);
|
|
187
192
|
projectionTables[origin] = table;
|
|
188
193
|
}
|
|
189
194
|
}
|