@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
|
@@ -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", () => {
|
|
@@ -17,6 +17,11 @@ describe("failUnprocessable", () => {
|
|
|
17
17
|
expect(f.error.httpStatus).toBe(422);
|
|
18
18
|
expect(f.error.details).toMatchObject({ reason: "custom_business_rule", extra: 42 });
|
|
19
19
|
});
|
|
20
|
+
|
|
21
|
+
test("reason-Argument überlebt ein details.reason aus dem Aufruf", () => {
|
|
22
|
+
const f = failUnprocessable("custom_business_rule", { reason: "raw cause text", extra: 42 });
|
|
23
|
+
expect(f.error.details).toEqual({ reason: "custom_business_rule", extra: 42 });
|
|
24
|
+
});
|
|
20
25
|
});
|
|
21
26
|
|
|
22
27
|
describe("failTransition", () => {
|
package/src/errors/classes.ts
CHANGED
|
@@ -207,7 +207,7 @@ export class UnprocessableError extends KumikoError {
|
|
|
207
207
|
message: `unprocessable: ${reason}`,
|
|
208
208
|
i18nKey: opts?.i18nKey ?? "errors.unprocessable",
|
|
209
209
|
...(opts?.i18nParams && { i18nParams: opts.i18nParams }),
|
|
210
|
-
details: {
|
|
210
|
+
details: { ...opts?.details, reason },
|
|
211
211
|
...(opts?.cause && { cause: opts.cause }),
|
|
212
212
|
});
|
|
213
213
|
}
|
|
@@ -23,7 +23,9 @@ export function writeFailure(err: KumikoError): WriteFailure {
|
|
|
23
23
|
|
|
24
24
|
// Focused convenience for the two most common handler failures: "X not found"
|
|
25
25
|
// (typed 404) and "business rule violated: REASON" (typed 422 with the reason
|
|
26
|
-
// string surfaced in details.reason
|
|
26
|
+
// string surfaced in details.reason — the positional `reason` always wins
|
|
27
|
+
// over a same-named key in `details`, so passing an unrelated `reason` there
|
|
28
|
+
// by mistake can't shadow the slug). Reach for the concrete classes when you
|
|
27
29
|
// need richer payload — these two cover the bulk of handler code.
|
|
28
30
|
// @wrapper-known error-helper
|
|
29
31
|
export function failNotFound(entity: string, id?: number | string): WriteFailure {
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
configurePiiSubjectKms,
|
|
18
18
|
InMemoryKmsAdapter,
|
|
19
19
|
isPiiCiphertext,
|
|
20
|
+
KeyNotFoundError,
|
|
20
21
|
PII_ERASED_SENTINEL,
|
|
21
22
|
} from "../../crypto";
|
|
22
23
|
import { applyEntityEvent } from "../../db/apply-entity-event";
|
|
@@ -255,6 +256,80 @@ describe("backfillEventPiiEncryption", () => {
|
|
|
255
256
|
expect(second.failures).toEqual([]);
|
|
256
257
|
});
|
|
257
258
|
|
|
259
|
+
test("dryRun mints no subject key (fw#2255) and predicts the real run's counters exactly", async () => {
|
|
260
|
+
const c1 = generateId();
|
|
261
|
+
await appendPlain(c1, "contact", "contact.created", { id: c1, email: "a@x.com" });
|
|
262
|
+
|
|
263
|
+
armKms();
|
|
264
|
+
const dry = await backfillEventPiiEncryption(testDb.db, registry, { dryRun: true });
|
|
265
|
+
expect(dry.failures).toEqual([]);
|
|
266
|
+
expect(dry.encryptedFields).toBe(1);
|
|
267
|
+
|
|
268
|
+
// No key exists in the separate subject-keys store — dry-run must not
|
|
269
|
+
// have called kms.createKey.
|
|
270
|
+
await expect(kms.getKey({ kind: "user", userId: c1 })).rejects.toThrow(KeyNotFoundError);
|
|
271
|
+
|
|
272
|
+
const real = await backfillEventPiiEncryption(testDb.db, registry);
|
|
273
|
+
expect(real.updatedEvents).toBe(dry.updatedEvents);
|
|
274
|
+
expect(real.encryptedFields).toBe(dry.encryptedFields);
|
|
275
|
+
expect(real.erasedFields).toBe(dry.erasedFields);
|
|
276
|
+
expect(real.ownerFromProjection).toBe(dry.ownerFromProjection);
|
|
277
|
+
expect(real.erasedUnresolvable).toBe(dry.erasedUnresolvable);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("dryRun on a KMS-era-erased subject (no *.forgotten event) predicts [[erased]] without touching the key store", async () => {
|
|
281
|
+
const author = generateId();
|
|
282
|
+
const noteId = generateId();
|
|
283
|
+
await appendPlain(noteId, "note", "note.created", {
|
|
284
|
+
id: noteId,
|
|
285
|
+
authorId: author,
|
|
286
|
+
body: "secret",
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
armKms();
|
|
290
|
+
// Layer 1 (header comment): a subject erased in the KMS era, with no
|
|
291
|
+
// *.forgotten event on the stream to catch it via isForgottenSubject.
|
|
292
|
+
const subject = { kind: "user" as const, userId: author };
|
|
293
|
+
await kms.createKey(subject);
|
|
294
|
+
await kms.eraseKey(subject);
|
|
295
|
+
|
|
296
|
+
const dry = await backfillEventPiiEncryption(testDb.db, registry, { dryRun: true });
|
|
297
|
+
expect(dry.failures).toEqual([]);
|
|
298
|
+
expect(dry.erasedFields).toBe(1);
|
|
299
|
+
expect(dry.encryptedFields).toBe(0);
|
|
300
|
+
|
|
301
|
+
const untouched = (await loadAggregate(testDb.db, noteId, TENANT))[0]?.payload as Record<
|
|
302
|
+
string,
|
|
303
|
+
unknown
|
|
304
|
+
>;
|
|
305
|
+
expect(untouched["body"]).toBe("secret");
|
|
306
|
+
|
|
307
|
+
const real = await backfillEventPiiEncryption(testDb.db, registry);
|
|
308
|
+
expect(real.erasedFields).toBe(dry.erasedFields);
|
|
309
|
+
expect(real.encryptedFields).toBe(dry.encryptedFields);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("dryRun over a catalogued custom event mints no key for its payload-resolved subject", async () => {
|
|
313
|
+
const p1 = generateId();
|
|
314
|
+
await appendPlain(p1, "ping", "mailer:event:ping", {
|
|
315
|
+
targetId: "u-7",
|
|
316
|
+
address: "u7@x.com",
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
armKms();
|
|
320
|
+
const dry = await backfillEventPiiEncryption(testDb.db, registry, { dryRun: true });
|
|
321
|
+
expect(dry.failures).toEqual([]);
|
|
322
|
+
expect(dry.encryptedFields).toBe(1);
|
|
323
|
+
|
|
324
|
+
// "u-7" comes straight from the event payload, not from aggregate_id —
|
|
325
|
+
// the catalog path is the one that mints a key for a subject that never
|
|
326
|
+
// existed anywhere else (the reported phronexsis prod symptom).
|
|
327
|
+
await expect(kms.getKey({ kind: "user", userId: "u-7" })).rejects.toThrow(KeyNotFoundError);
|
|
328
|
+
|
|
329
|
+
const real = await backfillEventPiiEncryption(testDb.db, registry);
|
|
330
|
+
expect(real.encryptedFields).toBe(dry.encryptedFields);
|
|
331
|
+
});
|
|
332
|
+
|
|
258
333
|
test("small batchSize pages through the estate completely", async () => {
|
|
259
334
|
const ids = [generateId(), generateId(), generateId(), generateId(), generateId()];
|
|
260
335
|
for (const id of ids) {
|
package/src/i18n/index.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { Registry, TranslationKeys } from "../engine/types";
|
|
2
2
|
|
|
3
3
|
export { hasMailTranslations, mailT, registerMailTranslations } from "./mail-registry";
|
|
4
|
+
export {
|
|
5
|
+
DEFAULT_LOCALE,
|
|
6
|
+
isValidLocaleTag,
|
|
7
|
+
pickAcceptLanguage,
|
|
8
|
+
resolveHeaderLocale,
|
|
9
|
+
} from "./request-locale";
|
|
4
10
|
|
|
5
11
|
export type I18nOptions = {
|
|
6
12
|
defaultLocale: string;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Request-scoped locale resolution — the language counterpart to ctx.tz
|
|
2
|
+
// (time/tz-context.ts). Unlike TzContext there's no closed catalog to
|
|
3
|
+
// validate against here: mail-registry.ts is a dynamic, per-package
|
|
4
|
+
// registry that locale packages (kumiko-locale-de, ...) populate at import
|
|
5
|
+
// time, so "known locale" isn't a fixed enum. Validation checks
|
|
6
|
+
// well-formedness instead — a header is user input either way.
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_LOCALE = "en";
|
|
9
|
+
|
|
10
|
+
// Loose BCP-47: 2-3 letter primary subtag, then 1-8 more alphanumeric
|
|
11
|
+
// subtags separated by "-" (region/script/variant/extension). Rejects
|
|
12
|
+
// control characters, oversized values, and header-injection shapes
|
|
13
|
+
// without implementing a full RFC 5646 parser.
|
|
14
|
+
const LOCALE_TAG_RE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/;
|
|
15
|
+
const MAX_LOCALE_TAG_LENGTH = 35;
|
|
16
|
+
|
|
17
|
+
export function isValidLocaleTag(value: string): boolean {
|
|
18
|
+
return value.length <= MAX_LOCALE_TAG_LENGTH && LOCALE_TAG_RE.test(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type AcceptLanguageCandidate = { readonly tag: string; readonly q: number; readonly index: number };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Picks the best well-formed tag from an Accept-Language header (RFC 9110
|
|
25
|
+
* §12.5.4): parses "tag;q=x" pairs, sorts by q descending (header order
|
|
26
|
+
* breaks ties), and returns the first tag that passes isValidLocaleTag.
|
|
27
|
+
* Tags with q=0 (explicitly excluded) are dropped entirely.
|
|
28
|
+
*/
|
|
29
|
+
export function pickAcceptLanguage(header: string | undefined): string | undefined {
|
|
30
|
+
if (header === undefined || header.length === 0) return undefined;
|
|
31
|
+
|
|
32
|
+
const candidates: AcceptLanguageCandidate[] = header
|
|
33
|
+
.split(",")
|
|
34
|
+
.map((part, index): AcceptLanguageCandidate | undefined => {
|
|
35
|
+
const [tagRaw, ...params] = part.trim().split(";");
|
|
36
|
+
const tag = tagRaw?.trim();
|
|
37
|
+
if (tag === undefined || tag.length === 0 || !isValidLocaleTag(tag)) return undefined;
|
|
38
|
+
const qParam = params.find((p) => p.trim().startsWith("q="));
|
|
39
|
+
const parsedQ = qParam !== undefined ? Number(qParam.trim().slice(2)) : 1;
|
|
40
|
+
const q = Number.isFinite(parsedQ) ? parsedQ : 0;
|
|
41
|
+
return { tag, q, index };
|
|
42
|
+
})
|
|
43
|
+
.filter((c): c is AcceptLanguageCandidate => c !== undefined && c.q > 0)
|
|
44
|
+
.sort((a, b) => b.q - a.q || a.index - b.index);
|
|
45
|
+
|
|
46
|
+
return candidates[0]?.tag;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Request-layer resolution: an explicit, validated X-Locale header wins;
|
|
51
|
+
* otherwise the best Accept-Language tag; otherwise undefined — no signal
|
|
52
|
+
* from this request, callers fall back further (the app's boot-configured
|
|
53
|
+
* defaultLocale, then DEFAULT_LOCALE — see dispatch-shared.ts).
|
|
54
|
+
*/
|
|
55
|
+
export function resolveHeaderLocale(options: {
|
|
56
|
+
readonly headerLocale?: string;
|
|
57
|
+
readonly acceptLanguage?: string;
|
|
58
|
+
}): string | undefined {
|
|
59
|
+
if (options.headerLocale !== undefined && isValidLocaleTag(options.headerLocale)) {
|
|
60
|
+
return options.headerLocale;
|
|
61
|
+
}
|
|
62
|
+
return pickAcceptLanguage(options.acceptLanguage);
|
|
63
|
+
}
|
|
@@ -56,8 +56,13 @@ function isI18nKey(value: string): boolean {
|
|
|
56
56
|
return value.includes(":");
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
/**
|
|
60
|
+
* `treatAsKey` bypasses the colon-only `isI18nKey` check — used by the Settings-Hub
|
|
61
|
+
* generator's dot-form labels (`${feature}.settings`, mask titles), which are
|
|
62
|
+
* always i18n references by construction, never literal display text (fw#2260).
|
|
63
|
+
*/
|
|
64
|
+
function pushKey(out: Set<string>, value: string | undefined, treatAsKey = false): void {
|
|
65
|
+
if (value !== undefined && (treatAsKey || isI18nKey(value))) out.add(value);
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
function editFieldName(f: string | { readonly field: string }): string {
|
|
@@ -111,10 +116,17 @@ function pushToolbarActionKeys(out: Set<string>, action: ToolbarAction): void {
|
|
|
111
116
|
// already pushed above.
|
|
112
117
|
}
|
|
113
118
|
|
|
119
|
+
export type RequiredKeysOptions = {
|
|
120
|
+
/** Bypass `isI18nKey`'s colon-only check for generated dot-form labels (fw#2260). */
|
|
121
|
+
readonly treatDotFormAsKey?: boolean;
|
|
122
|
+
};
|
|
123
|
+
|
|
114
124
|
export function requiredKeysFromScreen(
|
|
115
125
|
featureName: string,
|
|
116
126
|
screen: ScreenDefinition,
|
|
127
|
+
options: RequiredKeysOptions = {},
|
|
117
128
|
): readonly string[] {
|
|
129
|
+
const { treatDotFormAsKey = false } = options;
|
|
118
130
|
const out = new Set<string>();
|
|
119
131
|
pushKey(out, screenTitleKey(screen.id));
|
|
120
132
|
|
|
@@ -192,7 +204,7 @@ export function requiredKeysFromScreen(
|
|
|
192
204
|
pushKey(out, config.submitLabel);
|
|
193
205
|
for (const fieldName of Object.keys(config.fields)) {
|
|
194
206
|
const override = config.fieldLabels?.[fieldName];
|
|
195
|
-
if (override !== undefined) pushKey(out, override);
|
|
207
|
+
if (override !== undefined) pushKey(out, override, treatDotFormAsKey);
|
|
196
208
|
else out.add(fieldLabelKey(featureName, CONFIG_EDIT_ENTITY, fieldName));
|
|
197
209
|
}
|
|
198
210
|
for (const section of config.layout.sections) {
|
|
@@ -201,11 +213,11 @@ export function requiredKeysFromScreen(
|
|
|
201
213
|
continue;
|
|
202
214
|
}
|
|
203
215
|
if (section.kind === "relatedList") continue; // rejected at boot, unreachable here
|
|
204
|
-
pushKey(out, section.title);
|
|
216
|
+
pushKey(out, section.title, treatDotFormAsKey);
|
|
205
217
|
for (const f of section.fields) {
|
|
206
218
|
const fieldName = editFieldName(f);
|
|
207
219
|
const override = config.fieldLabels?.[fieldName];
|
|
208
|
-
if (override !== undefined) pushKey(out, override);
|
|
220
|
+
if (override !== undefined) pushKey(out, override, treatDotFormAsKey);
|
|
209
221
|
else out.add(fieldLabelKey(featureName, CONFIG_EDIT_ENTITY, fieldName));
|
|
210
222
|
}
|
|
211
223
|
}
|
|
@@ -240,9 +252,12 @@ export function requiredKeysFromScreen(
|
|
|
240
252
|
return [...out];
|
|
241
253
|
}
|
|
242
254
|
|
|
243
|
-
export function requiredKeysFromNav(
|
|
255
|
+
export function requiredKeysFromNav(
|
|
256
|
+
nav: NavDefinition,
|
|
257
|
+
options: RequiredKeysOptions = {},
|
|
258
|
+
): readonly string[] {
|
|
244
259
|
const out = new Set<string>();
|
|
245
|
-
pushKey(out, nav.label);
|
|
260
|
+
pushKey(out, nav.label, options.treatDotFormAsKey ?? false);
|
|
246
261
|
return [...out];
|
|
247
262
|
}
|
|
248
263
|
|
|
@@ -64,4 +64,35 @@ describe("distributed lock", () => {
|
|
|
64
64
|
const token = await lock.acquire("test-lock-5");
|
|
65
65
|
expect(token).not.toBeNull();
|
|
66
66
|
});
|
|
67
|
+
|
|
68
|
+
test("renew extends the TTL for the owning token", async () => {
|
|
69
|
+
const lock = createDistributedLock(testRedis.redis);
|
|
70
|
+
const token = await lock.acquire("test-lock-6", { ttlSeconds: 1 });
|
|
71
|
+
if (!token) throw new Error("expected token");
|
|
72
|
+
|
|
73
|
+
const renewed = await lock.renew("test-lock-6", token, 5);
|
|
74
|
+
expect(renewed).toBe(true);
|
|
75
|
+
|
|
76
|
+
// Past the original 1s TTL, but renew pushed it out to 5s — still held.
|
|
77
|
+
await new Promise((r) => setTimeout(r, 1200));
|
|
78
|
+
expect(await lock.acquire("test-lock-6")).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("renew with wrong token fails and does not extend the TTL", async () => {
|
|
82
|
+
const lock = createDistributedLock(testRedis.redis);
|
|
83
|
+
await lock.acquire("test-lock-7", { ttlSeconds: 1 });
|
|
84
|
+
|
|
85
|
+
const renewed = await lock.renew("test-lock-7", "wrong-token", 5);
|
|
86
|
+
expect(renewed).toBe(false);
|
|
87
|
+
|
|
88
|
+
// The original 1s TTL still applies — the wrong-token renew didn't touch it.
|
|
89
|
+
await new Promise((r) => setTimeout(r, 1100));
|
|
90
|
+
expect(await lock.acquire("test-lock-7")).not.toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("renew on an expired/absent key fails", async () => {
|
|
94
|
+
const lock = createDistributedLock(testRedis.redis);
|
|
95
|
+
const renewed = await lock.renew("test-lock-8-never-acquired", "some-token", 5);
|
|
96
|
+
expect(renewed).toBe(false);
|
|
97
|
+
});
|
|
67
98
|
});
|
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
// E.4 — PG LISTEN/NOTIFY wake-up. Without this, delivery latency is
|
|
2
|
-
// bounded below by pollIntervalMs
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// round-trip, typically sub-millisecond on localhost.
|
|
2
|
+
// bounded below by pollIntervalMs. With LISTEN, event-store.append fires
|
|
3
|
+
// `pg_notify` on commit and any subscribed dispatcher wakes immediately —
|
|
4
|
+
// latency becomes TCP round-trip, typically sub-millisecond on localhost.
|
|
6
5
|
//
|
|
7
6
|
// The polling timer stays on as a safety net for dropped subscriptions
|
|
8
7
|
// and crashes between commit and wake. These tests pin:
|
|
9
8
|
//
|
|
10
|
-
// 1. NOTIFY → runOnce fires
|
|
9
|
+
// 1. NOTIFY → runOnce fires promptly, without waiting for the timer.
|
|
11
10
|
// 2. The dispatcher starts cleanly when pgClient is wired and stops
|
|
12
|
-
// without leaking the LISTEN connection
|
|
11
|
+
// without leaking the LISTEN connection, and still wakes on NOTIFY
|
|
12
|
+
// after a restart cycle.
|
|
13
|
+
//
|
|
14
|
+
// #2042: these used to assert an absolute millisecond latency bound
|
|
15
|
+
// (`< 40`, later `< 100`) against the test-stack's default 50ms polling
|
|
16
|
+
// timer. On a shared CI runner a stalled event loop can push even a
|
|
17
|
+
// working LISTEN's delivery past 100ms — measured up to 154ms — so no
|
|
18
|
+
// millisecond bound both clears runner noise and stays under a 50ms
|
|
19
|
+
// timer. Fix: push the polling timer out to 60s for this stack
|
|
20
|
+
// (`eventDispatcherPollIntervalMs`) and assert delivery happens at all
|
|
21
|
+
// inside a 5s window. If LISTEN is dead, nothing arrives before the 5s
|
|
22
|
+
// deadline — a 12x margin under the 60s timer that no runner stall gets
|
|
23
|
+
// anywhere near. If LISTEN works, delivery is near-instant regardless of
|
|
24
|
+
// runner load. Verified by temporarily forcing pgClient to undefined in
|
|
25
|
+
// test-stack.ts: both tests then fail at toHaveLength(1) with 0 received
|
|
26
|
+
// after ~5s, confirming the assertion still discriminates.
|
|
13
27
|
|
|
14
28
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
15
29
|
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
@@ -47,6 +61,10 @@ beforeAll(async () => {
|
|
|
47
61
|
stack = await setupTestStack({
|
|
48
62
|
features: [listenFeature],
|
|
49
63
|
systemHooks: [],
|
|
64
|
+
// Timer effectively off — see file header (#2042). If LISTEN is
|
|
65
|
+
// broken, delivery only happens via this timer, so nothing arrives
|
|
66
|
+
// before the 60s mark; the tests below wait a mere 5s.
|
|
67
|
+
eventDispatcherPollIntervalMs: 60_000,
|
|
50
68
|
});
|
|
51
69
|
await unsafeCreateEntityTable(stack.db, sharedWidgetEntity, "widget");
|
|
52
70
|
tdb = createTenantDb(stack.db, admin.tenantId);
|
|
@@ -60,34 +78,18 @@ afterAll(async () => {
|
|
|
60
78
|
// --- Tests ---
|
|
61
79
|
|
|
62
80
|
describe("E.4 — PG NOTIFY/LISTEN wake-up", () => {
|
|
63
|
-
test("NOTIFY on commit triggers runOnce
|
|
64
|
-
// pollIntervalMs in the test-stack is 50ms. If LISTEN works, delivery
|
|
65
|
-
// lands within a few ms of commit; if LISTEN is broken, it takes up
|
|
66
|
-
// to pollIntervalMs. Use a generous upper bound that still discriminates:
|
|
67
|
-
// if the timer drives delivery, the gap between append and delivery
|
|
68
|
-
// is 25–50ms on average. If LISTEN drives it, it's sub-10ms.
|
|
81
|
+
test("NOTIFY on commit triggers runOnce without waiting for the poll timer", async () => {
|
|
69
82
|
deliveryTimes.length = 0;
|
|
70
83
|
|
|
71
84
|
await stack.eventDispatcher?.start();
|
|
72
85
|
try {
|
|
73
|
-
const appendedAt = Date.now();
|
|
74
86
|
await executor.create({ name: "latency-test" }, admin, tdb);
|
|
75
87
|
|
|
76
|
-
|
|
77
|
-
const deadline = Date.now() + 500;
|
|
88
|
+
const deadline = Date.now() + 5000;
|
|
78
89
|
while (Date.now() < deadline && deliveryTimes.length === 0) {
|
|
79
90
|
await new Promise((r) => setTimeout(r, 5));
|
|
80
91
|
}
|
|
81
92
|
expect(deliveryTimes).toHaveLength(1);
|
|
82
|
-
|
|
83
|
-
const latencyMs = (deliveryTimes[0] ?? 0) - appendedAt;
|
|
84
|
-
// LISTEN should beat the polling timer comfortably. Originally 40ms
|
|
85
|
-
// (LISTEN typical: <10ms; pollInterval: 50ms). ARM self-hosted runner
|
|
86
|
-
// schwankt bei 50-60ms wegen DB-IPC + clock-jitter im poll-loop —
|
|
87
|
-
// bound auf 2× pollInterval erweitert. Discriminierung bleibt:
|
|
88
|
-
// wenn LISTEN ganz broken ist, fällt der 500ms-Wait am `expect
|
|
89
|
-
// (deliveryTimes).toHaveLength(1)`-Check leer.
|
|
90
|
-
expect(latencyMs).toBeLessThan(100);
|
|
91
93
|
} finally {
|
|
92
94
|
await stack.eventDispatcher?.stop();
|
|
93
95
|
}
|
|
@@ -106,16 +108,13 @@ describe("E.4 — PG NOTIFY/LISTEN wake-up", () => {
|
|
|
106
108
|
deliveryTimes.length = 0;
|
|
107
109
|
await stack.eventDispatcher?.start();
|
|
108
110
|
try {
|
|
109
|
-
const appendedAt = Date.now();
|
|
110
111
|
await executor.create({ name: "restart-probe" }, admin, tdb);
|
|
111
|
-
|
|
112
|
+
|
|
113
|
+
const deadline = Date.now() + 5000;
|
|
112
114
|
while (Date.now() < deadline && deliveryTimes.length === 0) {
|
|
113
115
|
await new Promise((r) => setTimeout(r, 5));
|
|
114
116
|
}
|
|
115
117
|
expect(deliveryTimes).toHaveLength(1);
|
|
116
|
-
// Latency must still be LISTEN-fast (< pollInterval) — if the
|
|
117
|
-
// subscription silently dropped, the timer would deliver at ~50ms.
|
|
118
|
-
expect((deliveryTimes[0] ?? 0) - appendedAt).toBeLessThan(40);
|
|
119
118
|
} finally {
|
|
120
119
|
await stack.eventDispatcher?.stop();
|
|
121
120
|
}
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
} from "../event-store/snapshot";
|
|
52
52
|
import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster";
|
|
53
53
|
import { createFileContext } from "../files/file-handle";
|
|
54
|
+
import { DEFAULT_LOCALE } from "../i18n/request-locale";
|
|
54
55
|
import {
|
|
55
56
|
createMetricsHandle,
|
|
56
57
|
createNoopMetricsHandle,
|
|
@@ -620,6 +621,12 @@ export async function buildHandlerContext(
|
|
|
620
621
|
...(safeUserTz !== undefined && { user: safeUserTz }),
|
|
621
622
|
});
|
|
622
623
|
|
|
624
|
+
// ctx.locale — request-layer signal (X-Locale header → Accept-Language,
|
|
625
|
+
// resolved once at the HTTP boundary by request-id-middleware.ts) wins;
|
|
626
|
+
// falls back to the app's boot-configured defaultLocale, then
|
|
627
|
+
// DEFAULT_LOCALE. Mirrors ctx.tz's Request → Boot-Default chain above.
|
|
628
|
+
const locale = reqCtx?.locale ?? context.defaultLocale ?? DEFAULT_LOCALE;
|
|
629
|
+
|
|
623
630
|
return {
|
|
624
631
|
...context,
|
|
625
632
|
registry,
|
|
@@ -647,6 +654,7 @@ export async function buildHandlerContext(
|
|
|
647
654
|
metrics,
|
|
648
655
|
metricsFor,
|
|
649
656
|
tz,
|
|
657
|
+
locale,
|
|
650
658
|
// Cancellation signal flows from the HTTP middleware via
|
|
651
659
|
// requestContext. Conditional spread so non-HTTP entry-points
|
|
652
660
|
// (jobs, dispatcher MSP-applies) don't get a phantom signal that
|