@convex-dev/agent 0.7.0 → 0.7.1
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/dist/component/_generated/component.d.ts +5 -0
- 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.map +1 -1
- package/dist/component/messages.js +7 -3
- package/dist/component/messages.js.map +1 -1
- package/dist/component/schema.d.ts +58 -6
- 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 +10 -1
- package/dist/component/streams.d.ts.map +1 -1
- package/dist/component/streams.js +72 -17
- package/dist/component/streams.js.map +1 -1
- 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/vercel/UIMessages.js +1 -1
- package/dist/vercel/UIMessages.js.map +1 -1
- 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/streamText.d.ts.map +1 -1
- package/dist/vercel/client/streamText.js +2 -0
- package/dist/vercel/client/streamText.js.map +1 -1
- package/dist/vercel/client/streaming.d.ts +14 -0
- package/dist/vercel/client/streaming.d.ts.map +1 -1
- package/dist/vercel/client/streaming.js +35 -13
- package/dist/vercel/client/streaming.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/mapping.d.ts.map +1 -1
- package/dist/vercel/mapping.js +7 -1
- package/dist/vercel/mapping.js.map +1 -1
- package/package.json +1 -1
- package/src/component/_generated/component.ts +8 -2
- package/src/component/files.test.ts +81 -6
- package/src/component/files.ts +69 -41
- package/src/component/messages.test.ts +92 -0
- package/src/component/messages.ts +11 -3
- package/src/component/schema.ts +16 -2
- package/src/component/streams.test.ts +109 -0
- package/src/component/streams.ts +103 -20
- 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/streamText.ts +3 -0
- package/src/vercel/client/streaming.test.ts +71 -0
- package/src/vercel/client/streaming.ts +50 -16
- package/src/vercel/fileMaterialization.ts +174 -0
- package/src/vercel/mapping.test.ts +117 -0
- package/src/vercel/mapping.ts +15 -1
- package/src/vercel/toUIMessages.test.ts +7 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/// <reference types="vite/client" />
|
|
2
2
|
|
|
3
3
|
import { convexTest } from "convex-test";
|
|
4
|
-
import { describe, expect, test } from "vitest";
|
|
4
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
5
5
|
import { api } from "./_generated/api.js";
|
|
6
6
|
import schema from "./schema.js";
|
|
7
7
|
import { modules } from "./setup.test.js";
|
|
@@ -9,7 +9,8 @@ import type { Doc } from "./_generated/dataModel.js";
|
|
|
9
9
|
import type { PaginationResult } from "convex/server";
|
|
10
10
|
|
|
11
11
|
describe("files", () => {
|
|
12
|
-
|
|
12
|
+
afterEach(() => vi.useRealTimers());
|
|
13
|
+
test("addFile registers and reuses a file without acquiring ownership", async () => {
|
|
13
14
|
const t = convexTest(schema, modules);
|
|
14
15
|
const storageId = "storage-1";
|
|
15
16
|
const hash = "hash-1";
|
|
@@ -34,6 +35,9 @@ describe("files", () => {
|
|
|
34
35
|
mimeType: "text/plain",
|
|
35
36
|
});
|
|
36
37
|
expect(fileId2).toBe(fileId);
|
|
38
|
+
await expect(t.query(api.files.get, { fileId })).resolves.toMatchObject({
|
|
39
|
+
refcount: 0,
|
|
40
|
+
});
|
|
37
41
|
// Add the same file with a different filename (should create a new entry)
|
|
38
42
|
const { fileId: fileId3 } = await t.mutation(api.files.addFile, {
|
|
39
43
|
storageId,
|
|
@@ -83,7 +87,31 @@ describe("files", () => {
|
|
|
83
87
|
expect(fileId4).toBeNull();
|
|
84
88
|
});
|
|
85
89
|
|
|
90
|
+
test("reuses a legacy file row with no media type", async () => {
|
|
91
|
+
const t = convexTest(schema, modules);
|
|
92
|
+
const fileId = await t.run((ctx) =>
|
|
93
|
+
ctx.db.insert("files", {
|
|
94
|
+
storageId: "legacy-storage",
|
|
95
|
+
hash: "legacy-hash",
|
|
96
|
+
filename: "legacy.txt",
|
|
97
|
+
refcount: 0,
|
|
98
|
+
lastTouchedAt: Date.now(),
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
await expect(
|
|
103
|
+
t.mutation(api.files.addFile, {
|
|
104
|
+
storageId: "new-storage",
|
|
105
|
+
hash: "legacy-hash",
|
|
106
|
+
filename: "legacy.txt",
|
|
107
|
+
mediaType: "text/plain",
|
|
108
|
+
}),
|
|
109
|
+
).resolves.toEqual({ fileId, storageId: "legacy-storage" });
|
|
110
|
+
});
|
|
111
|
+
|
|
86
112
|
test("getFilesToDelete paginates through files with refcount 0 one at a time", async () => {
|
|
113
|
+
vi.useFakeTimers();
|
|
114
|
+
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
87
115
|
const t = convexTest(schema, modules);
|
|
88
116
|
// Add 3 files with refcount 0
|
|
89
117
|
const files = [];
|
|
@@ -94,12 +122,9 @@ describe("files", () => {
|
|
|
94
122
|
filename: `file-del-${i}.txt`,
|
|
95
123
|
mimeType: "text/plain",
|
|
96
124
|
});
|
|
97
|
-
// Manually set refcount to 0
|
|
98
|
-
await t.run(async (ctx) => {
|
|
99
|
-
await ctx.db.patch("files", fileId, { refcount: 0 });
|
|
100
|
-
});
|
|
101
125
|
files.push(fileId);
|
|
102
126
|
}
|
|
127
|
+
vi.advanceTimersByTime(24 * 60 * 60 * 1000);
|
|
103
128
|
// Paginate through files to delete one at a time
|
|
104
129
|
let cursor: string | null = null;
|
|
105
130
|
const seen: string[] = [];
|
|
@@ -127,4 +152,54 @@ describe("files", () => {
|
|
|
127
152
|
// All fileIds should be seen
|
|
128
153
|
expect(seen.sort()).toEqual(files.sort());
|
|
129
154
|
});
|
|
155
|
+
|
|
156
|
+
test("does not expose or delete freshly registered files", async () => {
|
|
157
|
+
const t = convexTest(schema, modules);
|
|
158
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
159
|
+
const { fileId } = await t.mutation(api.files.addFile, {
|
|
160
|
+
storageId: "storage-fresh",
|
|
161
|
+
hash: "hash-fresh",
|
|
162
|
+
filename: "fresh.txt",
|
|
163
|
+
mimeType: "text/plain",
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await expect(
|
|
167
|
+
t.query(api.files.getFilesToDelete, {
|
|
168
|
+
paginationOpts: { numItems: 10, cursor: null },
|
|
169
|
+
}),
|
|
170
|
+
).resolves.toMatchObject({ page: [] });
|
|
171
|
+
await expect(
|
|
172
|
+
t.mutation(api.files.deleteFiles, { fileIds: [fileId] }),
|
|
173
|
+
).resolves.toEqual([]);
|
|
174
|
+
await expect(t.query(api.files.get, { fileId })).resolves.not.toBeNull();
|
|
175
|
+
error.mockRestore();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("refreshes a cache hit before the cleanup grace period expires", async () => {
|
|
179
|
+
vi.useFakeTimers();
|
|
180
|
+
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
|
181
|
+
const t = convexTest(schema, modules);
|
|
182
|
+
const { fileId } = await t.mutation(api.files.addFile, {
|
|
183
|
+
storageId: "storage-cache-hit",
|
|
184
|
+
hash: "cache-hit-hash",
|
|
185
|
+
filename: "cache-hit.txt",
|
|
186
|
+
mediaType: "text/plain",
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
vi.advanceTimersByTime(24 * 60 * 60 * 1000 - 60 * 1000);
|
|
190
|
+
await expect(
|
|
191
|
+
t.mutation(api.files.useExistingFile, {
|
|
192
|
+
hash: "cache-hit-hash",
|
|
193
|
+
filename: "cache-hit.txt",
|
|
194
|
+
mediaType: "text/plain",
|
|
195
|
+
}),
|
|
196
|
+
).resolves.toMatchObject({ fileId });
|
|
197
|
+
|
|
198
|
+
vi.advanceTimersByTime(60 * 1000);
|
|
199
|
+
await expect(
|
|
200
|
+
t.query(api.files.getFilesToDelete, {
|
|
201
|
+
paginationOpts: { numItems: 10, cursor: null },
|
|
202
|
+
}),
|
|
203
|
+
).resolves.toMatchObject({ page: [] });
|
|
204
|
+
});
|
|
130
205
|
});
|
package/src/component/files.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { paginator } from "convex-helpers/server/pagination";
|
|
2
|
-
import type { Id } from "./_generated/dataModel.js";
|
|
2
|
+
import type { Doc, Id } from "./_generated/dataModel.js";
|
|
3
3
|
import { mutation, type MutationCtx, query } from "./_generated/server.js";
|
|
4
4
|
import { schema, v } from "./schema.js";
|
|
5
5
|
import { paginationOptsValidator } from "convex/server";
|
|
6
6
|
import type { Infer } from "convex/values";
|
|
7
7
|
|
|
8
|
+
const FILE_CLEANUP_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
|
|
8
10
|
const addFileArgs = v.object({
|
|
9
11
|
storageId: v.string(),
|
|
10
12
|
hash: v.string(),
|
|
@@ -28,18 +30,21 @@ export async function addFileHandler(
|
|
|
28
30
|
args: Infer<typeof addFileArgs>,
|
|
29
31
|
) {
|
|
30
32
|
// Support both mediaType (preferred) and mimeType (deprecated)
|
|
31
|
-
const mediaType = args.mediaType ?? args.mimeType;
|
|
33
|
+
const mediaType = normalizeMediaType(args.mediaType ?? args.mimeType);
|
|
32
34
|
|
|
33
|
-
const
|
|
35
|
+
const existingFiles = await ctx.db
|
|
34
36
|
.query("files")
|
|
35
37
|
.withIndex("hash", (q) => q.eq("hash", args.hash))
|
|
36
38
|
// eslint-disable-next-line @convex-dev/no-filter-in-query -- We do not expect many files with the same hash and different filenames
|
|
37
39
|
.filter((q) => q.eq(q.field("filename"), args.filename))
|
|
38
|
-
.
|
|
40
|
+
.collect();
|
|
41
|
+
const existingFile = existingFiles.find((file) =>
|
|
42
|
+
sameMediaType(file, mediaType),
|
|
43
|
+
);
|
|
39
44
|
if (existingFile) {
|
|
40
|
-
//
|
|
45
|
+
// Registration is not ownership. Persisted messages and in-flight streams
|
|
46
|
+
// acquire ownership explicitly.
|
|
41
47
|
await ctx.db.patch("files", existingFile._id, {
|
|
42
|
-
refcount: existingFile.refcount + 1,
|
|
43
48
|
lastTouchedAt: Date.now(),
|
|
44
49
|
});
|
|
45
50
|
return {
|
|
@@ -83,14 +88,18 @@ export const useExistingFile = mutation({
|
|
|
83
88
|
args: {
|
|
84
89
|
hash: v.string(),
|
|
85
90
|
filename: v.optional(v.string()),
|
|
91
|
+
mediaType: v.optional(v.string()),
|
|
86
92
|
},
|
|
87
93
|
handler: async (ctx, args) => {
|
|
88
|
-
const
|
|
94
|
+
const files = await ctx.db
|
|
89
95
|
.query("files")
|
|
90
96
|
.withIndex("hash", (q) => q.eq("hash", args.hash))
|
|
91
97
|
// eslint-disable-next-line @convex-dev/no-filter-in-query -- We do not expect many files with the same hash and different filenames
|
|
92
98
|
.filter((q) => q.eq(q.field("filename"), args.filename))
|
|
93
|
-
.
|
|
99
|
+
.collect();
|
|
100
|
+
const file = files.find((candidate) =>
|
|
101
|
+
sameMediaType(candidate, normalizeMediaType(args.mediaType)),
|
|
102
|
+
);
|
|
94
103
|
if (!file) {
|
|
95
104
|
return null;
|
|
96
105
|
}
|
|
@@ -108,36 +117,51 @@ export const useExistingFile = mutation({
|
|
|
108
117
|
),
|
|
109
118
|
});
|
|
110
119
|
|
|
120
|
+
function normalizeMediaType(value: string | undefined) {
|
|
121
|
+
const normalized = value?.trim().toLowerCase();
|
|
122
|
+
return normalized || undefined;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sameMediaType(
|
|
126
|
+
file: Pick<Doc<"files">, "mediaType" | "mimeType">,
|
|
127
|
+
expected: string | undefined,
|
|
128
|
+
) {
|
|
129
|
+
const actual = normalizeMediaType(file.mediaType ?? file.mimeType);
|
|
130
|
+
// File rows from before media-type tracking have no value to distinguish.
|
|
131
|
+
// Preserve the prior hash-and-filename reuse behavior for those rows.
|
|
132
|
+
return expected === undefined || actual === undefined || actual === expected;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Transfer ownership between two durable records. */
|
|
111
136
|
export async function changeRefcount(
|
|
112
137
|
ctx: MutationCtx,
|
|
113
|
-
|
|
138
|
+
previous: Id<"files">[],
|
|
114
139
|
next: Id<"files">[],
|
|
115
140
|
) {
|
|
116
|
-
const
|
|
141
|
+
const previousSet = new Set(previous);
|
|
117
142
|
const nextSet = new Set(next);
|
|
118
|
-
for (const fileId of
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
} else {
|
|
143
|
+
for (const fileId of new Set([...previousSet, ...nextSet])) {
|
|
144
|
+
const increment = Number(!previousSet.has(fileId) && nextSet.has(fileId));
|
|
145
|
+
const decrement = Number(previousSet.has(fileId) && !nextSet.has(fileId));
|
|
146
|
+
if (increment === 0 && decrement === 0) continue;
|
|
147
|
+
const file = await ctx.db.get("files", fileId);
|
|
148
|
+
if (!file) {
|
|
149
|
+
if (increment === 0) {
|
|
126
150
|
console.error(`File ${fileId} not found when decrementing refcount`);
|
|
151
|
+
continue;
|
|
127
152
|
}
|
|
153
|
+
throw new Error(`File ${fileId} not found when incrementing refcount`);
|
|
128
154
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (file) {
|
|
134
|
-
await ctx.db.patch("files", fileId, {
|
|
135
|
-
refcount: file.refcount + 1,
|
|
136
|
-
});
|
|
137
|
-
} else {
|
|
138
|
-
throw new Error(`File ${fileId} not found when incrementing refcount`);
|
|
139
|
-
}
|
|
155
|
+
if (file.refcount < decrement) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`File ${fileId} refcount underflow: ${file.refcount} - ${decrement}`,
|
|
158
|
+
);
|
|
140
159
|
}
|
|
160
|
+
const delta = increment - decrement;
|
|
161
|
+
await ctx.db.patch("files", fileId, {
|
|
162
|
+
refcount: file.refcount + delta,
|
|
163
|
+
lastTouchedAt: Date.now(),
|
|
164
|
+
});
|
|
141
165
|
}
|
|
142
166
|
}
|
|
143
167
|
|
|
@@ -153,22 +177,15 @@ export async function copyFileHandler(
|
|
|
153
177
|
ctx: MutationCtx,
|
|
154
178
|
args: { fileId: Id<"files"> },
|
|
155
179
|
) {
|
|
156
|
-
|
|
157
|
-
if (!file) {
|
|
158
|
-
throw new Error("File not found");
|
|
159
|
-
}
|
|
160
|
-
await ctx.db.patch("files", args.fileId, {
|
|
161
|
-
refcount: file.refcount + 1,
|
|
162
|
-
lastTouchedAt: Date.now(),
|
|
163
|
-
});
|
|
180
|
+
await changeRefcount(ctx, [], [args.fileId]);
|
|
164
181
|
}
|
|
165
182
|
|
|
166
183
|
/**
|
|
167
184
|
* Get files that are unused and can be deleted.
|
|
168
185
|
* This is useful for cleaning up files that are no longer needed.
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
186
|
+
* Files remain protected for 24 hours after registration or their last
|
|
187
|
+
* ownership change, so a file cannot be removed between registration and the
|
|
188
|
+
* transaction that saves its message or stream reference.
|
|
172
189
|
*/
|
|
173
190
|
export const getFilesToDelete = query({
|
|
174
191
|
args: {
|
|
@@ -177,7 +194,11 @@ export const getFilesToDelete = query({
|
|
|
177
194
|
handler: async (ctx, args) => {
|
|
178
195
|
const files = await paginator(ctx.db, schema)
|
|
179
196
|
.query("files")
|
|
180
|
-
.withIndex("
|
|
197
|
+
.withIndex("refcount_lastTouchedAt", (q) =>
|
|
198
|
+
q
|
|
199
|
+
.eq("refcount", 0)
|
|
200
|
+
.lte("lastTouchedAt", Date.now() - FILE_CLEANUP_GRACE_MS),
|
|
201
|
+
)
|
|
181
202
|
.paginate(args.paginationOpts);
|
|
182
203
|
return files;
|
|
183
204
|
},
|
|
@@ -210,6 +231,13 @@ export const deleteFiles = mutation({
|
|
|
210
231
|
return null;
|
|
211
232
|
}
|
|
212
233
|
}
|
|
234
|
+
if (
|
|
235
|
+
!args.force &&
|
|
236
|
+
file.lastTouchedAt > Date.now() - FILE_CLEANUP_GRACE_MS
|
|
237
|
+
) {
|
|
238
|
+
console.error(`File ${fileId} is still within the cleanup grace period, skipping...`);
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
213
241
|
await ctx.db.delete("files", fileId);
|
|
214
242
|
return fileId;
|
|
215
243
|
}),
|
|
@@ -5,6 +5,7 @@ import { describe, expect, test, vi } from "vitest";
|
|
|
5
5
|
import { api } from "./_generated/api.js";
|
|
6
6
|
import type { Id } from "./_generated/dataModel.js";
|
|
7
7
|
import { getMaxMessage } from "./messages.js";
|
|
8
|
+
import { timeoutStreamHandler } from "./streams.js";
|
|
8
9
|
import schema from "./schema.js";
|
|
9
10
|
import { initConvexTest, modules } from "./setup.test.js";
|
|
10
11
|
|
|
@@ -924,4 +925,95 @@ describe("agent", () => {
|
|
|
924
925
|
expect(state.messages[0].embeddingId).toBeUndefined();
|
|
925
926
|
expect(state.embedding).toBeNull();
|
|
926
927
|
});
|
|
928
|
+
|
|
929
|
+
test("transfers recovered file ownership and releases timed-out streams", async () => {
|
|
930
|
+
const t = initConvexTest();
|
|
931
|
+
const thread = await t.mutation(api.threads.createThread, {
|
|
932
|
+
userId: "stream-file-recovery",
|
|
933
|
+
});
|
|
934
|
+
const { messages } = await t.mutation(api.messages.addMessages, {
|
|
935
|
+
threadId: thread._id as Id<"threads">,
|
|
936
|
+
messages: [
|
|
937
|
+
{ message: { role: "assistant", content: [] }, status: "pending" },
|
|
938
|
+
],
|
|
939
|
+
});
|
|
940
|
+
const pending = messages[0]!;
|
|
941
|
+
const { fileId: recoveredFileId } = await t.mutation(api.files.addFile, {
|
|
942
|
+
storageId: "recovered-storage",
|
|
943
|
+
hash: "recovered-hash",
|
|
944
|
+
filename: "recovered.txt",
|
|
945
|
+
});
|
|
946
|
+
await t.mutation(api.files.copyFile, { fileId: recoveredFileId });
|
|
947
|
+
const recoveredStreamId = await t.run((ctx) =>
|
|
948
|
+
ctx.db.insert("streamingMessages", {
|
|
949
|
+
threadId: thread._id as Id<"threads">,
|
|
950
|
+
order: pending.order,
|
|
951
|
+
stepOrder: pending.stepOrder,
|
|
952
|
+
format: "UIMessageChunk",
|
|
953
|
+
state: { kind: "aborted", reason: "interrupted" },
|
|
954
|
+
fileRefs: [
|
|
955
|
+
{ url: "https://files.example/recovered", fileId: recoveredFileId },
|
|
956
|
+
],
|
|
957
|
+
}),
|
|
958
|
+
);
|
|
959
|
+
await t.run((ctx) =>
|
|
960
|
+
ctx.db.insert("streamDeltas", {
|
|
961
|
+
streamId: recoveredStreamId,
|
|
962
|
+
start: 0,
|
|
963
|
+
end: 3,
|
|
964
|
+
parts: [
|
|
965
|
+
{ type: "start" },
|
|
966
|
+
{
|
|
967
|
+
type: "file",
|
|
968
|
+
url: "https://files.example/recovered",
|
|
969
|
+
mediaType: "text/plain",
|
|
970
|
+
},
|
|
971
|
+
{ type: "finish" },
|
|
972
|
+
],
|
|
973
|
+
}),
|
|
974
|
+
);
|
|
975
|
+
await t.mutation(api.messages.finalizeMessage, {
|
|
976
|
+
messageId: pending._id as Id<"messages">,
|
|
977
|
+
result: { status: "success" },
|
|
978
|
+
});
|
|
979
|
+
await expect(
|
|
980
|
+
t.query(api.files.get, { fileId: recoveredFileId }),
|
|
981
|
+
).resolves.toMatchObject({
|
|
982
|
+
refcount: 1,
|
|
983
|
+
});
|
|
984
|
+
expect(
|
|
985
|
+
(await t.run((ctx) => ctx.db.get("streamingMessages", recoveredStreamId)))
|
|
986
|
+
?.fileRefs,
|
|
987
|
+
).toBeUndefined();
|
|
988
|
+
|
|
989
|
+
const { fileId: timedOutFileId } = await t.mutation(api.files.addFile, {
|
|
990
|
+
storageId: "timeout-storage",
|
|
991
|
+
hash: "timeout-hash",
|
|
992
|
+
filename: "timeout.txt",
|
|
993
|
+
});
|
|
994
|
+
await t.mutation(api.files.copyFile, { fileId: timedOutFileId });
|
|
995
|
+
const timedOutStreamId = await t.run((ctx) =>
|
|
996
|
+
ctx.db.insert("streamingMessages", {
|
|
997
|
+
threadId: thread._id as Id<"threads">,
|
|
998
|
+
order: pending.order + 1,
|
|
999
|
+
stepOrder: 0,
|
|
1000
|
+
format: "UIMessageChunk",
|
|
1001
|
+
state: { kind: "streaming", lastHeartbeat: Date.now() },
|
|
1002
|
+
fileRefs: [
|
|
1003
|
+
{ url: "https://files.example/timeout", fileId: timedOutFileId },
|
|
1004
|
+
],
|
|
1005
|
+
}),
|
|
1006
|
+
);
|
|
1007
|
+
await t.run((ctx) =>
|
|
1008
|
+
timeoutStreamHandler(ctx, { streamId: timedOutStreamId }),
|
|
1009
|
+
);
|
|
1010
|
+
await t.mutation(api.streams.deleteStreamSync, {
|
|
1011
|
+
streamId: timedOutStreamId,
|
|
1012
|
+
});
|
|
1013
|
+
await expect(
|
|
1014
|
+
t.query(api.files.get, { fileId: timedOutFileId }),
|
|
1015
|
+
).resolves.toMatchObject({
|
|
1016
|
+
refcount: 0,
|
|
1017
|
+
});
|
|
1018
|
+
});
|
|
927
1019
|
});
|
|
@@ -40,7 +40,11 @@ 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
|
+
} from "./streams.js";
|
|
44
48
|
import { partial } from "convex-helpers/validators";
|
|
45
49
|
|
|
46
50
|
function publicMessage(message: Doc<"messages">): MessageDoc {
|
|
@@ -334,7 +338,10 @@ async function addMessagesHandler(
|
|
|
334
338
|
// Atomically finish the stream if requested, preventing UI flickering
|
|
335
339
|
// from separate mutations for message save and stream finish (issue #181).
|
|
336
340
|
if (finishStreamId) {
|
|
337
|
-
await finishHandler(ctx, {
|
|
341
|
+
await finishHandler(ctx, {
|
|
342
|
+
streamId: finishStreamId,
|
|
343
|
+
});
|
|
344
|
+
await releaseStreamFileOwnershipByIds(ctx, [finishStreamId]);
|
|
338
345
|
}
|
|
339
346
|
return { messages: toReturn.map(publicMessage) };
|
|
340
347
|
}
|
|
@@ -409,7 +416,7 @@ export const finalizeMessage = mutation({
|
|
|
409
416
|
}
|
|
410
417
|
// See if we can add any in-progress data
|
|
411
418
|
if (!message.message?.content.length) {
|
|
412
|
-
const { messages, materializationFailures } =
|
|
419
|
+
const { messages, materializationFailures, streamsToRelease } =
|
|
413
420
|
await getStreamingMessagesWithMetadata(ctx, message, result);
|
|
414
421
|
if (materializationFailures.length > 0) {
|
|
415
422
|
console.error(
|
|
@@ -433,6 +440,7 @@ export const finalizeMessage = mutation({
|
|
|
433
440
|
userId: message.userId,
|
|
434
441
|
embeddings: undefined,
|
|
435
442
|
});
|
|
443
|
+
await releaseStreamFileOwnershipByIds(ctx, streamsToRelease);
|
|
436
444
|
return;
|
|
437
445
|
}
|
|
438
446
|
}
|
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
|
+
});
|