@cosmicdrift/kumiko-framework 0.211.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.
- package/package.json +7 -3
- package/src/__tests__/upgrade-cli.test.ts +370 -0
- package/src/arg-parser.ts +66 -0
- 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/required-surface-keys.ts +22 -7
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.test.ts +28 -29
- package/src/stack/test-stack.ts +6 -1
- package/src/upgrade-cli.ts +445 -0
|
@@ -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) {
|
|
@@ -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
|
|
|
@@ -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
|
}
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -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"),
|