@cosmicdrift/kumiko-framework 0.39.0 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.39.0",
3
+ "version": "0.40.1",
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>",
@@ -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
+ });
@@ -1890,6 +1890,56 @@ describe("boot-validator", () => {
1890
1890
  });
1891
1891
  });
1892
1892
 
1893
+ // --- rowAction payload-Extractor Feld-Referenzen (Tier 2.7e-3) ---
1894
+ // Row-Meta (id, version) ist auf jeder Entity-Row vorhanden ohne ein
1895
+ // Entity-Field zu sein — pick ["id", "version"] ist das Standard-Payload
1896
+ // für optimistic-lock-Lifecycle-Writes und darf den Boot nicht killen
1897
+ // (Prod-Incident publicstatus 2026-06-11: 0.40-Validator lehnte
1898
+ // maintenance-start/cancel/complete ab, CrashLoopBackOff).
1899
+ describe("entityList rowAction payload pick (Tier 2.7e-3)", () => {
1900
+ function makeFeature(pick: readonly string[]) {
1901
+ return defineFeature("shop", (r) => {
1902
+ r.entity("product", createEntity({ fields: { name: createTextField() } }));
1903
+ r.screen({
1904
+ id: "product-list",
1905
+ type: "entityList",
1906
+ entity: "product",
1907
+ columns: ["name"],
1908
+ rowActions: [
1909
+ {
1910
+ id: "archive",
1911
+ label: "actions.archive",
1912
+ handler: "shop:write:archive",
1913
+ payload: { pick: [...pick] },
1914
+ },
1915
+ ],
1916
+ });
1917
+ r.writeHandler(
1918
+ "archive",
1919
+ z.object({}),
1920
+ async () => ({ isSuccess: true as const, data: null }),
1921
+ {
1922
+ access: { roles: ["Admin"] },
1923
+ },
1924
+ );
1925
+ });
1926
+ }
1927
+
1928
+ test("pick mit Row-Meta id + version → kein Throw (optimistic-lock-Standard)", () => {
1929
+ expect(() => validateBoot([makeFeature(["id", "version"])])).not.toThrow();
1930
+ });
1931
+
1932
+ test("pick mit Entity-Field → kein Throw", () => {
1933
+ expect(() => validateBoot([makeFeature(["id", "name"])])).not.toThrow();
1934
+ });
1935
+
1936
+ test("pick mit unknown Field → Throw mit klarer Message", () => {
1937
+ expect(() => validateBoot([makeFeature(["id", "ghost"])])).toThrow(
1938
+ /rowAction "archive" payload references unknown field "ghost"/,
1939
+ );
1940
+ });
1941
+ });
1942
+
1893
1943
  // --- toolbarAction navigate + writeHandler Validierung (Tier 2.7e-2) ---
1894
1944
  describe("entityList toolbarAction navigate (Tier 2.7e-2)", () => {
1895
1945
  function makeFeature(targetScreen: string, withTarget: boolean) {
@@ -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,64 @@ 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
+ // Row-Meta ist immer da, ohne Entity-Field zu sein: id (Aggregat-Id)
52
+ // und version (optimistic lock — Standard-pick für Lifecycle-Writes).
53
+ if (source === "id" || source === "version") continue;
54
+ if (!fieldNames.has(source)) {
55
+ throw new Error(
56
+ `[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
57
+ `${label} references unknown field "${source}". Known fields: ${known()}.`,
58
+ );
59
+ }
60
+ }
61
+ };
62
+ checkExtractor("payload", payload);
63
+ checkExtractor("params", params);
64
+ if (visible !== undefined && typeof visible !== "boolean" && !fieldNames.has(visible.field)) {
65
+ throw new Error(
66
+ `[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
67
+ `visible.field references unknown field "${visible.field}". Known fields: ${known()}.`,
68
+ );
69
+ }
70
+ if (entityId !== undefined && entityId !== "id" && !fieldNames.has(entityId)) {
71
+ throw new Error(
72
+ `[Feature ${featureName}] Screen "${screenId}" ${actionKind} "${actionId}" ` +
73
+ `entityId references unknown field "${entityId}". Known fields: ${known()}.`,
74
+ );
75
+ }
76
+ }
77
+
19
78
  export function validateScreens(
20
79
  feature: FeatureDefinition,
21
80
  featureMap: ReadonlyMap<string, FeatureDefinition>,
@@ -370,6 +429,14 @@ export function validateScreens(
370
429
  );
371
430
  }
372
431
  }
432
+ validateActionFieldRefs(
433
+ feature.name,
434
+ screenId,
435
+ "rowAction",
436
+ action.id,
437
+ action,
438
+ fieldNames,
439
+ );
373
440
  }
374
441
  }
375
442
  // Tier 2.7e-2: toolbarActions — analog zu rowActions, aber bisher
@@ -394,6 +461,14 @@ export function validateScreens(
394
461
  );
395
462
  }
396
463
  }
464
+ validateActionFieldRefs(
465
+ feature.name,
466
+ screenId,
467
+ "toolbarAction",
468
+ action.id,
469
+ action,
470
+ fieldNames,
471
+ );
397
472
  }
398
473
  }
399
474
  } 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
- admin: ["Admin", "SystemAdmin"] as readonly string[], // @cast-boundary schema-walk
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
+ }
@@ -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,
@@ -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
+ });
@@ -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
+ }
@@ -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
- const seenTables = new Set<unknown>();
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
- if (seenTables.has(table)) continue;
186
- seenTables.add(table);
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
  }
@@ -68,6 +68,7 @@ export type {
68
68
  ToolbarAction,
69
69
  } from "../engine/types/screen";
70
70
  export {
71
+ evalFieldCondition,
71
72
  isExtensionEditSection,
72
73
  isFormatSpec,
73
74
  normalizeEditField,