@cosmicdrift/kumiko-bundled-features 0.209.0 → 0.210.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 (53) hide show
  1. package/package.json +8 -8
  2. package/src/auth-mfa/schema/user-mfa.ts +8 -5
  3. package/src/auth-mfa-user-data/hooks.ts +9 -8
  4. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +2 -2
  5. package/src/billing-foundation/entities.ts +5 -3
  6. package/src/billing-foundation/tenant-destroy-hook.ts +1 -1
  7. package/src/compliance-profiles/schema/profile-selection.ts +2 -1
  8. package/src/config/table.ts +4 -1
  9. package/src/crypto-shredding/__tests__/forget-subject.integration.test.ts +4 -4
  10. package/src/custom-fields/feature.ts +2 -2
  11. package/src/data-retention/__tests__/retention-cleanup.integration.test.ts +12 -4
  12. package/src/data-retention/schema/tenant-retention-override.ts +6 -3
  13. package/src/delivery/tables.ts +1 -1
  14. package/src/document-ingest-foundation/entity.ts +6 -3
  15. package/src/files/README.md +11 -11
  16. package/src/form-draft/entity.ts +6 -3
  17. package/src/inbound-mail-foundation/entities.ts +13 -11
  18. package/src/inbound-mail-foundation/feature.ts +5 -5
  19. package/src/jobs/__tests__/jobs-events.integration.test.ts +65 -81
  20. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +21 -18
  21. package/src/jobs/__tests__/jobs-retention.integration.test.ts +124 -0
  22. package/src/jobs/db/queries/retention.ts +44 -0
  23. package/src/jobs/events.ts +5 -8
  24. package/src/jobs/feature.ts +26 -146
  25. package/src/jobs/handlers/retention-cleanup.job.ts +21 -0
  26. package/src/jobs/index.ts +1 -1
  27. package/src/jobs/job-run-logger.ts +112 -59
  28. package/src/jobs/job-run-table.ts +36 -28
  29. package/src/ledger/entity.ts +5 -3
  30. package/src/managed-pages/screens/branding-screen.ts +2 -1
  31. package/src/managed-pages/table.ts +3 -2
  32. package/src/notes-history/entity.ts +8 -4
  33. package/src/notes-history-user-data/hooks.ts +6 -5
  34. package/src/personal-access-tokens/schema/api-token.ts +2 -1
  35. package/src/sessions/feature.ts +0 -4
  36. package/src/sessions/schema/user-session.ts +4 -2
  37. package/src/tags/entity.ts +11 -5
  38. package/src/template-resolver/__tests__/collection-ownership.integration.test.ts +1 -1
  39. package/src/template-resolver/table.ts +4 -1
  40. package/src/template-resolver/user-content-table.ts +5 -2
  41. package/src/template-resolver-user-data/hooks.ts +6 -5
  42. package/src/tenant/invitation-table.ts +3 -3
  43. package/src/tenant-lifecycle/boot-checks.ts +2 -2
  44. package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +58 -0
  45. package/src/tenant-settings/feature.ts +12 -0
  46. package/src/user/schema/user.ts +5 -5
  47. package/src/user-data-rights/__tests__/boot-checks.test.ts +73 -9
  48. package/src/user-data-rights/__tests__/forget-cleanup-search-purge-ordering.integration.test.ts +2 -2
  49. package/src/user-data-rights/boot-checks.ts +2 -2
  50. package/src/user-data-rights/feature.ts +4 -4
  51. package/src/user-data-rights/schema/download-token.ts +12 -10
  52. package/src/user-data-rights/schema/export-job.ts +5 -4
  53. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +1 -1
@@ -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,
@@ -0,0 +1,124 @@
1
+ // Integration test for the job-run retention-cleanup job (#2243). Dispatches
2
+ // jobs:job:retention-cleanup through the real jobRunner (setupTestStack +
3
+ // BullMQ worker) — the same enqueue path production's cron trigger uses —
4
+ // instead of hand-building a JobContext and casting into the handler. Each
5
+ // test seeds a row past the active cutoff alongside one that must survive,
6
+ // so waitFor's completion signal (the stale row's count dropping) is only
7
+ // reached once the real dispatch has actually run; the survivor's fate is
8
+ // decided by that same DELETE and can be asserted right after.
9
+
10
+ import { afterEach, describe, expect, test } from "bun:test";
11
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
12
+ import { sql } from "@cosmicdrift/kumiko-framework/db";
13
+ import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
14
+ import {
15
+ setupTestStack,
16
+ type TestStack,
17
+ unsafePushTables,
18
+ } from "@cosmicdrift/kumiko-framework/stack";
19
+ import { seedRow, waitFor } from "@cosmicdrift/kumiko-framework/testing";
20
+ import { createJobsFeature } from "../feature";
21
+ import { DEFAULT_JOB_RUN_RETENTION_DAYS } from "../handlers/retention-cleanup.job";
22
+ import { jobRunLogsTable, jobRunsTable } from "../job-run-table";
23
+
24
+ const RETENTION_JOB = "jobs:job:retention-cleanup";
25
+
26
+ let stack: TestStack | undefined;
27
+
28
+ async function bootStack(retentionDays?: number): Promise<TestStack> {
29
+ const s = await setupTestStack({
30
+ features: [createJobsFeature(retentionDays !== undefined ? { retentionDays } : {})],
31
+ jobs: { consumerLane: "worker" },
32
+ });
33
+ await unsafePushTables(s.db, { jobRunsTable, jobRunLogsTable });
34
+ return s;
35
+ }
36
+
37
+ afterEach(async () => {
38
+ if (stack) await stack.cleanup();
39
+ stack = undefined;
40
+ });
41
+
42
+ function currentStack(): TestStack {
43
+ if (!stack) throw new Error("stack not booted");
44
+ return stack;
45
+ }
46
+
47
+ async function seedRun(opts: { id: string; ageDays: number }): Promise<void> {
48
+ const insertedAt = sql`now() - ${sql.raw(`interval '${opts.ageDays} days'`)}`;
49
+ await seedRow(currentStack().db, jobRunsTable, {
50
+ id: opts.id,
51
+ tenantId: SYSTEM_TENANT_ID,
52
+ insertedAt,
53
+ jobName: "example:job:retention-probe",
54
+ bullJobId: `bull-${opts.id}`,
55
+ status: "completed",
56
+ attempt: 1,
57
+ startedAt: insertedAt,
58
+ });
59
+ }
60
+
61
+ async function countRuns(): Promise<number> {
62
+ return (await selectMany(currentStack().db, jobRunsTable)).length;
63
+ }
64
+
65
+ async function countLogs(): Promise<number> {
66
+ return (await selectMany(currentStack().db, jobRunLogsTable)).length;
67
+ }
68
+
69
+ // Dispatches the real job through jobRunner and blocks until the queued
70
+ // worker has actually applied it, proven by the run count reaching
71
+ // `expectedRuns` (not merely "some time has passed").
72
+ async function dispatchAndWaitForRunCount(expectedRuns: number): Promise<void> {
73
+ await currentStack().jobRunner?.dispatch(RETENTION_JOB);
74
+ await waitFor(async () => {
75
+ expect(await countRuns()).toBe(expectedRuns);
76
+ });
77
+ }
78
+
79
+ describe("jobs:job:retention-cleanup", () => {
80
+ test("default window (30d): older-than-cutoff runs go, recent ones stay", async () => {
81
+ stack = await bootStack();
82
+ await seedRun({
83
+ id: "11111111-1111-4111-8111-111111111111",
84
+ ageDays: DEFAULT_JOB_RUN_RETENTION_DAYS + 1,
85
+ });
86
+ await seedRun({ id: "22222222-2222-4222-8222-222222222222", ageDays: 1 });
87
+ expect(await countRuns()).toBe(2);
88
+
89
+ await dispatchAndWaitForRunCount(1);
90
+
91
+ const remaining = await selectMany(currentStack().db, jobRunsTable);
92
+ expect(remaining[0]?.id).toBe("22222222-2222-4222-8222-222222222222");
93
+ });
94
+
95
+ test("associated log rows are deleted along with their run", async () => {
96
+ stack = await bootStack();
97
+ const runId = "33333333-3333-4333-8333-333333333333";
98
+ const ageDays = DEFAULT_JOB_RUN_RETENTION_DAYS + 5;
99
+ await seedRun({ id: runId, ageDays });
100
+ await seedRow(currentStack().db, jobRunLogsTable, {
101
+ runId,
102
+ level: "info",
103
+ message: "old log line",
104
+ timestamp: sql`now() - ${sql.raw(`interval '${ageDays} days'`)}`,
105
+ });
106
+ expect(await countLogs()).toBe(1);
107
+
108
+ await dispatchAndWaitForRunCount(0);
109
+
110
+ expect(await countLogs()).toBe(0);
111
+ });
112
+
113
+ test("a custom retentionDays option is honored: past-cutoff runs go, in-window runs stay", async () => {
114
+ stack = await bootStack(3);
115
+ await seedRun({ id: "44444444-4444-4444-8444-444444444444", ageDays: 5 });
116
+ await seedRun({ id: "55555555-5555-4555-8555-555555555555", ageDays: 1 });
117
+ expect(await countRuns()).toBe(2);
118
+
119
+ await dispatchAndWaitForRunCount(1);
120
+
121
+ const remaining = await selectMany(currentStack().db, jobRunsTable);
122
+ expect(remaining[0]?.id).toBe("55555555-5555-4555-8555-555555555555");
123
+ });
124
+ });
@@ -0,0 +1,44 @@
1
+ // Job-run retention (#2243): store_job_runs/store_job_run_logs are
2
+ // direct-write stores now, not event-sourced projections — nothing else
3
+ // purges them, so without this query they grow forever with the run count
4
+ // instead of the runtime. Not the retention-cleanup executor: that executor
5
+ // is entity/tenant-scoped and jobRun has neither (system-scoped direct-write
6
+ // store). Each table is purged by its own timestamp column independently —
7
+ // run_id isn't a real FK constraint (see job-run-table.ts), so there is no
8
+ // ordering requirement between the two deletes.
9
+ //
10
+ // deleteManyBatched (typed query API, no raw SQL in this file) chunks each
11
+ // delete so a large backlog doesn't hold one lock for the whole sweep.
12
+
13
+ import { deleteManyBatched } from "@cosmicdrift/kumiko-framework/bun-db";
14
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
15
+ import { jobRunLogsTable, jobRunsTable } from "../../job-run-table";
16
+
17
+ const RETENTION_DELETE_BATCH_SIZE = 500;
18
+
19
+ export type JobRunRetentionResult = {
20
+ readonly runsDeleted: number;
21
+ readonly logsDeleted: number;
22
+ };
23
+
24
+ export async function deleteStaleJobRuns(
25
+ db: DbConnection,
26
+ retentionDays: number,
27
+ ): Promise<JobRunRetentionResult> {
28
+ const cutoff = Temporal.Now.instant().subtract({ hours: retentionDays * 24 });
29
+
30
+ const logsResult = await deleteManyBatched(
31
+ db,
32
+ jobRunLogsTable,
33
+ { timestamp: { lt: cutoff } },
34
+ { limit: RETENTION_DELETE_BATCH_SIZE },
35
+ );
36
+ const runsResult = await deleteManyBatched(
37
+ db,
38
+ jobRunsTable,
39
+ { insertedAt: { lt: cutoff } },
40
+ { limit: RETENTION_DELETE_BATCH_SIZE },
41
+ );
42
+
43
+ return { runsDeleted: runsResult.deleted, logsDeleted: logsResult.deleted };
44
+ }
@@ -1,11 +1,8 @@
1
- // Event-payload schemas for the jobRun aggregate. Shared between
2
- // jobs-feature.ts (registers them via r.defineEvent and consumes them
3
- // in the inline-projections) and job-run-logger.ts (parses payloads
4
- // before low-level append() so out-of-dispatcher writes stay as
5
- // type-safe as ctx.appendEvent writes).
6
- //
7
- // Keeping them in a separate module avoids the circular import between
8
- // jobs-feature.ts (imports the logger) and job-run-logger.ts.
1
+ // Payload schemas for the jobRun direct-write path (#2243). Pre-#2243 these
2
+ // backed jobRun's event-store payloads (r.defineEvent + inline projections);
3
+ // now job-run-logger.ts parses against them before writing straight into
4
+ // jobRunsTable/jobRunLogsTable, so an out-of-dispatcher write still gets the
5
+ // same validation guarantee ctx.appendEvent used to give it.
9
6
 
10
7
  import { z } from "zod";
11
8