@cosmicdrift/kumiko-framework 0.234.0 → 0.235.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.
Files changed (31) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/unprocessable-error-details-reason-codemod.test.ts +205 -0
  3. package/src/api/server.ts +3 -0
  4. package/src/changes.json +8 -0
  5. package/src/db/__tests__/list-row-meta-drift.test.ts +15 -0
  6. package/src/db/queries/event-consumer.ts +20 -5
  7. package/src/engine/__tests__/boot-validator.test.ts +114 -0
  8. package/src/engine/__tests__/build-app-schema.test.ts +48 -1
  9. package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +140 -0
  10. package/src/engine/boot-validator/entity-list-screens.ts +13 -1
  11. package/src/engine/boot-validator/i18n-keys.ts +13 -5
  12. package/src/engine/boot-validator/screens.ts +25 -1
  13. package/src/engine/build-app-schema.ts +14 -0
  14. package/src/engine/build-config-feature-schema.ts +216 -44
  15. package/src/engine/index.ts +2 -0
  16. package/src/engine/registry-validate.ts +16 -0
  17. package/src/engine/registry.ts +2 -0
  18. package/src/engine/types/index.ts +2 -0
  19. package/src/i18n/index.ts +1 -0
  20. package/src/i18n/required-surface-keys.ts +11 -0
  21. package/src/i18n/settings-hub-keys.ts +28 -0
  22. package/src/jobs/__tests__/jobs.integration.test.ts +127 -0
  23. package/src/jobs/job-runner.ts +37 -2
  24. package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +176 -0
  25. package/src/pipeline/event-dispatcher-delivery.ts +1 -1
  26. package/src/pipeline/event-dispatcher.ts +6 -0
  27. package/src/pipeline/multi-stream-apply-context.ts +4 -4
  28. package/src/scripts/codemod/unprocessable-error-details-reason.ts +273 -0
  29. package/src/testing/e2e-generator.ts +3 -0
  30. package/src/ui-types/index.ts +4 -0
  31. package/src/ui-types/list-row-meta.ts +25 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.234.0",
3
+ "version": "0.235.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>",
@@ -198,7 +198,7 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-types": "0.234.0",
201
+ "@cosmicdrift/kumiko-types": "0.235.1",
202
202
  "bullmq": "^5.76.7",
203
203
  "bun-types": "^1.3.13",
204
204
  "hono": "^4.13.1",
@@ -214,7 +214,7 @@
214
214
  "zod": "^4.4.3"
215
215
  },
216
216
  "devDependencies": {
217
- "@cosmicdrift/kumiko-dispatcher-live": "0.234.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.235.1",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -0,0 +1,205 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ const SCRIPT_PATH = join(
7
+ import.meta.dir,
8
+ "../scripts/codemod/unprocessable-error-details-reason.ts",
9
+ );
10
+
11
+ function makeFixtureDir(files: Record<string, string>): {
12
+ readonly dir: string;
13
+ readonly cleanup: () => void;
14
+ } {
15
+ const dir = mkdtempSync(join(tmpdir(), "unprocessable-error-codemod-"));
16
+ for (const [relPath, content] of Object.entries(files)) {
17
+ const full = join(dir, relPath);
18
+ mkdirSync(join(full, ".."), { recursive: true });
19
+ writeFileSync(full, content, "utf-8");
20
+ }
21
+ return {
22
+ dir,
23
+ cleanup: () => {
24
+ try {
25
+ rmSync(dir, { recursive: true, force: true });
26
+ } catch {
27
+ // ignore — best-effort
28
+ }
29
+ },
30
+ };
31
+ }
32
+
33
+ async function runCodemod(targetDir: string, extraArgs: readonly string[] = []): Promise<string> {
34
+ const proc = Bun.spawn({
35
+ cmd: ["bun", SCRIPT_PATH, targetDir, ...extraArgs],
36
+ stdout: "pipe",
37
+ stderr: "pipe",
38
+ });
39
+ const [stdout, stderr] = await Promise.all([
40
+ new Response(proc.stdout).text(),
41
+ new Response(proc.stderr).text(),
42
+ ]);
43
+ await proc.exited;
44
+ return `${stdout}${stderr}`;
45
+ }
46
+
47
+ function read(dir: string, relPath: string): string {
48
+ return readFileSync(join(dir, relPath), "utf-8");
49
+ }
50
+
51
+ describe("unprocessable-error-details-reason codemod", () => {
52
+ let cleanup: (() => void) | undefined;
53
+
54
+ afterEach(() => {
55
+ cleanup?.();
56
+ cleanup = undefined;
57
+ });
58
+
59
+ test("removes reason from the middle of a details object literal", async () => {
60
+ const fixture = makeFixtureDir({
61
+ "guard.ts": `import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
62
+
63
+ export function guard(spec: { field: string; code: string }, current: number, limit: number) {
64
+ throw new UnprocessableError(spec.code, {
65
+ i18nKey: "errors.cap",
66
+ details: { field: spec.field, reason: spec.code, current, limit },
67
+ });
68
+ }
69
+ `,
70
+ });
71
+ cleanup = fixture.cleanup;
72
+
73
+ await runCodemod(fixture.dir);
74
+
75
+ const out = read(fixture.dir, "guard.ts");
76
+ expect(out).toContain("details: { field: spec.field, current, limit }");
77
+ expect(out).not.toContain("reason: spec.code");
78
+ });
79
+
80
+ test("removes reason from the start of a details object literal", async () => {
81
+ const fixture = makeFixtureDir({
82
+ "guard.ts": `import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
83
+
84
+ export function guard(error: { reason: string; actual: number; max: number }) {
85
+ throw new UnprocessableError("cap", {
86
+ details: { reason: error.reason, actual: error.actual, max: error.max },
87
+ });
88
+ }
89
+ `,
90
+ });
91
+ cleanup = fixture.cleanup;
92
+
93
+ await runCodemod(fixture.dir);
94
+
95
+ const out = read(fixture.dir, "guard.ts");
96
+ expect(out).toContain("details: { actual: error.actual, max: error.max }");
97
+ expect(out).not.toContain("reason: error.reason");
98
+ });
99
+
100
+ test("removes reason when it is the only property, leaving a valid empty object literal", async () => {
101
+ const fixture = makeFixtureDir({
102
+ "guard.ts": `import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
103
+
104
+ export function guard() {
105
+ throw new UnprocessableError("apex", {
106
+ details: { reason: "no_tenant_context_on_apex" },
107
+ });
108
+ }
109
+ `,
110
+ });
111
+ cleanup = fixture.cleanup;
112
+
113
+ await runCodemod(fixture.dir);
114
+
115
+ const out = read(fixture.dir, "guard.ts");
116
+ expect(out).not.toContain("reason:");
117
+ expect(out).toMatch(/details:\s*\{\s*\}/);
118
+
119
+ // The result must still be syntactically valid TypeScript — TS2307
120
+ // (module not found) is expected and irrelevant here: there is no real
121
+ // node_modules in an in-memory project. Anything else is a real error.
122
+ const { Project } = await import("ts-morph");
123
+ const project = new Project({ skipAddingFilesFromTsConfig: true, useInMemoryFileSystem: true });
124
+ const sourceFile = project.createSourceFile("guard.ts", out);
125
+ const diagnostics = sourceFile.getPreEmitDiagnostics().filter((d) => d.getCode() !== 2307);
126
+ expect(diagnostics.length).toBe(0);
127
+ });
128
+
129
+ test("follows a renamed import (UnprocessableError as X)", async () => {
130
+ const fixture = makeFixtureDir({
131
+ "guard.ts": `import { UnprocessableError as MyError } from "@cosmicdrift/kumiko-framework/errors";
132
+
133
+ export function guard() {
134
+ throw new MyError("x", { details: { reason: "y", other: 1 } });
135
+ }
136
+ `,
137
+ });
138
+ cleanup = fixture.cleanup;
139
+
140
+ const output = await runCodemod(fixture.dir);
141
+
142
+ const out = read(fixture.dir, "guard.ts");
143
+ expect(out).toContain("details: { other: 1 }");
144
+ expect(out).not.toContain("reason:");
145
+ expect(output).toContain("removed 1 redundant");
146
+ });
147
+
148
+ test("does not touch a locally defined class with the same name", async () => {
149
+ const fixture = makeFixtureDir({
150
+ "guard.ts": `class UnprocessableError extends Error {}
151
+
152
+ export function guard() {
153
+ throw new UnprocessableError("local", { details: { reason: "y" } } as never);
154
+ }
155
+ `,
156
+ });
157
+ cleanup = fixture.cleanup;
158
+
159
+ const before = read(fixture.dir, "guard.ts");
160
+ const output = await runCodemod(fixture.dir);
161
+ const after = read(fixture.dir, "guard.ts");
162
+
163
+ expect(after).toBe(before);
164
+ expect(output).toContain("locally defined class");
165
+ });
166
+
167
+ test("skips a details object literal containing a spread", async () => {
168
+ const fixture = makeFixtureDir({
169
+ "guard.ts": `import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
170
+
171
+ export function guard(rest: Record<string, unknown>) {
172
+ throw new UnprocessableError("x", { details: { ...rest, reason: "y" } });
173
+ }
174
+ `,
175
+ });
176
+ cleanup = fixture.cleanup;
177
+
178
+ const before = read(fixture.dir, "guard.ts");
179
+ const output = await runCodemod(fixture.dir);
180
+ const after = read(fixture.dir, "guard.ts");
181
+
182
+ expect(after).toBe(before);
183
+ expect(output).toContain("spread");
184
+ });
185
+
186
+ test("--dry-run writes nothing", async () => {
187
+ const fixture = makeFixtureDir({
188
+ "guard.ts": `import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
189
+
190
+ export function guard() {
191
+ throw new UnprocessableError("x", { details: { reason: "y", other: 1 } });
192
+ }
193
+ `,
194
+ });
195
+ cleanup = fixture.cleanup;
196
+
197
+ const before = read(fixture.dir, "guard.ts");
198
+ const output = await runCodemod(fixture.dir, ["--dry-run"]);
199
+ const after = read(fixture.dir, "guard.ts");
200
+
201
+ expect(after).toBe(before);
202
+ expect(output).toContain("dry-run");
203
+ expect(output).toContain("removed 1 redundant");
204
+ });
205
+ });
package/src/api/server.ts CHANGED
@@ -506,6 +506,9 @@ export function buildServer(options: ServerOptions): KumikoServer {
506
506
  // Default (shared) is applied inside event-dispatcher, so omitting when
507
507
  // the MSP didn't declare one keeps the existing behaviour.
508
508
  ...(msp.delivery && { delivery: msp.delivery }),
509
+ // Carry the MSP's declared cursor-seed through to the consumer. Default
510
+ // (beginning) is applied inside insertConsumerIfAbsent.
511
+ ...(msp.startFrom && { startFrom: msp.startFrom }),
509
512
  handler: async (event, ctx) => {
510
513
  const applyFn = msp.apply[event.type];
511
514
  // skip: this MSP doesn't care about this event type — fast path,
package/src/changes.json CHANGED
@@ -1,4 +1,12 @@
1
1
  [
2
+ {
3
+ "version": "0.235.0",
4
+ "type": "breaking",
5
+ "title": "UnprocessableOpts.details can no longer carry its own `reason` key (fw#2460).",
6
+ "detail": "UnprocessableError builds its `details` as `{ ...opts?.details, reason }`, so `reason` was always owned by the ctor's first positional argument — but nothing stopped a caller from also putting `reason` inside `opts.details`, where it was silently overwritten. `UnprocessableOpts.details` is now typed `Readonly<Record<string, unknown>> & { readonly reason?: never }`, so that redundant key is now a compile-time error (TS2322) instead of a silent no-op. This was shipped in #2460 without a changeset or changelog entry, which is what this entry retroactively fixes.",
7
+ "migration": "Remove `reason` from any `details: { ... }` object literal passed to `new UnprocessableError(reason, { details: { ...} })` — the value is unchanged, it now flows only through the first positional argument. Run the codemod, or delete the property by hand where it isn't statically removable (the codemod reports those sites with file:line and a reason).",
8
+ "codemod": "scripts/codemod/unprocessable-error-details-reason.ts"
9
+ },
2
10
  {
3
11
  "version": "0.209.1",
4
12
  "type": "breaking",
@@ -0,0 +1,15 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta";
3
+ import { rowMetaFieldNames } from "../table-builder";
4
+
5
+ // Drift-Guard: LIST_ROW_META_COLUMNS (client-safe subpath, headless's
6
+ // computeListViewModel) duplicates the base row-meta column names instead
7
+ // of importing db/table-builder directly (that would pull drizzle into the
8
+ // client bundle — see ui-types/list-row-meta.ts). This test is the only
9
+ // place both sides meet, so a base-columns change here fails loud instead
10
+ // of silently drifting the two representations apart.
11
+ describe("LIST_ROW_META_COLUMNS ↔ rowMetaFieldNames(false)", () => {
12
+ test("key set matches the non-softDelete base row-meta columns exactly", () => {
13
+ expect(new Set(Object.keys(LIST_ROW_META_COLUMNS))).toEqual(new Set(rowMetaFieldNames(false)));
14
+ });
15
+ });
@@ -6,16 +6,31 @@ export async function lockEventConsumersShareMode(db: AnyDb): Promise<void> {
6
6
  await asRawClient(db).unsafe(`LOCK TABLE "kumiko_event_consumers" IN SHARE MODE`);
7
7
  }
8
8
 
9
+ // startFrom "now" seeds the FIRST-registration cursor at the current
10
+ // MAX(events.id) instead of the column default 0 — mounting a consumer into
11
+ // an existing app then skips the historical log instead of replaying it.
12
+ // ON CONFLICT DO NOTHING keeps both branches safe for an existing row: the
13
+ // subquery only ever affects the row this statement inserts.
9
14
  export async function insertConsumerIfAbsent(
10
15
  db: AnyDb,
11
16
  name: string,
12
17
  instanceId: string,
18
+ startFrom: "beginning" | "now" = "beginning",
13
19
  ): Promise<void> {
14
- await asRawClient(db).unsafe(
15
- `INSERT INTO "kumiko_event_consumers" ("name", "instance_id", "status") VALUES ($1, $2, 'idle')
16
- ON CONFLICT ("name", "instance_id") DO NOTHING`,
17
- [name, instanceId],
18
- );
20
+ if (startFrom === "now") {
21
+ await asRawClient(db).unsafe(
22
+ `INSERT INTO "kumiko_event_consumers" ("name", "instance_id", "status", "last_processed_event_id")
23
+ VALUES ($1, $2, 'idle', COALESCE((SELECT MAX("id") FROM "kumiko_events"), 0))
24
+ ON CONFLICT ("name", "instance_id") DO NOTHING`,
25
+ [name, instanceId],
26
+ );
27
+ } else {
28
+ await asRawClient(db).unsafe(
29
+ `INSERT INTO "kumiko_event_consumers" ("name", "instance_id", "status") VALUES ($1, $2, 'idle')
30
+ ON CONFLICT ("name", "instance_id") DO NOTHING`,
31
+ [name, instanceId],
32
+ );
33
+ }
19
34
  }
20
35
 
21
36
  export async function selectConsumerForUpdateSkipLocked(
@@ -2,8 +2,11 @@ import { describe, expect, spyOn, test } from "bun:test";
2
2
  import { z } from "zod";
3
3
  import type { SchemaTable } from "../../db/dialect";
4
4
  import { table, text } from "../../db/dialect";
5
+ import { rowMetaFieldNames } from "../../db/table-builder";
5
6
  import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
7
+ import { LIST_ROW_META_COLUMNS } from "../../ui-types/list-row-meta";
6
8
  import { validateBoot as validateBootRaw } from "../boot-validator";
9
+ import { validateEntityListScreens } from "../boot-validator/entity-list-screens";
7
10
  import { createSystemConfig, createTenantConfig } from "../config-helpers";
8
11
  import {
9
12
  createDerivedField,
@@ -1953,6 +1956,117 @@ describe("boot-validator", () => {
1953
1956
  });
1954
1957
  });
1955
1958
 
1959
+ // --- entityList: row-meta columns (id/tenantId/version/...) ---
1960
+ // Row-meta columns are real base-table columns, never declared entity
1961
+ // fields — a SystemAdmin cross-tenant list exposes tenantId this way.
1962
+ //
1963
+ // Two separate boot-validators check entityList columns (validateScreens
1964
+ // here, validateEntityListScreens in entity-list-screens.ts) — a #2601
1965
+ // review found they'd drifted to accept different column sets, which
1966
+ // would have let a softDelete column pass one gate and throw only in
1967
+ // computeListViewModel at render time. Table-driven over
1968
+ // LIST_ROW_META_COLUMNS (not one-off cases) so a future column added
1969
+ // there is proven against BOTH validators automatically. Companion
1970
+ // assertions: list.test.ts (headless renderer draws the same set) and
1971
+ // list-row-meta-drift.test.ts (LIST_ROW_META_COLUMNS key-set drift guard).
1972
+ describe("entityList row-meta column — accepted by both boot validators", () => {
1973
+ function noteFeatureWithColumn(
1974
+ columnName: string,
1975
+ options?: { readonly softDelete?: boolean },
1976
+ ) {
1977
+ return defineFeature("notes", (r) => {
1978
+ r.entity(
1979
+ "note",
1980
+ createEntity({ fields: { title: createTextField() }, softDelete: options?.softDelete }),
1981
+ );
1982
+ r.screen({
1983
+ id: "note-list",
1984
+ type: "entityList",
1985
+ entity: "note",
1986
+ columns: ["title", columnName],
1987
+ });
1988
+ });
1989
+ }
1990
+
1991
+ for (const [columnName, columnType] of Object.entries(LIST_ROW_META_COLUMNS)) {
1992
+ test(`"${columnName}" (${columnType}) on a non-softDelete entity → kein Throw`, () => {
1993
+ const feature = noteFeatureWithColumn(columnName);
1994
+ expect(() => validateBoot([feature])).not.toThrow();
1995
+ const fixed = withBootValidatorFixture([feature]);
1996
+ expect(() => validateEntityListScreens(fixed)).not.toThrow();
1997
+ });
1998
+ }
1999
+
2000
+ // rowMetaFieldNames(true) also carries the softDelete-only columns
2001
+ // (isDeleted/deletedAt/deletedById) — real base-table columns, but
2002
+ // deliberately absent from LIST_ROW_META_COLUMNS because
2003
+ // computeListViewModel can't render them. Both validators must reject
2004
+ // them, or a softDelete column would pass boot and only throw at
2005
+ // render-time.
2006
+ const softDeleteOnlyColumns = [...rowMetaFieldNames(true)].filter(
2007
+ (name) => LIST_ROW_META_COLUMNS[name] === undefined,
2008
+ );
2009
+ expect(softDeleteOnlyColumns.sort()).toEqual(["deletedAt", "deletedById", "isDeleted"]);
2010
+
2011
+ for (const columnName of softDeleteOnlyColumns) {
2012
+ test(`"${columnName}" (softDelete-only, not renderable) → Throw on both validators`, () => {
2013
+ const feature = noteFeatureWithColumn(columnName, { softDelete: true });
2014
+ expect(() => validateBoot([feature])).toThrow(
2015
+ new RegExp(`references field "${columnName}" which does not exist`),
2016
+ );
2017
+ const fixed = withBootValidatorFixture([feature]);
2018
+ expect(() => validateEntityListScreens(fixed)).toThrow(
2019
+ new RegExp(`unknown column field "${columnName}"`),
2020
+ );
2021
+ });
2022
+ }
2023
+ });
2024
+
2025
+ // --- entityList: typo columns must still fail (row-meta acceptance is
2026
+ // NOT a general gate-loosening) ---
2027
+ //
2028
+ // validateBoot only ever surfaces validateScreens' message here — it runs
2029
+ // before validateEntityListScreens in the per-feature loop (see
2030
+ // boot-validator/index.ts) and throws first. validateEntityListScreens'
2031
+ // own "unknown column field" message is only reachable by calling it
2032
+ // directly, which these tests also do.
2033
+ describe("entityList typo column → Throw on both validators (not silently accepted)", () => {
2034
+ const typoColumns = ["tenantID", "tenatId", "totallyMadeUp"];
2035
+
2036
+ for (const typo of typoColumns) {
2037
+ test(`"${typo}" fails validateScreens (via validateBoot)`, () => {
2038
+ const feature = defineFeature("notes", (r) => {
2039
+ r.entity("note", createEntity({ fields: { title: createTextField() } }));
2040
+ r.screen({
2041
+ id: "note-list",
2042
+ type: "entityList",
2043
+ entity: "note",
2044
+ columns: ["title", typo],
2045
+ });
2046
+ });
2047
+ expect(() => validateBoot([feature])).toThrow(
2048
+ new RegExp(`references field "${typo}" which does not exist`),
2049
+ );
2050
+ });
2051
+
2052
+ test(`"${typo}" fails validateEntityListScreens directly ("unknown column field")`, () => {
2053
+ const feature = defineFeature("notes", (r) => {
2054
+ r.entity("note", createEntity({ fields: { title: createTextField() } }));
2055
+ r.screen({
2056
+ id: "note-list",
2057
+ type: "entityList",
2058
+ entity: "note",
2059
+ columns: ["title", typo],
2060
+ });
2061
+ });
2062
+ const fixed = withBootValidatorFixture([feature]);
2063
+ expect(() => validateEntityListScreens(fixed)).toThrow(
2064
+ new RegExp(`unknown column field "${typo}"`),
2065
+ );
2066
+ });
2067
+ }
2068
+ });
2069
+
1956
2070
  // --- entityList: pagination + sort validation ---
1957
2071
  // Author-Fehler vor Production fangen, damit "Screen lädt nichts /
1958
2072
  // sortiert falsch / crasht beim Pager-Klick" nicht erst zur Laufzeit
@@ -589,8 +589,9 @@ describe("buildAppSchema", () => {
589
589
  type: "embedded",
590
590
  multiple: true,
591
591
  schema: {
592
- qty: { type: "number" },
592
+ qty: { type: "number", required: true },
593
593
  amount: { type: "money" },
594
+ status: { type: "select", options: ["open", "closed"] },
594
595
  },
595
596
  minItems: 1,
596
597
  maxItems: 20,
@@ -618,6 +619,14 @@ describe("buildAppSchema", () => {
618
619
  });
619
620
  expect(fields["lines"]?.["totals"]).toEqual(["amount"]);
620
621
  expect(fields["lines"]?.["totalsMatch"]).toEqual({ amount: "invoiceTotal" });
622
+ // fw#2507: `schema` itself — the embedded list's sub-field map — must
623
+ // survive too, or computeEditViewModel iterates `undefined` and the
624
+ // browser crashes on any entityEdit screen with an embedded list.
625
+ const schema = fields["lines"]?.["schema"] as Record<string, Record<string, unknown>>;
626
+ expect(Object.keys(schema)).toEqual(["qty", "amount", "status"]);
627
+ expect(schema["qty"]).toEqual({ type: "number", required: true });
628
+ expect(schema["amount"]).toEqual({ type: "money" });
629
+ expect(schema["status"]).toEqual({ type: "select", options: ["open", "closed"] });
621
630
  });
622
631
 
623
632
  test("embedded: derived/totalsMatch mit Function eine Ebene tief bleiben blockiert (fw#2497)", () => {
@@ -655,6 +664,44 @@ describe("buildAppSchema", () => {
655
664
  expect(fields["multi"]?.["multiline"]).toBeUndefined();
656
665
  });
657
666
 
667
+ test("embedded: schema übersteht die Projection, ein Function-Wert eines Sub-Fields wird aber gedroppt (fw#2507)", () => {
668
+ // Regression: `schema` — the embedded list's sub-field map — was never
669
+ // forwarded at all, crashing computeEditViewModel client-side
670
+ // (Object.entries(undefined)). It's projected recursively per sub-field
671
+ // (through projectField again), not via a blanket copy: a sub-field
672
+ // carrying a non-JSON-safe value (e.g. a validator/renderer function)
673
+ // must lose only that value, not the whole schema map.
674
+ const entity = {
675
+ fields: {
676
+ lines: {
677
+ type: "embedded",
678
+ multiple: true,
679
+ schema: {
680
+ qty: { type: "number", required: true, default: () => 1 },
681
+ amount: { type: "money" },
682
+ },
683
+ },
684
+ },
685
+ } as unknown as EntityDefinition;
686
+
687
+ const f = defineFeature("ent", (r) => {
688
+ r.entity("thing", entity);
689
+ });
690
+ const app = buildAppSchema(createRegistry([f]));
691
+ const fields = (
692
+ app.features[0]!.entities["thing"] as unknown as {
693
+ fields: Record<string, Record<string, unknown>>;
694
+ }
695
+ ).fields;
696
+
697
+ const schema = fields["lines"]?.["schema"] as Record<string, Record<string, unknown>>;
698
+ expect(schema).toBeDefined();
699
+ expect(schema["qty"]?.["type"]).toBe("number");
700
+ expect(schema["qty"]?.["required"]).toBe(true);
701
+ expect(schema["qty"]?.["default"]).toBeUndefined();
702
+ expect(schema["amount"]).toEqual({ type: "money" });
703
+ });
704
+
658
705
  test("default: JSON-safe Arrays/Objects überleben die Projection, Nicht-JSON-Werte bleiben blockiert (fw#2497)", () => {
659
706
  // isJsonSafeValue() now recurses into arrays/plain objects instead of
660
707
  // only accepting string/number/boolean/null — but the defense-in-depth
@@ -0,0 +1,140 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { buildConfigFeatureSchema } from "../build-config-feature-schema";
4
+ import { defineFeature } from "../define-feature";
5
+ import { createRegistry } from "../registry";
6
+ import type { NavDefinition } from "../types/nav";
7
+ import type { ScreenDefinition, SecretsEditScreenDefinition } from "../types/screen";
8
+
9
+ // Mirrors the real "secrets" bundled feature just enough to exercise the
10
+ // generator: a single writeHandler "set" is all buildConfigFeatureSchema
11
+ // checks for (registry.getWriteHandler("secrets:write:set")) to decide the
12
+ // secrets screen is "mounted" — the plaintext-storage internals are
13
+ // irrelevant to derivation and stay out of this test.
14
+ function secretsFeature(roles: readonly string[] = ["TenantAdmin", "Admin", "SystemAdmin"]) {
15
+ return defineFeature("secrets", (r) => {
16
+ r.writeHandler(
17
+ "set",
18
+ z.object({ key: z.string(), value: z.string() }),
19
+ async () => ({ isSuccess: true, data: null }),
20
+ { access: { roles: [...roles] } },
21
+ );
22
+ });
23
+ }
24
+
25
+ function secretsScreen(
26
+ schema: ReturnType<typeof buildConfigFeatureSchema>,
27
+ ): SecretsEditScreenDefinition {
28
+ const s: ScreenDefinition | undefined = schema.screens.find((x) => x.id === "secrets");
29
+ if (s?.type !== "secretsEdit") throw new Error('no "secretsEdit" screen "secrets"');
30
+ return s;
31
+ }
32
+
33
+ function navById(
34
+ schema: ReturnType<typeof buildConfigFeatureSchema>,
35
+ id: string,
36
+ ): NavDefinition | undefined {
37
+ return schema.navs.find((n) => n.id === id);
38
+ }
39
+
40
+ describe("buildConfigFeatureSchema — secrets derivation", () => {
41
+ test("mounted secrets feature + declared r.secret() yields exactly one secretsEdit screen", () => {
42
+ const stripe = defineFeature("stripe", (r) => {
43
+ r.secret("apiKey", { label: { en: "Stripe API Key" }, scope: "tenant", required: true });
44
+ r.secret("webhookSecret", {
45
+ label: { en: "Stripe Webhook Secret" },
46
+ hint: { en: "Found in the Stripe dashboard" },
47
+ scope: "tenant",
48
+ });
49
+ });
50
+ const schema = buildConfigFeatureSchema(createRegistry([secretsFeature(), stripe]));
51
+
52
+ expect(schema.screens.filter((s) => s.type === "secretsEdit")).toHaveLength(1);
53
+ const screen = secretsScreen(schema);
54
+ expect(screen.secretKeys).toEqual({
55
+ "stripe-api-key": "stripe:secret:api-key",
56
+ "stripe-webhook-secret": "stripe:secret:webhook-secret",
57
+ });
58
+ expect(screen.fieldLabels).toEqual({
59
+ "stripe-api-key": "config.secret.stripe.api-key.label",
60
+ "stripe-webhook-secret": "config.secret.stripe.webhook-secret.label",
61
+ });
62
+ expect(screen.fieldHints).toEqual({
63
+ "stripe-webhook-secret": "config.secret.stripe.webhook-secret.hint",
64
+ });
65
+ expect(screen.requiredFields).toEqual(["stripe-api-key"]);
66
+ expect(schema.translations).toEqual({
67
+ "config.secret.stripe.api-key.label": { en: "Stripe API Key" },
68
+ "config.secret.stripe.webhook-secret.label": { en: "Stripe Webhook Secret" },
69
+ "config.secret.stripe.webhook-secret.hint": { en: "Found in the Stripe dashboard" },
70
+ });
71
+ });
72
+
73
+ test("sections group by declaring feature; title only when '<feature>.settings' is a declared translation", () => {
74
+ const stripe = defineFeature("stripe", (r) => {
75
+ r.translations({ keys: { "stripe.settings": { en: "Stripe" } } });
76
+ r.secret("apiKey", { label: { en: "Stripe API Key" }, scope: "tenant" });
77
+ });
78
+ const mailer = defineFeature("mailer", (r) => {
79
+ // no r.translations() → no "mailer.settings" key declared anywhere
80
+ r.secret("smtpPassword", { label: { en: "SMTP Password" }, scope: "tenant" });
81
+ });
82
+ const schema = buildConfigFeatureSchema(createRegistry([secretsFeature(), stripe, mailer]));
83
+ const screen = secretsScreen(schema);
84
+
85
+ expect(screen.sections).toEqual([
86
+ { fields: ["mailer-smtp-password"] },
87
+ { title: "stripe.settings", fields: ["stripe-api-key"] },
88
+ ]);
89
+ expect(screen.sections.some((s) => "title" in s && s.title === "mailer.settings")).toBe(false);
90
+ });
91
+
92
+ test("audience-tenant nav + settings workspace arise from secrets alone (no masked config key)", () => {
93
+ const stripe = defineFeature("stripe", (r) => {
94
+ r.secret("apiKey", { label: { en: "Stripe API Key" }, scope: "tenant" });
95
+ });
96
+ const schema = buildConfigFeatureSchema(createRegistry([secretsFeature(), stripe]));
97
+
98
+ expect(navById(schema, "audience-tenant")).toBeDefined();
99
+ expect(navById(schema, "secrets")?.parent).toBe("audience-tenant");
100
+ expect(navById(schema, "secrets")?.screen).toBe("secrets");
101
+ expect(schema.workspace).toBeDefined();
102
+ expect(schema.workspace?.navMembers).toContain("config:nav:secrets");
103
+ });
104
+
105
+ test("screen and nav access mirror secrets:write:set's access rule, including custom roles", () => {
106
+ const stripe = defineFeature("stripe", (r) => {
107
+ r.secret("apiKey", { label: { en: "Stripe API Key" }, scope: "tenant" });
108
+ });
109
+ const customRoles = ["BillingOps"];
110
+ const schema = buildConfigFeatureSchema(createRegistry([secretsFeature(customRoles), stripe]));
111
+
112
+ expect(secretsScreen(schema).access).toEqual({ roles: customRoles });
113
+ expect(navById(schema, "secrets")?.access).toEqual({ roles: customRoles });
114
+ expect(navById(schema, "audience-tenant")?.access).toEqual({ roles: customRoles });
115
+ });
116
+
117
+ test("declared secrets without a mounted secrets feature yield no secretsEdit screen and no secrets nav", () => {
118
+ const stripe = defineFeature("stripe", (r) => {
119
+ r.secret("apiKey", { label: { en: "Stripe API Key" }, scope: "tenant" });
120
+ });
121
+ const schema = buildConfigFeatureSchema(createRegistry([stripe]));
122
+
123
+ expect(schema.screens).toHaveLength(0);
124
+ expect(schema.navs).toHaveLength(0);
125
+ });
126
+
127
+ test("generated screen is JSON-safe (no leaked SecretKeyDefinition.redact function)", () => {
128
+ const stripe = defineFeature("stripe", (r) => {
129
+ r.secret("apiKey", {
130
+ label: { en: "Stripe API Key" },
131
+ scope: "tenant",
132
+ redact: (plaintext) => `${plaintext.slice(0, 3)}...`,
133
+ });
134
+ });
135
+ const schema = buildConfigFeatureSchema(createRegistry([secretsFeature(), stripe]));
136
+ const screen = secretsScreen(schema);
137
+
138
+ expect(JSON.parse(JSON.stringify(screen))).toEqual(screen);
139
+ });
140
+ });