@convex-dev/agent 0.7.0 → 0.7.2

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 (116) hide show
  1. package/README.md +5 -8
  2. package/dist/client/messages.d.ts +10 -0
  3. package/dist/client/messages.d.ts.map +1 -1
  4. package/dist/client/messages.js +1 -0
  5. package/dist/client/messages.js.map +1 -1
  6. package/dist/component/_generated/component.d.ts +15 -8
  7. package/dist/component/_generated/component.d.ts.map +1 -1
  8. package/dist/component/files.d.ts +6 -4
  9. package/dist/component/files.d.ts.map +1 -1
  10. package/dist/component/files.js +53 -42
  11. package/dist/component/files.js.map +1 -1
  12. package/dist/component/messages.d.ts +340 -338
  13. package/dist/component/messages.d.ts.map +1 -1
  14. package/dist/component/messages.js +80 -16
  15. package/dist/component/messages.js.map +1 -1
  16. package/dist/component/schema.d.ts +782 -626
  17. package/dist/component/schema.d.ts.map +1 -1
  18. package/dist/component/schema.js +12 -2
  19. package/dist/component/schema.js.map +1 -1
  20. package/dist/component/streams.d.ts +16 -2
  21. package/dist/component/streams.d.ts.map +1 -1
  22. package/dist/component/streams.js +86 -30
  23. package/dist/component/streams.js.map +1 -1
  24. package/dist/component/vector/index.d.ts +9 -9
  25. package/dist/component/vector/index.d.ts.map +1 -1
  26. package/dist/component/vector/tables.d.ts +5 -5
  27. package/dist/component/vector/tables.d.ts.map +1 -1
  28. package/dist/component/vector/tables.js +1 -1
  29. package/dist/component/vector/tables.js.map +1 -1
  30. package/dist/errors.d.ts +2 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/errors.js +106 -0
  33. package/dist/errors.js.map +1 -0
  34. package/dist/streaming/materializePersistedUIMessageChunks.d.ts +8 -2
  35. package/dist/streaming/materializePersistedUIMessageChunks.d.ts.map +1 -1
  36. package/dist/streaming/materializePersistedUIMessageChunks.js +34 -3
  37. package/dist/streaming/materializePersistedUIMessageChunks.js.map +1 -1
  38. package/dist/validators.d.ts +1952 -1952
  39. package/dist/vercel/UIMessages.js +1 -1
  40. package/dist/vercel/UIMessages.js.map +1 -1
  41. package/dist/vercel/client/definePlaygroundAPI.d.ts +132 -132
  42. package/dist/vercel/client/files.d.ts.map +1 -1
  43. package/dist/vercel/client/files.js +51 -27
  44. package/dist/vercel/client/files.js.map +1 -1
  45. package/dist/vercel/client/messages.d.ts +50 -32
  46. package/dist/vercel/client/messages.d.ts.map +1 -1
  47. package/dist/vercel/client/messages.js +2 -0
  48. package/dist/vercel/client/messages.js.map +1 -1
  49. package/dist/vercel/client/search.d.ts +475 -32
  50. package/dist/vercel/client/search.d.ts.map +1 -1
  51. package/dist/vercel/client/search.js +34 -9
  52. package/dist/vercel/client/search.js.map +1 -1
  53. package/dist/vercel/client/start.d.ts.map +1 -1
  54. package/dist/vercel/client/start.js +1 -0
  55. package/dist/vercel/client/start.js.map +1 -1
  56. package/dist/vercel/client/streamText.d.ts +1 -2
  57. package/dist/vercel/client/streamText.d.ts.map +1 -1
  58. package/dist/vercel/client/streamText.js +49 -17
  59. package/dist/vercel/client/streamText.js.map +1 -1
  60. package/dist/vercel/client/streaming.d.ts +318 -301
  61. package/dist/vercel/client/streaming.d.ts.map +1 -1
  62. package/dist/vercel/client/streaming.js +56 -21
  63. package/dist/vercel/client/streaming.js.map +1 -1
  64. package/dist/vercel/client/types.d.ts +2 -0
  65. package/dist/vercel/client/types.d.ts.map +1 -1
  66. package/dist/vercel/client/types.js.map +1 -1
  67. package/dist/vercel/client/utils.d.ts +9 -5
  68. package/dist/vercel/client/utils.d.ts.map +1 -1
  69. package/dist/vercel/client/utils.js +13 -13
  70. package/dist/vercel/client/utils.js.map +1 -1
  71. package/dist/vercel/fileMaterialization.d.ts +24 -0
  72. package/dist/vercel/fileMaterialization.d.ts.map +1 -0
  73. package/dist/vercel/fileMaterialization.js +118 -0
  74. package/dist/vercel/fileMaterialization.js.map +1 -0
  75. package/dist/vercel/index.d.ts +91 -90
  76. package/dist/vercel/index.d.ts.map +1 -1
  77. package/dist/vercel/index.js +2 -0
  78. package/dist/vercel/index.js.map +1 -1
  79. package/dist/vercel/mapping.d.ts.map +1 -1
  80. package/dist/vercel/mapping.js +7 -1
  81. package/dist/vercel/mapping.js.map +1 -1
  82. package/package.json +16 -9
  83. package/src/client/messages.ts +12 -0
  84. package/src/component/_generated/component.ts +18 -2
  85. package/src/component/files.test.ts +81 -6
  86. package/src/component/files.ts +69 -41
  87. package/src/component/messages.test.ts +496 -0
  88. package/src/component/messages.ts +105 -15
  89. package/src/component/schema.ts +16 -2
  90. package/src/component/streams.test.ts +109 -0
  91. package/src/component/streams.ts +123 -35
  92. package/src/component/vector/tables.ts +1 -1
  93. package/src/errors.test.ts +76 -0
  94. package/src/errors.ts +113 -0
  95. package/src/streaming/materializePersistedUIMessageChunks.test.ts +33 -0
  96. package/src/streaming/materializePersistedUIMessageChunks.ts +37 -0
  97. package/src/vercel/UIMessages.ts +1 -1
  98. package/src/vercel/client/files.test.ts +56 -0
  99. package/src/vercel/client/files.ts +53 -28
  100. package/src/vercel/client/index.test.ts +47 -0
  101. package/src/vercel/client/messages.ts +20 -0
  102. package/src/vercel/client/search.test.ts +183 -8
  103. package/src/vercel/client/search.ts +46 -12
  104. package/src/vercel/client/start.ts +1 -0
  105. package/src/vercel/client/streamText.test.ts +113 -17
  106. package/src/vercel/client/streamText.ts +55 -28
  107. package/src/vercel/client/streaming.test.ts +142 -7
  108. package/src/vercel/client/streaming.ts +73 -19
  109. package/src/vercel/client/types.ts +2 -0
  110. package/src/vercel/client/utils.test.ts +97 -0
  111. package/src/vercel/client/utils.ts +15 -15
  112. package/src/vercel/fileMaterialization.ts +174 -0
  113. package/src/vercel/index.ts +2 -0
  114. package/src/vercel/mapping.test.ts +117 -0
  115. package/src/vercel/mapping.ts +15 -1
  116. package/src/vercel/toUIMessages.test.ts +7 -1
@@ -40,7 +40,12 @@ import {
40
40
  vVectorId,
41
41
  } from "./vector/tables.js";
42
42
  import { changeRefcount } from "./files.js";
43
- import { getStreamingMessagesWithMetadata, finishHandler } from "./streams.js";
43
+ import {
44
+ getStreamingMessagesWithMetadata,
45
+ finishHandler,
46
+ releaseStreamFileOwnershipByIds,
47
+ abortStreamsAtOrder,
48
+ } from "./streams.js";
44
49
  import { partial } from "convex-helpers/validators";
45
50
 
46
51
  function publicMessage(message: Doc<"messages">): MessageDoc {
@@ -61,21 +66,45 @@ export async function deleteMessage(
61
66
  }
62
67
  }
63
68
 
69
+ /**
70
+ * Deleting a message strands any generation still writing to its order, which
71
+ * would otherwise only surface as a missing-parent failure when that generation
72
+ * finalizes. Aborting the stream lets the in-flight run stop on its own.
73
+ */
74
+ async function abortStreamsForDeleted(
75
+ ctx: MutationCtx,
76
+ deleted: (Doc<"messages"> | null)[],
77
+ ) {
78
+ const seen = new Set<string>();
79
+ for (const message of deleted) {
80
+ if (!message) continue;
81
+ const key = `${message.threadId}:${message.order}`;
82
+ if (seen.has(key)) continue;
83
+ seen.add(key);
84
+ await abortStreamsAtOrder(ctx, {
85
+ threadId: message.threadId,
86
+ order: message.order,
87
+ reason: "Message deleted",
88
+ });
89
+ }
90
+ }
91
+
64
92
  export const deleteByIds = mutation({
65
93
  args: { messageIds: v.array(v.id("messages")) },
66
94
  returns: v.array(v.id("messages")),
67
95
  handler: async (ctx, args) => {
68
- const deletedMessageIds = await Promise.all(
96
+ const deleted = await Promise.all(
69
97
  args.messageIds.map(async (id) => {
70
98
  const message = await ctx.db.get("messages", id);
71
99
  if (message) {
72
100
  await deleteMessage(ctx, message);
73
- return id;
101
+ return message;
74
102
  }
75
103
  return null;
76
104
  }),
77
105
  );
78
- return deletedMessageIds.filter((id) => id !== null);
106
+ await abortStreamsForDeleted(ctx, deleted);
107
+ return deleted.filter((m) => m !== null).map((m) => m._id);
79
108
  },
80
109
  });
81
110
 
@@ -141,6 +170,7 @@ export const deleteByOrder = mutation({
141
170
  })
142
171
  .take(64);
143
172
  await Promise.all(messages.map((m) => deleteMessage(ctx, m)));
173
+ await abortStreamsForDeleted(ctx, messages);
144
174
  return {
145
175
  isDone: messages.length < 64,
146
176
  lastOrder: messages.at(-1)?.order,
@@ -153,6 +183,13 @@ const addMessagesArgs = {
153
183
  userId: v.optional(v.string()),
154
184
  threadId: v.id("threads"),
155
185
  promptMessageId: v.optional(v.id("messages")),
186
+ /**
187
+ * For saves that belong to a run anchored on promptMessageId: if that
188
+ * message is gone the run is obsolete, so abandon the save instead of
189
+ * throwing. A caller passing an id that never existed still gets an error.
190
+ */
191
+ abandonIfPromptMissing: v.optional(v.boolean()),
192
+ order: v.optional(v.union(v.number(), v.literal("next"))),
156
193
  agentName: v.optional(v.string()),
157
194
  messages: v.array(vMessageWithMetadataInternal),
158
195
  embeddings: v.optional(vMessageEmbeddingsWithDimension),
@@ -171,6 +208,15 @@ export const addMessages = mutation({
171
208
  handler: addMessagesHandler,
172
209
  returns: v.object({ messages: v.array(vMessageDoc) }),
173
210
  });
211
+
212
+ function incrementMessagePosition(value: number, field: "order" | "stepOrder") {
213
+ assert(
214
+ Number.isSafeInteger(value) && value < Number.MAX_SAFE_INTEGER,
215
+ `${field} cannot be incremented past Number.MAX_SAFE_INTEGER`,
216
+ );
217
+ return value + 1;
218
+ }
219
+
174
220
  async function addMessagesHandler(
175
221
  ctx: MutationCtx,
176
222
  args: ObjectType<typeof addMessagesArgs>,
@@ -189,12 +235,30 @@ async function addMessagesHandler(
189
235
  finishStreamId,
190
236
  messages,
191
237
  promptMessageId,
238
+ abandonIfPromptMissing,
239
+ order: requestedOrder,
192
240
  pendingMessageId,
193
241
  hideFromUserIdSearch,
194
242
  ...rest
195
243
  } = args;
196
244
  const promptMessage =
197
245
  promptMessageId && (await ctx.db.get("messages", promptMessageId));
246
+ assert(
247
+ requestedOrder === undefined ||
248
+ requestedOrder === "next" ||
249
+ (Number.isSafeInteger(requestedOrder) &&
250
+ requestedOrder >= 0 &&
251
+ requestedOrder < Number.MAX_SAFE_INTEGER),
252
+ "order must be a non-negative safe integer less than Number.MAX_SAFE_INTEGER",
253
+ );
254
+ assert(
255
+ requestedOrder === undefined || !promptMessageId,
256
+ "order and promptMessageId cannot both be provided",
257
+ );
258
+ assert(
259
+ requestedOrder === undefined || !pendingMessageId,
260
+ "order and pendingMessageId cannot both be provided",
261
+ );
198
262
  if (failPendingSteps) {
199
263
  assert(args.threadId, "threadId is required to fail pending steps");
200
264
  const pendingMessages = await ctx.db
@@ -224,7 +288,21 @@ async function addMessagesHandler(
224
288
  let order, stepOrder;
225
289
  let fail = false;
226
290
  let error: string | undefined;
227
- if (promptMessageId) {
291
+ const startsAtNextOrder = requestedOrder === "next";
292
+ const explicitOrder =
293
+ typeof requestedOrder === "number" ? requestedOrder : undefined;
294
+ if (startsAtNextOrder) {
295
+ const maxMessage = await getMaxMessage(ctx, threadId);
296
+ order = incrementMessagePosition(maxMessage?.order ?? -1, "order");
297
+ stepOrder = -1;
298
+ } else if (explicitOrder !== undefined) {
299
+ order = explicitOrder;
300
+ const maxMessage = await getMaxMessage(ctx, threadId, order);
301
+ stepOrder = maxMessage?.stepOrder ?? -1;
302
+ } else if (promptMessageId) {
303
+ if (!promptMessage && abandonIfPromptMissing) {
304
+ return { messages: [] };
305
+ }
228
306
  assert(promptMessage, `Parent message ${promptMessageId} not found`);
229
307
  if (promptMessage.status === "failed") {
230
308
  fail = true;
@@ -285,8 +363,8 @@ async function addMessagesHandler(
285
363
  if (pendingMessage.status === "failed") {
286
364
  fail = true;
287
365
  error =
288
- `Trying to update a message that failed: ${pendingMessageId}, ` +
289
- `error: ${pendingMessage.error ?? error}`;
366
+ pendingMessage.error ??
367
+ `Trying to update a message that failed: ${pendingMessageId}`;
290
368
  messageDoc.status = "failed";
291
369
  messageDoc.error = error;
292
370
  }
@@ -305,20 +383,28 @@ async function addMessagesHandler(
305
383
  toReturn.push((await ctx.db.get("messages", pendingMessage._id))!);
306
384
  continue;
307
385
  }
308
- if (message.message.role === "user") {
309
- if (promptMessage && promptMessage.order === order) {
310
- // see if there's a later message than the parent message order
386
+ if ((startsAtNextOrder || explicitOrder !== undefined) && i === 0) {
387
+ stepOrder = incrementMessagePosition(stepOrder, "stepOrder");
388
+ } else if (message.message.role === "user") {
389
+ if (
390
+ (explicitOrder !== undefined && order === explicitOrder) ||
391
+ (promptMessage && promptMessage.order === order)
392
+ ) {
393
+ // Avoid colliding with a later order when saving from an older one.
311
394
  const maxMessage = await getMaxMessage(ctx, threadId);
312
- order = (maxMessage?.order ?? order) + 1;
395
+ order = incrementMessagePosition(
396
+ Math.max(maxMessage?.order ?? order, order),
397
+ "order",
398
+ );
313
399
  } else {
314
- order++;
400
+ order = incrementMessagePosition(order, "order");
315
401
  }
316
402
  stepOrder = 0;
317
403
  } else {
318
404
  if (order < 0) {
319
405
  order = 0;
320
406
  }
321
- stepOrder++;
407
+ stepOrder = incrementMessagePosition(stepOrder, "stepOrder");
322
408
  }
323
409
  const messageId = await ctx.db.insert("messages", {
324
410
  ...messageDoc,
@@ -334,7 +420,10 @@ async function addMessagesHandler(
334
420
  // Atomically finish the stream if requested, preventing UI flickering
335
421
  // from separate mutations for message save and stream finish (issue #181).
336
422
  if (finishStreamId) {
337
- await finishHandler(ctx, { streamId: finishStreamId });
423
+ await finishHandler(ctx, {
424
+ streamId: finishStreamId,
425
+ });
426
+ await releaseStreamFileOwnershipByIds(ctx, [finishStreamId]);
338
427
  }
339
428
  return { messages: toReturn.map(publicMessage) };
340
429
  }
@@ -409,7 +498,7 @@ export const finalizeMessage = mutation({
409
498
  }
410
499
  // See if we can add any in-progress data
411
500
  if (!message.message?.content.length) {
412
- const { messages, materializationFailures } =
501
+ const { messages, materializationFailures, streamsToRelease } =
413
502
  await getStreamingMessagesWithMetadata(ctx, message, result);
414
503
  if (materializationFailures.length > 0) {
415
504
  console.error(
@@ -433,6 +522,7 @@ export const finalizeMessage = mutation({
433
522
  userId: message.userId,
434
523
  embeddings: undefined,
435
524
  });
525
+ await releaseStreamFileOwnershipByIds(ctx, streamsToRelease);
436
526
  return;
437
527
  }
438
528
  }
@@ -100,6 +100,15 @@ export const schema = defineSchema({
100
100
 
101
101
  threadId: v.id("threads"),
102
102
  order: v.number(),
103
+ /** Internal ownership sidecar for files materialized in stream deltas. */
104
+ fileRefs: v.optional(
105
+ v.array(
106
+ v.object({
107
+ url: v.string(),
108
+ fileId: v.id("files"),
109
+ }),
110
+ ),
111
+ ),
103
112
  /**
104
113
  * The step order of the first message in the stream.
105
114
  * If the stream ends up with both a tool call and a tool result,
@@ -117,7 +126,11 @@ export const schema = defineSchema({
117
126
  endedAt: v.number(),
118
127
  cleanupFnId: v.optional(v.id("_scheduled_functions")),
119
128
  }),
120
- v.object({ kind: v.literal("aborted"), reason: v.string() }),
129
+ v.object({
130
+ kind: v.literal("aborted"),
131
+ reason: v.string(),
132
+ cleanupFnId: v.optional(v.id("_scheduled_functions")),
133
+ }),
121
134
  ),
122
135
  })
123
136
  // There should only be one per "order" index
@@ -158,7 +171,8 @@ export const schema = defineSchema({
158
171
  lastTouchedAt: v.number(),
159
172
  })
160
173
  .index("hash", ["hash"])
161
- .index("refcount", ["refcount"]),
174
+ .index("refcount", ["refcount"])
175
+ .index("refcount_lastTouchedAt", ["refcount", "lastTouchedAt"]),
162
176
  ...vectorTables,
163
177
  // To authenticate playground usage
164
178
  // Delete a key to invalidate it
@@ -0,0 +1,109 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ import { describe, expect, test } from "vitest";
4
+ import { api } from "./_generated/api.js";
5
+ import type { Id } from "./_generated/dataModel.js";
6
+ import { initConvexTest } from "./setup.test.js";
7
+
8
+ async function seedStream(t: ReturnType<typeof initConvexTest>) {
9
+ const thread = await t.mutation(api.threads.createThread, {
10
+ userId: "stream-files",
11
+ });
12
+ const threadId = thread._id as Id<"threads">;
13
+ const streamId = await t.mutation(api.streams.create, {
14
+ threadId,
15
+ order: 0,
16
+ stepOrder: 0,
17
+ format: "UIMessageChunk",
18
+ });
19
+ const { fileId } = await t.mutation(api.files.addFile, {
20
+ storageId: "stream-storage",
21
+ hash: "stream-hash",
22
+ filename: "stream.txt",
23
+ });
24
+ return { threadId, streamId, fileId };
25
+ }
26
+
27
+ describe("streams", () => {
28
+ test("stream file ownership flows from addDelta to the final message", async () => {
29
+ const t = initConvexTest();
30
+ const { threadId, streamId, fileId } = await seedStream(t);
31
+ const url = "https://files.example/stream";
32
+
33
+ await t.mutation(api.streams.addDelta, {
34
+ streamId,
35
+ start: 0,
36
+ end: 1,
37
+ parts: [{ type: "start" }],
38
+ fileRefs: [{ url, fileId }],
39
+ });
40
+ await expect(t.query(api.files.get, { fileId })).resolves.toMatchObject({
41
+ refcount: 1,
42
+ });
43
+
44
+ // Repeating the same URL/file pair keeps a single reference.
45
+ await t.mutation(api.streams.addDelta, {
46
+ streamId,
47
+ start: 1,
48
+ end: 2,
49
+ parts: [{ type: "text-delta", id: "text-1", delta: "hi" }],
50
+ fileRefs: [{ url, fileId }],
51
+ });
52
+ await expect(t.query(api.files.get, { fileId })).resolves.toMatchObject({
53
+ refcount: 1,
54
+ });
55
+
56
+ // A durable file can be referenced by more than one persisted URL.
57
+ await t.mutation(api.streams.addDelta, {
58
+ streamId,
59
+ start: 2,
60
+ end: 3,
61
+ parts: [{ type: "text-delta", id: "text-1", delta: "there" }],
62
+ fileRefs: [{ url: `${url}-alternate`, fileId }],
63
+ });
64
+ await expect(t.query(api.files.get, { fileId })).resolves.toMatchObject({
65
+ refcount: 1,
66
+ });
67
+
68
+ const { fileId: otherFileId } = await t.mutation(api.files.addFile, {
69
+ storageId: "other-storage",
70
+ hash: "other-hash",
71
+ filename: "other.txt",
72
+ });
73
+ await expect(
74
+ t.mutation(api.streams.addDelta, {
75
+ streamId,
76
+ start: 3,
77
+ end: 4,
78
+ parts: [{ type: "finish" }],
79
+ fileRefs: [{ url, fileId: otherFileId }],
80
+ }),
81
+ ).rejects.toThrow("Stream file URL maps to multiple files");
82
+ await expect(
83
+ t.query(api.files.get, { fileId: otherFileId }),
84
+ ).resolves.toMatchObject({ refcount: 0 });
85
+ expect(
86
+ (await t.run((ctx) => ctx.db.get("streamingMessages", streamId)))
87
+ ?.fileRefs,
88
+ ).toEqual([{ url, fileId }, { url: `${url}-alternate`, fileId }]);
89
+
90
+ await t.mutation(api.messages.addMessages, {
91
+ threadId,
92
+ messages: [
93
+ {
94
+ message: { role: "assistant", content: "done" },
95
+ fileIds: [fileId],
96
+ },
97
+ ],
98
+ finishStreamId: streamId,
99
+ });
100
+ await expect(t.query(api.files.get, { fileId })).resolves.toMatchObject({
101
+ refcount: 1,
102
+ });
103
+ const stream = await t.run((ctx) =>
104
+ ctx.db.get("streamingMessages", streamId),
105
+ );
106
+ expect(stream?.state.kind).toBe("finished");
107
+ expect(stream?.fileRefs).toBeUndefined();
108
+ });
109
+ });
@@ -25,6 +25,7 @@ import {
25
25
  getPersistedUIMessageChunkParts,
26
26
  projectPersistedUIMessageChunks,
27
27
  } from "../streaming/materializePersistedUIMessageChunks.js";
28
+ import { changeRefcount } from "./files.js";
28
29
 
29
30
  const SECOND = 1000;
30
31
  const MINUTE = 60 * SECOND;
@@ -35,9 +36,16 @@ const TIMEOUT_INTERVAL = 10 * MINUTE;
35
36
  const DELETE_STREAM_DELAY = MINUTE * 5; // 5 minutes
36
37
 
37
38
  const deltaValidator = schema.tables.streamDeltas.validator;
39
+ const streamFileRefValidator = v.object({
40
+ url: v.string(),
41
+ fileId: v.id("files"),
42
+ });
38
43
 
39
44
  export const addDelta = mutation({
40
- args: deltaValidator,
45
+ args: {
46
+ ...deltaValidator.fields,
47
+ fileRefs: v.optional(v.array(streamFileRefValidator)),
48
+ },
41
49
  returns: v.boolean(),
42
50
  handler: async (ctx, args) => {
43
51
  const stream = await ctx.db.get("streamingMessages", args.streamId);
@@ -48,7 +56,30 @@ export const addDelta = mutation({
48
56
  if (stream.state.kind !== "streaming") {
49
57
  return false;
50
58
  }
51
- await ctx.db.insert("streamDeltas", args);
59
+ const { fileRefs, ...delta } = args;
60
+ if (fileRefs?.length) {
61
+ const previous = stream.fileRefs ?? [];
62
+ // The persisted chunks refer to files by URL. Several URLs can still
63
+ // resolve to one durable file, but a URL must resolve to exactly one.
64
+ const refsByUrl = new Map(previous.map((ref) => [ref.url, ref] as const));
65
+ for (const ref of fileRefs) {
66
+ const existing = refsByUrl.get(ref.url);
67
+ if (existing && existing.fileId !== ref.fileId) {
68
+ throw new Error(`Stream file URL maps to multiple files: ${ref.url}`);
69
+ }
70
+ refsByUrl.set(ref.url, ref);
71
+ }
72
+ const next = [...refsByUrl.values()];
73
+ await changeRefcount(
74
+ ctx,
75
+ previous.map(({ fileId }) => fileId),
76
+ next.map(({ fileId }) => fileId),
77
+ );
78
+ await ctx.db.patch("streamingMessages", args.streamId, {
79
+ fileRefs: next,
80
+ });
81
+ }
82
+ await ctx.db.insert("streamDeltas", delta);
52
83
  await heartbeatStream(ctx, { streamId: args.streamId });
53
84
  return true;
54
85
  },
@@ -89,7 +120,10 @@ export const listDeltas = query({
89
120
  });
90
121
 
91
122
  export const create = mutation({
92
- args: omit(schema.tables.streamingMessages.validator.fields, ["state"]),
123
+ args: omit(schema.tables.streamingMessages.validator.fields, [
124
+ "state",
125
+ "fileRefs",
126
+ ]),
93
127
  returns: v.id("streamingMessages"),
94
128
  handler: async (ctx, args) => {
95
129
  const state = { kind: "streaming" as const, lastHeartbeat: Date.now() };
@@ -163,24 +197,29 @@ function publicStreamMessage(m: Doc<"streamingMessages">): StreamMessage {
163
197
  };
164
198
  }
165
199
 
200
+ export async function abortStreamsAtOrder(
201
+ ctx: MutationCtx,
202
+ args: { threadId: Id<"threads">; order: number; reason: string },
203
+ ) {
204
+ const streams = await ctx.db
205
+ .query("streamingMessages")
206
+ .withIndex("threadId_state_order_stepOrder", (q) =>
207
+ q
208
+ .eq("threadId", args.threadId)
209
+ .eq("state.kind", "streaming")
210
+ .eq("order", args.order),
211
+ )
212
+ .take(100);
213
+ for (const stream of streams) {
214
+ await abortById(ctx, { streamId: stream._id, reason: args.reason });
215
+ }
216
+ return streams.length > 0;
217
+ }
218
+
166
219
  export const abortByOrder = mutation({
167
220
  args: { threadId: v.id("threads"), order: v.number(), reason: v.string() },
168
221
  returns: v.boolean(),
169
- handler: async (ctx, args) => {
170
- const streams = await ctx.db
171
- .query("streamingMessages")
172
- .withIndex("threadId_state_order_stepOrder", (q) =>
173
- q
174
- .eq("threadId", args.threadId)
175
- .eq("state.kind", "streaming")
176
- .eq("order", args.order),
177
- )
178
- .take(100);
179
- for (const stream of streams) {
180
- await abortById(ctx, { streamId: stream._id, reason: args.reason });
181
- }
182
- return streams.length > 0;
183
- },
222
+ handler: abortStreamsAtOrder,
184
223
  });
185
224
 
186
225
  export const abort = mutation({
@@ -212,8 +251,9 @@ async function abortById(
212
251
  return false;
213
252
  }
214
253
  await cleanupTimeoutFn(ctx, stream);
254
+ const cleanupFnId = await scheduleStreamDeletion(ctx, args.streamId);
215
255
  await ctx.db.patch("streamingMessages", args.streamId, {
216
- state: { kind: "aborted", reason: args.reason },
256
+ state: { kind: "aborted", reason: args.reason, cleanupFnId },
217
257
  });
218
258
  return true;
219
259
  }
@@ -264,11 +304,7 @@ export async function finishHandler(
264
304
  return;
265
305
  }
266
306
  await cleanupTimeoutFn(ctx, stream);
267
- const cleanupFnId = await ctx.scheduler.runAfter(
268
- DELETE_STREAM_DELAY,
269
- api.streams.deleteStreamAsync,
270
- { streamId: args.streamId },
271
- );
307
+ const cleanupFnId = await scheduleStreamDeletion(ctx, args.streamId);
272
308
  await ctx.db.patch("streamingMessages", args.streamId, {
273
309
  state: { kind: "finished", endedAt: Date.now(), cleanupFnId },
274
310
  });
@@ -282,6 +318,29 @@ export const heartbeat = mutation({
282
318
  handler: heartbeatStream,
283
319
  });
284
320
 
321
+ async function releaseStreamFileOwnership(
322
+ ctx: MutationCtx,
323
+ stream: Doc<"streamingMessages">,
324
+ ) {
325
+ if (!stream.fileRefs?.length) return;
326
+ await changeRefcount(
327
+ ctx,
328
+ stream.fileRefs.map(({ fileId }) => fileId),
329
+ [],
330
+ );
331
+ }
332
+
333
+ async function scheduleStreamDeletion(
334
+ ctx: MutationCtx,
335
+ streamId: Id<"streamingMessages">,
336
+ ) {
337
+ return ctx.scheduler.runAfter(
338
+ DELETE_STREAM_DELAY,
339
+ api.streams.deleteStreamAsync,
340
+ { streamId },
341
+ );
342
+ }
343
+
285
344
  async function heartbeatStream(
286
345
  ctx: MutationCtx,
287
346
  args: { streamId: Id<"streamingMessages"> },
@@ -325,18 +384,24 @@ async function heartbeatStream(
325
384
  export const timeoutStream = internalMutation({
326
385
  args: { streamId: v.id("streamingMessages") },
327
386
  returns: v.null(),
328
- handler: async (ctx, args) => {
329
- const stream = await ctx.db.get("streamingMessages", args.streamId);
330
- if (!stream || stream.state.kind !== "streaming") {
331
- console.warn("Stream not found", args.streamId);
332
- return;
333
- }
334
- await ctx.db.patch("streamingMessages", args.streamId, {
335
- state: { kind: "aborted", reason: "timeout" },
336
- });
337
- },
387
+ handler: timeoutStreamHandler,
338
388
  });
339
389
 
390
+ export async function timeoutStreamHandler(
391
+ ctx: MutationCtx,
392
+ args: { streamId: Id<"streamingMessages"> },
393
+ ) {
394
+ const stream = await ctx.db.get("streamingMessages", args.streamId);
395
+ if (!stream || stream.state.kind !== "streaming") {
396
+ console.warn("Stream not found", args.streamId);
397
+ return;
398
+ }
399
+ const cleanupFnId = await scheduleStreamDeletion(ctx, args.streamId);
400
+ await ctx.db.patch("streamingMessages", args.streamId, {
401
+ state: { kind: "aborted", reason: "timeout", cleanupFnId },
402
+ });
403
+ }
404
+
340
405
  async function deletePageForStreamId(
341
406
  ctx: MutationCtx,
342
407
  args: { streamId: Id<"streamingMessages">; cursor?: string },
@@ -354,8 +419,12 @@ async function deletePageForStreamId(
354
419
  if (deltas.isDone) {
355
420
  const stream = await ctx.db.get("streamingMessages", args.streamId);
356
421
  if (stream) {
422
+ await releaseStreamFileOwnership(ctx, stream);
357
423
  await cleanupTimeoutFn(ctx, stream);
358
- if (stream.state.kind === "finished" && stream.state.cleanupFnId) {
424
+ if (
425
+ (stream.state.kind === "finished" || stream.state.kind === "aborted") &&
426
+ stream.state.cleanupFnId
427
+ ) {
359
428
  const scheduledFunction = await ctx.db.system.get(
360
429
  "_scheduled_functions",
361
430
  stream.state.cleanupFnId,
@@ -370,6 +439,18 @@ async function deletePageForStreamId(
370
439
  return deltas;
371
440
  }
372
441
 
442
+ export async function releaseStreamFileOwnershipByIds(
443
+ ctx: MutationCtx,
444
+ streamIds: Id<"streamingMessages">[],
445
+ ) {
446
+ for (const streamId of new Set(streamIds)) {
447
+ const stream = await ctx.db.get("streamingMessages", streamId);
448
+ if (!stream?.fileRefs?.length) continue;
449
+ await releaseStreamFileOwnership(ctx, stream);
450
+ await ctx.db.patch("streamingMessages", streamId, { fileRefs: undefined });
451
+ }
452
+ }
453
+
373
454
  export async function deleteStreamsPageForThreadId(
374
455
  ctx: MutationCtx,
375
456
  args: { threadId: Id<"threads">; streamOrder?: number; deltaCursor?: string },
@@ -544,6 +625,7 @@ export async function getStreamingMessagesWithMetadata(
544
625
  streamId: Id<"streamingMessages">;
545
626
  reason: string;
546
627
  }>;
628
+ streamsToRelease: Id<"streamingMessages">[];
547
629
  }> {
548
630
  // See if there are any streaming messages for this order
549
631
  const streamingMessages = await getStreamingMessages(
@@ -570,8 +652,10 @@ export async function getStreamingMessagesWithMetadata(
570
652
  streamMessage,
571
653
  parts,
572
654
  metadata,
655
+ streamingMessage.fileRefs,
573
656
  ).slice(numToSkip),
574
657
  failure: undefined,
658
+ streamToRelease: streamingMessage._id,
575
659
  };
576
660
  } catch (error) {
577
661
  return {
@@ -580,6 +664,7 @@ export async function getStreamingMessagesWithMetadata(
580
664
  streamId: streamingMessage._id,
581
665
  reason: error instanceof Error ? error.message : String(error),
582
666
  },
667
+ streamToRelease: undefined,
583
668
  };
584
669
  }
585
670
  }),
@@ -589,5 +674,8 @@ export async function getStreamingMessagesWithMetadata(
589
674
  materializationFailures: materializedStreams.flatMap(({ failure }) =>
590
675
  failure ? [failure] : [],
591
676
  ),
677
+ streamsToRelease: materializedStreams.flatMap(({ streamToRelease }) =>
678
+ streamToRelease ? [streamToRelease] : [],
679
+ ),
592
680
  };
593
681
  }
@@ -68,7 +68,7 @@ export type VectorSchema = SchemaDefinition<
68
68
  >;
69
69
 
70
70
  export const VectorDimensions = [
71
- 128, 256, 512, 768, 1024, 1408, 1536, 2048, 3072, 4096,
71
+ 128, 256, 512, 768, 1024, 1408, 1536, 2048, 2560, 3072, 4096,
72
72
  ] as const;
73
73
  export function validateVectorDimension(
74
74
  dimension: number,