@cosmicdrift/kumiko-framework 0.211.0 → 0.213.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.
Files changed (35) hide show
  1. package/package.json +7 -3
  2. package/src/__tests__/schema-cli.integration.test.ts +45 -0
  3. package/src/__tests__/upgrade-cli.test.ts +370 -0
  4. package/src/api/__tests__/request-locale.integration.test.ts +96 -0
  5. package/src/api/api-constants.ts +7 -0
  6. package/src/api/request-context.ts +5 -0
  7. package/src/api/request-id-middleware.ts +10 -0
  8. package/src/arg-parser.ts +66 -0
  9. package/src/bun-db/index.ts +1 -0
  10. package/src/bun-db/query.ts +6 -3
  11. package/src/db/queries/backfill-pii.ts +19 -2
  12. package/src/engine/__tests__/boot-validator.test.ts +222 -0
  13. package/src/engine/__tests__/required-surface-keys.test.ts +47 -0
  14. package/src/engine/boot-validator/__tests__/i18n-keys.test.ts +64 -0
  15. package/src/engine/boot-validator/detail-screens.ts +16 -2
  16. package/src/engine/boot-validator/i18n-keys.ts +9 -2
  17. package/src/engine/boot-validator/index.ts +7 -2
  18. package/src/engine/boot-validator/screens.ts +114 -25
  19. package/src/engine/feature-changelog.ts +2 -0
  20. package/src/errors/__tests__/classes.test.ts +8 -0
  21. package/src/errors/__tests__/write-failures.test.ts +5 -0
  22. package/src/errors/classes.ts +1 -1
  23. package/src/errors/write-error-info.ts +3 -1
  24. package/src/event-store/__tests__/backfill-pii.integration.test.ts +75 -0
  25. package/src/i18n/index.ts +6 -0
  26. package/src/i18n/request-locale.ts +63 -0
  27. package/src/i18n/required-surface-keys.ts +22 -7
  28. package/src/pipeline/__tests__/distributed-lock.integration.test.ts +31 -0
  29. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +28 -29
  30. package/src/pipeline/dispatch-shared.ts +8 -0
  31. package/src/pipeline/distributed-lock.ts +26 -0
  32. package/src/schema-cli.ts +11 -0
  33. package/src/stack/test-stack.ts +6 -1
  34. package/src/testing/handler-context.ts +17 -12
  35. package/src/upgrade-cli.ts +445 -0
@@ -685,7 +685,10 @@ function isClosedConnectionError(err: unknown): boolean {
685
685
  );
686
686
  }
687
687
 
688
- async function unsafeRead<TRow>(
688
+ // Exported so raw-SQL query modules outside bun-db (e.g. bundled-features'
689
+ // db/queries/*.ts) can opt into the same #1163 retry instead of calling
690
+ // asRawClient(db).unsafe(...) directly and losing it.
691
+ export async function unsafeReadRetrying<TRow>(
689
692
  db: AnyDb,
690
693
  sqlText: string,
691
694
  params: readonly unknown[],
@@ -736,7 +739,7 @@ export async function selectMany<TRow = any>(
736
739
  }
737
740
  sqlText += ` LIMIT ${options.limit}`;
738
741
  }
739
- const raw = (await unsafeRead(db, sqlText, values)) as readonly Record<string, unknown>[];
742
+ const raw = (await unsafeReadRetrying(db, sqlText, values)) as readonly Record<string, unknown>[];
740
743
  return coerceRows(raw, info) as readonly TRow[];
741
744
  }
742
745
 
@@ -970,7 +973,7 @@ export async function countWhere(
970
973
  sqlText += ` WHERE ${w.sqlText}`;
971
974
  values = w.values;
972
975
  }
973
- const rows = (await unsafeRead(db, sqlText, values)) as readonly { count: number }[];
976
+ const rows = (await unsafeReadRetrying(db, sqlText, values)) as readonly { count: number }[];
974
977
  return rows[0]?.count ?? 0;
975
978
  }
976
979
 
@@ -41,7 +41,7 @@ import { asRawClient } from "../../bun-db";
41
41
  import { quoteIdent } from "../../crypto/ciphertext-pattern";
42
42
  import { configuredEventPiiCatalog } from "../../crypto/event-pii";
43
43
  import type { KmsContext, LocalKeyKmsAdapter, SubjectId } from "../../crypto/kms-adapter";
44
- import { KeyErasedError } from "../../crypto/kms-adapter";
44
+ import { KeyErasedError, KeyNotFoundError } from "../../crypto/kms-adapter";
45
45
  import {
46
46
  configuredPiiSubjectKms,
47
47
  encryptPiiValueForSubject,
@@ -83,7 +83,8 @@ export type PiiBackfillResult = {
83
83
 
84
84
  export type PiiBackfillOptions = {
85
85
  readonly batchSize?: number;
86
- // Scan + count only, write nothing.
86
+ // Scan + count only, write nothing — including the subject KMS: outcomes
87
+ // are predicted from a read-only kms.getKey probe, never kms.createKey.
87
88
  readonly dryRun?: boolean;
88
89
  // Stage 2: fall back to the entity's projection row (by aggregate_id)
89
90
  // when a lifecycle event's payload doesn't name the owner field.
@@ -350,6 +351,7 @@ export async function backfillEventPiiEncryption(
350
351
  section[field] = PII_ERASED_SENTINEL;
351
352
  return "erased";
352
353
  }
354
+ if (options.dryRun) return predictEncryptOutcome(subject);
353
355
  try {
354
356
  section[field] = await encryptPiiValueForSubject(
355
357
  kms as LocalKeyKmsAdapter,
@@ -367,6 +369,21 @@ export async function backfillEventPiiEncryption(
367
369
  throw e;
368
370
  }
369
371
  }
372
+
373
+ // getKey alone never mints (unlike getOrCreateDek → createKey on the real
374
+ // path) — a real run's outcome is fully determined by whether a key
375
+ // already exists, so probing it read-only predicts the outcome without
376
+ // writing to the subject-keys store.
377
+ async function predictEncryptOutcome(subject: SubjectId): Promise<FieldOutcome> {
378
+ try {
379
+ await (kms as LocalKeyKmsAdapter).getKey(subject, kmsCtx);
380
+ return "encrypted";
381
+ } catch (e) {
382
+ if (e instanceof KeyErasedError) return "erased";
383
+ if (e instanceof KeyNotFoundError) return "encrypted";
384
+ throw e;
385
+ }
386
+ }
370
387
  }
371
388
 
372
389
  function isForgottenSubject(subject: SubjectId, aggregateId: string): boolean {
@@ -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
- export function validateDetailForScreens(
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
- ): void {
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)) out.add(key);
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 { validateDetailForScreens } from "./detail-screens";
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