@cosmicdrift/kumiko-framework 0.210.0 → 0.212.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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) {
@@ -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
 
@@ -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
  }
@@ -153,6 +153,11 @@ export type TestStackOptions = {
153
153
  consumerLane?: JobRunIn;
154
154
  queueNamePrefix?: string;
155
155
  };
156
+ /** Override the event dispatcher's polling-timer interval. Default 50ms.
157
+ * Tests that assert LISTEN/NOTIFY wake-up latency need this pushed far
158
+ * out (e.g. 60_000) so the polling timer can't land inside the
159
+ * assertion window and mask a dead subscription — see E.4 (#2042). */
160
+ eventDispatcherPollIntervalMs?: number;
156
161
  };
157
162
 
158
163
  const DEFAULT_JWT_SECRET = "test-stack-secret-minimum-32-characters!!";
@@ -379,7 +384,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
379
384
  // plumbs through the LISTEN wake-up for tests that want to measure
380
385
  // post-commit latency (Sprint E.4).
381
386
  eventDispatcher: {
382
- pollIntervalMs: 50,
387
+ pollIntervalMs: options.eventDispatcherPollIntervalMs ?? 50,
383
388
  pgClient: testDb.client as PgClient | undefined,
384
389
  systemConsumers: {
385
390
  sse: enabledHooks.includes("sse"),