@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.
- package/package.json +7 -3
- package/src/__tests__/schema-cli.integration.test.ts +45 -0
- package/src/__tests__/upgrade-cli.test.ts +370 -0
- package/src/api/__tests__/request-locale.integration.test.ts +96 -0
- package/src/api/api-constants.ts +7 -0
- package/src/api/request-context.ts +5 -0
- package/src/api/request-id-middleware.ts +10 -0
- package/src/arg-parser.ts +66 -0
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +6 -3
- 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/index.ts +6 -0
- package/src/i18n/request-locale.ts +63 -0
- package/src/i18n/required-surface-keys.ts +22 -7
- package/src/pipeline/__tests__/distributed-lock.integration.test.ts +31 -0
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +28 -29
- package/src/pipeline/dispatch-shared.ts +8 -0
- package/src/pipeline/distributed-lock.ts +26 -0
- package/src/schema-cli.ts +11 -0
- package/src/stack/test-stack.ts +6 -1
- package/src/testing/handler-context.ts +17 -12
- package/src/upgrade-cli.ts +445 -0
package/src/bun-db/query.ts
CHANGED
|
@@ -685,7 +685,10 @@ function isClosedConnectionError(err: unknown): boolean {
|
|
|
685
685
|
);
|
|
686
686
|
}
|
|
687
687
|
|
|
688
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|