@cosmicdrift/kumiko-framework 0.188.0 → 0.190.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -3
- package/src/api/server.ts +12 -2
- package/src/bun-db/__tests__/coerce-row-plain-date.test.ts +70 -0
- package/src/bun-db/__tests__/where-patterns.integration.test.ts +28 -0
- package/src/bun-db/query.ts +84 -14
- package/src/db/__tests__/column-ddl.integration.test.ts +9 -0
- package/src/db/__tests__/migrate-generator.test.ts +23 -0
- package/src/db/__tests__/schema-migration.integration.test.ts +61 -1
- package/src/db/dialect.ts +10 -0
- package/src/db/entity-table-meta.ts +3 -0
- package/src/db/migrate-generator.ts +20 -7
- package/src/db/table-builder.ts +25 -19
- package/src/derivatives/__tests__/derivatives-context.integration.test.ts +162 -0
- package/src/derivatives/__tests__/derivatives-context.test.ts +167 -0
- package/src/derivatives/__tests__/variant-key.test.ts +55 -0
- package/src/derivatives/derivatives-context.ts +146 -0
- package/src/derivatives/index.ts +3 -0
- package/src/derivatives/variant-key.ts +40 -0
- package/src/engine/__tests__/boot-validator.test.ts +277 -0
- package/src/engine/boot-validator/screens.ts +81 -17
- package/src/engine/extension-names.ts +17 -0
- package/src/engine/index.ts +1 -0
- package/src/errors/__tests__/classes.test.ts +39 -0
- package/src/errors/zod-bridge.ts +18 -1
- package/src/event-store/__tests__/perf.integration.test.ts +33 -17
- package/src/files/in-memory-provider.ts +9 -0
- package/src/jobs/job-runner.ts +19 -2
- package/src/pipeline/dispatch-shared.ts +10 -0
- package/src/pipeline/multi-stream-apply-context.ts +4 -0
- package/src/utils/__tests__/safe-json-temporal.test.ts +14 -0
|
@@ -7,8 +7,11 @@ import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
|
7
7
|
import { createSystemConfig, createTenantConfig } from "../config-helpers";
|
|
8
8
|
import {
|
|
9
9
|
createDerivedField,
|
|
10
|
+
createEmbeddedField,
|
|
10
11
|
createEmbeddedListField,
|
|
11
12
|
createEntity,
|
|
13
|
+
createFilesField,
|
|
14
|
+
createJsonbField,
|
|
12
15
|
createMultiSelectField,
|
|
13
16
|
createTextField,
|
|
14
17
|
defineFeature,
|
|
@@ -1864,6 +1867,39 @@ describe("boot-validator", () => {
|
|
|
1864
1867
|
expect(() => validateBoot([makeFeature({ cancelTarget: false })])).not.toThrow();
|
|
1865
1868
|
});
|
|
1866
1869
|
|
|
1870
|
+
// --- redirect/cancelTarget: cross-feature QN (#1946) ---
|
|
1871
|
+
test("redirect → voll-qualifizierte Cross-Feature-QN → kein Throw", () => {
|
|
1872
|
+
const consumer = defineFeature("statements", (r) => {
|
|
1873
|
+
r.screen({ id: "statement-upload-list", type: "custom", renderer: { react: "stub" } });
|
|
1874
|
+
});
|
|
1875
|
+
expect(() =>
|
|
1876
|
+
validateBoot([
|
|
1877
|
+
makeFeature({ redirect: "statements:screen:statement-upload-list" }),
|
|
1878
|
+
consumer,
|
|
1879
|
+
]),
|
|
1880
|
+
).not.toThrow();
|
|
1881
|
+
});
|
|
1882
|
+
|
|
1883
|
+
test("redirect → unbekannte Cross-Feature-QN → Throw", () => {
|
|
1884
|
+
expect(() =>
|
|
1885
|
+
validateBoot([makeFeature({ redirect: "statements:screen:ghost-screen" })]),
|
|
1886
|
+
).toThrow(
|
|
1887
|
+
/redirect "statements:screen:ghost-screen" does not resolve to a registered screen/,
|
|
1888
|
+
);
|
|
1889
|
+
});
|
|
1890
|
+
|
|
1891
|
+
test("cancelTarget → voll-qualifizierte Cross-Feature-QN → kein Throw", () => {
|
|
1892
|
+
const consumer = defineFeature("statements", (r) => {
|
|
1893
|
+
r.screen({ id: "statement-upload-list", type: "custom", renderer: { react: "stub" } });
|
|
1894
|
+
});
|
|
1895
|
+
expect(() =>
|
|
1896
|
+
validateBoot([
|
|
1897
|
+
makeFeature({ cancelTarget: "statements:screen:statement-upload-list" }),
|
|
1898
|
+
consumer,
|
|
1899
|
+
]),
|
|
1900
|
+
).not.toThrow();
|
|
1901
|
+
});
|
|
1902
|
+
|
|
1867
1903
|
test("extension section ohne component → Throw (Parität zu entityEdit)", () => {
|
|
1868
1904
|
// synthesizeActionFormScreen reicht die layout 1:1 an RenderEdit weiter —
|
|
1869
1905
|
// eine Extension-Section ohne react/native-Marker rendert sonst stumm leer.
|
|
@@ -2173,6 +2209,247 @@ describe("boot-validator", () => {
|
|
|
2173
2209
|
});
|
|
2174
2210
|
});
|
|
2175
2211
|
|
|
2212
|
+
// --- entityEdit redirect (framework#1942) ---
|
|
2213
|
+
// Post-save navigation target, same validation as actionForm's redirect:
|
|
2214
|
+
// short screen-ID, same feature, must resolve to a registered screen.
|
|
2215
|
+
describe("entityEdit redirect", () => {
|
|
2216
|
+
function makeFeature(redirect?: string, extraScreens: readonly string[] = []) {
|
|
2217
|
+
return defineFeature("shop", (r) => {
|
|
2218
|
+
r.entity(
|
|
2219
|
+
"product",
|
|
2220
|
+
createEntity({ fields: { name: createTextField(), sku: createTextField() } }),
|
|
2221
|
+
);
|
|
2222
|
+
r.screen({
|
|
2223
|
+
id: "product-edit",
|
|
2224
|
+
type: "entityEdit",
|
|
2225
|
+
entity: "product",
|
|
2226
|
+
layout: { sections: [{ fields: ["name", "sku"] }] },
|
|
2227
|
+
...(redirect !== undefined && { redirect }),
|
|
2228
|
+
});
|
|
2229
|
+
for (const extra of extraScreens) {
|
|
2230
|
+
r.screen({
|
|
2231
|
+
id: extra,
|
|
2232
|
+
type: "custom",
|
|
2233
|
+
renderer: { react: "stub" },
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
2236
|
+
});
|
|
2237
|
+
}
|
|
2238
|
+
|
|
2239
|
+
test("redirect → existing screen-id im selben feature → kein Throw", () => {
|
|
2240
|
+
expect(() => validateBoot([makeFeature("product-list", ["product-list"])])).not.toThrow();
|
|
2241
|
+
});
|
|
2242
|
+
|
|
2243
|
+
test("redirect → unknown screen-id → Throw", () => {
|
|
2244
|
+
expect(() => validateBoot([makeFeature("ghost-screen")])).toThrow(
|
|
2245
|
+
/redirect "ghost-screen" does not resolve to a registered screen/,
|
|
2246
|
+
);
|
|
2247
|
+
});
|
|
2248
|
+
|
|
2249
|
+
test("kein redirect gesetzt → kein Throw (Default: Liste)", () => {
|
|
2250
|
+
expect(() => validateBoot([makeFeature()])).not.toThrow();
|
|
2251
|
+
});
|
|
2252
|
+
|
|
2253
|
+
// --- redirect: cross-feature QN (#1946) ---
|
|
2254
|
+
test("redirect → voll-qualifizierte Cross-Feature-QN → kein Throw", () => {
|
|
2255
|
+
const consumer = defineFeature("statements", (r) => {
|
|
2256
|
+
r.screen({ id: "statement-upload-list", type: "custom", renderer: { react: "stub" } });
|
|
2257
|
+
});
|
|
2258
|
+
expect(() =>
|
|
2259
|
+
validateBoot([makeFeature("statements:screen:statement-upload-list"), consumer]),
|
|
2260
|
+
).not.toThrow();
|
|
2261
|
+
});
|
|
2262
|
+
|
|
2263
|
+
test("redirect → unbekannte Cross-Feature-QN → Throw", () => {
|
|
2264
|
+
expect(() => validateBoot([makeFeature("statements:screen:ghost-screen")])).toThrow(
|
|
2265
|
+
/redirect "statements:screen:ghost-screen" does not resolve to a registered screen/,
|
|
2266
|
+
);
|
|
2267
|
+
});
|
|
2268
|
+
});
|
|
2269
|
+
|
|
2270
|
+
// --- no-widget field types + required (#1925) ---
|
|
2271
|
+
// jsonb/embedded/files/images render read-only on the auto-wired
|
|
2272
|
+
// entityEdit path (render-field.tsx) — a statically-`required: true`
|
|
2273
|
+
// field of one of these types would be unresolvable by the user, so it's
|
|
2274
|
+
// caught loudly at boot instead of silently accepting an unfillable form.
|
|
2275
|
+
// Only the static case (literal `true`, entity-level or screen-spec) is
|
|
2276
|
+
// checked; a dynamic FieldCondition can't be evaluated without runtime
|
|
2277
|
+
// form values and is a documented, accepted gap.
|
|
2278
|
+
describe("no-widget field types + required (#1925)", () => {
|
|
2279
|
+
test("embedded field, entity-level required: true → Throw", () => {
|
|
2280
|
+
const features = [
|
|
2281
|
+
defineFeature("shop", (r) => {
|
|
2282
|
+
r.entity(
|
|
2283
|
+
"order",
|
|
2284
|
+
createEntity({
|
|
2285
|
+
fields: {
|
|
2286
|
+
lines: createEmbeddedField({ note: { type: "text" } }, { required: true }),
|
|
2287
|
+
},
|
|
2288
|
+
}),
|
|
2289
|
+
);
|
|
2290
|
+
r.screen({
|
|
2291
|
+
id: "order-edit",
|
|
2292
|
+
type: "entityEdit",
|
|
2293
|
+
entity: "order",
|
|
2294
|
+
layout: { sections: [{ fields: ["lines"] }] },
|
|
2295
|
+
});
|
|
2296
|
+
}),
|
|
2297
|
+
];
|
|
2298
|
+
expect(() => validateBoot(features)).toThrow(
|
|
2299
|
+
/field "lines" is type "embedded", which renders read-only/,
|
|
2300
|
+
);
|
|
2301
|
+
});
|
|
2302
|
+
|
|
2303
|
+
test("embedded field, entity-level required: true, screen-spec required: false override → kein Throw", () => {
|
|
2304
|
+
const features = [
|
|
2305
|
+
defineFeature("shop", (r) => {
|
|
2306
|
+
r.entity(
|
|
2307
|
+
"order",
|
|
2308
|
+
createEntity({
|
|
2309
|
+
fields: {
|
|
2310
|
+
lines: createEmbeddedField({ note: { type: "text" } }, { required: true }),
|
|
2311
|
+
},
|
|
2312
|
+
}),
|
|
2313
|
+
);
|
|
2314
|
+
r.screen({
|
|
2315
|
+
id: "order-edit",
|
|
2316
|
+
type: "entityEdit",
|
|
2317
|
+
entity: "order",
|
|
2318
|
+
layout: { sections: [{ fields: [{ field: "lines", required: false }] }] },
|
|
2319
|
+
});
|
|
2320
|
+
}),
|
|
2321
|
+
];
|
|
2322
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
2323
|
+
});
|
|
2324
|
+
|
|
2325
|
+
test("embedded field, entity-level required: true, screen-spec readOnly: true → kein Throw", () => {
|
|
2326
|
+
const features = [
|
|
2327
|
+
defineFeature("shop", (r) => {
|
|
2328
|
+
r.entity(
|
|
2329
|
+
"order",
|
|
2330
|
+
createEntity({
|
|
2331
|
+
fields: {
|
|
2332
|
+
lines: createEmbeddedField({ note: { type: "text" } }, { required: true }),
|
|
2333
|
+
},
|
|
2334
|
+
}),
|
|
2335
|
+
);
|
|
2336
|
+
r.screen({
|
|
2337
|
+
id: "order-edit",
|
|
2338
|
+
type: "entityEdit",
|
|
2339
|
+
entity: "order",
|
|
2340
|
+
layout: { sections: [{ fields: [{ field: "lines", readOnly: true }] }] },
|
|
2341
|
+
});
|
|
2342
|
+
}),
|
|
2343
|
+
];
|
|
2344
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
2345
|
+
});
|
|
2346
|
+
|
|
2347
|
+
test("embedded field, entity-level required: true, dynamic screen-spec required condition → kein Throw (not statically resolvable)", () => {
|
|
2348
|
+
const features = [
|
|
2349
|
+
defineFeature("shop", (r) => {
|
|
2350
|
+
r.entity(
|
|
2351
|
+
"order",
|
|
2352
|
+
createEntity({
|
|
2353
|
+
fields: {
|
|
2354
|
+
kind: createTextField(),
|
|
2355
|
+
lines: createEmbeddedField({ note: { type: "text" } }, { required: true }),
|
|
2356
|
+
},
|
|
2357
|
+
}),
|
|
2358
|
+
);
|
|
2359
|
+
r.screen({
|
|
2360
|
+
id: "order-edit",
|
|
2361
|
+
type: "entityEdit",
|
|
2362
|
+
entity: "order",
|
|
2363
|
+
layout: {
|
|
2364
|
+
sections: [
|
|
2365
|
+
{ fields: ["kind", { field: "lines", required: { field: "kind", eq: "b2b" } }] },
|
|
2366
|
+
],
|
|
2367
|
+
},
|
|
2368
|
+
});
|
|
2369
|
+
}),
|
|
2370
|
+
];
|
|
2371
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
2372
|
+
});
|
|
2373
|
+
|
|
2374
|
+
test("jsonb field, screen-spec required: true override → Throw (JsonbFieldDef has no entity-level required)", () => {
|
|
2375
|
+
const features = [
|
|
2376
|
+
defineFeature("shop", (r) => {
|
|
2377
|
+
r.entity("order", createEntity({ fields: { data: createJsonbField() } }));
|
|
2378
|
+
r.screen({
|
|
2379
|
+
id: "order-edit",
|
|
2380
|
+
type: "entityEdit",
|
|
2381
|
+
entity: "order",
|
|
2382
|
+
layout: { sections: [{ fields: [{ field: "data", required: true }] }] },
|
|
2383
|
+
});
|
|
2384
|
+
}),
|
|
2385
|
+
];
|
|
2386
|
+
expect(() => validateBoot(features)).toThrow(
|
|
2387
|
+
/field "data" is type "jsonb", which renders read-only/,
|
|
2388
|
+
);
|
|
2389
|
+
});
|
|
2390
|
+
|
|
2391
|
+
test("files field, screen-spec required: true override → Throw (deliberately deferred, no multi-upload widget)", () => {
|
|
2392
|
+
const features = [
|
|
2393
|
+
defineFeature("shop", (r) => {
|
|
2394
|
+
r.entity("order", createEntity({ fields: { attachments: createFilesField() } }));
|
|
2395
|
+
r.screen({
|
|
2396
|
+
id: "order-edit",
|
|
2397
|
+
type: "entityEdit",
|
|
2398
|
+
entity: "order",
|
|
2399
|
+
layout: { sections: [{ fields: [{ field: "attachments", required: true }] }] },
|
|
2400
|
+
});
|
|
2401
|
+
}),
|
|
2402
|
+
];
|
|
2403
|
+
expect(() => validateBoot(features)).toThrow(
|
|
2404
|
+
/field "attachments" is type "files", which renders read-only/,
|
|
2405
|
+
);
|
|
2406
|
+
});
|
|
2407
|
+
|
|
2408
|
+
test("multiSelect field, entity-level required: true → kein Throw (has a combobox widget since #1925)", () => {
|
|
2409
|
+
const features = [
|
|
2410
|
+
defineFeature("shop", (r) => {
|
|
2411
|
+
r.entity(
|
|
2412
|
+
"product",
|
|
2413
|
+
createEntity({
|
|
2414
|
+
fields: {
|
|
2415
|
+
tags: createMultiSelectField({ options: ["a", "b"] as const, required: true }),
|
|
2416
|
+
},
|
|
2417
|
+
}),
|
|
2418
|
+
);
|
|
2419
|
+
r.screen({
|
|
2420
|
+
id: "product-edit",
|
|
2421
|
+
type: "entityEdit",
|
|
2422
|
+
entity: "product",
|
|
2423
|
+
layout: { sections: [{ fields: ["tags"] }] },
|
|
2424
|
+
});
|
|
2425
|
+
}),
|
|
2426
|
+
];
|
|
2427
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
2428
|
+
});
|
|
2429
|
+
|
|
2430
|
+
test("embedded LIST field (multiple: true), entity-level required: true → kein Throw (has an EmbeddedListField grid widget since #1838)", () => {
|
|
2431
|
+
const features = [
|
|
2432
|
+
defineFeature("shop", (r) => {
|
|
2433
|
+
r.entity(
|
|
2434
|
+
"invoice",
|
|
2435
|
+
createEntity({
|
|
2436
|
+
fields: {
|
|
2437
|
+
lines: createEmbeddedListField({ note: { type: "text" } }, { required: true }),
|
|
2438
|
+
},
|
|
2439
|
+
}),
|
|
2440
|
+
);
|
|
2441
|
+
r.screen({
|
|
2442
|
+
id: "invoice-edit",
|
|
2443
|
+
type: "entityEdit",
|
|
2444
|
+
entity: "invoice",
|
|
2445
|
+
layout: { sections: [{ fields: ["lines"] }] },
|
|
2446
|
+
});
|
|
2447
|
+
}),
|
|
2448
|
+
];
|
|
2449
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
2450
|
+
});
|
|
2451
|
+
});
|
|
2452
|
+
|
|
2176
2453
|
// --- Tier 2.7e-3: ReferenceFieldDef ---
|
|
2177
2454
|
describe("reference field (Tier 2.7e-3)", () => {
|
|
2178
2455
|
test("reference auf bestehende Entity → kein Throw", () => {
|
|
@@ -5,16 +5,17 @@
|
|
|
5
5
|
// same-folder require cycle.
|
|
6
6
|
|
|
7
7
|
import { rowMetaFieldNames } from "../../db/table-builder";
|
|
8
|
-
import { qualifyEntityName } from "../qualified-name";
|
|
8
|
+
import { isValidQn, qualifyEntityName } from "../qualified-name";
|
|
9
9
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
|
|
10
10
|
import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../screen-helpers";
|
|
11
|
-
import type { FeatureDefinition } from "../types";
|
|
11
|
+
import type { EntityDefinition, FeatureDefinition } from "../types";
|
|
12
12
|
import type {
|
|
13
13
|
DashboardCustomPanel,
|
|
14
14
|
DashboardFilterDefinition,
|
|
15
15
|
DashboardPanelDefinition,
|
|
16
16
|
DashboardScreenDefinition,
|
|
17
17
|
DashboardStatGroupPanel,
|
|
18
|
+
EditFieldSpec,
|
|
18
19
|
EditLayout,
|
|
19
20
|
FieldCondition,
|
|
20
21
|
RowAction,
|
|
@@ -23,6 +24,48 @@ import type {
|
|
|
23
24
|
ToolbarAction,
|
|
24
25
|
} from "../types/screen";
|
|
25
26
|
|
|
27
|
+
// Mirrors FIELD_TYPES_WITHOUT_WIDGET in packages/renderer/src/app/form-schema.ts.
|
|
28
|
+
// Can't import it directly — renderer depends on framework, not the reverse.
|
|
29
|
+
// Keep both lists in sync when a field type gains or loses an auto-wired widget.
|
|
30
|
+
const NO_WIDGET_FIELD_TYPES = new Set(["jsonb", "embedded", "files", "images"]);
|
|
31
|
+
|
|
32
|
+
// A field type in NO_WIDGET_FIELD_TYPES renders read-only on the auto-wired
|
|
33
|
+
// entityEdit path (#1925) — a required field the user can never fill would
|
|
34
|
+
// block every save. Only the statically-resolvable case is caught here: a
|
|
35
|
+
// literal `required: true` (screen-spec override or entity-level default).
|
|
36
|
+
// A dynamic FieldCondition depends on runtime form values and can't be
|
|
37
|
+
// evaluated at boot; buildFormSchema() silently skips presence-checking it.
|
|
38
|
+
function validateNoWidgetRequiredField(
|
|
39
|
+
featureName: string,
|
|
40
|
+
screenId: string,
|
|
41
|
+
entityDef: EntityDefinition,
|
|
42
|
+
fieldSpec: Exclude<EditFieldSpec, string>,
|
|
43
|
+
): void {
|
|
44
|
+
const fieldDef = entityDef.fields[fieldSpec.field];
|
|
45
|
+
// skip: field doesn't exist or its type already has a widget — nothing to validate.
|
|
46
|
+
if (fieldDef === undefined || !NO_WIDGET_FIELD_TYPES.has(fieldDef.type)) return;
|
|
47
|
+
// Embedded LIST fields (`multiple: true`) get their own EmbeddedListField
|
|
48
|
+
// grid widget (#1838) — only plain (non-list) embedded has no widget.
|
|
49
|
+
const isEmbeddedList =
|
|
50
|
+
fieldDef.type === "embedded" &&
|
|
51
|
+
(fieldDef as unknown as { multiple?: boolean }).multiple === true;
|
|
52
|
+
// skip: list variant has a widget — not the no-widget case this guard targets.
|
|
53
|
+
if (isEmbeddedList) return;
|
|
54
|
+
// skip: already read-only by spec — no fillable widget needed regardless of type.
|
|
55
|
+
if (fieldSpec.readOnly === true) return;
|
|
56
|
+
const entityRequired = "required" in fieldDef && fieldDef.required === true;
|
|
57
|
+
const isStaticallyRequired =
|
|
58
|
+
fieldSpec.required === undefined ? entityRequired : fieldSpec.required === true;
|
|
59
|
+
// skip: not required — a read-only widget-less field is fine to leave empty.
|
|
60
|
+
if (!isStaticallyRequired) return;
|
|
61
|
+
throw new Error(
|
|
62
|
+
`[Feature ${featureName}] Screen "${screenId}" (entityEdit) field "${fieldSpec.field}" is ` +
|
|
63
|
+
`type "${fieldDef.type}", which renders read-only on the auto-wired entityEdit path — a ` +
|
|
64
|
+
`required field the user could never fill would block every save. Set required: false, ` +
|
|
65
|
+
`move the field to a custom-component section, or drop the required constraint.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
26
69
|
// Tier 2.7e navigate rowAction → target-screen params validity. Shared by
|
|
27
70
|
// entityList and projectionList (framework#1708) — projectionList has no
|
|
28
71
|
// `screen.entity`, so there's no same-entity row["id"] auto-fill case: any
|
|
@@ -224,6 +267,15 @@ export function validateScreenShortIdCollisions(
|
|
|
224
267
|
}
|
|
225
268
|
}
|
|
226
269
|
|
|
270
|
+
// redirect/cancelTarget accept either a same-feature short id (unchanged
|
|
271
|
+
// behavior, qualified against the owning feature) or a fully-qualified
|
|
272
|
+
// cross-feature screen QN (`<feature>:screen:<id>`) given verbatim — a
|
|
273
|
+
// short id can never itself be a valid QN (QN_SEGMENT forbids colons), so
|
|
274
|
+
// the two forms don't collide.
|
|
275
|
+
function resolveScreenTargetQn(featureName: string, target: string): string {
|
|
276
|
+
return isValidQn(target) ? target : qualifyEntityName(featureName, "screen", target);
|
|
277
|
+
}
|
|
278
|
+
|
|
227
279
|
export function validateScreens(
|
|
228
280
|
feature: FeatureDefinition,
|
|
229
281
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
@@ -239,8 +291,9 @@ export function validateScreens(
|
|
|
239
291
|
// der Runtime-Router (create-app) löst eine bare screenId app-weit über ALLE
|
|
240
292
|
// Features auf (eine deklarative Liste im owning-Feature der Entity navigiert
|
|
241
293
|
// so zu den Custom-Editoren der Consumer-App). Der Validator spiegelt das:
|
|
242
|
-
// same-feature ODER irgendein Feature.
|
|
243
|
-
//
|
|
294
|
+
// same-feature ODER irgendein Feature. redirect/cancelTarget akzeptieren
|
|
295
|
+
// zusätzlich eine voll-qualifizierte Cross-Feature-QN (resolveScreenTargetQn,
|
|
296
|
+
// #1946) — kurze IDs bleiben same-feature wie zuvor.
|
|
244
297
|
const navTargetShortIds = screenShortIdsFrom(allScreenQns);
|
|
245
298
|
for (const [screenId, screen] of Object.entries(feature.screens)) {
|
|
246
299
|
if (screen.type === "custom") {
|
|
@@ -496,31 +549,29 @@ export function validateScreens(
|
|
|
496
549
|
}
|
|
497
550
|
validateWizardLayout(feature.name, screenId, "actionForm", screen.layout, featureMap);
|
|
498
551
|
if (screen.redirect !== undefined) {
|
|
499
|
-
// redirect ist die kurze Screen-ID (z.B.
|
|
500
|
-
//
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
const candidateQn =
|
|
552
|
+
// redirect ist entweder die kurze Screen-ID (same-feature, z.B.
|
|
553
|
+
// "item-list") oder eine voll-qualifizierte Cross-Feature-QN
|
|
554
|
+
// (`<feature>:screen:<id>`) — der Renderer strippt letztere beim
|
|
555
|
+
// Navigieren auf die kurze ID (lastSegment), die der nav-Router
|
|
556
|
+
// app-weit auflöst (#1946).
|
|
557
|
+
const candidateQn = resolveScreenTargetQn(feature.name, screen.redirect);
|
|
505
558
|
if (!allScreenQns.has(candidateQn)) {
|
|
506
559
|
throw new Error(
|
|
507
560
|
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) redirect "${screen.redirect}" ` +
|
|
508
|
-
`does not resolve to a registered screen
|
|
509
|
-
|
|
510
|
-
}.`,
|
|
561
|
+
`does not resolve to a registered screen (checked "${candidateQn}"). Known screens ` +
|
|
562
|
+
`in this feature: ${[...Object.keys(feature.screens)].sort().join(", ") || "(none)"}.`,
|
|
511
563
|
);
|
|
512
564
|
}
|
|
513
565
|
}
|
|
514
566
|
if (typeof screen.cancelTarget === "string") {
|
|
515
567
|
// Gleiche Regel wie redirect — `false` (kein Cancel-Button)
|
|
516
568
|
// braucht keine Validierung.
|
|
517
|
-
const candidateQn =
|
|
569
|
+
const candidateQn = resolveScreenTargetQn(feature.name, screen.cancelTarget);
|
|
518
570
|
if (!allScreenQns.has(candidateQn)) {
|
|
519
571
|
throw new Error(
|
|
520
572
|
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) cancelTarget "${screen.cancelTarget}" ` +
|
|
521
|
-
`does not resolve to a registered screen
|
|
522
|
-
|
|
523
|
-
}.`,
|
|
573
|
+
`does not resolve to a registered screen (checked "${candidateQn}"). Known screens ` +
|
|
574
|
+
`in this feature: ${[...Object.keys(feature.screens)].sort().join(", ") || "(none)"}.`,
|
|
524
575
|
);
|
|
525
576
|
}
|
|
526
577
|
}
|
|
@@ -824,9 +875,22 @@ export function validateScreens(
|
|
|
824
875
|
),
|
|
825
876
|
);
|
|
826
877
|
}
|
|
878
|
+
validateNoWidgetRequiredField(feature.name, screenId, entityDef, normalized);
|
|
827
879
|
}
|
|
828
880
|
}
|
|
829
881
|
validateWizardLayout(feature.name, screenId, "entityEdit", screen.layout, featureMap);
|
|
882
|
+
if (screen.redirect !== undefined) {
|
|
883
|
+
// Same rule as actionForm's redirect: short screen-ID (same-feature)
|
|
884
|
+
// or a fully-qualified cross-feature QN (#1946).
|
|
885
|
+
const candidateQn = resolveScreenTargetQn(feature.name, screen.redirect);
|
|
886
|
+
if (!allScreenQns.has(candidateQn)) {
|
|
887
|
+
throw new Error(
|
|
888
|
+
`[Feature ${feature.name}] Screen "${screenId}" (entityEdit) redirect "${screen.redirect}" ` +
|
|
889
|
+
`does not resolve to a registered screen (checked "${candidateQn}"). Known screens ` +
|
|
890
|
+
`in this feature: ${[...Object.keys(feature.screens)].sort().join(", ") || "(none)"}.`,
|
|
891
|
+
);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
830
894
|
}
|
|
831
895
|
}
|
|
832
896
|
}
|
|
@@ -87,6 +87,23 @@ export const EXT_FILE_PROVIDER = "fileProvider" as const;
|
|
|
87
87
|
// des file-foundation-Features MUSS diese Konstante mitziehen.
|
|
88
88
|
export const FILE_PROVIDER_CONFIG_KEY = "file-foundation:config:provider" as const;
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* `derivativeRenderer` — File-Derivative-Renderer-Plugin-Selection
|
|
92
|
+
* (file-derivatives).
|
|
93
|
+
*
|
|
94
|
+
* Renderer-Features (a future `derivatives-*` feature) register via
|
|
95
|
+
* `r.useExtension(EXT_DERIVATIVE_RENDERER, "<mimePattern>", { render })`,
|
|
96
|
+
* where `<mimePattern>` is an exact MIME type (`application/pdf`) or a
|
|
97
|
+
* type-wildcard (`image/*`). `ctx.derivatives.variant(...)` resolves the
|
|
98
|
+
* renderer for a FileRef's MIME type at call time — exact match first,
|
|
99
|
+
* wildcard second.
|
|
100
|
+
*
|
|
101
|
+
* No `r.extensionSelector` and no config key: unlike `fileProvider`, the
|
|
102
|
+
* resolution is deterministic from the MIME type — no tenant ever picks a
|
|
103
|
+
* different image library for the same content type.
|
|
104
|
+
*/
|
|
105
|
+
export const EXT_DERIVATIVE_RENDERER = "derivativeRenderer" as const;
|
|
106
|
+
|
|
90
107
|
/**
|
|
91
108
|
* `searchAdapter` — Search-Adapter-Forget-Hooks (Meilisearch-Index-Cleanup
|
|
92
109
|
* bei User-Forget oder Tenant-Destroy).
|
package/src/engine/index.ts
CHANGED
|
@@ -77,6 +77,7 @@ export type { EmitCtx } from "./event-helpers";
|
|
|
77
77
|
export { emitEvent, typedPayload } from "./event-helpers";
|
|
78
78
|
export type { KumikoExtensionName } from "./extension-names";
|
|
79
79
|
export {
|
|
80
|
+
EXT_DERIVATIVE_RENDERER,
|
|
80
81
|
EXT_EXTERNAL_RESOURCE,
|
|
81
82
|
EXT_FILE_PROVIDER,
|
|
82
83
|
EXT_INFRA_RESOURCE,
|
|
@@ -106,6 +106,45 @@ describe("ValidationError", () => {
|
|
|
106
106
|
expect(err.cause).toBe(result.error);
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
test("custom issue with params.i18nKey overrides the mechanical errors.validation.custom key", () => {
|
|
110
|
+
const schema = z.object({ name: z.string() }).superRefine((values, ctx) => {
|
|
111
|
+
if (values.name === "") {
|
|
112
|
+
ctx.addIssue({
|
|
113
|
+
code: "custom",
|
|
114
|
+
path: ["name"],
|
|
115
|
+
message: '"name" is required.',
|
|
116
|
+
params: { i18nKey: "kumiko.validation.required" },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
const result = schema.safeParse({ name: "" });
|
|
121
|
+
if (result.success) throw new Error("zod did not reject");
|
|
122
|
+
|
|
123
|
+
const err = validationErrorFromZod(result.error);
|
|
124
|
+
const fields = (err.details as { fields: Array<Record<string, unknown>> }).fields;
|
|
125
|
+
expect(fields[0]).toMatchObject({
|
|
126
|
+
code: "custom",
|
|
127
|
+
i18nKey: "kumiko.validation.required",
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("custom issue without params.i18nKey still falls back to errors.validation.custom", () => {
|
|
132
|
+
const schema = z.object({ name: z.string() }).superRefine((values, ctx) => {
|
|
133
|
+
if (values.name === "bad") {
|
|
134
|
+
ctx.addIssue({ code: "custom", path: ["name"], message: "not allowed" });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
const result = schema.safeParse({ name: "bad" });
|
|
138
|
+
if (result.success) throw new Error("zod did not reject");
|
|
139
|
+
|
|
140
|
+
const err = validationErrorFromZod(result.error);
|
|
141
|
+
const fields = (err.details as { fields: Array<Record<string, unknown>> }).fields;
|
|
142
|
+
expect(fields[0]).toMatchObject({
|
|
143
|
+
code: "custom",
|
|
144
|
+
i18nKey: "errors.validation.custom",
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
109
148
|
test('root-level zod issue maps to path "(root)"', () => {
|
|
110
149
|
const schema = z.string();
|
|
111
150
|
const result = schema.safeParse(123);
|
package/src/errors/zod-bridge.ts
CHANGED
|
@@ -30,13 +30,30 @@ export function validationErrorFromZod(error: ZodError): ValidationError {
|
|
|
30
30
|
return {
|
|
31
31
|
path: issue.path.map(String).join(".") || "(root)",
|
|
32
32
|
code: issue.code,
|
|
33
|
-
i18nKey:
|
|
33
|
+
i18nKey: resolveI18nKey(issue),
|
|
34
34
|
...(params && { params }),
|
|
35
35
|
};
|
|
36
36
|
});
|
|
37
37
|
return new ValidationError({ fields }, { cause: error });
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
// Every zod code maps mechanically to `errors.validation.<code>` — except
|
|
41
|
+
// `code: "custom"`, which is zod's one-size-fits-all bucket for every
|
|
42
|
+
// `superRefine`/`refine` check in the codebase (e.g. schema-builder.ts's
|
|
43
|
+
// totalsMatch check). Left mechanical, ALL of them would collapse onto the
|
|
44
|
+
// same `errors.validation.custom` ("Invalid value.") key. A `superRefine`
|
|
45
|
+
// that needs its own key sets `params.i18nKey` on the issue; this is the one
|
|
46
|
+
// place that honors it. Keep in sync with the client-side mirror
|
|
47
|
+
// (packages/headless/src/form/zod-bridge.ts) — a superRefine can run on
|
|
48
|
+
// either side.
|
|
49
|
+
function resolveI18nKey(issue: ZodIssue): string {
|
|
50
|
+
if (issue.code === "custom") {
|
|
51
|
+
const override = issue.params?.["i18nKey"];
|
|
52
|
+
if (typeof override === "string") return override;
|
|
53
|
+
}
|
|
54
|
+
return `errors.validation.${issue.code}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
40
57
|
function extractIssueParams(issue: ZodIssue): Readonly<Record<string, unknown>> | undefined {
|
|
41
58
|
// ZodIssue is a discriminated union with variant-specific params (minimum,
|
|
42
59
|
// maximum, expected, …); reading them generically requires widening since
|