@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.
- package/README.md +5 -8
- package/dist/client/messages.d.ts +10 -0
- package/dist/client/messages.d.ts.map +1 -1
- package/dist/client/messages.js +1 -0
- package/dist/client/messages.js.map +1 -1
- package/dist/component/_generated/component.d.ts +15 -8
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/files.d.ts +6 -4
- package/dist/component/files.d.ts.map +1 -1
- package/dist/component/files.js +53 -42
- package/dist/component/files.js.map +1 -1
- package/dist/component/messages.d.ts +340 -338
- package/dist/component/messages.d.ts.map +1 -1
- package/dist/component/messages.js +80 -16
- package/dist/component/messages.js.map +1 -1
- package/dist/component/schema.d.ts +782 -626
- package/dist/component/schema.d.ts.map +1 -1
- package/dist/component/schema.js +12 -2
- package/dist/component/schema.js.map +1 -1
- package/dist/component/streams.d.ts +16 -2
- package/dist/component/streams.d.ts.map +1 -1
- package/dist/component/streams.js +86 -30
- package/dist/component/streams.js.map +1 -1
- package/dist/component/vector/index.d.ts +9 -9
- package/dist/component/vector/index.d.ts.map +1 -1
- package/dist/component/vector/tables.d.ts +5 -5
- package/dist/component/vector/tables.d.ts.map +1 -1
- package/dist/component/vector/tables.js +1 -1
- package/dist/component/vector/tables.js.map +1 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +106 -0
- package/dist/errors.js.map +1 -0
- package/dist/streaming/materializePersistedUIMessageChunks.d.ts +8 -2
- package/dist/streaming/materializePersistedUIMessageChunks.d.ts.map +1 -1
- package/dist/streaming/materializePersistedUIMessageChunks.js +34 -3
- package/dist/streaming/materializePersistedUIMessageChunks.js.map +1 -1
- package/dist/validators.d.ts +1952 -1952
- package/dist/vercel/UIMessages.js +1 -1
- package/dist/vercel/UIMessages.js.map +1 -1
- package/dist/vercel/client/definePlaygroundAPI.d.ts +132 -132
- package/dist/vercel/client/files.d.ts.map +1 -1
- package/dist/vercel/client/files.js +51 -27
- package/dist/vercel/client/files.js.map +1 -1
- package/dist/vercel/client/messages.d.ts +50 -32
- package/dist/vercel/client/messages.d.ts.map +1 -1
- package/dist/vercel/client/messages.js +2 -0
- package/dist/vercel/client/messages.js.map +1 -1
- package/dist/vercel/client/search.d.ts +475 -32
- package/dist/vercel/client/search.d.ts.map +1 -1
- package/dist/vercel/client/search.js +34 -9
- package/dist/vercel/client/search.js.map +1 -1
- package/dist/vercel/client/start.d.ts.map +1 -1
- package/dist/vercel/client/start.js +1 -0
- package/dist/vercel/client/start.js.map +1 -1
- package/dist/vercel/client/streamText.d.ts +1 -2
- package/dist/vercel/client/streamText.d.ts.map +1 -1
- package/dist/vercel/client/streamText.js +49 -17
- package/dist/vercel/client/streamText.js.map +1 -1
- package/dist/vercel/client/streaming.d.ts +318 -301
- package/dist/vercel/client/streaming.d.ts.map +1 -1
- package/dist/vercel/client/streaming.js +56 -21
- package/dist/vercel/client/streaming.js.map +1 -1
- package/dist/vercel/client/types.d.ts +2 -0
- package/dist/vercel/client/types.d.ts.map +1 -1
- package/dist/vercel/client/types.js.map +1 -1
- package/dist/vercel/client/utils.d.ts +9 -5
- package/dist/vercel/client/utils.d.ts.map +1 -1
- package/dist/vercel/client/utils.js +13 -13
- package/dist/vercel/client/utils.js.map +1 -1
- package/dist/vercel/fileMaterialization.d.ts +24 -0
- package/dist/vercel/fileMaterialization.d.ts.map +1 -0
- package/dist/vercel/fileMaterialization.js +118 -0
- package/dist/vercel/fileMaterialization.js.map +1 -0
- package/dist/vercel/index.d.ts +91 -90
- package/dist/vercel/index.d.ts.map +1 -1
- package/dist/vercel/index.js +2 -0
- package/dist/vercel/index.js.map +1 -1
- package/dist/vercel/mapping.d.ts.map +1 -1
- package/dist/vercel/mapping.js +7 -1
- package/dist/vercel/mapping.js.map +1 -1
- package/package.json +16 -9
- package/src/client/messages.ts +12 -0
- package/src/component/_generated/component.ts +18 -2
- package/src/component/files.test.ts +81 -6
- package/src/component/files.ts +69 -41
- package/src/component/messages.test.ts +496 -0
- package/src/component/messages.ts +105 -15
- package/src/component/schema.ts +16 -2
- package/src/component/streams.test.ts +109 -0
- package/src/component/streams.ts +123 -35
- package/src/component/vector/tables.ts +1 -1
- package/src/errors.test.ts +76 -0
- package/src/errors.ts +113 -0
- package/src/streaming/materializePersistedUIMessageChunks.test.ts +33 -0
- package/src/streaming/materializePersistedUIMessageChunks.ts +37 -0
- package/src/vercel/UIMessages.ts +1 -1
- package/src/vercel/client/files.test.ts +56 -0
- package/src/vercel/client/files.ts +53 -28
- package/src/vercel/client/index.test.ts +47 -0
- package/src/vercel/client/messages.ts +20 -0
- package/src/vercel/client/search.test.ts +183 -8
- package/src/vercel/client/search.ts +46 -12
- package/src/vercel/client/start.ts +1 -0
- package/src/vercel/client/streamText.test.ts +113 -17
- package/src/vercel/client/streamText.ts +55 -28
- package/src/vercel/client/streaming.test.ts +142 -7
- package/src/vercel/client/streaming.ts +73 -19
- package/src/vercel/client/types.ts +2 -0
- package/src/vercel/client/utils.test.ts +97 -0
- package/src/vercel/client/utils.ts +15 -15
- package/src/vercel/fileMaterialization.ts +174 -0
- package/src/vercel/index.ts +2 -0
- package/src/vercel/mapping.test.ts +117 -0
- package/src/vercel/mapping.ts +15 -1
- 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 {
|
|
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
|
|
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
|
|
101
|
+
return message;
|
|
74
102
|
}
|
|
75
103
|
return null;
|
|
76
104
|
}),
|
|
77
105
|
);
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
289
|
-
`
|
|
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 (
|
|
309
|
-
|
|
310
|
-
|
|
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 = (
|
|
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, {
|
|
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
|
}
|
package/src/component/schema.ts
CHANGED
|
@@ -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({
|
|
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
|
+
});
|
package/src/component/streams.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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, [
|
|
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:
|
|
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.
|
|
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:
|
|
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 (
|
|
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,
|