Skip to content

Commit 23530bc

Browse files
committed
feat(run-engine): present a store waitpoint as the legacy row shape
The coordinator seam returns Prisma Waitpoint, and callers read its columns directly, but a store-resident waitpoint has no row. This maps the store's record, status and completion onto that shape. Every column is listed explicitly rather than spread. A missed non-null column would surface as undefined in a consumer far from here that had no reason to guard, and the type checker catches an omission here instead. An absent idempotency key throws rather than synthesizing one: the column is non-null and half of the (environmentId, idempotencyKey) unique index, so an invented value could collide with a real one.
1 parent f3f6096 commit 23530bc

2 files changed

Lines changed: 176 additions & 0 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { WaitpointRecordInput } from "./storeCoordinator.js";
3+
import { toPrismaWaitpoint } from "./waitpointShape.js";
4+
5+
const record: WaitpointRecordInput = {
6+
id: "abcdefghijklmnopqrstuvwxmw",
7+
friendlyId: "waitpoint_abcdefghijklmnopqrstuvwxmw",
8+
type: "MANUAL",
9+
environmentId: "env_1",
10+
projectId: "proj_1",
11+
createdAt: "2026-08-26T10:00:00.000Z",
12+
updatedAt: "2026-08-26T10:00:01.000Z",
13+
userProvidedIdempotencyKey: true,
14+
tags: ["alpha", "beta"],
15+
idempotencyKey: "user-key",
16+
};
17+
18+
describe("toPrismaWaitpoint", () => {
19+
it("fills every non-null column on a PENDING waitpoint", () => {
20+
const waitpoint = toPrismaWaitpoint(record, "PENDING");
21+
22+
expect(waitpoint.id).toBe(record.id);
23+
expect(waitpoint.friendlyId).toBe(record.friendlyId);
24+
expect(waitpoint.type).toBe("MANUAL");
25+
expect(waitpoint.status).toBe("PENDING");
26+
expect(waitpoint.idempotencyKey).toBe("user-key");
27+
expect(waitpoint.userProvidedIdempotencyKey).toBe(true);
28+
expect(waitpoint.projectId).toBe("proj_1");
29+
expect(waitpoint.environmentId).toBe("env_1");
30+
expect(waitpoint.tags).toEqual(["alpha", "beta"]);
31+
expect(waitpoint.createdAt).toEqual(new Date("2026-08-26T10:00:00.000Z"));
32+
expect(waitpoint.updatedAt).toEqual(new Date("2026-08-26T10:00:01.000Z"));
33+
34+
// The columns with database defaults, which a consumer reads unconditionally.
35+
expect(waitpoint.outputType).toBe("application/json");
36+
expect(waitpoint.outputIsError).toBe(false);
37+
38+
// Nullable columns that must be null rather than undefined: a consumer distinguishes
39+
// "no value" from "field missing", and `inactiveIdempotencyKey` is not ported at all.
40+
expect(waitpoint.completedAt).toBeNull();
41+
expect(waitpoint.output).toBeNull();
42+
expect(waitpoint.inactiveIdempotencyKey).toBeNull();
43+
expect(waitpoint.idempotencyKeyExpiresAt).toBeNull();
44+
expect(waitpoint.completedByTaskRunId).toBeNull();
45+
expect(waitpoint.completedByBatchId).toBeNull();
46+
expect(waitpoint.completedAfter).toBeNull();
47+
});
48+
49+
it("carries an inline completion onto a COMPLETED waitpoint", () => {
50+
const waitpoint = toPrismaWaitpoint(record, "COMPLETED", {
51+
completedAt: "2026-08-26T11:00:00.000Z",
52+
outputType: "application/json",
53+
outputIsError: true,
54+
output: { inline: '{"boom":true}' },
55+
});
56+
57+
expect(waitpoint.status).toBe("COMPLETED");
58+
expect(waitpoint.completedAt).toEqual(new Date("2026-08-26T11:00:00.000Z"));
59+
expect(waitpoint.output).toBe('{"boom":true}');
60+
expect(waitpoint.outputType).toBe("application/json");
61+
expect(waitpoint.outputIsError).toBe(true);
62+
});
63+
64+
it("carries an offloaded reference in the output column, as the legacy row does", () => {
65+
const waitpoint = toPrismaWaitpoint(record, "COMPLETED", {
66+
completedAt: "2026-08-26T11:00:00.000Z",
67+
outputType: "application/store",
68+
outputIsError: false,
69+
output: { ref: "waitpoints/abc/output.json" },
70+
});
71+
72+
expect(waitpoint.output).toBe("waitpoints/abc/output.json");
73+
expect(waitpoint.outputType).toBe("application/store");
74+
});
75+
76+
it("leaves output null when the completion carries none", () => {
77+
// A BATCH completion, and the deriveFromRun case: the value is re-derived at read
78+
// time and is never copied onto the row.
79+
const waitpoint = toPrismaWaitpoint({ ...record, type: "BATCH" }, "COMPLETED", {
80+
completedAt: "2026-08-26T11:00:00.000Z",
81+
outputType: "application/json",
82+
outputIsError: false,
83+
output: null,
84+
});
85+
86+
expect(waitpoint.status).toBe("COMPLETED");
87+
expect(waitpoint.output).toBeNull();
88+
});
89+
90+
it("maps the optional anchor and timing columns when the record carries them", () => {
91+
const waitpoint = toPrismaWaitpoint(
92+
{
93+
...record,
94+
type: "RUN",
95+
completedByTaskRunId: "run_1",
96+
completedByBatchId: "batch_1",
97+
completedAfter: "2026-08-27T00:00:00.000Z",
98+
idempotencyKeyExpiresAt: "2026-08-28T00:00:00.000Z",
99+
},
100+
"PENDING"
101+
);
102+
103+
expect(waitpoint.completedByTaskRunId).toBe("run_1");
104+
expect(waitpoint.completedByBatchId).toBe("batch_1");
105+
expect(waitpoint.completedAfter).toEqual(new Date("2026-08-27T00:00:00.000Z"));
106+
expect(waitpoint.idempotencyKeyExpiresAt).toEqual(new Date("2026-08-28T00:00:00.000Z"));
107+
});
108+
109+
it("throws when the record carries no idempotency key", () => {
110+
// The column is non-null and participates in the (environmentId, idempotencyKey)
111+
// unique index, so inventing a value here could collide. The arm always mints one;
112+
// an absent key means the arm has a defect, and it must surface as one.
113+
const { idempotencyKey, ...withoutKey } = record;
114+
115+
expect(() => toPrismaWaitpoint(withoutKey, "PENDING")).toThrow(/idempotency key/i);
116+
});
117+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { Waitpoint } from "@trigger.dev/database";
2+
import type {
3+
WaitpointCompletion,
4+
WaitpointRecordInput,
5+
WaitpointStatus,
6+
} from "./storeCoordinator.js";
7+
8+
/**
9+
* Present a store-resident waitpoint as the Postgres row shape the seam returns.
10+
*
11+
* A store waitpoint has no row, but `WaitpointCoordinator`'s return types are the Prisma
12+
* `Waitpoint`, and callers reach for its columns directly. Every column is listed
13+
* explicitly rather than spread: a missing non-null column surfaces as `undefined` far
14+
* from here, in a consumer that had no reason to guard.
15+
*/
16+
export function toPrismaWaitpoint(
17+
record: WaitpointRecordInput,
18+
status: WaitpointStatus,
19+
completion?: WaitpointCompletion
20+
): Waitpoint {
21+
if (!record.idempotencyKey) {
22+
// Non-null in the schema, and half of the (environmentId, idempotencyKey) unique
23+
// index, so a synthesized value could collide with a real one. Every arm mints one.
24+
throw new Error(`Waitpoint ${record.id} has no idempotency key`);
25+
}
26+
27+
const output = completion?.output;
28+
29+
return {
30+
id: record.id,
31+
friendlyId: record.friendlyId,
32+
type: record.type,
33+
status,
34+
completedAt: completion ? new Date(completion.completedAt) : null,
35+
idempotencyKey: record.idempotencyKey,
36+
userProvidedIdempotencyKey: record.userProvidedIdempotencyKey,
37+
idempotencyKeyExpiresAt: optionalDate(record.idempotencyKeyExpiresAt),
38+
// Not ported: clearing an idempotency key is a legacy debounce mechanism the store
39+
// replaces with key expiry.
40+
inactiveIdempotencyKey: null,
41+
completedByTaskRunId: record.completedByTaskRunId ?? null,
42+
completedAfter: optionalDate(record.completedAfter),
43+
completedByBatchId: record.completedByBatchId ?? null,
44+
// An offloaded reference rides the output column exactly as it does on a legacy row,
45+
// with outputType naming it. A null output is re-derived at read time, never copied.
46+
output: output ? ("inline" in output ? output.inline : output.ref) : null,
47+
outputType: completion?.outputType ?? "application/json",
48+
outputIsError: completion?.outputIsError ?? false,
49+
projectId: record.projectId,
50+
environmentId: record.environmentId,
51+
createdAt: new Date(record.createdAt),
52+
updatedAt: new Date(record.updatedAt),
53+
tags: record.tags,
54+
};
55+
}
56+
57+
function optionalDate(value: string | undefined): Date | null {
58+
return value ? new Date(value) : null;
59+
}

0 commit comments

Comments
 (0)