@rivet-dev/vercel-world 2.3.6

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 (81) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/README.md +31 -0
  3. package/dist/actors/coordinator.d.ts +114 -0
  4. package/dist/actors/coordinator.d.ts.map +1 -0
  5. package/dist/actors/coordinator.js +232 -0
  6. package/dist/actors/coordinator.js.map +1 -0
  7. package/dist/actors/db.d.ts +8 -0
  8. package/dist/actors/db.d.ts.map +1 -0
  9. package/dist/actors/db.js +282 -0
  10. package/dist/actors/db.js.map +1 -0
  11. package/dist/actors/dispatcher.d.ts +84 -0
  12. package/dist/actors/dispatcher.d.ts.map +1 -0
  13. package/dist/actors/dispatcher.js +498 -0
  14. package/dist/actors/dispatcher.js.map +1 -0
  15. package/dist/actors/hook-token.d.ts +26 -0
  16. package/dist/actors/hook-token.d.ts.map +1 -0
  17. package/dist/actors/hook-token.js +151 -0
  18. package/dist/actors/hook-token.js.map +1 -0
  19. package/dist/actors/shared.d.ts +366 -0
  20. package/dist/actors/shared.d.ts.map +1 -0
  21. package/dist/actors/shared.js +112 -0
  22. package/dist/actors/shared.js.map +1 -0
  23. package/dist/actors/streams.d.ts +26 -0
  24. package/dist/actors/streams.d.ts.map +1 -0
  25. package/dist/actors/streams.js +127 -0
  26. package/dist/actors/streams.js.map +1 -0
  27. package/dist/actors/workflow-run.d.ts +890 -0
  28. package/dist/actors/workflow-run.d.ts.map +1 -0
  29. package/dist/actors/workflow-run.js +863 -0
  30. package/dist/actors/workflow-run.js.map +1 -0
  31. package/dist/actors.d.ts +2204 -0
  32. package/dist/actors.d.ts.map +1 -0
  33. package/dist/actors.js +16 -0
  34. package/dist/actors.js.map +1 -0
  35. package/dist/codec.d.ts +4 -0
  36. package/dist/codec.d.ts.map +1 -0
  37. package/dist/codec.js +27 -0
  38. package/dist/codec.js.map +1 -0
  39. package/dist/index.d.ts +843 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +553 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/runtime.d.ts +2 -0
  44. package/dist/runtime.d.ts.map +1 -0
  45. package/dist/runtime.js +24 -0
  46. package/dist/runtime.js.map +1 -0
  47. package/package.json +51 -0
  48. package/scripts/conformance/run.ts +278 -0
  49. package/scripts/integration/run.ts +935 -0
  50. package/src/actors/coordinator.ts +360 -0
  51. package/src/actors/db.ts +291 -0
  52. package/src/actors/dispatcher.ts +787 -0
  53. package/src/actors/hook-token.ts +239 -0
  54. package/src/actors/shared.ts +153 -0
  55. package/src/actors/streams.ts +215 -0
  56. package/src/actors/workflow-run.ts +1466 -0
  57. package/src/actors.ts +18 -0
  58. package/src/codec.ts +28 -0
  59. package/src/index.ts +768 -0
  60. package/src/runtime.ts +29 -0
  61. package/tests/conformance.test.ts +8 -0
  62. package/tests/helpers/db.ts +62 -0
  63. package/tests/helpers/dispatcher-driver.ts +71 -0
  64. package/tests/helpers/harness.ts +161 -0
  65. package/tests/integration/crash-restart.test.ts +145 -0
  66. package/tests/integration/dispatcher-loop.test.ts +144 -0
  67. package/tests/integration/hook-token.test.ts +160 -0
  68. package/tests/integration/hooks.test.ts +123 -0
  69. package/tests/integration/streams.test.ts +178 -0
  70. package/tests/integration/workflow-events.test.ts +326 -0
  71. package/tests/setup.ts +10 -0
  72. package/tests/unit/codec.test.ts +73 -0
  73. package/tests/unit/coordinator-record.test.ts +177 -0
  74. package/tests/unit/db-migrations.test.ts +65 -0
  75. package/tests/unit/dispatcher-queue.test.ts +274 -0
  76. package/tests/unit/logging.test.ts +49 -0
  77. package/tests/unit/readiness.test.ts +102 -0
  78. package/tests/unit/transaction.test.ts +76 -0
  79. package/tsconfig.build.json +13 -0
  80. package/tsconfig.json +12 -0
  81. package/vitest.config.ts +32 -0
@@ -0,0 +1,326 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { setupTest } from "rivetkit/test";
3
+ import { afterEach, describe, expect, test } from "vitest";
4
+ import { SPEC_VERSION_CURRENT } from "@workflow/world";
5
+ import { registry } from "../../src/actors.ts";
6
+
7
+ const uid = () => randomUUID().slice(0, 8);
8
+ type Client = Awaited<ReturnType<typeof setupTest>>["client"];
9
+
10
+ function runActor(client: Client, runId: string) {
11
+ return client.workflowRun.getOrCreate([runId]);
12
+ }
13
+
14
+ async function createRun(client: Client, runId: string) {
15
+ return runActor(client, runId).appendEvent(runId, {
16
+ eventType: "run_created",
17
+ specVersion: SPEC_VERSION_CURRENT,
18
+ eventData: { deploymentId: "rivet", workflowName: "evt", input: { n: 1 } },
19
+ });
20
+ }
21
+
22
+ describe("workflowRun event dedup + materialization", () => {
23
+ let dispose: (() => Promise<void>) | undefined;
24
+ afterEach(async () => {
25
+ if (dispose) await dispose();
26
+ dispose = undefined;
27
+ });
28
+
29
+ test("run_created materializes the run row and is idempotent on redelivery", async (c) => {
30
+ const { client } = await setupTest(c, registry);
31
+ const runId = `wrun_${uid()}`;
32
+ const first = await createRun(client, runId);
33
+ expect(first.run?.runId).toBe(runId);
34
+ expect(first.run?.status).toBe("pending");
35
+
36
+ // Redeliver the same creation event: must collapse, not create a second.
37
+ const second = await createRun(client, runId);
38
+ expect(second.run?.runId).toBe(runId);
39
+
40
+ const events = await runActor(client, runId).listEvents({
41
+ runId,
42
+ pagination: { limit: 100 },
43
+ });
44
+ const created = events.data.filter((e) => e.eventType === "run_created");
45
+ expect(created).toHaveLength(1);
46
+ });
47
+
48
+ test("step_created / wait_created materialize and dedupe by correlationId", async (c) => {
49
+ const { client } = await setupTest(c, registry);
50
+ const runId = `wrun_${uid()}`;
51
+ await createRun(client, runId);
52
+
53
+ const stepId = `step-${uid()}`;
54
+ await runActor(client, runId).appendEvent(runId, {
55
+ eventType: "step_created",
56
+ specVersion: SPEC_VERSION_CURRENT,
57
+ correlationId: stepId,
58
+ eventData: { stepName: "s1", input: {} },
59
+ });
60
+ await runActor(client, runId).appendEvent(runId, {
61
+ eventType: "step_created",
62
+ specVersion: SPEC_VERSION_CURRENT,
63
+ correlationId: stepId,
64
+ eventData: { stepName: "s1", input: {} },
65
+ });
66
+ const step = await runActor(client, runId).getStep(runId, stepId, {
67
+ resolveData: "none",
68
+ });
69
+ expect(step.stepId).toBe(stepId);
70
+
71
+ const waitCorrelationId = `wait-${uid()}`;
72
+ const waitData = {
73
+ eventType: "wait_created" as const,
74
+ specVersion: SPEC_VERSION_CURRENT,
75
+ correlationId: waitCorrelationId,
76
+ eventData: { resumeAt: new Date(Date.now() + 60_000) },
77
+ };
78
+ const firstWait = await runActor(client, runId).appendEvent(
79
+ runId,
80
+ waitData,
81
+ );
82
+ const secondWait = await runActor(client, runId).appendEvent(
83
+ runId,
84
+ waitData,
85
+ );
86
+ expect(secondWait.wait?.waitId).toBe(firstWait.wait?.waitId);
87
+
88
+ const events = await runActor(client, runId).listEvents({
89
+ runId,
90
+ pagination: { limit: 100 },
91
+ });
92
+ expect(
93
+ events.data.filter((e) => e.eventType === "step_created"),
94
+ ).toHaveLength(1);
95
+ expect(
96
+ events.data.filter((e) => e.eventType === "wait_created"),
97
+ ).toHaveLength(1);
98
+ });
99
+
100
+ test("listEventsByCorrelationId advances its cursor", async (c) => {
101
+ const { client } = await setupTest(c, registry);
102
+ const runId = `wrun_${uid()}`;
103
+ await createRun(client, runId);
104
+ const correlationId = `hook-${uid()}`;
105
+ for (let i = 0; i < 3; i++) {
106
+ await runActor(client, runId).appendEvent(runId, {
107
+ eventType: "hook_received",
108
+ specVersion: SPEC_VERSION_CURRENT,
109
+ correlationId,
110
+ eventData: { payload: { i } },
111
+ });
112
+ }
113
+
114
+ const first = await runActor(client, runId).listEventsByCorrelationId({
115
+ correlationId,
116
+ pagination: { limit: 2 },
117
+ });
118
+ expect(first.data).toHaveLength(2);
119
+ expect(first.hasMore).toBe(true);
120
+ const second = await runActor(client, runId).listEventsByCorrelationId({
121
+ correlationId,
122
+ pagination: { limit: 2, cursor: first.cursor ?? undefined },
123
+ });
124
+ expect(second.data).toHaveLength(1);
125
+ expect(second.hasMore).toBe(false);
126
+ expect(second.data[0]?.eventId).not.toBe(first.data[0]?.eventId);
127
+ expect(second.data[0]?.eventId).not.toBe(first.data[1]?.eventId);
128
+ });
129
+
130
+ test("actor actions reject keys that do not match their actor", async (c) => {
131
+ const { client } = await setupTest(c, registry);
132
+ const runId = `wrun_${uid()}`;
133
+ const otherRunId = `wrun_${uid()}`;
134
+ await expect(
135
+ runActor(client, runId).appendEvent(otherRunId, {
136
+ eventType: "run_created",
137
+ specVersion: SPEC_VERSION_CURRENT,
138
+ eventData: { deploymentId: "rivet", workflowName: "evt", input: {} },
139
+ }),
140
+ ).rejects.toThrow();
141
+ await expect(runActor(client, runId).getRun(otherRunId)).rejects.toThrow();
142
+ await expect(
143
+ client.workflowRun
144
+ .getOrCreate([runId])
145
+ .enqueue(
146
+ otherRunId,
147
+ `msg_${uid()}`,
148
+ "__wkf_workflow_probe",
149
+ "http://127.0.0.1:1",
150
+ JSON.stringify({ runId: otherRunId }),
151
+ ),
152
+ ).rejects.toThrow();
153
+ });
154
+
155
+ test("ownsHook only matches the actor's persisted hook and token", async (c) => {
156
+ const { client } = await setupTest(c, registry);
157
+ const runId = `wrun_${uid()}`;
158
+ await createRun(client, runId);
159
+ const hookId = `hook-${uid()}`;
160
+ const token = `token-${uid()}`;
161
+ await runActor(client, runId).appendEvent(runId, {
162
+ eventType: "hook_created",
163
+ specVersion: SPEC_VERSION_CURRENT,
164
+ correlationId: hookId,
165
+ eventData: { token },
166
+ });
167
+ expect(await runActor(client, runId).ownsHook(hookId, token)).toBe(true);
168
+ expect(await runActor(client, runId).ownsHook(hookId, "wrong-token")).toBe(
169
+ false,
170
+ );
171
+ });
172
+
173
+ test("run_started recreates the run when run_created was lost (resilient start)", async (c) => {
174
+ const { client } = await setupTest(c, registry);
175
+ const runId = `wrun_${uid()}`;
176
+ // No run_created first — simulate it being lost. run_started carries the
177
+ // creation fields, so the run must be materialized and marked running.
178
+ const started = await runActor(client, runId).appendEvent(runId, {
179
+ eventType: "run_started",
180
+ specVersion: SPEC_VERSION_CURRENT,
181
+ eventData: {
182
+ deploymentId: "rivet",
183
+ workflowName: "resilient",
184
+ input: { recovered: true },
185
+ },
186
+ });
187
+ expect(started.run?.runId).toBe(runId);
188
+ expect(started.run?.status).toBe("running");
189
+
190
+ const run = await runActor(client, runId).getRun(runId);
191
+ expect(run.workflowName).toBe("resilient");
192
+ expect(run.input).toEqual({ recovered: true });
193
+ });
194
+
195
+ test("lazy step_started atomically creates the step and its replay event", async (c) => {
196
+ const { client } = await setupTest(c, registry);
197
+ const runId = `wrun_${uid()}`;
198
+ const stepId = `step-${uid()}`;
199
+ await createRun(client, runId);
200
+ const result = await runActor(client, runId).appendEvent(runId, {
201
+ eventType: "step_started",
202
+ specVersion: SPEC_VERSION_CURRENT,
203
+ correlationId: stepId,
204
+ eventData: {
205
+ stepName: "lazy",
206
+ input: { value: 1 },
207
+ ownerMessageId: `msg_${uid()}`,
208
+ },
209
+ });
210
+ expect(result.stepCreated).toBe(true);
211
+ expect(result.step?.status).toBe("running");
212
+
213
+ const events = await runActor(client, runId).listEvents({
214
+ runId,
215
+ pagination: { limit: 100 },
216
+ });
217
+ expect(
218
+ events.data
219
+ .filter((event) => event.correlationId === stepId)
220
+ .map((event) => event.eventType),
221
+ ).toEqual(["step_created", "step_started"]);
222
+ const replay = await runActor(client, runId).appendEvent(runId, {
223
+ eventType: "step_started",
224
+ specVersion: SPEC_VERSION_CURRENT,
225
+ correlationId: stepId,
226
+ eventData: { stepName: "lazy", input: { value: 1 } },
227
+ });
228
+ expect(replay.step?.status).toBe("running");
229
+ expect(replay.step?.attempt).toBe(1);
230
+
231
+ await expect(
232
+ runActor(client, runId).appendEvent(runId, {
233
+ eventType: "step_started",
234
+ specVersion: SPEC_VERSION_CURRENT,
235
+ correlationId: stepId,
236
+ eventData: { stepName: "lazy", input: { value: 2 } },
237
+ }),
238
+ ).rejects.toThrow();
239
+ });
240
+
241
+ test("rejects child events without a run and after the run terminates", async (c) => {
242
+ const { client } = await setupTest(c, registry);
243
+ const missingRunId = `wrun_${uid()}`;
244
+ await expect(
245
+ runActor(client, missingRunId).appendEvent(missingRunId, {
246
+ eventType: "step_created",
247
+ specVersion: SPEC_VERSION_CURRENT,
248
+ correlationId: `step-${uid()}`,
249
+ eventData: { stepName: "orphan", input: {} },
250
+ }),
251
+ ).rejects.toThrow();
252
+
253
+ const runId = `wrun_${uid()}`;
254
+ await createRun(client, runId);
255
+ await runActor(client, runId).appendEvent(runId, {
256
+ eventType: "run_completed",
257
+ specVersion: SPEC_VERSION_CURRENT,
258
+ eventData: { output: null },
259
+ });
260
+ await expect(
261
+ runActor(client, runId).appendEvent(runId, {
262
+ eventType: "hook_received",
263
+ specVersion: SPEC_VERSION_CURRENT,
264
+ correlationId: `hook-${uid()}`,
265
+ eventData: { payload: {} },
266
+ }),
267
+ ).rejects.toThrow();
268
+ });
269
+
270
+ test("concurrent run_created turns for one run yield exactly one creation event", async (c) => {
271
+ const { client } = await setupTest(c, registry);
272
+ const runId = `wrun_${uid()}`;
273
+ // Two turns racing to create the same run (recovery re-enqueue vs live, or
274
+ // duplicate delivery). The creation-only unique index must collapse them.
275
+ const results = await Promise.all([
276
+ createRun(client, runId),
277
+ createRun(client, runId),
278
+ ]);
279
+ for (const r of results) expect(r.run?.runId).toBe(runId);
280
+
281
+ const events = await runActor(client, runId).listEvents({
282
+ runId,
283
+ pagination: { limit: 100 },
284
+ });
285
+ expect(
286
+ events.data.filter((e) => e.eventType === "run_created"),
287
+ ).toHaveLength(1);
288
+ });
289
+
290
+ test("concurrent step_created turns for one correlationId yield exactly one step", async (c) => {
291
+ const { client } = await setupTest(c, registry);
292
+ const runId = `wrun_${uid()}`;
293
+ await createRun(client, runId);
294
+ const stepId = `step-${uid()}`;
295
+ // Two genuinely-concurrent creation turns for the same correlationId is
296
+ // enough to exercise the creation-only unique index race (matches the
297
+ // run_created concurrency test); the SQL-level dedup itself is covered
298
+ // exhaustively by the deterministic unit suite.
299
+ await Promise.all([
300
+ runActor(client, runId).appendEvent(runId, {
301
+ eventType: "step_created",
302
+ specVersion: SPEC_VERSION_CURRENT,
303
+ correlationId: stepId,
304
+ eventData: { stepName: "s", input: {} },
305
+ }),
306
+ runActor(client, runId).appendEvent(runId, {
307
+ eventType: "step_created",
308
+ specVersion: SPEC_VERSION_CURRENT,
309
+ correlationId: stepId,
310
+ eventData: { stepName: "s", input: {} },
311
+ }),
312
+ ]);
313
+ const events = await runActor(client, runId).listEvents({
314
+ runId,
315
+ pagination: { limit: 100 },
316
+ });
317
+ expect(
318
+ events.data.filter((e) => e.eventType === "step_created"),
319
+ ).toHaveLength(1);
320
+ const steps = await runActor(client, runId).listSteps({
321
+ runId,
322
+ pagination: { limit: 100 },
323
+ });
324
+ expect(steps.data.filter((s) => s.stepId === stepId)).toHaveLength(1);
325
+ });
326
+ });
package/tests/setup.ts ADDED
@@ -0,0 +1,10 @@
1
+ // Runs before each test file is imported, so the dispatcher module (which reads
2
+ // these at evaluation time) picks up the test-tuned queue knobs. Keeps retry /
3
+ // dead-letter / stale-reclaim windows short and deterministic.
4
+ process.env.RIVET_WORLD_RIVET_MAX_DISPATCH_ATTEMPTS ??= "3";
5
+ process.env.RIVET_WORLD_RIVET_RETRY_DELAY_MS ??= "100";
6
+ process.env.RIVET_WORLD_RIVET_STALE_INFLIGHT_MS ??= "400";
7
+ process.env.RIVET_WORLD_RIVET_TESTING ??= "1";
8
+
9
+ const { registry } = await import("../src/actors.ts");
10
+ registry.config.runtime = "native";
@@ -0,0 +1,73 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { compact, decodeValue, encodeValue } from "../../src/codec.ts";
3
+
4
+ function roundtrip<T>(value: T): T {
5
+ return decodeValue<T>(encodeValue(value)) as T;
6
+ }
7
+
8
+ describe("codec round-trip (large + edge-case payloads)", () => {
9
+ test("undefined encodes to null and decodes back to undefined", () => {
10
+ expect(encodeValue(undefined)).toBeNull();
11
+ expect(decodeValue(null)).toBeUndefined();
12
+ expect(decodeValue(undefined)).toBeUndefined();
13
+ });
14
+
15
+ test("primitives and null round-trip", () => {
16
+ for (const value of [0, -1, 3.14159, "", "hello", true, false, null]) {
17
+ expect(roundtrip(value)).toEqual(value);
18
+ }
19
+ });
20
+
21
+ test("large string (1 MiB) round-trips", () => {
22
+ const value = "x".repeat(1024 * 1024);
23
+ expect(roundtrip(value)).toBe(value);
24
+ });
25
+
26
+ test("deeply nested structure round-trips", () => {
27
+ let nested: unknown = { leaf: 42 };
28
+ for (let i = 0; i < 200; i++) nested = { i, child: nested };
29
+ expect(roundtrip(nested)).toEqual(nested);
30
+ });
31
+
32
+ test("all-binary payload (Uint8Array with null bytes) round-trips", () => {
33
+ const bytes = new Uint8Array(2048);
34
+ for (let i = 0; i < bytes.length; i++) bytes[i] = i % 256;
35
+ const output = roundtrip(bytes);
36
+ expect(output).toBeInstanceOf(Uint8Array);
37
+ expect(Array.from(output)).toEqual(Array.from(bytes));
38
+ });
39
+
40
+ test("strings containing null bytes and unicode round-trip", () => {
41
+ const value = "a\0b\u{1F680}c\nd\te\\f\"g";
42
+ expect(roundtrip(value)).toBe(value);
43
+ });
44
+
45
+ test("mixed nested object with binary, arrays, and large fields round-trips", () => {
46
+ const payload = {
47
+ id: `wrun_${"z".repeat(64)}`,
48
+ input: {
49
+ items: Array.from({ length: 500 }, (_, i) => ({ i, name: `item-${i}` })),
50
+ },
51
+ blob: new Uint8Array([0, 1, 2, 255, 0, 128]),
52
+ meta: {
53
+ nested: { deep: { value: "\0end" } },
54
+ flag: false,
55
+ n: Number.MAX_SAFE_INTEGER,
56
+ },
57
+ };
58
+ expect(roundtrip(payload)).toEqual(payload);
59
+ });
60
+
61
+ test("decodeValue accepts Buffer and number-array encodings", () => {
62
+ const encoded = encodeValue({ a: 1, b: "two" });
63
+ expect(encoded).not.toBeNull();
64
+ expect(decodeValue(Buffer.from(encoded!))).toEqual({ a: 1, b: "two" });
65
+ expect(decodeValue(Array.from(encoded!))).toEqual({ a: 1, b: "two" });
66
+ });
67
+
68
+ test("compact drops undefined and null but keeps falsy values", () => {
69
+ expect(
70
+ compact({ a: 1, b: undefined, c: null, d: 0, e: "", f: false }),
71
+ ).toEqual({ a: 1, d: 0, e: "", f: false });
72
+ });
73
+ });
@@ -0,0 +1,177 @@
1
+ import { SPEC_VERSION_CURRENT, type WorkflowRun } from "@workflow/world";
2
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
3
+ import {
4
+ applyCoordinatorUpdate,
5
+ coordinator,
6
+ type CoordinatorContext,
7
+ } from "../../src/actors/coordinator.ts";
8
+ import { migrateCoordinatorDb } from "../../src/actors/db.ts";
9
+ import { makeCtx, type TestCtx } from "../helpers/db.ts";
10
+
11
+ type Ctx = CoordinatorContext & { raw: TestCtx["raw"]; close: () => void };
12
+ type ActorCtx = CoordinatorContext & { key: readonly unknown[] };
13
+
14
+ const actions = coordinator.config.actions as unknown as {
15
+ listRuns: (c: ActorCtx, params?: Record<string, unknown>) => Promise<{
16
+ data: WorkflowRun[];
17
+ cursor: string | null;
18
+ hasMore: boolean;
19
+ }>;
20
+ listHookIndex: (
21
+ c: ActorCtx,
22
+ params?: Record<string, unknown>,
23
+ ) => Promise<{
24
+ data: { hookId: string; runId: string }[];
25
+ cursor: string | null;
26
+ hasMore: boolean;
27
+ }>;
28
+ };
29
+
30
+ async function makeCoordCtx(): Promise<Ctx> {
31
+ const base = await makeCtx(migrateCoordinatorDb);
32
+ return { db: base.db, raw: base.raw, close: base.close };
33
+ }
34
+
35
+ function run(
36
+ runId: string,
37
+ status: WorkflowRun["status"] = "running",
38
+ createdAt = new Date("2026-01-01T00:00:00.000Z"),
39
+ ): WorkflowRun {
40
+ return {
41
+ runId,
42
+ status,
43
+ deploymentId: "rivet",
44
+ workflowName: "wf",
45
+ specVersion: SPEC_VERSION_CURRENT,
46
+ createdAt,
47
+ updatedAt: createdAt,
48
+ } as WorkflowRun;
49
+ }
50
+
51
+ describe("coordinator derived indexes", () => {
52
+ let ctx: Ctx;
53
+ beforeEach(async () => {
54
+ ctx = await makeCoordCtx();
55
+ });
56
+ afterEach(() => ctx.close());
57
+
58
+ test("run upserts cannot regress to an older revision", async () => {
59
+ await applyCoordinatorUpdate(ctx, {
60
+ type: "run.upsert",
61
+ run: run("wrun_1", "completed"),
62
+ runRevision: 2,
63
+ });
64
+ await applyCoordinatorUpdate(ctx, {
65
+ type: "run.upsert",
66
+ run: run("wrun_1", "running"),
67
+ runRevision: 1,
68
+ });
69
+
70
+ const row = ctx.raw
71
+ .prepare("SELECT run_revision, status FROM runs_index")
72
+ .get();
73
+ expect(row).toEqual({ run_revision: 2, status: "completed" });
74
+ });
75
+
76
+ test("correlation tombstones fence delayed puts and retain ownership", async () => {
77
+ await applyCoordinatorUpdate(ctx, {
78
+ type: "correlation.delete",
79
+ correlationId: "hook_1",
80
+ runId: "wrun_1",
81
+ kind: "hook",
82
+ runRevision: 3,
83
+ });
84
+ await applyCoordinatorUpdate(ctx, {
85
+ type: "correlation.put",
86
+ correlationId: "hook_1",
87
+ runId: "wrun_1",
88
+ kind: "hook",
89
+ runRevision: 3,
90
+ });
91
+ expect(
92
+ ctx.raw
93
+ .prepare(
94
+ "SELECT run_revision, status FROM correlation_index WHERE correlation_id = ?",
95
+ )
96
+ .get("hook_1"),
97
+ ).toEqual({ run_revision: 3, status: "deleted" });
98
+
99
+ await expect(
100
+ applyCoordinatorUpdate(ctx, {
101
+ type: "correlation.put",
102
+ correlationId: "hook_1",
103
+ runId: "wrun_2",
104
+ kind: "hook",
105
+ runRevision: 4,
106
+ }),
107
+ ).rejects.toThrow("another workflow entity");
108
+ });
109
+
110
+ test("hook tombstones fence delayed upserts", async () => {
111
+ const common = {
112
+ hookId: "hook_1",
113
+ runId: "wrun_1",
114
+ token: "token_1",
115
+ createdAt: 10,
116
+ updatedAt: 20,
117
+ };
118
+ await applyCoordinatorUpdate(ctx, {
119
+ ...common,
120
+ type: "hook.delete",
121
+ runRevision: 5,
122
+ });
123
+ await applyCoordinatorUpdate(ctx, {
124
+ ...common,
125
+ type: "hook.upsert",
126
+ runRevision: 6,
127
+ });
128
+ expect(
129
+ ctx.raw
130
+ .prepare("SELECT run_revision, status FROM hooks_index")
131
+ .get(),
132
+ ).toEqual({ run_revision: 5, status: "deleted" });
133
+ });
134
+
135
+ test("run and hook listings use stable tuple cursors and hide tombstones", async () => {
136
+ const createdAt = new Date("2026-01-01T00:00:00.000Z");
137
+ for (const runId of ["wrun_1", "wrun_2", "wrun_3"]) {
138
+ await applyCoordinatorUpdate(ctx, {
139
+ type: "run.upsert",
140
+ run: run(runId, "running", createdAt),
141
+ runRevision: 1,
142
+ });
143
+ }
144
+ const actorCtx = { db: ctx.db, key: ["coordinator"] };
145
+ const firstRuns = await actions.listRuns(actorCtx, {
146
+ pagination: { limit: 2 },
147
+ });
148
+ expect(firstRuns.data.map((value) => value.runId)).toEqual([
149
+ "wrun_3",
150
+ "wrun_2",
151
+ ]);
152
+ expect(firstRuns.hasMore).toBe(true);
153
+ const secondRuns = await actions.listRuns(actorCtx, {
154
+ pagination: { limit: 2, cursor: firstRuns.cursor },
155
+ });
156
+ expect(secondRuns.data.map((value) => value.runId)).toEqual(["wrun_1"]);
157
+
158
+ for (const hookId of ["hook_1", "hook_2", "hook_3"]) {
159
+ await applyCoordinatorUpdate(ctx, {
160
+ type: hookId === "hook_2" ? "hook.delete" : "hook.upsert",
161
+ hookId,
162
+ runId: `run_${hookId}`,
163
+ token: `token_${hookId}`,
164
+ runRevision: 1,
165
+ createdAt: 10,
166
+ updatedAt: 10,
167
+ });
168
+ }
169
+ const hooks = await actions.listHookIndex(actorCtx, {
170
+ pagination: { limit: 10 },
171
+ });
172
+ expect(hooks.data.map((value) => value.hookId)).toEqual([
173
+ "hook_3",
174
+ "hook_1",
175
+ ]);
176
+ });
177
+ });
@@ -0,0 +1,65 @@
1
+ import { afterEach, describe, expect, test } from "vitest";
2
+ import {
3
+ migrateCoordinatorDb,
4
+ migrateHookTokenDb,
5
+ migrateWorkflowDb,
6
+ } from "../../src/actors/db.ts";
7
+ import { makeCtx, type TestCtx } from "../helpers/db.ts";
8
+
9
+ const contexts: TestCtx[] = [];
10
+ afterEach(() => {
11
+ for (const ctx of contexts.splice(0)) ctx.close();
12
+ });
13
+
14
+ function columns(ctx: TestCtx, table: string): string[] {
15
+ return ctx.raw
16
+ .prepare(`PRAGMA table_info(${table})`)
17
+ .all()
18
+ .map((row) => String(row.name));
19
+ }
20
+
21
+ describe("Vercel World database migrations", () => {
22
+ test("creates the consolidated workflowRun schema at v1", async () => {
23
+ const ctx = await makeCtx(migrateWorkflowDb);
24
+ contexts.push(ctx);
25
+ expect(columns(ctx, "workflow_runs")).toEqual(
26
+ expect.arrayContaining(["run_revision", "attributes"]),
27
+ );
28
+ expect(columns(ctx, "workflow_waits")).toEqual(
29
+ expect.arrayContaining([
30
+ "runtime_url",
31
+ "queue_name",
32
+ "headers",
33
+ "resume_enqueued_at",
34
+ ]),
35
+ );
36
+ expect(columns(ctx, "dispatcher_queue")).toContain("idem_key");
37
+ expect(columns(ctx, "workflow_hooks")).toContain("token_generation");
38
+ expect(columns(ctx, "stream_chunks")).toContain("sequence");
39
+ expect(columns(ctx, "pending_ops")).toEqual(
40
+ expect.arrayContaining(["operation_revision", "claim_id", "next_at"]),
41
+ );
42
+ });
43
+
44
+ test("creates the derived coordinator schema at v1", async () => {
45
+ const ctx = await makeCtx(migrateCoordinatorDb);
46
+ contexts.push(ctx);
47
+ expect(columns(ctx, "runs_index")).toEqual(
48
+ expect.arrayContaining(["run_revision", "attributes"]),
49
+ );
50
+ expect(columns(ctx, "correlation_index")).toEqual(
51
+ expect.arrayContaining(["kind", "run_revision", "status"]),
52
+ );
53
+ expect(columns(ctx, "hooks_index")).toEqual(
54
+ expect.arrayContaining(["run_revision", "token", "status"]),
55
+ );
56
+ });
57
+
58
+ test("creates generation-fenced hook ownership at v1", async () => {
59
+ const ctx = await makeCtx(migrateHookTokenDb);
60
+ contexts.push(ctx);
61
+ expect(columns(ctx, "hook_token_state")).toEqual(
62
+ expect.arrayContaining(["generation", "status", "expires_at"]),
63
+ );
64
+ });
65
+ });