@cosmicdrift/kumiko-framework 0.324.0 → 0.325.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.324.0",
3
+ "version": "0.325.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -202,8 +202,8 @@
202
202
  "./package.json": "./package.json"
203
203
  },
204
204
  "dependencies": {
205
- "@cosmicdrift/kumiko-http": "0.324.0",
206
- "@cosmicdrift/kumiko-types": "0.324.0",
205
+ "@cosmicdrift/kumiko-http": "0.325.0",
206
+ "@cosmicdrift/kumiko-types": "0.325.0",
207
207
  "bullmq": "^5.76.7",
208
208
  "bun-types": "^1.3.13",
209
209
  "hono": "^4.13.1",
@@ -219,7 +219,7 @@
219
219
  "zod": "^4.4.3"
220
220
  },
221
221
  "devDependencies": {
222
- "@cosmicdrift/kumiko-dispatcher-live": "0.324.0",
222
+ "@cosmicdrift/kumiko-dispatcher-live": "0.325.0",
223
223
  "bun-types": "^1.3.13",
224
224
  "pino-pretty": "^13.1.3"
225
225
  },
package/src/changes.json CHANGED
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.325.0",
4
+ "type": "improvement",
5
+ "title": "JobContext exposes ctx.attempt and ctx.finalAttempt",
6
+ "detail": "Job handlers can read the 1-based run number and whether the current run is\nthe last one BullMQ will attempt, so a handler can defer writing a\nbusiness-facing failure state until the final attempt instead of writing\nit on every retry.\n\nMigration note: hand-built `JobContext` objects (test doubles, boot\nseeders) must add `attempt` and `finalAttempt` (e.g. `attempt: 1,\nfinalAttempt: true`)."
7
+ },
2
8
  {
3
9
  "version": "0.324.0",
4
10
  "type": "breaking",
@@ -0,0 +1,76 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import * as z from "zod";
3
+ import { defineFeature } from "../../engine";
4
+ import { setupTestStack, type TestStack, TestUsers } from "../../stack";
5
+
6
+ const retryRuns: [number, boolean][] = [];
7
+ const singleRuns: [number, boolean][] = [];
8
+
9
+ const attemptFixtureFeature = defineFeature("attemptfixture", (r) => {
10
+ r.writeHandler(
11
+ "trigger-retry",
12
+ z.object({}),
13
+ async () => ({ isSuccess: true as const, data: {} }),
14
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
15
+ );
16
+ r.job(
17
+ "retry",
18
+ { trigger: { on: "attemptfixture:write:trigger-retry" }, retries: 2 },
19
+ async (_payload, ctx) => {
20
+ retryRuns.push([ctx.attempt, ctx.finalAttempt]);
21
+ if (ctx.attempt < 3) throw new Error(`fails on attempt ${ctx.attempt}`);
22
+ },
23
+ );
24
+
25
+ r.writeHandler(
26
+ "trigger-single",
27
+ z.object({}),
28
+ async () => ({ isSuccess: true as const, data: {} }),
29
+ { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
30
+ );
31
+ r.job(
32
+ "single",
33
+ { trigger: { on: "attemptfixture:write:trigger-single" } },
34
+ async (_payload, ctx) => {
35
+ singleRuns.push([ctx.attempt, ctx.finalAttempt]);
36
+ },
37
+ );
38
+ });
39
+
40
+ let stack: TestStack;
41
+
42
+ beforeAll(async () => {
43
+ stack = await setupTestStack({
44
+ features: [attemptFixtureFeature],
45
+ jobs: { consumerLane: "worker" },
46
+ });
47
+ });
48
+
49
+ afterAll(async () => {
50
+ await stack.cleanup();
51
+ });
52
+
53
+ beforeEach(() => {
54
+ retryRuns.length = 0;
55
+ singleRuns.length = 0;
56
+ });
57
+
58
+ describe("JobContext.attempt / finalAttempt", () => {
59
+ test("increments per retry and marks the last configured attempt final", async () => {
60
+ await stack.http.writeOk("attemptfixture:write:trigger-retry", {}, TestUsers.admin);
61
+
62
+ await stack.drainJobs();
63
+ expect(retryRuns).toEqual([
64
+ [1, false],
65
+ [2, false],
66
+ [3, true],
67
+ ]);
68
+ });
69
+
70
+ test("a job without retries starts at attempt 1 and is already final", async () => {
71
+ await stack.http.writeOk("attemptfixture:write:trigger-single", {}, TestUsers.admin);
72
+
73
+ await stack.drainJobs();
74
+ expect(singleRuns).toEqual([[1, true]]);
75
+ });
76
+ });
@@ -745,10 +745,11 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
745
745
  // retry number, so audit queries distinguish fresh from retry runs
746
746
  // without peeking at BullMQ internals.
747
747
  const rawData = bullJob.data as DbRow;
748
+ const attempt = bullJob.attemptsMade + 1;
748
749
  const meta: JobMeta = {
749
750
  triggeredById: rawData["_triggeredById"] as string | undefined, // @cast-boundary dynamic-key
750
751
  payload: rawData["_payload"] as string | undefined, // @cast-boundary dynamic-key
751
- attempt: bullJob.attemptsMade + 1,
752
+ attempt,
752
753
  };
753
754
 
754
755
  // Build handler payload (without internal meta fields)
@@ -773,7 +774,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
773
774
  : null;
774
775
  // BullMQ stops retrying once attemptsMade reaches the configured attempts
775
776
  // (`retries + 1`), so this is the attempt whose failure is final.
776
- const finalAttempt = bullJob.attemptsMade + 1 >= (jobDef.retries ?? 0) + 1;
777
+ const finalAttempt = attempt >= (jobDef.retries ?? 0) + 1;
777
778
  const outcomeMeta = (messageKey: string | null): JobOutcomeMeta => ({
778
779
  tenantId,
779
780
  finalAttempt,
@@ -898,6 +899,8 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
898
899
  ...(selfRunner !== undefined && { jobRunner: selfRunner }),
899
900
  systemUser: jobSystemUser,
900
901
  triggeredBy: triggeredById !== null ? { id: triggeredById, tenantId } : null,
902
+ attempt,
903
+ finalAttempt,
901
904
  log: createJobLogger(logs),
902
905
  ...(triggerName !== undefined && { triggerName }),
903
906
  ...(triggerEvent !== undefined && { triggerEvent }),
@@ -1003,7 +1006,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
1003
1006
  attributes: {
1004
1007
  "job.name": jobName,
1005
1008
  "job.id": jobId,
1006
- "job.attempt": bullJob.attemptsMade + 1,
1009
+ "job.attempt": attempt,
1007
1010
  "kumiko.tenant_id": tenantId,
1008
1011
  // Lane-routing attributes (Welle 2.6). `run_in` is the job's
1009
1012
  // declared lane (explicit or default-"worker"); `consumer_lane`