@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
@@ -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
  }),