@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.
- package/package.json +3 -3
- package/src/__tests__/unprocessable-error-details-reason-codemod.test.ts +205 -0
- package/src/api/server.ts +3 -0
- package/src/changes.json +8 -0
- package/src/db/__tests__/list-row-meta-drift.test.ts +15 -0
- package/src/db/queries/event-consumer.ts +20 -5
- package/src/engine/__tests__/boot-validator.test.ts +114 -0
- package/src/engine/__tests__/build-app-schema.test.ts +48 -1
- package/src/engine/__tests__/build-config-feature-schema-secrets.test.ts +140 -0
- package/src/engine/boot-validator/entity-list-screens.ts +13 -1
- package/src/engine/boot-validator/i18n-keys.ts +13 -5
- package/src/engine/boot-validator/screens.ts +25 -1
- package/src/engine/build-app-schema.ts +14 -0
- package/src/engine/build-config-feature-schema.ts +216 -44
- package/src/engine/index.ts +2 -0
- package/src/engine/registry-validate.ts +16 -0
- package/src/engine/registry.ts +2 -0
- package/src/engine/types/index.ts +2 -0
- package/src/i18n/index.ts +1 -0
- package/src/i18n/required-surface-keys.ts +11 -0
- package/src/i18n/settings-hub-keys.ts +28 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +127 -0
- package/src/jobs/job-runner.ts +37 -2
- package/src/pipeline/__tests__/event-dispatcher-start-from.integration.test.ts +176 -0
- package/src/pipeline/event-dispatcher-delivery.ts +1 -1
- package/src/pipeline/event-dispatcher.ts +6 -0
- package/src/pipeline/multi-stream-apply-context.ts +4 -4
- package/src/scripts/codemod/unprocessable-error-details-reason.ts +273 -0
- package/src/testing/e2e-generator.ts +3 -0
- package/src/ui-types/index.ts +4 -0
- 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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
+
});
|