@cosmicdrift/kumiko-framework 0.210.0 → 0.212.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/__tests__/upgrade-cli.test.ts +370 -0
- package/src/arg-parser.ts +66 -0
- package/src/changes.json +56 -0
- package/src/db/queries/backfill-pii.ts +19 -2
- package/src/engine/__tests__/boot-validator.test.ts +222 -0
- package/src/engine/__tests__/required-surface-keys.test.ts +47 -0
- package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +64 -0
- package/src/engine/boot-validator/detail-screens.ts +16 -2
- package/src/engine/boot-validator/i18n-keys.ts +9 -2
- package/src/engine/boot-validator/index.ts +7 -2
- package/src/engine/boot-validator/screens.ts +114 -25
- package/src/engine/feature-changelog.ts +2 -0
- package/src/errors/__tests__/classes.test.ts +8 -0
- package/src/errors/__tests__/write-failures.test.ts +5 -0
- package/src/errors/classes.ts +1 -1
- package/src/errors/write-error-info.ts +3 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +75 -0
- package/src/i18n/required-surface-keys.ts +22 -7
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +28 -29
- package/src/stack/test-stack.ts +6 -1
- package/src/upgrade-cli.ts +445 -0
|
@@ -3192,6 +3192,228 @@ describe("boot-validator", () => {
|
|
|
3192
3192
|
});
|
|
3193
3193
|
});
|
|
3194
3194
|
|
|
3195
|
+
// --- fw#2228: rowAction navigate-target as an entity instead of a screen ---
|
|
3196
|
+
describe("entityList rowAction kind=navigate entity-target (fw#2228)", () => {
|
|
3197
|
+
test("entity-target resolving via detailFor → kein Throw", () => {
|
|
3198
|
+
const feature = defineFeature("shop", (r) => {
|
|
3199
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
3200
|
+
r.screen({
|
|
3201
|
+
id: "product-list",
|
|
3202
|
+
type: "entityList",
|
|
3203
|
+
entity: "product",
|
|
3204
|
+
columns: ["name"],
|
|
3205
|
+
rowActions: [{ kind: "navigate", id: "view", label: "actions.view", entity: "product" }],
|
|
3206
|
+
});
|
|
3207
|
+
r.screen({
|
|
3208
|
+
id: "product-detail",
|
|
3209
|
+
type: "custom",
|
|
3210
|
+
renderer: { react: "stub" },
|
|
3211
|
+
detailFor: "product",
|
|
3212
|
+
});
|
|
3213
|
+
});
|
|
3214
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
3215
|
+
});
|
|
3216
|
+
|
|
3217
|
+
test("entity-target with no detailFor screen anywhere → Throw mit klarer Message", () => {
|
|
3218
|
+
const feature = defineFeature("shop", (r) => {
|
|
3219
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
3220
|
+
r.screen({
|
|
3221
|
+
id: "product-list",
|
|
3222
|
+
type: "entityList",
|
|
3223
|
+
entity: "product",
|
|
3224
|
+
columns: ["name"],
|
|
3225
|
+
rowActions: [{ kind: "navigate", id: "view", label: "actions.view", entity: "product" }],
|
|
3226
|
+
});
|
|
3227
|
+
});
|
|
3228
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
3229
|
+
/rowAction "view" navigate-target entity "product" has no screen declaring detailFor: "product"/,
|
|
3230
|
+
);
|
|
3231
|
+
});
|
|
3232
|
+
|
|
3233
|
+
test("both screen and entity set → Throw", () => {
|
|
3234
|
+
const feature = defineFeature("shop", (r) => {
|
|
3235
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
3236
|
+
r.screen({
|
|
3237
|
+
id: "product-list",
|
|
3238
|
+
type: "entityList",
|
|
3239
|
+
entity: "product",
|
|
3240
|
+
columns: ["name"],
|
|
3241
|
+
rowActions: [
|
|
3242
|
+
{
|
|
3243
|
+
kind: "navigate",
|
|
3244
|
+
id: "view",
|
|
3245
|
+
label: "actions.view",
|
|
3246
|
+
screen: "product-detail",
|
|
3247
|
+
entity: "product",
|
|
3248
|
+
},
|
|
3249
|
+
],
|
|
3250
|
+
});
|
|
3251
|
+
r.screen({
|
|
3252
|
+
id: "product-detail",
|
|
3253
|
+
type: "custom",
|
|
3254
|
+
renderer: { react: "stub" },
|
|
3255
|
+
detailFor: "product",
|
|
3256
|
+
});
|
|
3257
|
+
});
|
|
3258
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
3259
|
+
/rowAction "view" sets both "screen" and "entity"/,
|
|
3260
|
+
);
|
|
3261
|
+
});
|
|
3262
|
+
|
|
3263
|
+
test("neither screen nor entity set → Throw", () => {
|
|
3264
|
+
const feature = defineFeature("shop", (r) => {
|
|
3265
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
3266
|
+
r.screen({
|
|
3267
|
+
id: "product-list",
|
|
3268
|
+
type: "entityList",
|
|
3269
|
+
entity: "product",
|
|
3270
|
+
columns: ["name"],
|
|
3271
|
+
rowActions: [{ kind: "navigate", id: "view", label: "actions.view" }],
|
|
3272
|
+
});
|
|
3273
|
+
});
|
|
3274
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
3275
|
+
/rowAction "view" sets neither "screen" nor "entity"/,
|
|
3276
|
+
);
|
|
3277
|
+
});
|
|
3278
|
+
|
|
3279
|
+
test("entity-target resolving cross-feature to a NON-entityEdit detailFor screen → kein Throw", () => {
|
|
3280
|
+
// Mirrors the existing cross-feature screen-target case above, but via
|
|
3281
|
+
// detailFor resolution — the owning feature's list navigates to a
|
|
3282
|
+
// detail screen a consumer app registers for the entity.
|
|
3283
|
+
const list = defineFeature("shop", (r) => {
|
|
3284
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
3285
|
+
r.screen({
|
|
3286
|
+
id: "product-list",
|
|
3287
|
+
type: "entityList",
|
|
3288
|
+
entity: "product",
|
|
3289
|
+
columns: ["name"],
|
|
3290
|
+
rowActions: [{ kind: "navigate", id: "view", label: "actions.view", entity: "product" }],
|
|
3291
|
+
});
|
|
3292
|
+
});
|
|
3293
|
+
const consumer = defineFeature("app", (r) => {
|
|
3294
|
+
r.screen({
|
|
3295
|
+
id: "product-detail",
|
|
3296
|
+
type: "custom",
|
|
3297
|
+
renderer: { react: "stub" },
|
|
3298
|
+
detailFor: "product",
|
|
3299
|
+
});
|
|
3300
|
+
});
|
|
3301
|
+
expect(() => validateBoot([list, consumer])).not.toThrow();
|
|
3302
|
+
});
|
|
3303
|
+
|
|
3304
|
+
test("entity-target resolving cross-feature to an entityEdit detailFor screen WITHOUT entityId → kein Throw", () => {
|
|
3305
|
+
// Regression guard: the cross-feature-entityEdit-needs-explicit-entityId
|
|
3306
|
+
// check (screen-target branch) must NOT fire for entity-targets — the
|
|
3307
|
+
// renderer always supplies an id for those (explicit entityId, else
|
|
3308
|
+
// row["id"]), so the same-feature-fallback gap that check guards
|
|
3309
|
+
// against doesn't exist here.
|
|
3310
|
+
const list = defineFeature("shop", (r) => {
|
|
3311
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
3312
|
+
r.screen({
|
|
3313
|
+
id: "product-list",
|
|
3314
|
+
type: "entityList",
|
|
3315
|
+
entity: "product",
|
|
3316
|
+
columns: ["name"],
|
|
3317
|
+
rowActions: [{ kind: "navigate", id: "edit", label: "actions.edit", entity: "invoice" }],
|
|
3318
|
+
});
|
|
3319
|
+
});
|
|
3320
|
+
const consumer = defineFeature("billing", (r) => {
|
|
3321
|
+
r.entity("invoice", createEntity({ fields: { name: createTextField() } }));
|
|
3322
|
+
r.screen({
|
|
3323
|
+
id: "invoice-edit",
|
|
3324
|
+
type: "entityEdit",
|
|
3325
|
+
entity: "invoice",
|
|
3326
|
+
layout: { sections: [{ columns: 1, fields: ["name"] }] },
|
|
3327
|
+
detailFor: "invoice",
|
|
3328
|
+
});
|
|
3329
|
+
});
|
|
3330
|
+
expect(() => validateBoot([list, consumer])).not.toThrow();
|
|
3331
|
+
});
|
|
3332
|
+
|
|
3333
|
+
test("projectionList rowAction entity-target without explicit entityId → Throw", () => {
|
|
3334
|
+
// projectionList rows come from an arbitrary query projection with no
|
|
3335
|
+
// guaranteed "id" field, unlike entityList rows — an entity-target
|
|
3336
|
+
// there must name the field to read the id from.
|
|
3337
|
+
const feature = defineFeature("shop", (r) => {
|
|
3338
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
3339
|
+
r.queryHandler("products", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
3340
|
+
access: { openToAll: true },
|
|
3341
|
+
});
|
|
3342
|
+
r.screen({
|
|
3343
|
+
id: "product-projection",
|
|
3344
|
+
type: "projectionList",
|
|
3345
|
+
query: "shop:query:products",
|
|
3346
|
+
columns: ["name"],
|
|
3347
|
+
rowActions: [{ kind: "navigate", id: "view", label: "actions.view", entity: "product" }],
|
|
3348
|
+
});
|
|
3349
|
+
r.screen({
|
|
3350
|
+
id: "product-detail",
|
|
3351
|
+
type: "custom",
|
|
3352
|
+
renderer: { react: "stub" },
|
|
3353
|
+
detailFor: "product",
|
|
3354
|
+
});
|
|
3355
|
+
});
|
|
3356
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
3357
|
+
/rowAction "view" navigate-target entity "product" needs an explicit "entityId"/,
|
|
3358
|
+
);
|
|
3359
|
+
});
|
|
3360
|
+
|
|
3361
|
+
test("projectionList rowAction entity-target with explicit entityId → kein Throw", () => {
|
|
3362
|
+
const feature = defineFeature("shop", (r) => {
|
|
3363
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
3364
|
+
r.queryHandler("products", z.object({}), async () => ({ rows: [], nextCursor: null }), {
|
|
3365
|
+
access: { openToAll: true },
|
|
3366
|
+
});
|
|
3367
|
+
r.screen({
|
|
3368
|
+
id: "product-projection",
|
|
3369
|
+
type: "projectionList",
|
|
3370
|
+
query: "shop:query:products",
|
|
3371
|
+
columns: ["name"],
|
|
3372
|
+
rowActions: [
|
|
3373
|
+
{
|
|
3374
|
+
kind: "navigate",
|
|
3375
|
+
id: "view",
|
|
3376
|
+
label: "actions.view",
|
|
3377
|
+
entity: "product",
|
|
3378
|
+
entityId: "productId",
|
|
3379
|
+
},
|
|
3380
|
+
],
|
|
3381
|
+
});
|
|
3382
|
+
r.screen({
|
|
3383
|
+
id: "product-detail",
|
|
3384
|
+
type: "custom",
|
|
3385
|
+
renderer: { react: "stub" },
|
|
3386
|
+
detailFor: "product",
|
|
3387
|
+
});
|
|
3388
|
+
});
|
|
3389
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
3390
|
+
});
|
|
3391
|
+
|
|
3392
|
+
test("projectionDetail action entity-target without explicit entityId → Throw", () => {
|
|
3393
|
+
const feature = defineFeature("shop", (r) => {
|
|
3394
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
3395
|
+
r.screen({
|
|
3396
|
+
id: "order-detail",
|
|
3397
|
+
type: "projectionDetail",
|
|
3398
|
+
query: "shop:query:order-detail",
|
|
3399
|
+
layout: { sections: [{ fields: ["total"] }] },
|
|
3400
|
+
actions: [
|
|
3401
|
+
{ kind: "navigate", id: "view-product", label: "actions.view", entity: "product" },
|
|
3402
|
+
],
|
|
3403
|
+
});
|
|
3404
|
+
r.screen({
|
|
3405
|
+
id: "product-detail",
|
|
3406
|
+
type: "custom",
|
|
3407
|
+
renderer: { react: "stub" },
|
|
3408
|
+
detailFor: "product",
|
|
3409
|
+
});
|
|
3410
|
+
});
|
|
3411
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
3412
|
+
/action "view-product" navigate-target entity "product" needs an explicit "entityId"/,
|
|
3413
|
+
);
|
|
3414
|
+
});
|
|
3415
|
+
});
|
|
3416
|
+
|
|
3195
3417
|
// --- Screen short-id collision across features ---
|
|
3196
3418
|
describe("screen short-id collisions across features", () => {
|
|
3197
3419
|
test("two features registering the same short screen-id → Throw", () => {
|
|
@@ -169,3 +169,50 @@ describe("requiredKeysFromNav / requiredKeysFromWorkspace", () => {
|
|
|
169
169
|
).toEqual(["bmc:workspace.disposition"]);
|
|
170
170
|
});
|
|
171
171
|
});
|
|
172
|
+
|
|
173
|
+
// fw#2260: isI18nKey's colon-only check silently drops dot-form labels like
|
|
174
|
+
// `${feature}.settings` — the Settings-Hub generator's own convention (see
|
|
175
|
+
// buildConfigFeatureSchema). requiredKeysFromScreen/requiredKeysFromNav take
|
|
176
|
+
// an opt-in `treatDotFormAsKey` option so the boot-validator can register
|
|
177
|
+
// those generated keys directly instead of relying on isI18nKey to recognize
|
|
178
|
+
// them.
|
|
179
|
+
describe("dot-form labels + treatDotFormAsKey (fw#2260)", () => {
|
|
180
|
+
const configScreen: ConfigEditScreenDefinition = {
|
|
181
|
+
id: "billing-tenant",
|
|
182
|
+
type: "configEdit",
|
|
183
|
+
scope: "tenant",
|
|
184
|
+
configKeys: { apiKey: "billing:config:api-key" },
|
|
185
|
+
fieldLabels: { apiKey: "billing.api-key" },
|
|
186
|
+
fields: { apiKey: { type: "text" } },
|
|
187
|
+
layout: { sections: [{ title: "billing.settings", fields: ["apiKey"] }] },
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
test("configEdit section title + fieldLabels override: dot-form is dropped by default", () => {
|
|
191
|
+
const keys = requiredKeysFromScreen("config", configScreen);
|
|
192
|
+
expect(keys).not.toContain("billing.settings");
|
|
193
|
+
expect(keys).not.toContain("billing.api-key");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("configEdit section title + fieldLabels override: treatDotFormAsKey surfaces the dot-form keys", () => {
|
|
197
|
+
const keys = requiredKeysFromScreen("config", configScreen, { treatDotFormAsKey: true });
|
|
198
|
+
expect(keys).toContain("billing.settings");
|
|
199
|
+
expect(keys).toContain("billing.api-key");
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("colon-form keys are still required with treatDotFormAsKey (no regression for the normal path)", () => {
|
|
203
|
+
const screen: ConfigEditScreenDefinition = {
|
|
204
|
+
...configScreen,
|
|
205
|
+
fieldLabels: { apiKey: "billing:override.apiKey" },
|
|
206
|
+
layout: { sections: [{ title: "billing:section.basics", fields: ["apiKey"] }] },
|
|
207
|
+
};
|
|
208
|
+
const keys = requiredKeysFromScreen("config", screen, { treatDotFormAsKey: true });
|
|
209
|
+
expect(keys).toContain("billing:override.apiKey");
|
|
210
|
+
expect(keys).toContain("billing:section.basics");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("nav label: dot-form is dropped by default, treatDotFormAsKey surfaces it", () => {
|
|
214
|
+
const nav = { id: "billing-tenant", label: "billing.settings" };
|
|
215
|
+
expect(requiredKeysFromNav(nav)).not.toContain("billing.settings");
|
|
216
|
+
expect(requiredKeysFromNav(nav, { treatDotFormAsKey: true })).toContain("billing.settings");
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { access, createTenantConfig } from "../../config-helpers";
|
|
3
|
+
import { defineFeature } from "../../define-feature";
|
|
4
|
+
import { validateBoot } from "../index";
|
|
5
|
+
|
|
6
|
+
// fw#2260: the Settings-Hub generator (buildConfigFeatureSchema) labels a
|
|
7
|
+
// masked config key's generated nav entry / configEdit section with the
|
|
8
|
+
// dot-form key `${feature}.settings` — never colon-form. isI18nKey's
|
|
9
|
+
// colon-only check dropped that label from the required-keys set, so a
|
|
10
|
+
// feature could ship a generated Settings screen whose label was never
|
|
11
|
+
// translated and boot validation stayed silent.
|
|
12
|
+
describe("validateI18nSurfaceKeys — Settings-Hub generated dot-form label (fw#2260)", () => {
|
|
13
|
+
// Mirrors packages/bundled-features/src/config/i18n.ts — the audience-parent
|
|
14
|
+
// labels every masked config key's generated hub requires regardless of
|
|
15
|
+
// which feature owns the key.
|
|
16
|
+
const configHub = defineFeature("config", (r) => {
|
|
17
|
+
r.translations({
|
|
18
|
+
keys: {
|
|
19
|
+
"config.settings.title": { en: "Settings" },
|
|
20
|
+
"config.settings.system": { en: "Platform" },
|
|
21
|
+
"config.settings.tenant": { en: "Organization" },
|
|
22
|
+
"config.settings.user": { en: "Personal" },
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function billingFeature(translationKeys: Record<string, { readonly en: string }>) {
|
|
28
|
+
return defineFeature("billing", (r) => {
|
|
29
|
+
r.config({
|
|
30
|
+
keys: {
|
|
31
|
+
// write restricted to a non-elevated role (see ELEVATED_ROLES in
|
|
32
|
+
// build-config-feature-schema.ts) so this stays a single
|
|
33
|
+
// tenant-scope screen — no SystemAdmin cascade to a second one.
|
|
34
|
+
apiKey: createTenantConfig("text", {
|
|
35
|
+
write: access.roles("TenantAdmin"),
|
|
36
|
+
mask: { title: "billing.api-key" },
|
|
37
|
+
}),
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
if (Object.keys(translationKeys).length > 0) {
|
|
41
|
+
r.translations({ keys: translationKeys });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("generated nav label 'billing.settings' left untranslated fails boot", () => {
|
|
47
|
+
const billing = billingFeature({
|
|
48
|
+
"billing.api-key": { en: "API Key" },
|
|
49
|
+
"screen:billing-tenant.title": { en: "Billing Settings" },
|
|
50
|
+
});
|
|
51
|
+
expect(() => validateBoot([configHub, billing])).toThrow(
|
|
52
|
+
/Settings-Hub: required translation key missing: "billing\.settings"/,
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("translating the generated dot-form label lets boot pass", () => {
|
|
57
|
+
const billing = billingFeature({
|
|
58
|
+
"billing.api-key": { en: "API Key" },
|
|
59
|
+
"screen:billing-tenant.title": { en: "Billing Settings" },
|
|
60
|
+
"billing.settings": { en: "Billing Settings" },
|
|
61
|
+
});
|
|
62
|
+
expect(() => validateBoot([configHub, billing])).not.toThrow();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
import { qualifyEntityName } from "../qualified-name";
|
|
2
2
|
import type { FeatureDefinition } from "../types";
|
|
3
|
+
import type { ScreenDefinition } from "../types/screen";
|
|
3
4
|
import { findEntityFeature } from "./screens";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
// Entity name → the one screen that declares detailFor: "<entity>". Built
|
|
7
|
+
// once, up front (before the per-feature validateScreens loop) so both this
|
|
8
|
+
// module's own uniqueness check AND rowAction entity-targets (fw#2228,
|
|
9
|
+
// screens.ts) resolve against the same map instead of re-walking every
|
|
10
|
+
// feature's screens twice.
|
|
11
|
+
export function collectDetailForScreens(
|
|
6
12
|
features: readonly FeatureDefinition[],
|
|
7
13
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
8
|
-
):
|
|
14
|
+
): Map<string, { readonly featureName: string; readonly screen: ScreenDefinition }> {
|
|
9
15
|
const screenQnByEntity = new Map<string, string>();
|
|
16
|
+
const result = new Map<
|
|
17
|
+
string,
|
|
18
|
+
{ readonly featureName: string; readonly screen: ScreenDefinition }
|
|
19
|
+
>();
|
|
10
20
|
|
|
11
21
|
for (const feature of features) {
|
|
12
22
|
for (const [screenId, screen] of Object.entries(feature.screens)) {
|
|
@@ -30,6 +40,10 @@ export function validateDetailForScreens(
|
|
|
30
40
|
`but no feature registers an entity with that name.`,
|
|
31
41
|
);
|
|
32
42
|
}
|
|
43
|
+
|
|
44
|
+
result.set(detailFor, { featureName: feature.name, screen });
|
|
33
45
|
}
|
|
34
46
|
}
|
|
47
|
+
|
|
48
|
+
return result;
|
|
35
49
|
}
|
|
@@ -16,11 +16,18 @@ function requiredKeysFromGeneratedConfigHub(registry: Registry): readonly string
|
|
|
16
16
|
if (schema.navs.length === 0) return [];
|
|
17
17
|
const out = new Set<string>();
|
|
18
18
|
|
|
19
|
+
// The generator's section titles + mask-title field-label overrides are
|
|
20
|
+
// dot-form i18n references (`${feature}.settings`), never literal display
|
|
21
|
+
// text, so treatDotFormAsKey bypasses isI18nKey's colon-only check (fw#2260).
|
|
19
22
|
for (const screen of schema.screens) {
|
|
20
|
-
for (const key of requiredKeysFromScreen(SETTINGS_HUB_FEATURE, screen
|
|
23
|
+
for (const key of requiredKeysFromScreen(SETTINGS_HUB_FEATURE, screen, {
|
|
24
|
+
treatDotFormAsKey: true,
|
|
25
|
+
})) {
|
|
26
|
+
out.add(key);
|
|
27
|
+
}
|
|
21
28
|
}
|
|
22
29
|
for (const nav of schema.navs) {
|
|
23
|
-
for (const key of requiredKeysFromNav(nav)) out.add(key);
|
|
30
|
+
for (const key of requiredKeysFromNav(nav, { treatDotFormAsKey: true })) out.add(key);
|
|
24
31
|
}
|
|
25
32
|
if (schema.workspace) {
|
|
26
33
|
for (const key of requiredKeysFromWorkspace(schema.workspace.definition)) out.add(key);
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
validateConfigReads,
|
|
17
17
|
warnOnToggleableDependencies,
|
|
18
18
|
} from "./config-deps";
|
|
19
|
-
import {
|
|
19
|
+
import { collectDetailForScreens } from "./detail-screens";
|
|
20
20
|
import {
|
|
21
21
|
validateDerivedFieldCollisions,
|
|
22
22
|
validateEmbeddedFields,
|
|
@@ -142,6 +142,11 @@ export function validateBoot(
|
|
|
142
142
|
const allWriteHandlerQns = collectWriteHandlerQns(features);
|
|
143
143
|
const screensByShortId = collectScreensByShortId(features);
|
|
144
144
|
validateScreenShortIdCollisions(screensByShortId);
|
|
145
|
+
// Cross-feature entity → detailFor-screen map — built (+ uniqueness-
|
|
146
|
+
// validated) up front so per-feature rowAction entity-targets (fw#2228)
|
|
147
|
+
// resolve against it inside the loop below, instead of at the very end
|
|
148
|
+
// where this used to run.
|
|
149
|
+
const detailForScreens = collectDetailForScreens(features, featureMap);
|
|
145
150
|
|
|
146
151
|
// Cross-feature API exposure-map — jedes Feature deklariert Marker via
|
|
147
152
|
// r.exposesApi(name). Per-feature validateApiExposureMatching walkt
|
|
@@ -202,6 +207,7 @@ export function validateBoot(
|
|
|
202
207
|
allScreenQns,
|
|
203
208
|
allConfigKeyQns,
|
|
204
209
|
screensByShortId,
|
|
210
|
+
detailForScreens,
|
|
205
211
|
);
|
|
206
212
|
validateNavs(feature, allScreenQns, allNavQns, allWorkspaceQns);
|
|
207
213
|
validateWorkspaces(feature, allNavQns);
|
|
@@ -211,7 +217,6 @@ export function validateBoot(
|
|
|
211
217
|
validateDefaultWorkspaceUniqueness(allWorkspaceQns);
|
|
212
218
|
validateI18nSurfaceKeys(features);
|
|
213
219
|
validateEntityListScreens(features);
|
|
214
|
-
validateDetailForScreens(features, featureMap);
|
|
215
220
|
// Must run before validateProjectionListScreens: an unresolvable query
|
|
216
221
|
// there is silently treated as "capability absent" and surfaces as a
|
|
217
222
|
// misleading "no search parameter in its Zod schema" error instead of
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
EditLayout,
|
|
21
21
|
FieldCondition,
|
|
22
22
|
RowAction,
|
|
23
|
+
RowActionNavigate,
|
|
23
24
|
RowFieldExtractor,
|
|
24
25
|
ScreenDefinition,
|
|
25
26
|
ToolbarAction,
|
|
@@ -118,9 +119,11 @@ function validateRowActionNavigateParams(
|
|
|
118
119
|
: `same entity "${screenEntity}" auto-fills row["id"]`
|
|
119
120
|
})`
|
|
120
121
|
: `screen type "${target.screen.type}"`;
|
|
122
|
+
const targetDescriptor =
|
|
123
|
+
action.screen !== undefined ? `"${action.screen}"` : `entity "${action.entity}"`;
|
|
121
124
|
throw new Error(
|
|
122
125
|
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) rowAction "${action.id}" ` +
|
|
123
|
-
`sets params on navigate-target
|
|
126
|
+
`sets params on navigate-target ${targetDescriptor} which ${reason} — only actionForm ` +
|
|
124
127
|
`and entityEdit-create targets read URL search params as initial values. Remove the ` +
|
|
125
128
|
`params extractor or retarget to an actionForm / cross-entity entityEdit-create screen.`,
|
|
126
129
|
);
|
|
@@ -336,6 +339,74 @@ function validateToolbarDrawerAction(
|
|
|
336
339
|
}
|
|
337
340
|
}
|
|
338
341
|
|
|
342
|
+
// fw#2228: a navigate rowAction (or projectionDetail header action, which
|
|
343
|
+
// reuses the same RowActionNavigate shape) names its target as either a
|
|
344
|
+
// screen (existing) or an entity (new) — exactly one. Shared by all three
|
|
345
|
+
// call sites so the mutual-exclusivity check and the entity→detailFor
|
|
346
|
+
// resolution don't drift between them (same drift risk
|
|
347
|
+
// validateRowActionNavigateParams above is already shared to avoid).
|
|
348
|
+
function resolveRowActionNavigateTarget(
|
|
349
|
+
featureName: string,
|
|
350
|
+
screenId: string,
|
|
351
|
+
screenType: "entityList" | "projectionList" | "projectionDetail",
|
|
352
|
+
actionLabel: "rowAction" | "action",
|
|
353
|
+
action: RowActionNavigate,
|
|
354
|
+
allScreenQns: ReadonlySet<string>,
|
|
355
|
+
navTargetShortIds: ReadonlySet<string>,
|
|
356
|
+
screensByShortId: ReadonlyMap<
|
|
357
|
+
string,
|
|
358
|
+
ReadonlyArray<{ readonly featureName: string; readonly screen: ScreenDefinition }>
|
|
359
|
+
>,
|
|
360
|
+
detailForScreens: ReadonlyMap<
|
|
361
|
+
string,
|
|
362
|
+
{ readonly featureName: string; readonly screen: ScreenDefinition }
|
|
363
|
+
>,
|
|
364
|
+
): { readonly featureName: string; readonly screen: ScreenDefinition } | undefined {
|
|
365
|
+
if (action.entity !== undefined) {
|
|
366
|
+
if (action.screen !== undefined) {
|
|
367
|
+
throw new Error(
|
|
368
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
369
|
+
`sets both "screen" and "entity" — exactly one navigate-target form is allowed.`,
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
if (screenType !== "entityList" && action.entityId === undefined) {
|
|
373
|
+
// entityList rows are always a real entity record, so row["id"] is a
|
|
374
|
+
// safe implicit default. projectionList/projectionDetail rows come from
|
|
375
|
+
// an arbitrary query projection with no guaranteed "id" field — an
|
|
376
|
+
// entity-target there needs an explicit entityId, or navigation silently
|
|
377
|
+
// opens the detail screen with no entity context at runtime.
|
|
378
|
+
throw new Error(
|
|
379
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
380
|
+
`navigate-target entity "${action.entity}" needs an explicit "entityId" — ${screenType} rows ` +
|
|
381
|
+
`come from a query projection with no guaranteed "id" field.`,
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
const detail = detailForScreens.get(action.entity);
|
|
385
|
+
if (detail === undefined) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
388
|
+
`navigate-target entity "${action.entity}" has no screen declaring ` +
|
|
389
|
+
`detailFor: "${action.entity}".`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
return detail;
|
|
393
|
+
}
|
|
394
|
+
if (action.screen === undefined) {
|
|
395
|
+
throw new Error(
|
|
396
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
397
|
+
`sets neither "screen" nor "entity" — exactly one navigate-target form is required.`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
const candidateQn = qualifyEntityName(featureName, "screen", action.screen);
|
|
401
|
+
if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
|
|
402
|
+
throw new Error(
|
|
403
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) ${actionLabel} "${action.id}" ` +
|
|
404
|
+
`navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
return screensByShortId.get(action.screen)?.[0];
|
|
408
|
+
}
|
|
409
|
+
|
|
339
410
|
export function validateScreens(
|
|
340
411
|
feature: FeatureDefinition,
|
|
341
412
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
@@ -346,6 +417,10 @@ export function validateScreens(
|
|
|
346
417
|
string,
|
|
347
418
|
ReadonlyArray<{ readonly featureName: string; readonly screen: ScreenDefinition }>
|
|
348
419
|
>,
|
|
420
|
+
detailForScreens: ReadonlyMap<
|
|
421
|
+
string,
|
|
422
|
+
{ readonly featureName: string; readonly screen: ScreenDefinition }
|
|
423
|
+
>,
|
|
349
424
|
): void {
|
|
350
425
|
// navigate-Targets (rowAction/toolbarAction) dürfen cross-feature zeigen —
|
|
351
426
|
// der Runtime-Router (create-app) löst eine bare screenId app-weit über ALLE
|
|
@@ -433,14 +508,17 @@ export function validateScreens(
|
|
|
433
508
|
if (screen.rowActions !== undefined) {
|
|
434
509
|
for (const action of screen.rowActions) {
|
|
435
510
|
if (action.kind === "navigate") {
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
511
|
+
const target = resolveRowActionNavigateTarget(
|
|
512
|
+
feature.name,
|
|
513
|
+
screenId,
|
|
514
|
+
"projectionList",
|
|
515
|
+
"rowAction",
|
|
516
|
+
action,
|
|
517
|
+
allScreenQns,
|
|
518
|
+
navTargetShortIds,
|
|
519
|
+
screensByShortId,
|
|
520
|
+
detailForScreens,
|
|
521
|
+
);
|
|
444
522
|
validateRowActionNavigateParams(
|
|
445
523
|
feature.name,
|
|
446
524
|
screenId,
|
|
@@ -545,14 +623,17 @@ export function validateScreens(
|
|
|
545
623
|
);
|
|
546
624
|
}
|
|
547
625
|
if (action.kind === "navigate") {
|
|
548
|
-
const
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
626
|
+
const target = resolveRowActionNavigateTarget(
|
|
627
|
+
feature.name,
|
|
628
|
+
screenId,
|
|
629
|
+
"projectionDetail",
|
|
630
|
+
"action",
|
|
631
|
+
action,
|
|
632
|
+
allScreenQns,
|
|
633
|
+
navTargetShortIds,
|
|
634
|
+
screensByShortId,
|
|
635
|
+
detailForScreens,
|
|
636
|
+
);
|
|
556
637
|
validateRowActionNavigateParams(
|
|
557
638
|
feature.name,
|
|
558
639
|
screenId,
|
|
@@ -934,20 +1015,28 @@ export function validateScreens(
|
|
|
934
1015
|
if (screen.rowActions !== undefined) {
|
|
935
1016
|
for (const action of screen.rowActions) {
|
|
936
1017
|
if (action.kind === "navigate") {
|
|
937
|
-
const
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
1018
|
+
const target = resolveRowActionNavigateTarget(
|
|
1019
|
+
feature.name,
|
|
1020
|
+
screenId,
|
|
1021
|
+
"entityList",
|
|
1022
|
+
"rowAction",
|
|
1023
|
+
action,
|
|
1024
|
+
allScreenQns,
|
|
1025
|
+
navTargetShortIds,
|
|
1026
|
+
screensByShortId,
|
|
1027
|
+
detailForScreens,
|
|
1028
|
+
);
|
|
944
1029
|
// The renderer's default-entityId fallback (row["id"]) only fires
|
|
945
1030
|
// for a same-feature entityEdit target — it can't safely guess
|
|
946
1031
|
// the id for a screen owned by a different feature. Cross-feature
|
|
947
1032
|
// + entityEdit therefore MUST set an explicit entityId, or the
|
|
948
1033
|
// edit screen silently opens with no entity context at runtime.
|
|
949
|
-
|
|
1034
|
+
// Entity-targets (fw#2228) are exempt: the renderer always
|
|
1035
|
+
// supplies an id for them (explicit entityId, else row["id"]),
|
|
1036
|
+
// regardless of which feature the resolved detailFor screen
|
|
1037
|
+
// belongs to.
|
|
950
1038
|
if (
|
|
1039
|
+
action.screen !== undefined &&
|
|
951
1040
|
target !== undefined &&
|
|
952
1041
|
target.featureName !== feature.name &&
|
|
953
1042
|
target.screen.type === "entityEdit" &&
|
|
@@ -15,6 +15,8 @@ export type ChangelogEntry = {
|
|
|
15
15
|
readonly detail?: string;
|
|
16
16
|
/** Required when type=breaking. Shown in `kumiko upgrade` output. */
|
|
17
17
|
readonly migration?: string;
|
|
18
|
+
/** Path (repo-root-relative, under scripts/codemod/) run by `kumiko upgrade --apply`. */
|
|
19
|
+
readonly codemod?: string;
|
|
18
20
|
};
|
|
19
21
|
|
|
20
22
|
export type FeatureChangelog = {
|
|
@@ -323,6 +323,14 @@ describe("UnprocessableError", () => {
|
|
|
323
323
|
expect(err.details).toMatchObject({ reason: "order.already_cancelled", orderId: 7 });
|
|
324
324
|
expect(err.i18nKey).toBe("orders.errors.alreadyCancelled");
|
|
325
325
|
});
|
|
326
|
+
|
|
327
|
+
test("positional reason survives a conflicting details.reason from the caller", () => {
|
|
328
|
+
const err = new UnprocessableError("order.already_cancelled", {
|
|
329
|
+
details: { reason: "some unrelated cause text", orderId: 7 },
|
|
330
|
+
});
|
|
331
|
+
expect(err.details).toEqual({ reason: "order.already_cancelled", orderId: 7 });
|
|
332
|
+
expect(err.docsUrl).toBe("https://docs.kumiko.rocks/errors/order.already_cancelled");
|
|
333
|
+
});
|
|
326
334
|
});
|
|
327
335
|
|
|
328
336
|
describe("InternalError", () => {
|