@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
@@ -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 "${action.screen}" which ${reason} — only actionForm ` +
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 candidateQn = qualifyEntityName(feature.name, "screen", action.screen);
437
- if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
438
- throw new Error(
439
- `[Feature ${feature.name}] Screen "${screenId}" (projectionList) rowAction "${action.id}" ` +
440
- `navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
441
- );
442
- }
443
- const target = screensByShortId.get(action.screen)?.[0];
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 candidateQn = qualifyEntityName(feature.name, "screen", action.screen);
549
- if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
550
- throw new Error(
551
- `[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) action "${action.id}" ` +
552
- `navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
553
- );
554
- }
555
- const target = screensByShortId.get(action.screen)?.[0];
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 candidateQn = qualifyEntityName(feature.name, "screen", action.screen);
938
- if (!allScreenQns.has(candidateQn) && !navTargetShortIds.has(action.screen)) {
939
- throw new Error(
940
- `[Feature ${feature.name}] Screen "${screenId}" (entityList) rowAction "${action.id}" ` +
941
- `navigate-target "${action.screen}" does not resolve to a registered screen in any feature.`,
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
- const target = screensByShortId.get(action.screen)?.[0];
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", () => {
@@ -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: { reason, ...opts?.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). Reach for the concrete classes when you
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
- function pushKey(out: Set<string>, value: string | undefined): void {
60
- if (value !== undefined && isI18nKey(value)) out.add(value);
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(nav: NavDefinition): readonly string[] {
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 (default 100ms, test-stack 50ms). With
3
- // LISTEN, event-store.append fires `pg_notify` on commit and any
4
- // subscribed dispatcher wakes immediately latency becomes TCP
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 faster than one pollInterval.
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 faster than one pollInterval", async () => {
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
- // Wait up to 500ms, then check latency.
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
- const deadline = Date.now() + 500;
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