@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.
- package/.turbo/turbo-build.log +4 -0
- package/README.md +31 -0
- package/dist/actors/coordinator.d.ts +114 -0
- package/dist/actors/coordinator.d.ts.map +1 -0
- package/dist/actors/coordinator.js +232 -0
- package/dist/actors/coordinator.js.map +1 -0
- package/dist/actors/db.d.ts +8 -0
- package/dist/actors/db.d.ts.map +1 -0
- package/dist/actors/db.js +282 -0
- package/dist/actors/db.js.map +1 -0
- package/dist/actors/dispatcher.d.ts +84 -0
- package/dist/actors/dispatcher.d.ts.map +1 -0
- package/dist/actors/dispatcher.js +498 -0
- package/dist/actors/dispatcher.js.map +1 -0
- package/dist/actors/hook-token.d.ts +26 -0
- package/dist/actors/hook-token.d.ts.map +1 -0
- package/dist/actors/hook-token.js +151 -0
- package/dist/actors/hook-token.js.map +1 -0
- package/dist/actors/shared.d.ts +366 -0
- package/dist/actors/shared.d.ts.map +1 -0
- package/dist/actors/shared.js +112 -0
- package/dist/actors/shared.js.map +1 -0
- package/dist/actors/streams.d.ts +26 -0
- package/dist/actors/streams.d.ts.map +1 -0
- package/dist/actors/streams.js +127 -0
- package/dist/actors/streams.js.map +1 -0
- package/dist/actors/workflow-run.d.ts +890 -0
- package/dist/actors/workflow-run.d.ts.map +1 -0
- package/dist/actors/workflow-run.js +863 -0
- package/dist/actors/workflow-run.js.map +1 -0
- package/dist/actors.d.ts +2204 -0
- package/dist/actors.d.ts.map +1 -0
- package/dist/actors.js +16 -0
- package/dist/actors.js.map +1 -0
- package/dist/codec.d.ts +4 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/codec.js +27 -0
- package/dist/codec.js.map +1 -0
- package/dist/index.d.ts +843 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +553 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime.d.ts +2 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +24 -0
- package/dist/runtime.js.map +1 -0
- package/package.json +51 -0
- package/scripts/conformance/run.ts +278 -0
- package/scripts/integration/run.ts +935 -0
- package/src/actors/coordinator.ts +360 -0
- package/src/actors/db.ts +291 -0
- package/src/actors/dispatcher.ts +787 -0
- package/src/actors/hook-token.ts +239 -0
- package/src/actors/shared.ts +153 -0
- package/src/actors/streams.ts +215 -0
- package/src/actors/workflow-run.ts +1466 -0
- package/src/actors.ts +18 -0
- package/src/codec.ts +28 -0
- package/src/index.ts +768 -0
- package/src/runtime.ts +29 -0
- package/tests/conformance.test.ts +8 -0
- package/tests/helpers/db.ts +62 -0
- package/tests/helpers/dispatcher-driver.ts +71 -0
- package/tests/helpers/harness.ts +161 -0
- package/tests/integration/crash-restart.test.ts +145 -0
- package/tests/integration/dispatcher-loop.test.ts +144 -0
- package/tests/integration/hook-token.test.ts +160 -0
- package/tests/integration/hooks.test.ts +123 -0
- package/tests/integration/streams.test.ts +178 -0
- package/tests/integration/workflow-events.test.ts +326 -0
- package/tests/setup.ts +10 -0
- package/tests/unit/codec.test.ts +73 -0
- package/tests/unit/coordinator-record.test.ts +177 -0
- package/tests/unit/db-migrations.test.ts +65 -0
- package/tests/unit/dispatcher-queue.test.ts +274 -0
- package/tests/unit/logging.test.ts +49 -0
- package/tests/unit/readiness.test.ts +102 -0
- package/tests/unit/transaction.test.ts +76 -0
- package/tsconfig.build.json +13 -0
- package/tsconfig.json +12 -0
- package/vitest.config.ts +32 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { beforeEach, afterEach, describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
claimNextDispatch,
|
|
4
|
+
failDispatch,
|
|
5
|
+
markDispatchDone,
|
|
6
|
+
nextDispatchAt,
|
|
7
|
+
nextInflightRecoveryAt,
|
|
8
|
+
reclaimStaleInflight,
|
|
9
|
+
rescheduleDispatch,
|
|
10
|
+
STALE_INFLIGHT_MS,
|
|
11
|
+
} from "../../src/actors/dispatcher.ts";
|
|
12
|
+
import { migrateWorkflowDb } from "../../src/actors/db.ts";
|
|
13
|
+
import { makeCtx, type TestCtx } from "../helpers/db.ts";
|
|
14
|
+
|
|
15
|
+
const BASE = {
|
|
16
|
+
queue_name: "__wkf_workflow_probe",
|
|
17
|
+
route: "flow",
|
|
18
|
+
runtime_url: "http://127.0.0.1:1",
|
|
19
|
+
body: JSON.stringify({ runId: "wrun_x" }),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
async function insertRow(
|
|
23
|
+
ctx: TestCtx,
|
|
24
|
+
messageId: string,
|
|
25
|
+
overrides: Partial<{
|
|
26
|
+
status: string;
|
|
27
|
+
attempt: number;
|
|
28
|
+
next_at: number;
|
|
29
|
+
created_at: number;
|
|
30
|
+
started_at: number | null;
|
|
31
|
+
idem_key: string | null;
|
|
32
|
+
}> = {},
|
|
33
|
+
) {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
await ctx.db.execute(
|
|
36
|
+
`
|
|
37
|
+
INSERT INTO dispatcher_queue (
|
|
38
|
+
message_id, queue_name, route, runtime_url, body, headers, idem_key,
|
|
39
|
+
status, attempt, next_at, created_at, updated_at, started_at
|
|
40
|
+
)
|
|
41
|
+
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)
|
|
42
|
+
`,
|
|
43
|
+
messageId,
|
|
44
|
+
BASE.queue_name,
|
|
45
|
+
BASE.route,
|
|
46
|
+
BASE.runtime_url,
|
|
47
|
+
BASE.body,
|
|
48
|
+
overrides.idem_key ?? null,
|
|
49
|
+
overrides.status ?? "ready",
|
|
50
|
+
overrides.attempt ?? 0,
|
|
51
|
+
overrides.next_at ?? now,
|
|
52
|
+
overrides.created_at ?? now,
|
|
53
|
+
now,
|
|
54
|
+
overrides.started_at ?? null,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function row(ctx: TestCtx, messageId: string) {
|
|
59
|
+
return (
|
|
60
|
+
await ctx.db.execute(
|
|
61
|
+
"SELECT * FROM dispatcher_queue WHERE message_id = ?",
|
|
62
|
+
messageId,
|
|
63
|
+
)
|
|
64
|
+
)[0];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe("dispatcher queue SQL helpers", () => {
|
|
68
|
+
let ctx: TestCtx;
|
|
69
|
+
beforeEach(async () => {
|
|
70
|
+
ctx = await makeCtx(migrateWorkflowDb);
|
|
71
|
+
});
|
|
72
|
+
afterEach(() => ctx.close());
|
|
73
|
+
|
|
74
|
+
test("claimNextDispatch claims the oldest ready row, flips to inflight, bumps attempt", async () => {
|
|
75
|
+
await insertRow(ctx, "msg_a", { created_at: 1000 });
|
|
76
|
+
await insertRow(ctx, "msg_b", { created_at: 2000 });
|
|
77
|
+
const claimed = await claimNextDispatch(ctx);
|
|
78
|
+
expect(claimed?.message_id).toBe("msg_a");
|
|
79
|
+
expect(claimed?.attempt).toBe(1);
|
|
80
|
+
expect((await row(ctx, "msg_a")).status).toBe("inflight");
|
|
81
|
+
expect((await row(ctx, "msg_b")).status).toBe("ready");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("consecutive claims take distinct rows (single-consumer ordering)", async () => {
|
|
85
|
+
await insertRow(ctx, "msg_a", { created_at: 1000 });
|
|
86
|
+
await insertRow(ctx, "msg_b", { created_at: 2000 });
|
|
87
|
+
const first = await claimNextDispatch(ctx);
|
|
88
|
+
const second = await claimNextDispatch(ctx);
|
|
89
|
+
expect(first?.message_id).toBe("msg_a");
|
|
90
|
+
expect(second?.message_id).toBe("msg_b");
|
|
91
|
+
expect(await claimNextDispatch(ctx)).toBeNull();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("claimNextDispatch skips rows whose next_at is in the future", async () => {
|
|
95
|
+
await insertRow(ctx, "msg_future", { next_at: Date.now() + 60_000 });
|
|
96
|
+
expect(await claimNextDispatch(ctx)).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("reclaimStaleInflight returns stale rows to ready without a terminal error/finished stamp", async () => {
|
|
100
|
+
// started_at far in the past, attempt below the cap.
|
|
101
|
+
await insertRow(ctx, "msg_stale", {
|
|
102
|
+
status: "inflight",
|
|
103
|
+
attempt: 1,
|
|
104
|
+
started_at: Date.now() - 10 * 60_000,
|
|
105
|
+
});
|
|
106
|
+
await reclaimStaleInflight(ctx);
|
|
107
|
+
const r = await row(ctx, "msg_stale");
|
|
108
|
+
expect(r.status).toBe("ready");
|
|
109
|
+
// Transient reclaim must NOT mark the row finished or stamp an error.
|
|
110
|
+
expect(r.finished_at).toBeNull();
|
|
111
|
+
expect(r.error).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("reclaimStaleInflight dead-letters stale rows that hit the attempt cap", async () => {
|
|
115
|
+
await insertRow(ctx, "msg_dead", {
|
|
116
|
+
status: "inflight",
|
|
117
|
+
attempt: 256,
|
|
118
|
+
started_at: Date.now() - 10 * 60_000,
|
|
119
|
+
});
|
|
120
|
+
await reclaimStaleInflight(ctx);
|
|
121
|
+
const r = await row(ctx, "msg_dead");
|
|
122
|
+
expect(r.status).toBe("failed");
|
|
123
|
+
expect(String(r.error)).toContain("max attempts");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("reclaimStaleInflight leaves fresh inflight rows untouched", async () => {
|
|
127
|
+
await insertRow(ctx, "msg_fresh", {
|
|
128
|
+
status: "inflight",
|
|
129
|
+
attempt: 1,
|
|
130
|
+
started_at: Date.now(),
|
|
131
|
+
});
|
|
132
|
+
await reclaimStaleInflight(ctx);
|
|
133
|
+
expect((await row(ctx, "msg_fresh")).status).toBe("inflight");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("reclaimStaleInflight leaves locally active requests untouched", async () => {
|
|
137
|
+
await insertRow(ctx, "msg_active", {
|
|
138
|
+
status: "inflight",
|
|
139
|
+
attempt: 1,
|
|
140
|
+
started_at: Date.now() - 10 * 60_000,
|
|
141
|
+
});
|
|
142
|
+
await reclaimStaleInflight(ctx, new Set(["msg_active"]));
|
|
143
|
+
expect((await row(ctx, "msg_active")).status).toBe("inflight");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("markDispatchDone / rescheduleDispatch / failDispatch transitions", async () => {
|
|
147
|
+
await insertRow(ctx, "msg_done", { status: "inflight" });
|
|
148
|
+
await markDispatchDone(ctx, "msg_done", 0, 200);
|
|
149
|
+
expect((await row(ctx, "msg_done")).status).toBe("done");
|
|
150
|
+
expect(Number((await row(ctx, "msg_done")).http_status)).toBe(200);
|
|
151
|
+
|
|
152
|
+
await insertRow(ctx, "msg_retry", { status: "inflight" });
|
|
153
|
+
await rescheduleDispatch(ctx, "msg_retry", 0, 5_000, 503, "HTTP 503");
|
|
154
|
+
const retry = await row(ctx, "msg_retry");
|
|
155
|
+
expect(retry.status).toBe("ready");
|
|
156
|
+
expect(Number(retry.next_at)).toBeGreaterThan(Date.now());
|
|
157
|
+
|
|
158
|
+
await insertRow(ctx, "msg_fail", { status: "inflight" });
|
|
159
|
+
await failDispatch(ctx, "msg_fail", 0, 503, "dead letter");
|
|
160
|
+
expect((await row(ctx, "msg_fail")).status).toBe("failed");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("a stale attempt cannot overwrite a newer claim", async () => {
|
|
164
|
+
await insertRow(ctx, "msg_raced", { status: "inflight", attempt: 2 });
|
|
165
|
+
await rescheduleDispatch(ctx, "msg_raced", 1, 0, 500, "old attempt");
|
|
166
|
+
const raced = await row(ctx, "msg_raced");
|
|
167
|
+
expect(raced.status).toBe("inflight");
|
|
168
|
+
expect(raced.error).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("nextDispatchAt returns the earliest ready next_at", async () => {
|
|
172
|
+
await insertRow(ctx, "msg_late", { next_at: 9000 });
|
|
173
|
+
await insertRow(ctx, "msg_early", { next_at: 3000 });
|
|
174
|
+
expect(await nextDispatchAt(ctx)).toBe(3000);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("nextInflightRecoveryAt returns the first stale deadline", async () => {
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
await insertRow(ctx, "msg_late", {
|
|
180
|
+
status: "inflight",
|
|
181
|
+
started_at: now - 100,
|
|
182
|
+
});
|
|
183
|
+
await insertRow(ctx, "msg_early", {
|
|
184
|
+
status: "inflight",
|
|
185
|
+
started_at: now - 300,
|
|
186
|
+
});
|
|
187
|
+
const recoveryAt = await nextInflightRecoveryAt(ctx);
|
|
188
|
+
expect(recoveryAt).not.toBeNull();
|
|
189
|
+
expect(recoveryAt!).toBeGreaterThanOrEqual(now);
|
|
190
|
+
expect(recoveryAt!).toBeLessThanOrEqual(now + 150);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("nextInflightRecoveryAt re-arms locally active stale rows in the future", async () => {
|
|
194
|
+
await insertRow(ctx, "msg_active", {
|
|
195
|
+
status: "inflight",
|
|
196
|
+
started_at: Date.now() - 10 * 60_000,
|
|
197
|
+
});
|
|
198
|
+
const before = Date.now();
|
|
199
|
+
const recoveryAt = await nextInflightRecoveryAt(
|
|
200
|
+
ctx,
|
|
201
|
+
new Set(["msg_active"]),
|
|
202
|
+
);
|
|
203
|
+
expect(recoveryAt).toBeGreaterThanOrEqual(before + STALE_INFLIGHT_MS);
|
|
204
|
+
expect(recoveryAt).toBeLessThanOrEqual(Date.now() + STALE_INFLIGHT_MS);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("re-delivered enqueue with the same idem_key returns the ORIGINAL message id (deterministic)", async () => {
|
|
208
|
+
// Mirrors dispatcher.enqueue: INSERT OR IGNORE then the dedup SELECT. A
|
|
209
|
+
// re-delivery generates a fresh messageId (msg_B) but must resolve back to
|
|
210
|
+
// the originally-stored messageId (msg_A) for the same idem_key, so the
|
|
211
|
+
// caller never sees a new id on redelivery (matches world-postgres jobKey).
|
|
212
|
+
await insertRow(ctx, "msg_A", { idem_key: "stable" });
|
|
213
|
+
const dedupSelect = async (incomingMessageId: string, idemKey: string) => {
|
|
214
|
+
await ctx.db.execute(
|
|
215
|
+
`
|
|
216
|
+
INSERT OR IGNORE INTO dispatcher_queue (
|
|
217
|
+
message_id, queue_name, route, runtime_url, body, headers, idem_key,
|
|
218
|
+
status, attempt, next_at, created_at, updated_at
|
|
219
|
+
)
|
|
220
|
+
VALUES (?, ?, ?, ?, ?, NULL, ?, 'ready', 0, ?, ?, ?)
|
|
221
|
+
`,
|
|
222
|
+
incomingMessageId,
|
|
223
|
+
BASE.queue_name,
|
|
224
|
+
BASE.route,
|
|
225
|
+
BASE.runtime_url,
|
|
226
|
+
BASE.body,
|
|
227
|
+
idemKey,
|
|
228
|
+
Date.now(),
|
|
229
|
+
Date.now(),
|
|
230
|
+
Date.now(),
|
|
231
|
+
);
|
|
232
|
+
const stored = await ctx.db.execute(
|
|
233
|
+
`
|
|
234
|
+
SELECT message_id FROM dispatcher_queue
|
|
235
|
+
WHERE message_id = ? OR (? IS NOT NULL AND idem_key = ?)
|
|
236
|
+
LIMIT 1
|
|
237
|
+
`,
|
|
238
|
+
incomingMessageId,
|
|
239
|
+
idemKey,
|
|
240
|
+
idemKey,
|
|
241
|
+
);
|
|
242
|
+
return String(stored[0]?.message_id ?? incomingMessageId);
|
|
243
|
+
};
|
|
244
|
+
expect(await dedupSelect("msg_B", "stable")).toBe("msg_A");
|
|
245
|
+
expect(await dedupSelect("msg_C", "stable")).toBe("msg_A");
|
|
246
|
+
// A genuinely new idem_key is NOT deduped (gets its own id).
|
|
247
|
+
expect(await dedupSelect("msg_D", "other")).toBe("msg_D");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("idempotency unique index collapses duplicate idem_key enqueues", async () => {
|
|
251
|
+
await insertRow(ctx, "msg_1", { idem_key: "dup" });
|
|
252
|
+
await ctx.db.execute(
|
|
253
|
+
`
|
|
254
|
+
INSERT OR IGNORE INTO dispatcher_queue (
|
|
255
|
+
message_id, queue_name, route, runtime_url, body, headers, idem_key,
|
|
256
|
+
status, attempt, next_at, created_at, updated_at
|
|
257
|
+
)
|
|
258
|
+
VALUES ('msg_2', ?, ?, ?, ?, NULL, 'dup', 'ready', 0, ?, ?, ?)
|
|
259
|
+
`,
|
|
260
|
+
BASE.queue_name,
|
|
261
|
+
BASE.route,
|
|
262
|
+
BASE.runtime_url,
|
|
263
|
+
BASE.body,
|
|
264
|
+
Date.now(),
|
|
265
|
+
Date.now(),
|
|
266
|
+
Date.now(),
|
|
267
|
+
);
|
|
268
|
+
const rows = await ctx.db.execute(
|
|
269
|
+
"SELECT message_id FROM dispatcher_queue WHERE idem_key = 'dup'",
|
|
270
|
+
);
|
|
271
|
+
expect(rows).toHaveLength(1);
|
|
272
|
+
expect(String(rows[0].message_id)).toBe("msg_1");
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { debugLog } from "../../src/actors/dispatcher.ts";
|
|
3
|
+
|
|
4
|
+
describe("structured debug logging", () => {
|
|
5
|
+
let spy: ReturnType<typeof vi.spyOn>;
|
|
6
|
+
const prev = process.env.RIVET_WORLD_RIVET_DEBUG;
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
process.env.RIVET_WORLD_RIVET_DEBUG = "1";
|
|
9
|
+
spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
10
|
+
});
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
spy.mockRestore();
|
|
13
|
+
if (prev === undefined) delete process.env.RIVET_WORLD_RIVET_DEBUG;
|
|
14
|
+
else process.env.RIVET_WORLD_RIVET_DEBUG = prev;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("emits a single structured JSON line with scope, event, and trace id", () => {
|
|
18
|
+
debugLog("dispatcher.deliver", { messageId: "msg_1", route: "flow" });
|
|
19
|
+
expect(spy).toHaveBeenCalledTimes(1);
|
|
20
|
+
const line = JSON.parse(spy.mock.calls[0][0] as string);
|
|
21
|
+
expect(line).toMatchObject({
|
|
22
|
+
scope: "@rivet-dev/vercel-world",
|
|
23
|
+
event: "dispatcher.deliver",
|
|
24
|
+
traceId: "msg_1",
|
|
25
|
+
route: "flow",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("NEVER logs the bearer secret — sensitive keys are redacted", () => {
|
|
30
|
+
debugLog("dispatcher.deliver", {
|
|
31
|
+
messageId: "msg_2",
|
|
32
|
+
headers: { authorization: "Bearer super-secret" },
|
|
33
|
+
authorization: "Bearer super-secret",
|
|
34
|
+
token: "super-secret",
|
|
35
|
+
});
|
|
36
|
+
const raw = spy.mock.calls[0][0] as string;
|
|
37
|
+
expect(raw).not.toContain("super-secret");
|
|
38
|
+
const line = JSON.parse(raw);
|
|
39
|
+
expect(line.authorization).toBe("[redacted]");
|
|
40
|
+
expect(line.headers).toBe("[redacted]");
|
|
41
|
+
expect(line.token).toBe("[redacted]");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("is silent unless RIVET_WORLD_RIVET_DEBUG=1", () => {
|
|
45
|
+
delete process.env.RIVET_WORLD_RIVET_DEBUG;
|
|
46
|
+
debugLog("dispatcher.deliver", { messageId: "msg_3" });
|
|
47
|
+
expect(spy).not.toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { Registry } from "rivetkit";
|
|
2
|
+
import type { Client } from "rivetkit/client";
|
|
3
|
+
import { describe, expect, test, vi } from "vitest";
|
|
4
|
+
import { registry } from "../../src/actors.js";
|
|
5
|
+
import { RivetClientWorld } from "../../src/index.js";
|
|
6
|
+
|
|
7
|
+
function testWorld(startAndWait: () => Promise<void>) {
|
|
8
|
+
const getRun = vi.fn(async (runId: string) => ({ runId }));
|
|
9
|
+
const client = {
|
|
10
|
+
workflowRun: {
|
|
11
|
+
getOrCreate: () => ({ getRun }),
|
|
12
|
+
},
|
|
13
|
+
dispose: vi.fn(async () => {}),
|
|
14
|
+
} as unknown as Client<typeof registry>;
|
|
15
|
+
const applicationRegistry = {
|
|
16
|
+
startAndWait,
|
|
17
|
+
parseConfig: () => ({
|
|
18
|
+
namespace: "test",
|
|
19
|
+
envoy: { poolName: "test" },
|
|
20
|
+
}),
|
|
21
|
+
} as unknown as Registry<any> & { startAndWait(): Promise<void> };
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
getRun,
|
|
25
|
+
world: new RivetClientWorld({ client, registry: applicationRegistry }),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("World registry readiness", () => {
|
|
30
|
+
test("waits for readiness before the first actor operation", async () => {
|
|
31
|
+
let resolveReady!: () => void;
|
|
32
|
+
const ready = new Promise<void>((resolve) => {
|
|
33
|
+
resolveReady = resolve;
|
|
34
|
+
});
|
|
35
|
+
const startAndWait = vi.fn(() => ready);
|
|
36
|
+
const { getRun, world } = testWorld(startAndWait);
|
|
37
|
+
|
|
38
|
+
const result = world.runs.get("run-1");
|
|
39
|
+
await Promise.resolve();
|
|
40
|
+
expect(startAndWait).toHaveBeenCalledOnce();
|
|
41
|
+
expect(getRun).not.toHaveBeenCalled();
|
|
42
|
+
|
|
43
|
+
resolveReady();
|
|
44
|
+
await expect(result).resolves.toEqual({ runId: "run-1" });
|
|
45
|
+
expect(getRun).toHaveBeenCalledOnce();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("intentionally checks the shared readiness promise for every operation", async () => {
|
|
49
|
+
const startAndWait = vi.fn(async () => {});
|
|
50
|
+
const { getRun, world } = testWorld(startAndWait);
|
|
51
|
+
|
|
52
|
+
await Promise.all([world.runs.get("run-1"), world.runs.get("run-2")]);
|
|
53
|
+
|
|
54
|
+
expect(startAndWait).toHaveBeenCalledTimes(2);
|
|
55
|
+
expect(getRun).toHaveBeenCalledTimes(2);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("waits for readiness before opening a stream connection", async () => {
|
|
59
|
+
let resolveReady!: () => void;
|
|
60
|
+
const ready = new Promise<void>((resolve) => {
|
|
61
|
+
resolveReady = resolve;
|
|
62
|
+
});
|
|
63
|
+
const startAndWait = vi.fn(() => ready);
|
|
64
|
+
const connect = vi.fn(() => ({
|
|
65
|
+
on: vi.fn(() => vi.fn()),
|
|
66
|
+
onOpen: vi.fn(() => vi.fn()),
|
|
67
|
+
dispose: vi.fn(async () => {}),
|
|
68
|
+
}));
|
|
69
|
+
const store = {
|
|
70
|
+
connect,
|
|
71
|
+
getStreamChunks: vi.fn(async () => ({
|
|
72
|
+
data: [],
|
|
73
|
+
done: true,
|
|
74
|
+
hasMore: false,
|
|
75
|
+
})),
|
|
76
|
+
};
|
|
77
|
+
const client = {
|
|
78
|
+
workflowRun: { getOrCreate: () => store },
|
|
79
|
+
dispose: vi.fn(async () => {}),
|
|
80
|
+
} as unknown as Client<typeof registry>;
|
|
81
|
+
const applicationRegistry = {
|
|
82
|
+
startAndWait,
|
|
83
|
+
parseConfig: () => ({
|
|
84
|
+
namespace: "test",
|
|
85
|
+
envoy: { poolName: "test" },
|
|
86
|
+
}),
|
|
87
|
+
} as unknown as Registry<any> & { startAndWait(): Promise<void> };
|
|
88
|
+
const world = new RivetClientWorld({
|
|
89
|
+
client,
|
|
90
|
+
registry: applicationRegistry,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const streamPromise = world.readFromStream("run-1", "output");
|
|
94
|
+
await Promise.resolve();
|
|
95
|
+
expect(connect).not.toHaveBeenCalled();
|
|
96
|
+
|
|
97
|
+
resolveReady();
|
|
98
|
+
const stream = await streamPromise;
|
|
99
|
+
await stream.getReader().read();
|
|
100
|
+
expect(connect).toHaveBeenCalledOnce();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { beforeEach, afterEach, describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
withActorTransaction,
|
|
4
|
+
type Ctx,
|
|
5
|
+
} from "../../src/actors/shared.ts";
|
|
6
|
+
import { makeCtx, type TestCtx } from "../helpers/db.ts";
|
|
7
|
+
|
|
8
|
+
type TxCtx = Ctx & { raw: TestCtx["raw"]; close: () => void };
|
|
9
|
+
|
|
10
|
+
async function makeTxCtx(): Promise<TxCtx> {
|
|
11
|
+
const base = await makeCtx(async (db) => {
|
|
12
|
+
await db.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)");
|
|
13
|
+
});
|
|
14
|
+
return { db: base.db, raw: base.raw, close: base.close };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function count(ctx: TxCtx) {
|
|
18
|
+
return Number((await ctx.db.execute("SELECT COUNT(*) AS n FROM t"))[0].n);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("withActorTransaction", () => {
|
|
22
|
+
let ctx: TxCtx;
|
|
23
|
+
beforeEach(async () => {
|
|
24
|
+
ctx = await makeTxCtx();
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => ctx.close());
|
|
27
|
+
|
|
28
|
+
test("commits all writes when fn succeeds", async () => {
|
|
29
|
+
await withActorTransaction(ctx, async (tx) => {
|
|
30
|
+
await tx.db.execute("INSERT INTO t (v) VALUES ('a')");
|
|
31
|
+
await tx.db.execute("INSERT INTO t (v) VALUES ('b')");
|
|
32
|
+
});
|
|
33
|
+
expect(await count(ctx)).toBe(2);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("rolls back ALL writes when fn throws mid-action (atomicity)", async () => {
|
|
37
|
+
await expect(
|
|
38
|
+
withActorTransaction(ctx, async (tx) => {
|
|
39
|
+
await tx.db.execute("INSERT INTO t (v) VALUES ('a')");
|
|
40
|
+
// Simulate a crash/failure after the first write but before the
|
|
41
|
+
// second — without a transaction, 'a' would leak (partial state).
|
|
42
|
+
throw new Error("boom");
|
|
43
|
+
}),
|
|
44
|
+
).rejects.toThrow("boom");
|
|
45
|
+
expect(await count(ctx)).toBe(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("serializes overlapping transactions (no nested BEGIN on one connection)", async () => {
|
|
49
|
+
// Fire two transactions concurrently on the same actor database.
|
|
50
|
+
const results = await Promise.allSettled([
|
|
51
|
+
withActorTransaction(ctx, async (tx) => {
|
|
52
|
+
await tx.db.execute("INSERT INTO t (v) VALUES ('x')");
|
|
53
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
54
|
+
await tx.db.execute("INSERT INTO t (v) VALUES ('y')");
|
|
55
|
+
}),
|
|
56
|
+
withActorTransaction(ctx, async (tx) => {
|
|
57
|
+
await tx.db.execute("INSERT INTO t (v) VALUES ('z')");
|
|
58
|
+
}),
|
|
59
|
+
]);
|
|
60
|
+
expect(results.every((r) => r.status === "fulfilled")).toBe(true);
|
|
61
|
+
expect(await count(ctx)).toBe(3);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("a failed transaction does not poison the next one", async () => {
|
|
65
|
+
await expect(
|
|
66
|
+
withActorTransaction(ctx, async (tx) => {
|
|
67
|
+
await tx.db.execute("INSERT INTO t (v) VALUES ('a')");
|
|
68
|
+
throw new Error("fail");
|
|
69
|
+
}),
|
|
70
|
+
).rejects.toThrow();
|
|
71
|
+
await withActorTransaction(ctx, async (tx) => {
|
|
72
|
+
await tx.db.execute("INSERT INTO t (v) VALUES ('b')");
|
|
73
|
+
});
|
|
74
|
+
expect(await count(ctx)).toBe(1);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "./tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"allowImportingTsExtensions": false,
|
|
5
|
+
"declaration": true,
|
|
6
|
+
"declarationMap": true,
|
|
7
|
+
"noEmit": false,
|
|
8
|
+
"outDir": "dist",
|
|
9
|
+
"rootDir": "src",
|
|
10
|
+
"sourceMap": true
|
|
11
|
+
},
|
|
12
|
+
"include": ["src"]
|
|
13
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"target": "ES2022",
|
|
5
|
+
"module": "NodeNext",
|
|
6
|
+
"moduleResolution": "NodeNext",
|
|
7
|
+
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
|
8
|
+
"allowImportingTsExtensions": true,
|
|
9
|
+
"noEmit": true
|
|
10
|
+
},
|
|
11
|
+
"include": ["src", "scripts"]
|
|
12
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { defineConfig, mergeConfig } from "vitest/config";
|
|
2
|
+
import baseConfig from "../../vitest.base.ts";
|
|
3
|
+
|
|
4
|
+
export default mergeConfig(
|
|
5
|
+
baseConfig,
|
|
6
|
+
defineConfig({
|
|
7
|
+
test: {
|
|
8
|
+
setupFiles: ["./tests/setup.ts"],
|
|
9
|
+
// vitest.base enables sequence.concurrent for the engine's own
|
|
10
|
+
// concurrency-safe suites. These actor/SQL suites share per-test
|
|
11
|
+
// `beforeEach` fixtures and assert deterministic row ordering, so they
|
|
12
|
+
// must run sequentially within each file.
|
|
13
|
+
sequence: { concurrent: false },
|
|
14
|
+
// Actor / chaos tests stand up native runtimes and poll; give them room.
|
|
15
|
+
testTimeout: 30_000,
|
|
16
|
+
hookTimeout: 30_000,
|
|
17
|
+
// The RivetKit test driver occasionally drops an actor mid-
|
|
18
|
+
// provisioning (`actor_wake_retries_exceeded`); that's harness flakiness,
|
|
19
|
+
// not product behavior (real regressions fail deterministically across
|
|
20
|
+
// retries — see the negative-control checks in each suite). Retry to keep
|
|
21
|
+
// the gate reliable without masking genuine failures.
|
|
22
|
+
retry: 2,
|
|
23
|
+
// RivetKit actors keep module-level singletons; isolate files.
|
|
24
|
+
pool: "forks",
|
|
25
|
+
// Each setupTest file spins up its own native engine; running files in
|
|
26
|
+
// parallel makes those engines contend (sporadic `fetch failed` across whole
|
|
27
|
+
// files). Serialize file execution — the deterministic suites are fast, so
|
|
28
|
+
// the wall-clock cost is small and the gate becomes reliable.
|
|
29
|
+
fileParallelism: false,
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
);
|