@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.
Files changed (61) hide show
  1. package/dist/component/_generated/component.d.ts +5 -0
  2. package/dist/component/_generated/component.d.ts.map +1 -1
  3. package/dist/component/files.d.ts +6 -4
  4. package/dist/component/files.d.ts.map +1 -1
  5. package/dist/component/files.js +53 -42
  6. package/dist/component/files.js.map +1 -1
  7. package/dist/component/messages.d.ts.map +1 -1
  8. package/dist/component/messages.js +7 -3
  9. package/dist/component/messages.js.map +1 -1
  10. package/dist/component/schema.d.ts +58 -6
  11. package/dist/component/schema.d.ts.map +1 -1
  12. package/dist/component/schema.js +12 -2
  13. package/dist/component/schema.js.map +1 -1
  14. package/dist/component/streams.d.ts +10 -1
  15. package/dist/component/streams.d.ts.map +1 -1
  16. package/dist/component/streams.js +72 -17
  17. package/dist/component/streams.js.map +1 -1
  18. package/dist/streaming/materializePersistedUIMessageChunks.d.ts +8 -2
  19. package/dist/streaming/materializePersistedUIMessageChunks.d.ts.map +1 -1
  20. package/dist/streaming/materializePersistedUIMessageChunks.js +34 -3
  21. package/dist/streaming/materializePersistedUIMessageChunks.js.map +1 -1
  22. package/dist/vercel/UIMessages.js +1 -1
  23. package/dist/vercel/UIMessages.js.map +1 -1
  24. package/dist/vercel/client/files.d.ts.map +1 -1
  25. package/dist/vercel/client/files.js +51 -27
  26. package/dist/vercel/client/files.js.map +1 -1
  27. package/dist/vercel/client/streamText.d.ts.map +1 -1
  28. package/dist/vercel/client/streamText.js +2 -0
  29. package/dist/vercel/client/streamText.js.map +1 -1
  30. package/dist/vercel/client/streaming.d.ts +14 -0
  31. package/dist/vercel/client/streaming.d.ts.map +1 -1
  32. package/dist/vercel/client/streaming.js +35 -13
  33. package/dist/vercel/client/streaming.js.map +1 -1
  34. package/dist/vercel/fileMaterialization.d.ts +24 -0
  35. package/dist/vercel/fileMaterialization.d.ts.map +1 -0
  36. package/dist/vercel/fileMaterialization.js +118 -0
  37. package/dist/vercel/fileMaterialization.js.map +1 -0
  38. package/dist/vercel/mapping.d.ts.map +1 -1
  39. package/dist/vercel/mapping.js +7 -1
  40. package/dist/vercel/mapping.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/component/_generated/component.ts +8 -2
  43. package/src/component/files.test.ts +81 -6
  44. package/src/component/files.ts +69 -41
  45. package/src/component/messages.test.ts +92 -0
  46. package/src/component/messages.ts +11 -3
  47. package/src/component/schema.ts +16 -2
  48. package/src/component/streams.test.ts +109 -0
  49. package/src/component/streams.ts +103 -20
  50. package/src/streaming/materializePersistedUIMessageChunks.test.ts +33 -0
  51. package/src/streaming/materializePersistedUIMessageChunks.ts +37 -0
  52. package/src/vercel/UIMessages.ts +1 -1
  53. package/src/vercel/client/files.test.ts +56 -0
  54. package/src/vercel/client/files.ts +53 -28
  55. package/src/vercel/client/streamText.ts +3 -0
  56. package/src/vercel/client/streaming.test.ts +71 -0
  57. package/src/vercel/client/streaming.ts +50 -16
  58. package/src/vercel/fileMaterialization.ts +174 -0
  59. package/src/vercel/mapping.test.ts +117 -0
  60. package/src/vercel/mapping.ts +15 -1
  61. 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
- test("addFile increments refcount and does not create a new entry", async () => {
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
  });
@@ -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 existingFile = await ctx.db
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
- .first();
40
+ .collect();
41
+ const existingFile = existingFiles.find((file) =>
42
+ sameMediaType(file, mediaType),
43
+ );
39
44
  if (existingFile) {
40
- // increment the refcount
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 file = await ctx.db
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
- .first();
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
- prev: Id<"files">[],
138
+ previous: Id<"files">[],
114
139
  next: Id<"files">[],
115
140
  ) {
116
- const prevSet = new Set(prev);
141
+ const previousSet = new Set(previous);
117
142
  const nextSet = new Set(next);
118
- for (const fileId of prevSet) {
119
- if (!nextSet.has(fileId)) {
120
- const file = await ctx.db.get("files", fileId);
121
- if (file) {
122
- await ctx.db.patch("files", fileId, {
123
- refcount: file.refcount - 1,
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
- for (const fileId of nextSet) {
131
- if (!prevSet.has(fileId)) {
132
- const file = await ctx.db.get("files", fileId);
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
- const file = await ctx.db.get("files", args.fileId);
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
- * Note: recently added files that have not been saved yet will show up here.
170
- * You can inspect the `lastTouchedAt` field to see how recently it was used.
171
- * I'd recommend not deleting anything touched in the last 24 hours.
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("refcount", (q) => q.eq("refcount", 0))
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 { getStreamingMessagesWithMetadata, finishHandler } from "./streams.js";
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, { streamId: finishStreamId });
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
  }
@@ -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
+ });