@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,239 @@
1
+ import { actor } from "rivetkit";
2
+ import { hookTokenDb } from "./db.js";
3
+ import { one } from "./shared.js";
4
+
5
+ export const PENDING_RESERVATION_TTL_MS = 60_000;
6
+
7
+ type HookTokenContext = {
8
+ key: readonly unknown[];
9
+ db: {
10
+ execute(
11
+ sql: string,
12
+ ...args: unknown[]
13
+ ): Promise<Record<string, unknown>[]>;
14
+ transaction<T>(
15
+ callback: (db: HookTokenContext["db"]) => Promise<T> | T,
16
+ ): Promise<T>;
17
+ };
18
+ client<T>(): T;
19
+ };
20
+
21
+ type HookTokenRow = {
22
+ generation: number;
23
+ runId?: string;
24
+ hookId?: string;
25
+ status: "empty" | "pending" | "confirmed";
26
+ createdAt?: number;
27
+ updatedAt: number;
28
+ expiresAt?: number;
29
+ };
30
+
31
+ function assertTokenKey(c: { key: readonly unknown[] }, token: string) {
32
+ if (String(c.key[0] ?? "") !== token) {
33
+ throw new Error("hookToken actor key must match token");
34
+ }
35
+ }
36
+
37
+ async function readState(c: { db: HookTokenContext["db"] }): Promise<HookTokenRow> {
38
+ const row = await one(
39
+ c,
40
+ `SELECT generation, run_id, hook_id, status, created_at, updated_at, expires_at
41
+ FROM hook_token_state WHERE singleton = 1`,
42
+ );
43
+ if (!row) throw new Error("hookToken state is missing");
44
+ return {
45
+ generation: Number(row.generation),
46
+ runId: row.run_id == null ? undefined : String(row.run_id),
47
+ hookId: row.hook_id == null ? undefined : String(row.hook_id),
48
+ status: String(row.status) as HookTokenRow["status"],
49
+ createdAt: row.created_at == null ? undefined : Number(row.created_at),
50
+ updatedAt: Number(row.updated_at),
51
+ expiresAt: row.expires_at == null ? undefined : Number(row.expires_at),
52
+ };
53
+ }
54
+
55
+ async function verifyPendingOwner(
56
+ c: HookTokenContext,
57
+ token: string,
58
+ state: HookTokenRow,
59
+ ): Promise<HookTokenRow | null> {
60
+ if (
61
+ state.status !== "pending" ||
62
+ state.runId == null ||
63
+ state.hookId == null
64
+ ) {
65
+ return state.status === "confirmed" ? state : null;
66
+ }
67
+
68
+ let ownsHook: boolean;
69
+ try {
70
+ ownsHook = await c
71
+ .client<any>()
72
+ .workflowRun.getOrCreate([state.runId])
73
+ .ownsHook(state.hookId, token);
74
+ } catch {
75
+ // Canonical ownership could not be checked. A pending reservation must
76
+ // never become externally visible or be stolen on an inconclusive result.
77
+ return null;
78
+ }
79
+
80
+ const now = Date.now();
81
+ if (ownsHook) {
82
+ await c.db.execute(
83
+ `UPDATE hook_token_state
84
+ SET status = 'confirmed', updated_at = ?, expires_at = NULL
85
+ WHERE singleton = 1 AND generation = ? AND run_id = ? AND hook_id = ?
86
+ AND status = 'pending'`,
87
+ now,
88
+ state.generation,
89
+ state.runId,
90
+ state.hookId,
91
+ );
92
+ const current = await readState(c);
93
+ return current.generation === state.generation &&
94
+ current.status === "confirmed" &&
95
+ current.runId === state.runId &&
96
+ current.hookId === state.hookId
97
+ ? current
98
+ : null;
99
+ }
100
+
101
+ if ((state.expiresAt ?? Number.POSITIVE_INFINITY) > now) return null;
102
+ await c.db.execute(
103
+ `UPDATE hook_token_state
104
+ SET generation = generation + 1, run_id = NULL, hook_id = NULL,
105
+ status = 'empty', created_at = NULL, updated_at = ?, expires_at = NULL
106
+ WHERE singleton = 1 AND generation = ? AND run_id = ? AND hook_id = ?
107
+ AND status = 'pending'`,
108
+ now,
109
+ state.generation,
110
+ state.runId,
111
+ state.hookId,
112
+ );
113
+ return null;
114
+ }
115
+
116
+ export const hookToken = actor({
117
+ db: hookTokenDb,
118
+ actions: {
119
+ reserve: async (c, token: string, runId: string, hookId: string) => {
120
+ const ctx = c as unknown as HookTokenContext;
121
+ assertTokenKey(ctx, token);
122
+ let state = await readState(ctx);
123
+ if (
124
+ state.status === "pending" &&
125
+ (state.runId !== runId || state.hookId !== hookId) &&
126
+ (state.expiresAt ?? Number.POSITIVE_INFINITY) <= Date.now()
127
+ ) {
128
+ await verifyPendingOwner(ctx, token, state);
129
+ state = await readState(ctx);
130
+ }
131
+ if (
132
+ state.status !== "empty" &&
133
+ state.runId === runId &&
134
+ state.hookId === hookId
135
+ ) {
136
+ return { ok: true, generation: state.generation };
137
+ }
138
+ if (state.status !== "empty") {
139
+ return {
140
+ ok: false,
141
+ generation: state.generation,
142
+ runId: state.runId,
143
+ hookId: state.hookId,
144
+ };
145
+ }
146
+
147
+ const now = Date.now();
148
+ await ctx.db.execute(
149
+ `UPDATE hook_token_state
150
+ SET generation = generation + 1, run_id = ?, hook_id = ?,
151
+ status = 'pending', created_at = ?, updated_at = ?, expires_at = ?
152
+ WHERE singleton = 1 AND status = 'empty'`,
153
+ runId,
154
+ hookId,
155
+ now,
156
+ now,
157
+ now + PENDING_RESERVATION_TTL_MS,
158
+ );
159
+ const reserved = await readState(ctx);
160
+ return {
161
+ ok:
162
+ reserved.status === "pending" &&
163
+ reserved.runId === runId &&
164
+ reserved.hookId === hookId,
165
+ generation: reserved.generation,
166
+ };
167
+ },
168
+ confirm: async (
169
+ c,
170
+ token: string,
171
+ generation: number,
172
+ runId: string,
173
+ hookId: string,
174
+ ) => {
175
+ const ctx = c as unknown as HookTokenContext;
176
+ assertTokenKey(ctx, token);
177
+ await ctx.db.execute(
178
+ `UPDATE hook_token_state
179
+ SET status = 'confirmed', updated_at = ?, expires_at = NULL
180
+ WHERE singleton = 1 AND generation = ? AND run_id = ? AND hook_id = ?
181
+ AND status IN ('pending', 'confirmed')`,
182
+ Date.now(),
183
+ generation,
184
+ runId,
185
+ hookId,
186
+ );
187
+ const state = await readState(ctx);
188
+ return {
189
+ ok:
190
+ state.generation === generation &&
191
+ state.status === "confirmed" &&
192
+ state.runId === runId &&
193
+ state.hookId === hookId,
194
+ };
195
+ },
196
+ get: async (c, token: string) => {
197
+ const ctx = c as unknown as HookTokenContext;
198
+ assertTokenKey(ctx, token);
199
+ let state = await readState(ctx);
200
+ if (state.status === "pending") {
201
+ await verifyPendingOwner(ctx, token, state);
202
+ state = await readState(ctx);
203
+ }
204
+ if (state.status !== "confirmed") return null;
205
+ return {
206
+ runId: state.runId as string,
207
+ hookId: state.hookId as string,
208
+ generation: state.generation,
209
+ };
210
+ },
211
+ release: async (
212
+ c,
213
+ token: string,
214
+ generation: number,
215
+ runId: string,
216
+ hookId?: string,
217
+ ) => {
218
+ const ctx = c as unknown as HookTokenContext;
219
+ assertTokenKey(ctx, token);
220
+ await ctx.db.execute(
221
+ `UPDATE hook_token_state
222
+ SET generation = generation + 1, run_id = NULL, hook_id = NULL,
223
+ status = 'empty', created_at = NULL, updated_at = ?, expires_at = NULL
224
+ WHERE singleton = 1 AND generation = ? AND run_id = ?
225
+ AND (? IS NULL OR hook_id = ?) AND status IN ('pending', 'confirmed')`,
226
+ Date.now(),
227
+ generation,
228
+ runId,
229
+ hookId ?? null,
230
+ hookId ?? null,
231
+ );
232
+ const state = await readState(ctx);
233
+ return {
234
+ ok:
235
+ state.status === "empty" && state.generation >= generation + 1,
236
+ };
237
+ },
238
+ },
239
+ });
@@ -0,0 +1,153 @@
1
+ import { WorkflowRunNotFoundError } from "@workflow/errors";
2
+ import {
3
+ EventSchema,
4
+ HookSchema,
5
+ StepSchema,
6
+ WorkflowRunSchema,
7
+ type Hook,
8
+ type Wait,
9
+ } from "@workflow/world";
10
+ import { compact, decodeValue } from "../codec.js";
11
+
12
+ export type Db = {
13
+ execute(sql: string, ...args: unknown[]): Promise<Record<string, unknown>[]>;
14
+ transaction<T>(callback: (tx: Db) => Promise<T> | T): Promise<T>;
15
+ };
16
+
17
+ export type Ctx = { db: Db };
18
+
19
+ export async function withActorTransaction<T>(
20
+ c: Ctx,
21
+ fn: (tx: Ctx) => Promise<T>,
22
+ ): Promise<T> {
23
+ return c.db.transaction((db) => fn({ db }));
24
+ }
25
+
26
+ export function toDate(value: unknown): Date | undefined {
27
+ return value == null ? undefined : new Date(Number(value));
28
+ }
29
+
30
+ export function toMs(value: unknown): number | null {
31
+ if (value == null) return null;
32
+ if (value instanceof Date) return value.getTime();
33
+ return new Date(value as string | number).getTime();
34
+ }
35
+
36
+ export function rowToRun(row: Record<string, unknown>) {
37
+ return WorkflowRunSchema.parse(
38
+ compact({
39
+ runId: row.run_id,
40
+ status: row.status,
41
+ deploymentId: row.deployment_id,
42
+ workflowName: row.workflow_name,
43
+ specVersion:
44
+ row.spec_version == null ? undefined : Number(row.spec_version),
45
+ executionContext: decodeValue(row.execution_context),
46
+ attributes: decodeValue(row.attributes) ?? {},
47
+ input: decodeValue(row.input),
48
+ output: decodeValue(row.output),
49
+ error: decodeValue(row.error),
50
+ expiredAt: toDate(row.expired_at),
51
+ startedAt: toDate(row.started_at),
52
+ completedAt: toDate(row.completed_at),
53
+ createdAt: toDate(row.created_at),
54
+ updatedAt: toDate(row.updated_at),
55
+ }),
56
+ );
57
+ }
58
+
59
+ export function rowToEvent(row: Record<string, unknown>) {
60
+ return EventSchema.parse(
61
+ compact({
62
+ runId: row.run_id,
63
+ eventId: row.event_id,
64
+ eventType: row.event_type,
65
+ correlationId: row.correlation_id,
66
+ eventData: decodeValue(row.event_data),
67
+ specVersion:
68
+ row.spec_version == null ? undefined : Number(row.spec_version),
69
+ createdAt: toDate(row.created_at),
70
+ }),
71
+ );
72
+ }
73
+
74
+ export function rowToStep(row: Record<string, unknown>) {
75
+ return StepSchema.parse(
76
+ compact({
77
+ runId: row.run_id,
78
+ stepId: row.step_id,
79
+ stepName: row.step_name,
80
+ status: row.status,
81
+ input: decodeValue(row.input),
82
+ output: decodeValue(row.output),
83
+ error: decodeValue(row.error),
84
+ attempt: Number(row.attempt),
85
+ startedAt: toDate(row.started_at),
86
+ completedAt: toDate(row.completed_at),
87
+ createdAt: toDate(row.created_at),
88
+ updatedAt: toDate(row.updated_at),
89
+ retryAfter: toDate(row.retry_after),
90
+ specVersion:
91
+ row.spec_version == null ? undefined : Number(row.spec_version),
92
+ }),
93
+ );
94
+ }
95
+
96
+ export function rowToHook(row: Record<string, unknown>) {
97
+ const parsed = HookSchema.parse(
98
+ compact({
99
+ runId: row.run_id,
100
+ hookId: row.hook_id,
101
+ token: row.token,
102
+ ownerId: row.owner_id,
103
+ projectId: row.project_id,
104
+ environment: row.environment,
105
+ metadata: decodeValue(row.metadata),
106
+ createdAt: toDate(row.created_at),
107
+ specVersion:
108
+ row.spec_version == null ? undefined : Number(row.spec_version),
109
+ isWebhook: row.is_webhook == null ? undefined : Boolean(row.is_webhook),
110
+ }),
111
+ );
112
+ parsed.isWebhook ??= true;
113
+ return parsed;
114
+ }
115
+
116
+ export function rowToWait(row: Record<string, unknown>): Wait {
117
+ return compact({
118
+ waitId: row.wait_id,
119
+ runId: row.run_id,
120
+ status: row.status,
121
+ resumeAt: toDate(row.resume_at),
122
+ completedAt: toDate(row.completed_at),
123
+ createdAt: toDate(row.created_at),
124
+ updatedAt: toDate(row.updated_at),
125
+ specVersion: row.spec_version == null ? undefined : Number(row.spec_version),
126
+ }) as Wait;
127
+ }
128
+
129
+ export function filterData<T extends { input?: unknown; output?: unknown }>(
130
+ value: T,
131
+ resolveData?: string,
132
+ ) {
133
+ if (resolveData !== "none") return value;
134
+ return { ...value, input: undefined, output: undefined };
135
+ }
136
+
137
+ export function filterHookData<T extends { metadata?: unknown }>(
138
+ value: T,
139
+ resolveData?: string,
140
+ ) {
141
+ if (resolveData !== "none") return value;
142
+ return { ...value, metadata: undefined };
143
+ }
144
+
145
+ export async function one(c: Ctx, sql: string, ...args: unknown[]) {
146
+ return (await c.db.execute(sql, ...args))[0];
147
+ }
148
+
149
+ export async function requireRun(c: Ctx, runId: string) {
150
+ const row = await one(c, "SELECT * FROM workflow_runs WHERE run_id = ?", runId);
151
+ if (!row) throw new WorkflowRunNotFoundError(runId);
152
+ return rowToRun(row);
153
+ }
@@ -0,0 +1,215 @@
1
+ import type { StreamChunksResponse, StreamInfoResponse } from "@workflow/world";
2
+ import { event, type Type } from "rivetkit";
3
+ import { decodeValue, encodeValue } from "../codec.js";
4
+ import { withActorTransaction, type Ctx } from "./shared.js";
5
+
6
+ function envInteger(name: string, fallback: number, min: number) {
7
+ const value = Number(process.env[name]);
8
+ return Number.isFinite(value) ? Math.max(min, Math.floor(value)) : fallback;
9
+ }
10
+
11
+ export const STREAM_TTL_MS = envInteger(
12
+ "RIVET_WORLD_RIVET_STREAM_TTL_MS",
13
+ 86_400_000,
14
+ 0,
15
+ );
16
+
17
+ export const streamAppended: Type<{
18
+ streamId: string;
19
+ runId: string;
20
+ chunkId: string;
21
+ eof: boolean;
22
+ }> = event();
23
+
24
+ type StreamContext = Ctx & {
25
+ key: readonly string[];
26
+ broadcast(name: "streamAppended", value: unknown): void;
27
+ };
28
+
29
+ function assertRunKey(c: StreamContext, runId: string) {
30
+ if (c.key[0] !== runId) throw new Error("workflowRun actor key must match runId");
31
+ }
32
+
33
+ function decodeBase64Chunk(value: string) {
34
+ return new Uint8Array(Buffer.from(value, "base64"));
35
+ }
36
+
37
+ let broadcastCounter = 0;
38
+ function shouldDropBroadcast(eof: boolean) {
39
+ if (eof) return false;
40
+ const rate = Number(process.env.RIVET_WORLD_RIVET_INJECT_BROADCAST_DROP_RATE);
41
+ if (!Number.isFinite(rate) || rate <= 0) return false;
42
+ const r = Math.min(rate, 1);
43
+ const n = broadcastCounter++;
44
+ return Math.floor((n + 1) * r) > Math.floor(n * r);
45
+ }
46
+
47
+ async function injectReadDelay() {
48
+ const ms = Number(process.env.RIVET_WORLD_RIVET_INJECT_STREAM_READ_DELAY_MS);
49
+ if (Number.isFinite(ms) && ms > 0) {
50
+ await new Promise((resolve) => setTimeout(resolve, ms));
51
+ }
52
+ }
53
+
54
+ async function cleanupExpired(c: Ctx, streamId: string) {
55
+ if (STREAM_TTL_MS === 0) return 0;
56
+ const expired = await c.db.execute(
57
+ `SELECT 1 FROM stream_chunks WHERE stream_id = ? AND eof = 1
58
+ AND created_at <= ? LIMIT 1`,
59
+ streamId,
60
+ Date.now() - STREAM_TTL_MS,
61
+ );
62
+ if (expired.length === 0) return 0;
63
+ return (
64
+ await c.db.execute(
65
+ "DELETE FROM stream_chunks WHERE stream_id = ? RETURNING sequence",
66
+ streamId,
67
+ )
68
+ ).length;
69
+ }
70
+
71
+ export async function writeStream(
72
+ c: StreamContext,
73
+ streamId: string,
74
+ runId: string,
75
+ chunkBase64: string,
76
+ eof: boolean,
77
+ ) {
78
+ assertRunKey(c, runId);
79
+ await cleanupExpired(c, streamId);
80
+ const result = await withActorTransaction(c, async (tx) => {
81
+ const closed = await tx.db.execute(
82
+ "SELECT sequence FROM stream_chunks WHERE stream_id = ? AND eof = 1 LIMIT 1",
83
+ streamId,
84
+ );
85
+ if (closed[0]) {
86
+ if (eof) return { sequence: Number(closed[0].sequence), inserted: false };
87
+ throw new Error(`stream "${streamId}" is already closed`);
88
+ }
89
+ const next = await tx.db.execute(
90
+ "SELECT COALESCE(MAX(sequence), -1) + 1 AS sequence FROM stream_chunks WHERE stream_id = ?",
91
+ streamId,
92
+ );
93
+ const value = Number(next[0]?.sequence ?? 0);
94
+ await tx.db.execute(
95
+ `INSERT INTO stream_chunks (stream_id, sequence, chunk_data, eof, created_at)
96
+ VALUES (?, ?, ?, ?, ?)`,
97
+ streamId,
98
+ value,
99
+ encodeValue(decodeBase64Chunk(chunkBase64)),
100
+ eof ? 1 : 0,
101
+ Date.now(),
102
+ );
103
+ await tx.db.execute(
104
+ "INSERT OR IGNORE INTO run_streams (stream_id) VALUES (?)",
105
+ streamId,
106
+ );
107
+ return { sequence: value, inserted: true };
108
+ });
109
+ const chunkId = String(result.sequence);
110
+ if (result.inserted && !shouldDropBroadcast(eof)) {
111
+ c.broadcast("streamAppended", { streamId, runId, chunkId, eof });
112
+ }
113
+ return { chunkId };
114
+ }
115
+
116
+ export async function getStreamChunks(
117
+ c: StreamContext,
118
+ streamId: string,
119
+ runId: string,
120
+ startIndex = 0,
121
+ limit = 100,
122
+ ): Promise<StreamChunksResponse> {
123
+ assertRunKey(c, runId);
124
+ await cleanupExpired(c, streamId);
125
+ const rows = await c.db.execute(
126
+ `SELECT sequence, chunk_data FROM stream_chunks
127
+ WHERE stream_id = ? AND eof = 0 AND sequence >= ?
128
+ ORDER BY sequence ASC LIMIT ?`,
129
+ streamId,
130
+ startIndex,
131
+ limit + 1,
132
+ );
133
+ const hasMore = rows.length > limit;
134
+ const values = rows.slice(0, limit);
135
+ const data = values.map((row) => ({
136
+ index: Number(row.sequence),
137
+ data: decodeValue<Uint8Array>(row.chunk_data) ?? new Uint8Array(),
138
+ }));
139
+ const closed = await c.db.execute(
140
+ "SELECT 1 FROM stream_chunks WHERE stream_id = ? AND eof = 1 LIMIT 1",
141
+ streamId,
142
+ );
143
+ await injectReadDelay();
144
+ const nextSequence = values.length
145
+ ? Number(values.at(-1)?.sequence) + 1
146
+ : startIndex;
147
+ return {
148
+ data,
149
+ cursor: hasMore
150
+ ? Buffer.from(JSON.stringify({ i: nextSequence })).toString("base64")
151
+ : null,
152
+ hasMore,
153
+ done: !hasMore && closed.length > 0,
154
+ };
155
+ }
156
+
157
+ export async function getStreamInfo(
158
+ c: StreamContext,
159
+ streamId: string,
160
+ runId: string,
161
+ ): Promise<StreamInfoResponse> {
162
+ assertRunKey(c, runId);
163
+ await cleanupExpired(c, streamId);
164
+ const row = (
165
+ await c.db.execute(
166
+ `SELECT SUM(CASE WHEN eof = 0 THEN 1 ELSE 0 END) AS chunk_count,
167
+ MAX(eof) AS done FROM stream_chunks WHERE stream_id = ?`,
168
+ streamId,
169
+ )
170
+ )[0];
171
+ const count = Number(row?.chunk_count ?? 0);
172
+ return { tailIndex: count - 1, done: Number(row?.done ?? 0) === 1 };
173
+ }
174
+
175
+ export async function listStreams(c: StreamContext, runId: string) {
176
+ assertRunKey(c, runId);
177
+ return (
178
+ await c.db.execute(`SELECT r.stream_id FROM run_streams r
179
+ WHERE EXISTS (SELECT 1 FROM stream_chunks c WHERE c.stream_id = r.stream_id)
180
+ ORDER BY r.stream_id`)
181
+ ).map((row) => String(row.stream_id));
182
+ }
183
+
184
+ export async function cleanupExpiredStreams(c: Ctx) {
185
+ if (STREAM_TTL_MS === 0) return;
186
+ const cutoff = Date.now() - STREAM_TTL_MS;
187
+ const streams = await c.db.execute(
188
+ `SELECT DISTINCT stream_id FROM stream_chunks
189
+ WHERE eof = 1 AND created_at <= ?
190
+ ORDER BY stream_id LIMIT 32`,
191
+ cutoff,
192
+ );
193
+ for (const row of streams) {
194
+ const streamId = String(row.stream_id);
195
+ await c.db.execute("DELETE FROM stream_chunks WHERE stream_id = ?", streamId);
196
+ await c.db.execute("DELETE FROM run_streams WHERE stream_id = ?", streamId);
197
+ }
198
+ }
199
+
200
+ export async function expireClosedStreamForTesting(
201
+ c: StreamContext,
202
+ streamId: string,
203
+ runId: string,
204
+ ) {
205
+ assertRunKey(c, runId);
206
+ if (process.env.RIVET_WORLD_RIVET_TESTING !== "1") {
207
+ throw new Error("expireClosedForTesting requires test mode");
208
+ }
209
+ await c.db.execute(
210
+ "UPDATE stream_chunks SET created_at = ? WHERE stream_id = ? AND eof = 1",
211
+ Date.now() - STREAM_TTL_MS - 1_000,
212
+ streamId,
213
+ );
214
+ return { deletedChunks: await cleanupExpired(c, streamId) };
215
+ }