@cosmicdrift/kumiko-bundled-features 0.208.3 → 0.209.1

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 (46) hide show
  1. package/package.json +8 -8
  2. package/src/compliance-profiles/__tests__/profile-picker.boot.test.ts +10 -2
  3. package/src/compliance-profiles/__tests__/profile-picker.security.integration.test.ts +50 -0
  4. package/src/compliance-profiles/changes.json +8 -1
  5. package/src/compliance-profiles/constants.ts +5 -0
  6. package/src/compliance-profiles/feature.ts +28 -3
  7. package/src/compliance-profiles/i18n.ts +25 -0
  8. package/src/compliance-profiles/web/client-plugin.tsx +7 -4
  9. package/src/compliance-profiles/web/compliance-profile-catalog.tsx +55 -0
  10. package/src/compliance-profiles/web/i18n.ts +1 -7
  11. package/src/compliance-profiles/web/index.ts +1 -1
  12. package/src/delivery/__tests__/delivery-log-query.integration.test.ts +132 -0
  13. package/src/delivery/__tests__/delivery-pii-kms.integration.test.ts +5 -5
  14. package/src/delivery/__tests__/delivery-screens.boot.test.ts +2 -2
  15. package/src/delivery/__tests__/delivery.integration.test.ts +1 -1
  16. package/src/delivery/changes.json +6 -0
  17. package/src/delivery/constants.ts +1 -0
  18. package/src/delivery/feature.ts +28 -3
  19. package/src/delivery/handlers/log.query.ts +115 -18
  20. package/src/delivery/i18n.ts +4 -0
  21. package/src/delivery/public-names.ts +5 -0
  22. package/src/delivery/web/client-plugin.tsx +6 -7
  23. package/src/delivery/web/delivery-status-cell.tsx +26 -0
  24. package/src/delivery/web/index.ts +1 -1
  25. package/src/jobs/__tests__/jobs-events.integration.test.ts +65 -81
  26. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +21 -18
  27. package/src/jobs/__tests__/jobs-retention.integration.test.ts +124 -0
  28. package/src/jobs/db/queries/retention.ts +44 -0
  29. package/src/jobs/events.ts +5 -8
  30. package/src/jobs/feature.ts +26 -146
  31. package/src/jobs/handlers/retention-cleanup.job.ts +21 -0
  32. package/src/jobs/index.ts +1 -1
  33. package/src/jobs/job-run-logger.ts +112 -59
  34. package/src/jobs/job-run-table.ts +36 -28
  35. package/src/sessions/__tests__/session-last-seen.integration.test.ts +165 -0
  36. package/src/sessions/changes.json +6 -0
  37. package/src/sessions/constants.ts +4 -0
  38. package/src/sessions/feature.ts +17 -1
  39. package/src/sessions/schema/user-session.ts +5 -0
  40. package/src/sessions/session-callbacks.ts +20 -1
  41. package/src/sessions/session-checker-fail-open.test.ts +38 -0
  42. package/src/user-data-rights/feature.ts +4 -4
  43. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +1 -1
  44. package/src/compliance-profiles/web/compliance-profile-screen.tsx +0 -152
  45. package/src/delivery/web/delivery-log-screen.tsx +0 -103
  46. package/src/delivery/web/i18n.ts +0 -15
@@ -1,14 +1,77 @@
1
- import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
- import { access, defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { selectMany, type WhereObject } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import { decodeCursor, encodeCursor } from "@cosmicdrift/kumiko-framework/db";
3
+ import {
4
+ access,
5
+ definePagedQueryHandler,
6
+ MAX_LIST_LIMIT,
7
+ type NotifyPriority,
8
+ } from "@cosmicdrift/kumiko-framework/engine";
3
9
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
10
+ import { Temporal } from "temporal-polyfill";
4
11
  import { z } from "zod";
5
12
  import { decryptStoredPii } from "../../shared";
13
+ import type { DeliveryStatusValue } from "../constants";
6
14
  import { deliveryAttemptsTable } from "../tables";
7
15
 
8
- export const logQuery = defineQueryHandler({
16
+ type DeliveryLogRow = {
17
+ id: string;
18
+ tenantId: string;
19
+ notificationType: string;
20
+ channel: string;
21
+ recipientId: string | null;
22
+ recipientAddress: string | null;
23
+ status: DeliveryStatusValue;
24
+ error: string | null;
25
+ priority: NotifyPriority;
26
+ createdAt: Temporal.Instant;
27
+ };
28
+
29
+ // Sort whitelist keyed by the DISPLAY field the client actually sends: the
30
+ // declarative projectionList renderer marks every listed column uniformly
31
+ // sortable and a header click sends `sort=<screen.columns[].field>`, not the
32
+ // DB column name (see projection-list-shim.ts synthesizeProjectionEntity).
33
+ // "recipient" is deliberately absent — it's PII decrypted per-row in
34
+ // application code, so there is no column to ORDER BY in SQL for it.
35
+ const DELIVERY_LOG_SORT_COLUMNS = {
36
+ createdAt: "createdAt",
37
+ type: "notificationType",
38
+ channel: "channel",
39
+ status: "status",
40
+ } as const;
41
+ type DeliveryLogSortField = keyof typeof DELIVERY_LOG_SORT_COLUMNS;
42
+
43
+ function isDeliveryLogSortField(value: string): value is DeliveryLogSortField {
44
+ return value in DELIVERY_LOG_SORT_COLUMNS;
45
+ }
46
+
47
+ type DeliveryLogSortColumn = (typeof DELIVERY_LOG_SORT_COLUMNS)[DeliveryLogSortField];
48
+
49
+ // createdAt round-trips through Temporal.Instant (matches how the WHERE
50
+ // builder coerces timestamptz values — see bun-db/query.ts prepareValue);
51
+ // every other whitelisted column is a plain text column. Keyed by the
52
+ // PHYSICAL column, not the display alias — row only has the DB field names
53
+ // (e.g. notificationType), never the alias (e.g. "type").
54
+ function encodeSortCursor(column: DeliveryLogSortColumn, row: DeliveryLogRow): string {
55
+ if (column === "createdAt") return encodeCursor(row.createdAt.toString());
56
+ return encodeCursor(String(row[column]));
57
+ }
58
+
59
+ function decodeSortCursor(
60
+ column: DeliveryLogSortColumn,
61
+ cursor: string,
62
+ ): Temporal.Instant | string {
63
+ const decoded = decodeCursor(cursor);
64
+ if (column === "createdAt") return Temporal.Instant.from(decoded);
65
+ return decoded;
66
+ }
67
+
68
+ export const logQuery = definePagedQueryHandler({
9
69
  name: "log",
10
70
  schema: z.object({
11
- limit: z.number().min(1).max(100).default(50),
71
+ cursor: z.string().optional(),
72
+ limit: z.number().int().min(1).max(MAX_LIST_LIMIT).default(50),
73
+ sort: z.string().optional(),
74
+ sortDirection: z.enum(["asc", "desc"]).optional(),
12
75
  }),
13
76
  access: { roles: access.admin },
14
77
  handler: async (query, ctx) => {
@@ -20,27 +83,61 @@ export const logQuery = defineQueryHandler({
20
83
  // tenantId, not a query filter — it returns that same unfiltered db, so
21
84
  // the explicit `where` below still does the actual tenant scoping.
22
85
  const db = ctx.systemDb.assertTenantMatch(query.user.tenantId);
23
- const rows = await selectMany(
24
- db,
25
- deliveryAttemptsTable,
26
- { tenantId: query.user.tenantId },
27
- {
28
- orderBy: { col: "createdAt", direction: "desc" },
29
- limit: query.payload.limit,
30
- },
31
- );
86
+
87
+ const requestedSort = query.payload.sort;
88
+ const sortField: DeliveryLogSortField =
89
+ requestedSort !== undefined && isDeliveryLogSortField(requestedSort)
90
+ ? requestedSort
91
+ : "createdAt";
92
+ const sortDirection = query.payload.sortDirection ?? "desc";
93
+ const sortColumn = DELIVERY_LOG_SORT_COLUMNS[sortField];
94
+
95
+ const where: WhereObject = { tenantId: query.user.tenantId };
96
+ if (query.payload.cursor) {
97
+ where[sortColumn] = {
98
+ [sortDirection === "asc" ? "gt" : "lt"]: decodeSortCursor(sortColumn, query.payload.cursor),
99
+ };
100
+ }
101
+
102
+ const rows = await selectMany<DeliveryLogRow>(db, deliveryAttemptsTable, where, {
103
+ // Tie-breaker on id keeps ordering stable across pages when the sort
104
+ // column has duplicate values (e.g. many rows with the same status).
105
+ orderBy: [
106
+ { col: sortColumn, direction: sortDirection },
107
+ { col: "id", direction: "asc" },
108
+ ],
109
+ limit: query.payload.limit,
110
+ });
111
+
112
+ const lastRow = rows[rows.length - 1];
113
+ const nextCursor =
114
+ rows.length === query.payload.limit && lastRow ? encodeSortCursor(sortColumn, lastRow) : null;
115
+
32
116
  // recipientAddress is stored encrypted under the recipient's DEK (#799)
33
117
  // — decrypt for the admin log view; forgotten subjects show [[erased]].
118
+ // The notificationType/recipientAddress → type/recipient rename happens
119
+ // here, not in the client: a projectionList screen has no entity to
120
+ // derive a field-mapping from, so the row shape the query returns is the
121
+ // shape the declarative columns read directly.
34
122
  return {
35
123
  rows: await Promise.all(
36
124
  rows.map(async (row) => ({
37
- ...row,
38
- recipientAddress:
39
- typeof row["recipientAddress"] === "string"
40
- ? await decryptStoredPii(row["recipientAddress"], "recipientAddress", "delivery-log")
41
- : row["recipientAddress"],
125
+ id: row.id,
126
+ tenantId: row.tenantId,
127
+ recipientId: row.recipientId,
128
+ type: row.notificationType,
129
+ channel: row.channel,
130
+ recipient:
131
+ row.recipientAddress !== null
132
+ ? await decryptStoredPii(row.recipientAddress, "recipientAddress", "delivery-log")
133
+ : row.recipientAddress,
134
+ status: row.status,
135
+ error: row.error,
136
+ priority: row.priority,
137
+ createdAt: row.createdAt,
42
138
  })),
43
139
  ),
140
+ nextCursor,
44
141
  };
45
142
  },
46
143
  });
@@ -3,4 +3,8 @@ type LocalizedString = { readonly en: string };
3
3
  export const DELIVERY_I18N: Readonly<Record<string, LocalizedString>> = {
4
4
  "screen:delivery-log.title": { en: "Delivery log" },
5
5
  "delivery:nav.deliveryLog": { en: "Delivery" },
6
+ "delivery.log.col.type": { en: "Type" },
7
+ "delivery.log.col.channel": { en: "Channel" },
8
+ "delivery.log.col.recipient": { en: "Recipient" },
9
+ "delivery.log.col.status": { en: "Status" },
6
10
  };
@@ -14,6 +14,11 @@ export const DeliveryQueries = {
14
14
 
15
15
  export const DELIVERY_LOG_SCREEN_ID = "delivery-log" as const;
16
16
 
17
+ // Client column-renderer registered for the delivery-log screen's status
18
+ // column — see `screen.columns[].renderer.react.__component` in feature.ts
19
+ // and `deliveryClient()`'s `columnRenderers` map.
20
+ export const DELIVERY_STATUS_CELL_COMPONENT = "DeliveryStatusCell" as const;
21
+
17
22
  export const DeliveryErrors = {
18
23
  noRecipient: "delivery_no_recipient",
19
24
  channelFailed: "delivery_channel_failed",
@@ -1,9 +1,8 @@
1
1
  // @runtime client
2
- import { mergeTranslations, type TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
2
+ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
3
3
  import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
4
- import { DELIVERY_FEATURE, DELIVERY_LOG_SCREEN_ID } from "../public-names";
5
- import { DeliveryLogScreen } from "./delivery-log-screen";
6
- import { defaultTranslations } from "./i18n";
4
+ import { DELIVERY_FEATURE, DELIVERY_STATUS_CELL_COMPONENT } from "../public-names";
5
+ import { DeliveryStatusCell } from "./delivery-status-cell";
7
6
 
8
7
  export type DeliveryClientOptions = {
9
8
  readonly translations?: TranslationsByLocale;
@@ -12,9 +11,9 @@ export type DeliveryClientOptions = {
12
11
  export function deliveryClient(options?: DeliveryClientOptions): ClientFeatureDefinition {
13
12
  return {
14
13
  name: DELIVERY_FEATURE,
15
- translations: mergeTranslations(defaultTranslations, options?.translations ?? {}),
16
- components: {
17
- [DELIVERY_LOG_SCREEN_ID]: DeliveryLogScreen,
14
+ columnRenderers: {
15
+ [DELIVERY_STATUS_CELL_COMPONENT]: DeliveryStatusCell,
18
16
  },
17
+ ...(options?.translations !== undefined && { translations: options.translations }),
19
18
  };
20
19
  }
@@ -0,0 +1,26 @@
1
+ // @runtime client
2
+ // Column-renderer for the delivery-log screen's status column, registered
3
+ // via `deliveryClient()`'s `columnRenderers` map (screen.columns[].renderer
4
+ // in feature.ts). The list frame itself is the generic declarative
5
+ // projectionList renderer — only this one cell stays TSX.
6
+
7
+ import type { ColumnRendererProps } from "@cosmicdrift/kumiko-renderer";
8
+ import { StatusBadge, type StatusTone } from "@cosmicdrift/kumiko-renderer-web";
9
+ import type { ReactNode } from "react";
10
+ import { DeliveryStatus } from "../public-names";
11
+
12
+ const STATUS_TONE: Readonly<Record<string, StatusTone>> = {
13
+ [DeliveryStatus.sent]: "ok",
14
+ [DeliveryStatus.failed]: "bad",
15
+ [DeliveryStatus.queued]: "muted",
16
+ [DeliveryStatus.skipped]: "muted",
17
+ };
18
+
19
+ export function DeliveryStatusCell({ row }: ColumnRendererProps): ReactNode {
20
+ const status = typeof row["status"] === "string" ? row["status"] : "";
21
+ return (
22
+ <StatusBadge tone={STATUS_TONE[status] ?? "muted"} testId="delivery-status-cell">
23
+ {status}
24
+ </StatusBadge>
25
+ );
26
+ }
@@ -1,3 +1,3 @@
1
1
  // @runtime client
2
2
  export { type DeliveryClientOptions, deliveryClient } from "./client-plugin";
3
- export { DeliveryLogScreen } from "./delivery-log-screen";
3
+ export { DeliveryStatusCell } from "./delivery-status-cell";
@@ -1,16 +1,16 @@
1
- // Event-shape contract for jobRun aggregate. Pins the three domain
2
- // events (run-started / run-completed / run-failed) against their
3
- // registered schemas + the stable type-name constants. A silent rename
4
- // (event-type-string, aggregateType, or payload-shape) fails here
5
- // instead of breaking MSP consumers and audit exports.
1
+ // Direct-write shape contract for job runs (#2243). Pins onJobStart/
2
+ // -Complete/-Failed against the tables they write — jobRunsTable status
3
+ // transitions and jobRunLogsTable batched log rows. A silent shape drift
4
+ // (missing field, wrong status string) fails here instead of breaking the
5
+ // operator UI silently.
6
6
  //
7
7
  // The jobs integration test (jobs-feature.integration.ts) covers the
8
- // projection side (list + detail queries). This file covers the event
9
- // side — complementary coverage, minimal overlap.
8
+ // projection side (list + detail queries). This file covers the
9
+ // write side — complementary coverage, minimal overlap.
10
10
 
11
11
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
12
12
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
13
- import { createRegistry, SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
13
+ import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
14
14
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
15
15
  import {
16
16
  createTestDb,
@@ -20,14 +20,8 @@ import {
20
20
  unsafePushTables,
21
21
  } from "@cosmicdrift/kumiko-framework/stack";
22
22
  import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
23
- import { runCompletedSchema, runFailedSchema, runStartedSchema } from "../events";
24
23
  import { createJobsFeature } from "../feature";
25
- import {
26
- createJobRunLogger,
27
- JOB_RUN_COMPLETED_EVENT,
28
- JOB_RUN_FAILED_EVENT,
29
- JOB_RUN_STARTED_EVENT,
30
- } from "../job-run-logger";
24
+ import { createJobRunLogger } from "../job-run-logger";
31
25
  import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
32
26
 
33
27
  let testDb: TestDb;
@@ -40,6 +34,8 @@ beforeAll(async () => {
40
34
  testRedis = await createTestRedis();
41
35
  registry = createRegistry([createJobsFeature()]);
42
36
  await unsafePushTables(testDb.db, { jobRunsTable, jobRunLogsTable });
37
+ // Kept only for the negative assertion below (no jobs:event:run-* rows) —
38
+ // the write path itself no longer touches the event store.
43
39
  await createEventsTable(testDb.db);
44
40
  logger = createJobRunLogger({ db: testDb.db, registry });
45
41
  });
@@ -53,114 +49,102 @@ beforeEach(async () => {
53
49
  await resetTestTables(testDb.db, [eventsTable, jobRunsTable, jobRunLogsTable]);
54
50
  });
55
51
 
56
- describe("jobRun event shapes", () => {
57
- test("event-type constants are stable strings", () => {
58
- // Guard against silent rename. Tests that subscribe via string-match
59
- // (MSPs written in userland, audit export tools) break without this.
60
- expect(JOB_RUN_STARTED_EVENT).toBe("jobs:event:run-started");
61
- expect(JOB_RUN_COMPLETED_EVENT).toBe("jobs:event:run-completed");
62
- expect(JOB_RUN_FAILED_EVENT).toBe("jobs:event:run-failed");
63
- });
64
-
65
- test("onJobStart writes a run-started event on the jobRun aggregate", async () => {
52
+ describe("jobRun direct writes", () => {
53
+ test("onJobStart inserts a row into jobRunsTable", async () => {
66
54
  await logger.onJobStart?.("example:job:import", "bull-42", {
67
55
  triggeredById: "u-99",
68
56
  payload: JSON.stringify({ foo: 1 }),
69
57
  attempt: 1,
70
58
  });
71
59
 
72
- const events = await selectMany(testDb.db, eventsTable, { type: JOB_RUN_STARTED_EVENT });
73
-
74
- expect(events.length).toBe(1);
75
- const e = events[0];
76
- expect(e?.aggregateType).toBe("jobRun");
77
- expect(e?.tenantId).toBe(SYSTEM_TENANT_ID);
78
- // Payload round-trips through the registered schema — drift would
79
- // fail parse here, not silently land on the stream.
80
- expect(() => runStartedSchema.parse(e?.payload)).not.toThrow();
81
- const p = runStartedSchema.parse(e?.payload);
82
- expect(p.jobName).toBe("example:job:import");
83
- expect(p.bullJobId).toBe("bull-42");
84
- expect(p.triggeredById).toBe("u-99");
85
- expect(p.attempt).toBe(1);
60
+ const runs = await selectMany(testDb.db, jobRunsTable, { bullJobId: "bull-42" });
61
+
62
+ expect(runs.length).toBe(1);
63
+ const run = runs[0];
64
+ expect(run?.jobName).toBe("example:job:import");
65
+ expect(run?.status).toBe("running");
66
+ expect(run?.triggeredById).toBe("u-99");
67
+ expect(run?.attempt).toBe(1);
86
68
  });
87
69
 
88
- test("onJobComplete writes a run-completed event with batched logs", async () => {
70
+ test("onJobComplete updates the row to completed with batched logs", async () => {
89
71
  await logger.onJobStart?.("example:job:export", "bull-1", {});
90
72
  await logger.onJobComplete?.("example:job:export", "bull-1", 123, [
91
73
  { level: "info", message: "started", timestamp: Temporal.Now.instant() },
92
74
  { level: "info", message: "done", timestamp: Temporal.Now.instant() },
93
75
  ]);
94
76
 
95
- const events = await selectMany(testDb.db, eventsTable, { type: JOB_RUN_COMPLETED_EVENT });
77
+ const runs = await selectMany(testDb.db, jobRunsTable, { bullJobId: "bull-1" });
78
+ expect(runs.length).toBe(1);
79
+ expect(runs[0]?.status).toBe("completed");
80
+ expect(runs[0]?.duration).toBe(123);
96
81
 
97
- expect(events.length).toBe(1);
98
- const p = runCompletedSchema.parse(events[0]?.payload);
99
- expect(p.duration).toBe(123);
100
- expect(p.logs).toHaveLength(2);
101
- expect(p.logs[0]?.level).toBe("info");
82
+ const logs = await selectMany(testDb.db, jobRunLogsTable, { runId: runs[0]?.id as string });
83
+ expect(logs).toHaveLength(2);
84
+ expect(logs[0]?.level).toBe("info");
102
85
  });
103
86
 
104
- test("onJobFailed writes a run-failed event with error + logs", async () => {
87
+ test("onJobFailed updates the row to failed with error + logs", async () => {
105
88
  await logger.onJobStart?.("example:job:fragile", "bull-9", {});
106
89
  await logger.onJobFailed?.("example:job:fragile", "bull-9", "boom", [
107
90
  { level: "error", message: "kaboom", timestamp: Temporal.Now.instant() },
108
91
  ]);
109
92
 
110
- const events = await selectMany(testDb.db, eventsTable, { type: JOB_RUN_FAILED_EVENT });
93
+ const runs = await selectMany(testDb.db, jobRunsTable, { bullJobId: "bull-9" });
94
+ expect(runs.length).toBe(1);
95
+ expect(runs[0]?.status).toBe("failed");
96
+ expect(runs[0]?.error).toBe("boom");
111
97
 
112
- expect(events.length).toBe(1);
113
- const p = runFailedSchema.parse(events[0]?.payload);
114
- expect(p.error).toBe("boom");
115
- expect(p.logs).toHaveLength(1);
98
+ const logs = await selectMany(testDb.db, jobRunLogsTable, { runId: runs[0]?.id as string });
99
+ expect(logs).toHaveLength(1);
116
100
  });
117
101
 
118
- test("start + complete both land on the SAME aggregate stream", async () => {
102
+ test("start + complete both act on the SAME row", async () => {
119
103
  await logger.onJobStart?.("example:job:stream", "bull-99", {});
120
104
  await logger.onJobComplete?.("example:job:stream", "bull-99", 10, []);
121
105
 
122
- // Both events should share the same aggregateId — that's what makes
123
- // the jobRun a single stream and lets ctx.loadAggregate() reduce
124
- // them into a coherent state.
125
- const events = await selectMany(testDb.db, eventsTable, { aggregateType: "jobRun" });
126
-
127
- expect(events.length).toBe(2);
128
- const ids = new Set(events.map((e) => e.aggregateId));
129
- expect(ids.size).toBe(1);
106
+ // Exactly one row for this bullJobId — the complete-callback updated
107
+ // the row onJobStart created, it did not insert a second one.
108
+ const runs = await selectMany(testDb.db, jobRunsTable, { bullJobId: "bull-99" });
109
+ expect(runs.length).toBe(1);
110
+ expect(runs[0]?.status).toBe("completed");
130
111
  });
131
112
 
132
- test("complete/fail without a prior start skips — does not forge a jobRun stream", async () => {
133
- // State-loss path: worker restart with empty cache AND no projection row for
134
- // this bullJobId. Dropping the terminal event is intentional — forging an
135
- // aggregate from scratch would invent a run that never started.
113
+ test("complete/fail without a prior start skips — does not forge a run row", async () => {
114
+ // State-loss path: worker restart with empty cache AND no row for this
115
+ // bullJobId. Dropping the terminal write is intentional — forging a
116
+ // row from scratch would invent a run that never started.
136
117
  await logger.onJobComplete?.("example:job:orphan", "bull-orphan-complete", 50, []);
137
118
  await logger.onJobFailed?.("example:job:orphan", "bull-orphan-failed", "boom", []);
138
119
 
139
- const completed = await selectMany(testDb.db, eventsTable, { type: JOB_RUN_COMPLETED_EVENT });
140
- const failed = await selectMany(testDb.db, eventsTable, { type: JOB_RUN_FAILED_EVENT });
141
120
  const runs = await selectMany(testDb.db, jobRunsTable);
142
-
143
- expect(completed).toHaveLength(0);
144
- expect(failed).toHaveLength(0);
145
121
  expect(runs).toHaveLength(0);
146
122
  });
147
123
 
148
- test("complete after cache loss recovers runId from the projection (same stream)", async () => {
149
- // Simulates worker process restart: in-memory bullJobId→runId cache is gone,
150
- // but the run-started projection row still has bull_job_id. A fresh logger
151
- // must DB-lookup and append onto the original aggregate — not mint a second.
124
+ test("complete after cache loss recovers runId from jobRunsTable (same row)", async () => {
125
+ // Simulates worker process restart: in-memory bullJobId→runId cache is
126
+ // gone, but the run-started row still has bull_job_id. A fresh logger
127
+ // must DB-lookup and update the original row — not insert a second one.
152
128
  await logger.onJobStart?.("example:job:restart", "bull-restart-1", {});
153
- const started = await selectMany(testDb.db, eventsTable, { type: JOB_RUN_STARTED_EVENT });
129
+ const started = await selectMany(testDb.db, jobRunsTable, { bullJobId: "bull-restart-1" });
154
130
  expect(started).toHaveLength(1);
155
- const originalAggregateId = started[0]?.aggregateId;
156
- expect(originalAggregateId).toBeTruthy();
131
+ const originalId = started[0]?.id;
132
+ expect(originalId).toBeTruthy();
157
133
 
158
134
  const coldLogger = createJobRunLogger({ db: testDb.db, registry });
159
135
  await coldLogger.onJobComplete?.("example:job:restart", "bull-restart-1", 42, []);
160
136
 
161
- const all = await selectMany(testDb.db, eventsTable, { aggregateType: "jobRun" });
162
- expect(all).toHaveLength(2);
163
- expect(new Set(all.map((e) => e.aggregateId))).toEqual(new Set([originalAggregateId]));
164
- expect(all.some((e) => e.type === JOB_RUN_COMPLETED_EVENT)).toBe(true);
137
+ const all = await selectMany(testDb.db, jobRunsTable, { bullJobId: "bull-restart-1" });
138
+ expect(all).toHaveLength(1);
139
+ expect(all[0]?.id).toBe(originalId);
140
+ expect(all[0]?.status).toBe("completed");
141
+ });
142
+
143
+ test("no jobs:event:run-* rows ever land in the event store", async () => {
144
+ await logger.onJobStart?.("example:job:no-events", "bull-no-events", {});
145
+ await logger.onJobComplete?.("example:job:no-events", "bull-no-events", 5, []);
146
+
147
+ const events = await selectMany(testDb.db, eventsTable, { aggregateType: "jobRun" });
148
+ expect(events).toHaveLength(0);
165
149
  });
166
150
  });
@@ -1,9 +1,9 @@
1
- // Event-PII on the jobs run-logger (#799): runStarted.payload can carry
2
- // arbitrary user data and is written via LOW-LEVEL append() (not
3
- // ctx.appendEvent) — exactly the path the event-PII catalog must cover.
4
- // With a KMS active the stored event AND the projected read-row carry
5
- // ciphertext under the triggering user's DEK; erasing that key makes the
6
- // payload unreadable ([[erased]]) without touching the append-only stream.
1
+ // PII on the jobs run-logger, direct-write path (#2243, formerly #799):
2
+ // run-started payload can carry arbitrary user data and is written straight
3
+ // into jobRunsTable (no event store, no event-PII catalog — #2243 removed
4
+ // the jobRun r.defineEvent registrations). With a KMS active the stored row
5
+ // carries ciphertext under the triggering user's DEK; erasing that key
6
+ // makes the payload unreadable ([[erased]]) without touching the row.
7
7
 
8
8
  import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
9
  import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
@@ -25,7 +25,7 @@ import {
25
25
  } from "@cosmicdrift/kumiko-framework/stack";
26
26
  import { resetPiiSubjectKmsForTests, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
27
27
  import { createJobsFeature } from "../feature";
28
- import { createJobRunLogger, JOB_RUN_STARTED_EVENT } from "../job-run-logger";
28
+ import { createJobRunLogger } from "../job-run-logger";
29
29
  import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
30
30
 
31
31
  let testDb: TestDb;
@@ -39,10 +39,10 @@ const SECRET_PAYLOAD = JSON.stringify({ iban: "DE89370400440532013000" });
39
39
  beforeAll(async () => {
40
40
  testDb = await createTestDb();
41
41
  testRedis = await createTestRedis();
42
- // createRegistry publishes the event-PII catalog as a module singleton —
43
- // the logger's low-level append() picks it up without further wiring.
44
42
  const registry = createRegistry([createJobsFeature()]);
45
43
  await unsafePushTables(testDb.db, { jobRunsTable, jobRunLogsTable });
44
+ // Kept only so the "no event store involved" assertion below has a table
45
+ // to assert against — the write path itself never touches it.
46
46
  await createEventsTable(testDb.db);
47
47
  logger = createJobRunLogger({ db: testDb.db, registry });
48
48
  });
@@ -63,25 +63,28 @@ afterEach(() => {
63
63
  });
64
64
 
65
65
  describe("jobs run-started payload under KMS", () => {
66
- test("stored event carries ciphertext payload, plaintext triggeredById", async () => {
66
+ test("stored row carries ciphertext payload, plaintext triggeredById — no event-store write", async () => {
67
67
  await logger.onJobStart?.("app:job:export", "bull-1", {
68
68
  triggeredById: USER_ID,
69
69
  payload: SECRET_PAYLOAD,
70
70
  attempt: 1,
71
71
  });
72
72
 
73
- const events = await selectMany(testDb.db, eventsTable, { type: JOB_RUN_STARTED_EVENT });
74
- expect(events.length).toBe(1);
75
- const payload = events[0]?.payload as Record<string, unknown>;
76
- expect(isPiiCiphertext(payload["payload"])).toBe(true);
77
- expect(String(payload["payload"])).toContain(`user:${USER_ID}`);
78
- expect(payload["triggeredById"]).toBe(USER_ID);
73
+ const row = await fetchOne(testDb.db, jobRunsTable, { bullJobId: "bull-1" });
74
+ expect(isPiiCiphertext(row?.["payload"])).toBe(true);
75
+ expect(String(row?.["payload"])).toContain(`user:${USER_ID}`);
76
+ expect(row?.["triggeredById"]).toBe(USER_ID);
79
77
 
80
- const back = await decryptPiiFieldValues(payload, ["payload"], kms, { requestId: "t" });
78
+ const back = await decryptPiiFieldValues({ payload: row?.["payload"] }, ["payload"], kms, {
79
+ requestId: "t",
80
+ });
81
81
  expect(back["payload"]).toBe(SECRET_PAYLOAD);
82
+
83
+ const events = await selectMany(testDb.db, eventsTable, { aggregateType: "jobRun" });
84
+ expect(events).toHaveLength(0);
82
85
  });
83
86
 
84
- test("projected read-row carries the same ciphertext; erase → [[erased]]", async () => {
87
+ test("erase subject key → row payload decrypts to [[erased]]", async () => {
85
88
  await logger.onJobStart?.("app:job:export", "bull-2", {
86
89
  triggeredById: USER_ID,
87
90
  payload: SECRET_PAYLOAD,