@samebase/convex-hosting 0.0.0-stage → 0.3.0

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/CHANGELOG.md +127 -0
  2. package/INTEGRATION.md +399 -0
  3. package/LICENSE +201 -0
  4. package/NOTICE +7 -0
  5. package/README.md +470 -2
  6. package/dist/cli/args.d.ts +28 -0
  7. package/dist/cli/args.d.ts.map +1 -0
  8. package/dist/cli/args.js +148 -0
  9. package/dist/cli/args.js.map +1 -0
  10. package/dist/cli/argumentChunks.d.ts +3 -0
  11. package/dist/cli/argumentChunks.d.ts.map +1 -0
  12. package/dist/cli/argumentChunks.js +29 -0
  13. package/dist/cli/argumentChunks.js.map +1 -0
  14. package/dist/cli/commands.d.ts +8 -0
  15. package/dist/cli/commands.d.ts.map +1 -0
  16. package/dist/cli/commands.js +56 -0
  17. package/dist/cli/commands.js.map +1 -0
  18. package/dist/cli/componentName.d.ts +32 -0
  19. package/dist/cli/componentName.d.ts.map +1 -0
  20. package/dist/cli/componentName.js +43 -0
  21. package/dist/cli/componentName.js.map +1 -0
  22. package/dist/cli/deploy.d.ts +16 -0
  23. package/dist/cli/deploy.d.ts.map +1 -0
  24. package/dist/cli/deploy.js +283 -0
  25. package/dist/cli/deploy.js.map +1 -0
  26. package/dist/cli/deployEnvironment.d.ts +10 -0
  27. package/dist/cli/deployEnvironment.d.ts.map +1 -0
  28. package/dist/cli/deployEnvironment.js +12 -0
  29. package/dist/cli/deployEnvironment.js.map +1 -0
  30. package/dist/cli/index.d.ts +12 -0
  31. package/dist/cli/index.d.ts.map +1 -0
  32. package/dist/cli/index.js +60 -0
  33. package/dist/cli/index.js.map +1 -0
  34. package/dist/cli/upload.d.ts +22 -0
  35. package/dist/cli/upload.d.ts.map +1 -0
  36. package/dist/cli/upload.js +668 -0
  37. package/dist/cli/upload.js.map +1 -0
  38. package/dist/client/_generated/_ignore.d.ts +1 -0
  39. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  40. package/dist/client/_generated/_ignore.js +3 -0
  41. package/dist/client/_generated/_ignore.js.map +1 -0
  42. package/dist/client/index.d.ts +119 -0
  43. package/dist/client/index.d.ts.map +1 -0
  44. package/dist/client/index.js +272 -0
  45. package/dist/client/index.js.map +1 -0
  46. package/dist/component/_generated/api.d.ts +38 -0
  47. package/dist/component/_generated/api.d.ts.map +1 -0
  48. package/dist/component/_generated/api.js +31 -0
  49. package/dist/component/_generated/api.js.map +1 -0
  50. package/dist/component/_generated/component.d.ts +43 -0
  51. package/dist/component/_generated/component.d.ts.map +1 -0
  52. package/dist/component/_generated/component.js +11 -0
  53. package/dist/component/_generated/component.js.map +1 -0
  54. package/dist/component/_generated/dataModel.d.ts +46 -0
  55. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  56. package/dist/component/_generated/dataModel.js +11 -0
  57. package/dist/component/_generated/dataModel.js.map +1 -0
  58. package/dist/component/_generated/server.d.ts +121 -0
  59. package/dist/component/_generated/server.d.ts.map +1 -0
  60. package/dist/component/_generated/server.js +78 -0
  61. package/dist/component/_generated/server.js.map +1 -0
  62. package/dist/component/convex.config.d.ts +3 -0
  63. package/dist/component/convex.config.d.ts.map +1 -0
  64. package/dist/component/convex.config.js +3 -0
  65. package/dist/component/convex.config.js.map +1 -0
  66. package/dist/component/http.d.ts +4 -0
  67. package/dist/component/http.d.ts.map +1 -0
  68. package/dist/component/http.js +125 -0
  69. package/dist/component/http.js.map +1 -0
  70. package/dist/component/lib.d.ts +164 -0
  71. package/dist/component/lib.d.ts.map +1 -0
  72. package/dist/component/lib.js +768 -0
  73. package/dist/component/lib.js.map +1 -0
  74. package/dist/component/schema.d.ts +84 -0
  75. package/dist/component/schema.d.ts.map +1 -0
  76. package/dist/component/schema.js +69 -0
  77. package/dist/component/schema.js.map +1 -0
  78. package/dist/component/serving.d.ts +17 -0
  79. package/dist/component/serving.d.ts.map +1 -0
  80. package/dist/component/serving.js +150 -0
  81. package/dist/component/serving.js.map +1 -0
  82. package/dist/react/index.d.ts +59 -0
  83. package/dist/react/index.d.ts.map +1 -0
  84. package/dist/react/index.js +137 -0
  85. package/dist/react/index.js.map +1 -0
  86. package/package.json +78 -3
  87. package/src/cli/args.test.ts +79 -0
  88. package/src/cli/args.ts +153 -0
  89. package/src/cli/argumentChunks.test.ts +27 -0
  90. package/src/cli/argumentChunks.ts +40 -0
  91. package/src/cli/commands.ts +68 -0
  92. package/src/cli/componentName.test.ts +41 -0
  93. package/src/cli/componentName.ts +46 -0
  94. package/src/cli/deploy.ts +336 -0
  95. package/src/cli/deployEnvironment.test.ts +27 -0
  96. package/src/cli/deployEnvironment.ts +17 -0
  97. package/src/cli/index.ts +65 -0
  98. package/src/cli/upload.ts +885 -0
  99. package/src/client/_generated/_ignore.ts +1 -0
  100. package/src/client/index.test.ts +510 -0
  101. package/src/client/index.ts +355 -0
  102. package/src/client/setup.test.ts +22 -0
  103. package/src/component/_generated/api.ts +54 -0
  104. package/src/component/_generated/component.ts +55 -0
  105. package/src/component/_generated/dataModel.ts +60 -0
  106. package/src/component/_generated/server.ts +156 -0
  107. package/src/component/convex.config.ts +3 -0
  108. package/src/component/http.test.ts +223 -0
  109. package/src/component/http.ts +144 -0
  110. package/src/component/lib.test.ts +1007 -0
  111. package/src/component/lib.ts +862 -0
  112. package/src/component/schema.ts +75 -0
  113. package/src/component/serving.ts +158 -0
  114. package/src/component/setup.test.ts +11 -0
  115. package/src/react/index.tsx +197 -0
  116. package/src/test.ts +23 -0
@@ -0,0 +1,1007 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
4
+ import { api, internal } from "./_generated/api.js";
5
+ import { initConvexTest } from "./setup.test.js";
6
+
7
+ describe("component lib", () => {
8
+ beforeEach(async () => {
9
+ vi.useFakeTimers();
10
+ });
11
+ afterEach(() => {
12
+ vi.useRealTimers();
13
+ });
14
+
15
+ test("generates upload URLs", async () => {
16
+ const t = initConvexTest();
17
+ const uploadUrl = await t.mutation(internal.lib.generateUploadUrl, {});
18
+ expect(typeof uploadUrl).toBe("string");
19
+ expect(uploadUrl.length).toBeGreaterThan(0);
20
+
21
+ const urls = await t.mutation(internal.lib.generateUploadUrls, {
22
+ count: 3,
23
+ });
24
+ expect(urls).toHaveLength(3);
25
+ for (const u of urls) {
26
+ expect(typeof u).toBe("string");
27
+ }
28
+
29
+ await expect(t.mutation(internal.lib.generateUploadUrls, { count: 101 })).rejects.toThrow(
30
+ "integer from 1 to 100",
31
+ );
32
+ });
33
+
34
+ test("getByPath returns null when absent", async () => {
35
+ const t = initConvexTest();
36
+ const asset = await t.query(internal.lib.getByPath, {
37
+ path: "/index.html",
38
+ });
39
+ expect(asset).toBeNull();
40
+ });
41
+
42
+ test("listAssets is empty by default", async () => {
43
+ const t = initConvexTest();
44
+ const assets = await t.query(internal.lib.listAssets, {});
45
+ expect(assets).toHaveLength(0);
46
+ });
47
+
48
+ test("commitDeployment on empty db returns zero", async () => {
49
+ const t = initConvexTest();
50
+ const result = await t.mutation(internal.lib.commitDeployment, {
51
+ currentDeploymentId: "deploy-1",
52
+ });
53
+ expect(result.deleted).toBe(0);
54
+ expect(result.blobIds).toHaveLength(0);
55
+ });
56
+
57
+ test("recordAsset stores and replaces blob assets", async () => {
58
+ const t = initConvexTest();
59
+
60
+ await t.mutation(internal.lib.recordAsset, {
61
+ path: "/test.js",
62
+ blobId: "blob-123",
63
+ contentType: "application/javascript; charset=utf-8",
64
+ deploymentId: "deploy-1",
65
+ });
66
+
67
+ const first = await t.query(internal.lib.getByPath, { path: "/test.js" });
68
+ expect(first?.blobId).toBe("blob-123");
69
+
70
+ await t.mutation(internal.lib.recordAsset, {
71
+ path: "/test.js",
72
+ blobId: "blob-456",
73
+ contentType: "application/javascript; charset=utf-8",
74
+ deploymentId: "deploy-2",
75
+ });
76
+
77
+ const second = await t.query(internal.lib.getByPath, { path: "/test.js" });
78
+ expect(second?.blobId).toBe("blob-456");
79
+ });
80
+
81
+ test("replaces a legacy or already-deleted storage reference", async () => {
82
+ const t = initConvexTest();
83
+ const storageId = await t.run(async (ctx) => {
84
+ return await ctx.storage.store(new Blob(["old"]));
85
+ });
86
+ await t.mutation(internal.lib.recordAsset, {
87
+ path: "/index.html",
88
+ storageId,
89
+ contentType: "text/html; charset=utf-8",
90
+ deploymentId: "deploy-old",
91
+ });
92
+ await t.run(async (ctx) => {
93
+ await ctx.storage.delete(storageId);
94
+ });
95
+
96
+ await expect(
97
+ t.mutation(internal.lib.recordAsset, {
98
+ path: "/index.html",
99
+ blobId: "replacement",
100
+ contentType: "text/html; charset=utf-8",
101
+ deploymentId: "deploy-new",
102
+ }),
103
+ ).resolves.toBeNull();
104
+ });
105
+
106
+ test("commitDeployment returns blobIds for old CDN assets and bumps deployment", async () => {
107
+ const t = initConvexTest();
108
+
109
+ await t.mutation(internal.lib.recordAsset, {
110
+ path: "/assets/main.js",
111
+ blobId: "blob-abc",
112
+ contentType: "application/javascript; charset=utf-8",
113
+ deploymentId: "deploy-old",
114
+ });
115
+
116
+ const result = await t.mutation(internal.lib.commitDeployment, {
117
+ currentDeploymentId: "deploy-new",
118
+ });
119
+ expect(result.deleted).toBe(0);
120
+ expect(result.blobIds).toEqual(["blob-abc"]);
121
+
122
+ const deployment = await t.query(api.lib.getCurrentDeployment, {});
123
+ expect(deployment?.currentDeploymentId).toBe("deploy-new");
124
+ });
125
+
126
+ test("commitDeployment skips a legacy or already-deleted storage ID", async () => {
127
+ const t = initConvexTest();
128
+ const storageId = await t.run(async (ctx) => {
129
+ return await ctx.storage.store(new Blob(["legacy"]));
130
+ });
131
+ await t.mutation(internal.lib.recordAsset, {
132
+ path: "/legacy.css",
133
+ storageId,
134
+ contentType: "text/css; charset=utf-8",
135
+ deploymentId: "deploy-old",
136
+ });
137
+ await t.run(async (ctx) => {
138
+ await ctx.storage.delete(storageId);
139
+ });
140
+
141
+ const result = await t.mutation(internal.lib.commitDeployment, {
142
+ currentDeploymentId: "deploy-new",
143
+ });
144
+
145
+ expect(result).toEqual({ deleted: 0, blobIds: [] });
146
+ expect(await t.query(internal.lib.listAssets, {})).toEqual([]);
147
+ });
148
+
149
+ test("deletes newly uploaded files after a failed publish", async () => {
150
+ const t = initConvexTest();
151
+ const [first, alreadyMissing] = await t.run(async (ctx) => {
152
+ return await Promise.all([
153
+ ctx.storage.store(new Blob(["first"])),
154
+ ctx.storage.store(new Blob(["missing"])),
155
+ ]);
156
+ });
157
+ await t.run(async (ctx) => {
158
+ await ctx.storage.delete(alreadyMissing);
159
+ });
160
+
161
+ const result = await t.mutation(internal.lib.deleteUploadedFiles, {
162
+ storageIds: [first, alreadyMissing],
163
+ });
164
+
165
+ expect(result).toEqual({
166
+ deleted: 1,
167
+ alreadyMissing: 1,
168
+ stillReferenced: 0,
169
+ });
170
+ await t.run(async (ctx) => {
171
+ expect(await ctx.storage.get(first)).toBeNull();
172
+ });
173
+ });
174
+
175
+ test("failed-upload cleanup never deletes a file referenced by the live manifest", async () => {
176
+ const t = initConvexTest();
177
+ const storageId = await t.run(async (ctx) => {
178
+ return await ctx.storage.store(new Blob(["live"]));
179
+ });
180
+ await t.mutation(internal.lib.recordAsset, {
181
+ path: "/index.html",
182
+ storageId,
183
+ contentType: "text/html; charset=utf-8",
184
+ deploymentId: "deploy-live",
185
+ });
186
+
187
+ const result = await t.mutation(internal.lib.deleteUploadedFiles, {
188
+ storageIds: [storageId],
189
+ });
190
+
191
+ expect(result).toEqual({
192
+ deleted: 0,
193
+ alreadyMissing: 0,
194
+ stillReferenced: 1,
195
+ });
196
+ await t.run(async (ctx) => {
197
+ expect(await ctx.storage.get(storageId)).not.toBeNull();
198
+ });
199
+ });
200
+
201
+ test("cleanup after a lost publish response keeps the newly live file", async () => {
202
+ const t = initConvexTest();
203
+ const storageId = await t.run(async (ctx) => {
204
+ return await ctx.storage.store(new Blob(["new live deployment"]));
205
+ });
206
+ await t.mutation(internal.lib.stageAssets, {
207
+ assets: [
208
+ {
209
+ path: "/index.html",
210
+ storageId,
211
+ contentType: "text/html; charset=utf-8",
212
+ deploymentId: "deploy-ambiguous",
213
+ },
214
+ ],
215
+ });
216
+ await t.mutation(internal.lib.publishDeployment, {
217
+ currentDeploymentId: "deploy-ambiguous",
218
+ expectedAssetCount: 1,
219
+ });
220
+
221
+ // This is the sequence the CLI runs when the publish mutation committed
222
+ // but its response was lost.
223
+ expect(
224
+ await t.mutation(internal.lib.discardStagedDeployment, {
225
+ deploymentId: "deploy-ambiguous",
226
+ }),
227
+ ).toEqual({ discarded: 0 });
228
+ expect(
229
+ await t.mutation(internal.lib.deleteUploadedFiles, {
230
+ storageIds: [storageId],
231
+ }),
232
+ ).toEqual({ deleted: 0, alreadyMissing: 0, stillReferenced: 1 });
233
+ await t.run(async (ctx) => {
234
+ expect(await ctx.storage.get(storageId)).not.toBeNull();
235
+ });
236
+ });
237
+
238
+ test("publishes the manifest, deployment info, and cleanup atomically", async () => {
239
+ const t = initConvexTest();
240
+ const oldStorageId = await t.run(async (ctx) => {
241
+ return await ctx.storage.store(new Blob(["old"]));
242
+ });
243
+ await t.mutation(internal.lib.recordAssets, {
244
+ assets: [
245
+ {
246
+ path: "/old.css",
247
+ storageId: oldStorageId,
248
+ contentType: "text/css; charset=utf-8",
249
+ deploymentId: "deploy-old",
250
+ },
251
+ {
252
+ path: "/old.js",
253
+ blobId: "old-cdn-blob",
254
+ contentType: "application/javascript; charset=utf-8",
255
+ deploymentId: "deploy-old",
256
+ },
257
+ ],
258
+ });
259
+ const newStorageId = await t.run(async (ctx) => {
260
+ return await ctx.storage.store(new Blob(["new"]));
261
+ });
262
+
263
+ await t.mutation(internal.lib.stageAssets, {
264
+ assets: [
265
+ {
266
+ path: "/index.html",
267
+ storageId: newStorageId,
268
+ contentType: "text/html; charset=utf-8",
269
+ deploymentId: "deploy-new",
270
+ },
271
+ ],
272
+ });
273
+ const result = await t.mutation(internal.lib.publishDeployment, {
274
+ currentDeploymentId: "deploy-new",
275
+ expectedAssetCount: 1,
276
+ spaFallback: false,
277
+ });
278
+
279
+ expect(result).toEqual({ deleted: 0, pendingBlobCleanup: 1 });
280
+ expect(await t.query(internal.lib.listPendingBlobCleanup, {})).toEqual(["old-cdn-blob"]);
281
+ expect(await t.query(internal.lib.listAssets, {})).toMatchObject([
282
+ {
283
+ path: "/index.html",
284
+ storageId: newStorageId,
285
+ deploymentId: "deploy-new",
286
+ },
287
+ ]);
288
+ expect(await t.query(api.lib.getCurrentDeployment, {})).toMatchObject({
289
+ currentDeploymentId: "deploy-new",
290
+ spaFallback: false,
291
+ pendingBlobCleanupCount: 1,
292
+ });
293
+ const concurrentStorageId = await t.run(async (ctx) => {
294
+ return await ctx.storage.store(new Blob(["concurrent upload"]));
295
+ });
296
+ await t.run(async (ctx) => {
297
+ expect(await ctx.storage.get(oldStorageId)).not.toBeNull();
298
+ expect(await ctx.storage.get(newStorageId)).not.toBeNull();
299
+ });
300
+ // The replaced file is kept for seven days, so open pages can still load it.
301
+ expect(await t.mutation(internal.lib.cleanupPendingStorage, {})).toEqual({
302
+ processed: 0,
303
+ deleted: 0,
304
+ needsAnotherPass: false,
305
+ });
306
+ await t.run(async (ctx) => {
307
+ expect(await ctx.storage.get(oldStorageId)).not.toBeNull();
308
+ });
309
+ vi.advanceTimersByTime(7 * 24 * 60 * 60 * 1000);
310
+ expect(await t.mutation(internal.lib.cleanupPendingStorage, {})).toEqual({
311
+ processed: 1,
312
+ deleted: 1,
313
+ needsAnotherPass: false,
314
+ });
315
+ await t.run(async (ctx) => {
316
+ expect(await ctx.storage.get(oldStorageId)).toBeNull();
317
+ expect(await ctx.storage.get(newStorageId)).not.toBeNull();
318
+ expect(await ctx.storage.get(concurrentStorageId)).not.toBeNull();
319
+ });
320
+
321
+ expect(
322
+ await t.mutation(internal.lib.acknowledgeBlobCleanup, {
323
+ blobIds: ["old-cdn-blob"],
324
+ }),
325
+ ).toEqual({ acknowledged: 1 });
326
+ expect(await t.query(internal.lib.listPendingBlobCleanup, {})).toEqual([]);
327
+ expect(await t.query(api.lib.getCurrentDeployment, {})).toMatchObject({
328
+ pendingBlobCleanupCount: 0,
329
+ });
330
+
331
+ expect(
332
+ await t.mutation(internal.lib.queueBlobCleanup, {
333
+ blobIds: ["retry-cdn-blob", "retry-cdn-blob"],
334
+ }),
335
+ ).toEqual({ queued: 1 });
336
+ expect(await t.query(internal.lib.listPendingBlobCleanup, {})).toEqual(["retry-cdn-blob"]);
337
+ expect(await t.query(api.lib.getCurrentDeployment, {})).toMatchObject({
338
+ pendingBlobCleanupCount: 1,
339
+ });
340
+ });
341
+
342
+ test("rejects a staged manifest without index.html and keeps the live manifest", async () => {
343
+ const t = initConvexTest();
344
+ await t.mutation(internal.lib.recordAsset, {
345
+ path: "/index.html",
346
+ blobId: "still-live",
347
+ contentType: "text/html; charset=utf-8",
348
+ deploymentId: "deploy-old",
349
+ });
350
+
351
+ await t.mutation(internal.lib.stageAssets, {
352
+ assets: [
353
+ {
354
+ path: "/only.css",
355
+ blobId: "not-published",
356
+ contentType: "text/css; charset=utf-8",
357
+ deploymentId: "deploy-new",
358
+ },
359
+ ],
360
+ });
361
+
362
+ await expect(
363
+ t.mutation(internal.lib.publishDeployment, {
364
+ currentDeploymentId: "deploy-new",
365
+ expectedAssetCount: 1,
366
+ }),
367
+ ).rejects.toThrow("must include /index.html");
368
+
369
+ expect(await t.query(internal.lib.getByPath, { path: "/index.html" })).toMatchObject({
370
+ blobId: "still-live",
371
+ deploymentId: "deploy-old",
372
+ });
373
+ });
374
+
375
+ test("rejects a zero expected asset count without changing the live manifest", async () => {
376
+ const t = initConvexTest();
377
+ await t.mutation(internal.lib.recordAsset, {
378
+ path: "/index.html",
379
+ blobId: "still-live",
380
+ contentType: "text/html; charset=utf-8",
381
+ deploymentId: "deploy-old",
382
+ });
383
+
384
+ await expect(
385
+ t.mutation(internal.lib.publishDeployment, {
386
+ currentDeploymentId: "deploy-empty",
387
+ expectedAssetCount: 0,
388
+ }),
389
+ ).rejects.toThrow("integer from 1 to 1800");
390
+
391
+ expect(await t.query(internal.lib.getByPath, { path: "/index.html" })).toMatchObject({
392
+ blobId: "still-live",
393
+ deploymentId: "deploy-old",
394
+ });
395
+ });
396
+
397
+ test("keeps CDN cleanup accounting queued before the first deployment", async () => {
398
+ const t = initConvexTest();
399
+ expect(
400
+ await t.mutation(internal.lib.queueBlobCleanup, {
401
+ blobIds: ["failed-first-upload"],
402
+ }),
403
+ ).toEqual({ queued: 1 });
404
+ await t.mutation(internal.lib.stageAssets, {
405
+ assets: [
406
+ {
407
+ path: "/index.html",
408
+ blobId: "first-live-index",
409
+ contentType: "text/html; charset=utf-8",
410
+ deploymentId: "deploy-first-success",
411
+ },
412
+ ],
413
+ });
414
+
415
+ await expect(
416
+ t.mutation(internal.lib.publishDeployment, {
417
+ currentDeploymentId: "deploy-first-success",
418
+ expectedAssetCount: 1,
419
+ }),
420
+ ).resolves.toEqual({ deleted: 0, pendingBlobCleanup: 1 });
421
+ expect(await t.query(api.lib.getCurrentDeployment, {})).toMatchObject({
422
+ currentDeploymentId: "deploy-first-success",
423
+ pendingBlobCleanupCount: 1,
424
+ });
425
+ });
426
+
427
+ test("rejects a staged manifest whose storage file disappeared", async () => {
428
+ const t = initConvexTest();
429
+ await t.mutation(internal.lib.recordAsset, {
430
+ path: "/index.html",
431
+ blobId: "still-live",
432
+ contentType: "text/html; charset=utf-8",
433
+ deploymentId: "deploy-old",
434
+ });
435
+ const missingStorageId = await t.run(async (ctx) => {
436
+ const storageId = await ctx.storage.store(new Blob(["temporary"]));
437
+ await ctx.storage.delete(storageId);
438
+ return storageId;
439
+ });
440
+ await expect(
441
+ t.mutation(internal.lib.stageAssets, {
442
+ assets: [
443
+ {
444
+ path: "/index.html",
445
+ storageId: missingStorageId,
446
+ contentType: "text/html; charset=utf-8",
447
+ deploymentId: "deploy-missing",
448
+ },
449
+ ],
450
+ }),
451
+ ).rejects.toThrow("Staged storage file is missing");
452
+ expect(await t.query(internal.lib.getByPath, { path: "/index.html" })).toMatchObject({
453
+ blobId: "still-live",
454
+ deploymentId: "deploy-old",
455
+ });
456
+ });
457
+
458
+ test("publishes a manifest staged in multiple chunks", async () => {
459
+ const t = initConvexTest();
460
+ await t.mutation(internal.lib.stageAssets, {
461
+ assets: [
462
+ {
463
+ path: "/index.html",
464
+ blobId: "index",
465
+ contentType: "text/html; charset=utf-8",
466
+ deploymentId: "deploy-chunked",
467
+ },
468
+ ],
469
+ });
470
+ await t.mutation(internal.lib.stageAssets, {
471
+ assets: [
472
+ {
473
+ path: "/app.js",
474
+ blobId: "app",
475
+ contentType: "application/javascript; charset=utf-8",
476
+ deploymentId: "deploy-chunked",
477
+ },
478
+ ],
479
+ });
480
+
481
+ await t.mutation(internal.lib.publishDeployment, {
482
+ currentDeploymentId: "deploy-chunked",
483
+ expectedAssetCount: 2,
484
+ });
485
+
486
+ expect(await t.query(internal.lib.listAssets, {})).toMatchObject([
487
+ { path: "/index.html", deploymentId: "deploy-chunked" },
488
+ { path: "/app.js", deploymentId: "deploy-chunked" },
489
+ ]);
490
+ expect(
491
+ await t.mutation(internal.lib.discardStagedDeployment, {
492
+ deploymentId: "deploy-chunked",
493
+ }),
494
+ ).toEqual({ discarded: 0 });
495
+ });
496
+
497
+ test("rejects manifests above the safe transaction-sized file limit", async () => {
498
+ const t = initConvexTest();
499
+ await t.mutation(internal.lib.stageAssets, {
500
+ assets: Array.from({ length: 1801 }, (_, index) => ({
501
+ path: index === 0 ? "/index.html" : `/asset-${index}.js`,
502
+ blobId: `blob-${index}`,
503
+ contentType:
504
+ index === 0 ? "text/html; charset=utf-8" : "application/javascript; charset=utf-8",
505
+ deploymentId: "deploy-too-large",
506
+ })),
507
+ });
508
+
509
+ await expect(
510
+ t.mutation(internal.lib.publishDeployment, {
511
+ currentDeploymentId: "deploy-too-large",
512
+ expectedAssetCount: 1801,
513
+ }),
514
+ ).rejects.toThrow("integer from 1 to 1800");
515
+ await expect(
516
+ t.mutation(internal.lib.publishDeployment, {
517
+ currentDeploymentId: "deploy-too-large",
518
+ expectedAssetCount: 1800,
519
+ }),
520
+ ).rejects.toThrow("Staged manifest has 1801 files; expected 1800");
521
+ });
522
+
523
+ test("publishes large inherited rows before bounded storage cleanup", async () => {
524
+ const t = initConvexTest();
525
+ const sharedStorageId = await t.run(async (ctx) => {
526
+ const storageId = await ctx.storage.store(new Blob(["legacy"]));
527
+ await Promise.all(
528
+ Array.from({ length: 1800 }, (_, index) =>
529
+ ctx.db.insert("staticAssets", {
530
+ path: `/legacy-${index}`,
531
+ storageId,
532
+ blobId: `legacy-blob-${index}`,
533
+ contentType: "application/octet-stream",
534
+ deploymentId: "deploy-legacy",
535
+ }),
536
+ ),
537
+ );
538
+ return storageId;
539
+ });
540
+ expect(sharedStorageId).toBeTruthy();
541
+ await t.mutation(internal.lib.stageAssets, {
542
+ assets: Array.from({ length: 1800 }, (_, index) => ({
543
+ path: index === 0 ? "/index.html" : `/new-${index}.js`,
544
+ blobId: `new-blob-${index}`,
545
+ contentType:
546
+ index === 0 ? "text/html; charset=utf-8" : "application/javascript; charset=utf-8",
547
+ deploymentId: "deploy-new",
548
+ })),
549
+ });
550
+
551
+ await expect(
552
+ t.mutation(internal.lib.publishDeployment, {
553
+ currentDeploymentId: "deploy-new",
554
+ expectedAssetCount: 1800,
555
+ }),
556
+ ).resolves.toEqual({ deleted: 0, pendingBlobCleanup: 1800 });
557
+ expect(await t.mutation(internal.lib.cleanupPendingStorage, {})).toEqual({
558
+ processed: 1,
559
+ deleted: 1,
560
+ needsAnotherPass: false,
561
+ });
562
+ await t.run(async (ctx) => {
563
+ expect(await ctx.storage.get(sharedStorageId)).toBeNull();
564
+ });
565
+ });
566
+
567
+ test("rejects manifests above the serialized metadata budget", async () => {
568
+ const t = initConvexTest();
569
+ const assets = Array.from({ length: 1800 }, (_, index) => ({
570
+ path: index === 0 ? "/index.html" : `/${index}-${"nested-path/".repeat(100)}asset.js`,
571
+ blobId: `blob-${index}`,
572
+ contentType:
573
+ index === 0 ? "text/html; charset=utf-8" : "application/javascript; charset=utf-8",
574
+ deploymentId: "deploy-too-many-bytes",
575
+ }));
576
+ await t.mutation(internal.lib.stageAssets, { assets });
577
+
578
+ await expect(
579
+ t.mutation(internal.lib.publishDeployment, {
580
+ currentDeploymentId: "deploy-too-many-bytes",
581
+ expectedAssetCount: assets.length,
582
+ }),
583
+ ).rejects.toThrow("above the safe limit");
584
+ });
585
+
586
+ test("rejects an oversized combined legacy and new manifest safely", async () => {
587
+ const t = initConvexTest();
588
+ await t.run(async (ctx) => {
589
+ await Promise.all(
590
+ Array.from({ length: 2001 }, (_, index) =>
591
+ ctx.db.insert("staticAssets", {
592
+ path: `/legacy-${index}.js`,
593
+ blobId: `legacy-${index}`,
594
+ contentType: "application/javascript; charset=utf-8",
595
+ deploymentId: "deploy-legacy",
596
+ }),
597
+ ),
598
+ );
599
+ });
600
+ const assets = Array.from({ length: 1800 }, (_, index) => ({
601
+ path: index === 0 ? "/index.html" : `/new-${index}.js`,
602
+ blobId: `new-${index}`,
603
+ contentType:
604
+ index === 0 ? "text/html; charset=utf-8" : "application/javascript; charset=utf-8",
605
+ deploymentId: "deploy-new",
606
+ }));
607
+ await t.mutation(internal.lib.stageAssets, { assets });
608
+
609
+ await expect(
610
+ t.mutation(internal.lib.publishDeployment, {
611
+ currentDeploymentId: "deploy-new",
612
+ expectedAssetCount: assets.length,
613
+ }),
614
+ ).rejects.toThrow("more than 3800 rows combined");
615
+ });
616
+
617
+ test("rejects a truncated staged manifest even when index.html remains", async () => {
618
+ const t = initConvexTest();
619
+ await t.mutation(internal.lib.recordAsset, {
620
+ path: "/index.html",
621
+ blobId: "still-live",
622
+ contentType: "text/html; charset=utf-8",
623
+ deploymentId: "deploy-old",
624
+ });
625
+ await t.mutation(internal.lib.stageAssets, {
626
+ assets: [
627
+ {
628
+ path: "/app.js",
629
+ blobId: "app",
630
+ contentType: "application/javascript; charset=utf-8",
631
+ deploymentId: "deploy-truncated",
632
+ },
633
+ {
634
+ path: "/index.html",
635
+ blobId: "index",
636
+ contentType: "text/html; charset=utf-8",
637
+ deploymentId: "deploy-truncated",
638
+ },
639
+ ],
640
+ });
641
+ await t.run(async (ctx) => {
642
+ const stagedAssets = await ctx.db
643
+ .query("stagedAssets")
644
+ .withIndex("by_deploymentId", (q) => q.eq("deploymentId", "deploy-truncated"))
645
+ .collect();
646
+ const appAsset = stagedAssets.find((asset) => asset.path === "/app.js");
647
+ if (!appAsset) throw new Error("Expected staged app asset");
648
+ await ctx.db.delete("stagedAssets", appAsset._id);
649
+ });
650
+
651
+ await expect(
652
+ t.mutation(internal.lib.publishDeployment, {
653
+ currentDeploymentId: "deploy-truncated",
654
+ expectedAssetCount: 2,
655
+ }),
656
+ ).rejects.toThrow("Staged manifest has 1 files; expected 2");
657
+ expect(await t.query(internal.lib.getByPath, { path: "/index.html" })).toMatchObject({
658
+ blobId: "still-live",
659
+ deploymentId: "deploy-old",
660
+ });
661
+ });
662
+
663
+ test("removes old unreferenced component storage after an interrupted upload", async () => {
664
+ const t = initConvexTest();
665
+ const orphan = await t.run(async (ctx) => {
666
+ return await ctx.storage.store(new Blob(["orphan"]));
667
+ });
668
+
669
+ const result = await t.mutation(internal.lib.cleanupUnreferencedStorage, {
670
+ before: Date.now() + 1,
671
+ limit: 10,
672
+ });
673
+
674
+ expect(result.deleted).toBe(1);
675
+ await t.run(async (ctx) => {
676
+ expect(await ctx.storage.get(orphan)).toBeNull();
677
+ });
678
+ });
679
+
680
+ test("recovers an abandoned staged manifest and queues its CDN blob", async () => {
681
+ const t = initConvexTest();
682
+ const storageId = await t.run(async (ctx) => {
683
+ return await ctx.storage.store(new Blob(["abandoned"]));
684
+ });
685
+ await t.mutation(internal.lib.stageAssets, {
686
+ assets: [
687
+ {
688
+ path: "/index.html",
689
+ storageId,
690
+ contentType: "text/html; charset=utf-8",
691
+ deploymentId: "deploy-abandoned",
692
+ },
693
+ {
694
+ path: "/app.js",
695
+ blobId: "abandoned-cdn-blob",
696
+ contentType: "application/javascript; charset=utf-8",
697
+ deploymentId: "deploy-abandoned",
698
+ },
699
+ ],
700
+ });
701
+
702
+ expect(
703
+ await t.mutation(internal.lib.cleanupAbandonedStaging, {
704
+ before: Date.now() + 1,
705
+ }),
706
+ ).toEqual({ discarded: 2, deletedFiles: 1, queuedBlobIds: 1 });
707
+ expect(await t.query(internal.lib.listPendingBlobCleanup, {})).toEqual(["abandoned-cdn-blob"]);
708
+ expect(
709
+ await t.mutation(internal.lib.discardStagedDeployment, {
710
+ deploymentId: "deploy-abandoned",
711
+ }),
712
+ ).toEqual({ discarded: 0 });
713
+ await t.run(async (ctx) => {
714
+ expect(await ctx.storage.get(storageId)).toBeNull();
715
+ });
716
+ });
717
+
718
+ test("recordAssets batches multiple inserts", async () => {
719
+ const t = initConvexTest();
720
+
721
+ const result = await t.mutation(internal.lib.recordAssets, {
722
+ assets: [
723
+ {
724
+ path: "/a.js",
725
+ blobId: "blob-a",
726
+ contentType: "application/javascript; charset=utf-8",
727
+ deploymentId: "deploy-1",
728
+ },
729
+ {
730
+ path: "/b.css",
731
+ blobId: "blob-b",
732
+ contentType: "text/css; charset=utf-8",
733
+ deploymentId: "deploy-1",
734
+ },
735
+ ],
736
+ });
737
+
738
+ expect(result).toEqual({ blobIds: [] });
739
+ const all = await t.query(internal.lib.listAssets, {});
740
+ expect(all).toHaveLength(2);
741
+ });
742
+
743
+ test("recordAssets returns replaced CDN blobs for cleanup", async () => {
744
+ const t = initConvexTest();
745
+ await t.mutation(internal.lib.recordAsset, {
746
+ path: "/app.js",
747
+ blobId: "old-blob",
748
+ contentType: "application/javascript; charset=utf-8",
749
+ deploymentId: "deploy-old",
750
+ });
751
+
752
+ const storageId = await t.run(async (ctx) => {
753
+ return await ctx.storage.store(new Blob(["new"]));
754
+ });
755
+ const result = await t.mutation(internal.lib.recordAssets, {
756
+ assets: [
757
+ {
758
+ path: "/app.js",
759
+ storageId,
760
+ contentType: "application/javascript; charset=utf-8",
761
+ deploymentId: "deploy-new",
762
+ },
763
+ ],
764
+ });
765
+
766
+ expect(result).toEqual({ blobIds: ["old-blob"] });
767
+ const current = await t.query(internal.lib.getByPath, { path: "/app.js" });
768
+ expect(current?.storageId).toBe(storageId);
769
+ expect(current?.blobId).toBeUndefined();
770
+ });
771
+
772
+ test("recordAssets rejects ambiguous locations and duplicate paths", async () => {
773
+ const t = initConvexTest();
774
+
775
+ await expect(
776
+ t.mutation(internal.lib.recordAssets, {
777
+ assets: [
778
+ {
779
+ path: "/missing-location.js",
780
+ contentType: "application/javascript; charset=utf-8",
781
+ deploymentId: "deploy-1",
782
+ },
783
+ ],
784
+ }),
785
+ ).rejects.toThrow("exactly one of storageId or blobId");
786
+
787
+ await expect(
788
+ t.mutation(internal.lib.recordAssets, {
789
+ assets: [
790
+ {
791
+ path: "/duplicate.js",
792
+ blobId: "blob-a",
793
+ contentType: "application/javascript; charset=utf-8",
794
+ deploymentId: "deploy-1",
795
+ },
796
+ {
797
+ path: "/duplicate.js",
798
+ blobId: "blob-b",
799
+ contentType: "application/javascript; charset=utf-8",
800
+ deploymentId: "deploy-1",
801
+ },
802
+ ],
803
+ }),
804
+ ).rejects.toThrow("Duplicate asset path");
805
+ });
806
+
807
+ describe("replaced files", () => {
808
+ const sevenDays = 7 * 24 * 60 * 60 * 1000;
809
+
810
+ async function publish(
811
+ t: ReturnType<typeof initConvexTest>,
812
+ deploymentId: string,
813
+ paths: string[],
814
+ ) {
815
+ const assets = await t.run(async (ctx) => {
816
+ const result = [];
817
+ for (const path of ["/index.html", ...paths]) {
818
+ result.push({
819
+ path,
820
+ storageId: await ctx.storage.store(new Blob([deploymentId + path])),
821
+ contentType: path.endsWith(".html")
822
+ ? "text/html; charset=utf-8"
823
+ : "application/javascript; charset=utf-8",
824
+ deploymentId,
825
+ });
826
+ }
827
+ return result;
828
+ });
829
+ await t.mutation(internal.lib.stageAssets, { assets });
830
+ await t.mutation(internal.lib.publishDeployment, {
831
+ currentDeploymentId: deploymentId,
832
+ expectedAssetCount: assets.length,
833
+ });
834
+ return new Map(assets.map((asset) => [asset.path, asset.storageId]));
835
+ }
836
+
837
+ test("serves a replaced file, but never replaced HTML, for seven days", async () => {
838
+ const t = initConvexTest();
839
+ const a = await publish(t, "deploy-a", ["/assets/app-A1b2C3d4.js", "/about.html"]);
840
+ const b = await publish(t, "deploy-b", ["/assets/app-E5f6G7h8.js"]);
841
+
842
+ const resolve = (path: string) => t.query(api.lib.resolveAssetForHttp, { path });
843
+ expect(await resolve("/assets/app-A1b2C3d4.js")).toMatchObject({
844
+ etag: `"${a.get("/assets/app-A1b2C3d4.js")}"`,
845
+ });
846
+ expect(await resolve("/about.html")).toBeNull();
847
+ // Paths without an extension still get the current SPA shell.
848
+ expect(await resolve("/dashboard")).toMatchObject({
849
+ etag: `"${b.get("/index.html")}"`,
850
+ });
851
+
852
+ vi.advanceTimersByTime(sevenDays);
853
+ await t.mutation(internal.lib.cleanupPendingStorage, {});
854
+ expect(await resolve("/assets/app-A1b2C3d4.js")).toBeNull();
855
+ await t.run(async (ctx) => {
856
+ expect(await ctx.storage.get(a.get("/assets/app-A1b2C3d4.js")!)).toBeNull();
857
+ });
858
+ });
859
+
860
+ test("no cleanup deletes a replaced file before it expires", async () => {
861
+ const t = initConvexTest();
862
+ const a = await publish(t, "deploy-a", ["/assets/app-A1b2C3d4.js"]);
863
+ await publish(t, "deploy-b", []);
864
+ const retired = a.get("/assets/app-A1b2C3d4.js")!;
865
+ // Old enough for every cleanup to consider it, young enough to keep.
866
+ vi.advanceTimersByTime(2 * 24 * 60 * 60 * 1000);
867
+
868
+ await t.mutation(internal.lib.cleanupPendingStorage, {});
869
+ await t.mutation(internal.lib.cleanupUnreferencedStorage, {});
870
+ expect(
871
+ await t.mutation(internal.lib.deleteUploadedFiles, {
872
+ storageIds: [retired],
873
+ }),
874
+ ).toMatchObject({ stillReferenced: 1 });
875
+
876
+ await t.run(async (ctx) => {
877
+ expect(await ctx.storage.get(retired)).not.toBeNull();
878
+ });
879
+ expect(
880
+ await t.query(api.lib.resolveAssetForHttp, {
881
+ path: "/assets/app-A1b2C3d4.js",
882
+ }),
883
+ ).not.toBeNull();
884
+ });
885
+ });
886
+
887
+ describe("resolveAsset", () => {
888
+ async function seedIndex(t: ReturnType<typeof initConvexTest>) {
889
+ await t.mutation(internal.lib.recordAsset, {
890
+ path: "/index.html",
891
+ blobId: "blob-index",
892
+ contentType: "text/html; charset=utf-8",
893
+ deploymentId: "deploy-1",
894
+ });
895
+ }
896
+
897
+ test("returns the exact match when present", async () => {
898
+ const t = initConvexTest();
899
+ await t.mutation(internal.lib.recordAsset, {
900
+ path: "/assets/app-B71cUw87.js",
901
+ blobId: "blob-app",
902
+ contentType: "application/javascript; charset=utf-8",
903
+ deploymentId: "deploy-1",
904
+ });
905
+
906
+ const asset = await t.query(internal.lib.resolveAsset, {
907
+ path: "/assets/app-B71cUw87.js",
908
+ });
909
+ expect(asset?.path).toBe("/assets/app-B71cUw87.js");
910
+ });
911
+
912
+ test("falls back to index.html for extension-less misses by default", async () => {
913
+ const t = initConvexTest();
914
+ await seedIndex(t);
915
+
916
+ const asset = await t.query(internal.lib.resolveAsset, {
917
+ path: "/dashboard/settings",
918
+ });
919
+ expect(asset?.path).toBe("/index.html");
920
+ });
921
+
922
+ test("does not fall back for paths with an extension", async () => {
923
+ const t = initConvexTest();
924
+ await seedIndex(t);
925
+
926
+ const asset = await t.query(internal.lib.resolveAsset, {
927
+ path: "/missing.js",
928
+ });
929
+ expect(asset).toBeNull();
930
+ });
931
+
932
+ test("does not fall back when the deployment disables it", async () => {
933
+ const t = initConvexTest();
934
+ await seedIndex(t);
935
+ await t.mutation(internal.lib.commitDeployment, {
936
+ currentDeploymentId: "deploy-1",
937
+ spaFallback: false,
938
+ });
939
+
940
+ const asset = await t.query(internal.lib.resolveAsset, {
941
+ path: "/dashboard",
942
+ });
943
+ expect(asset).toBeNull();
944
+ });
945
+
946
+ test("returns a storage URL for app-owned HTTP routes", async () => {
947
+ const t = initConvexTest();
948
+ const storageId = await t.run(async (ctx) => {
949
+ return await ctx.storage.store(new Blob(["<h1>Hello</h1>"], { type: "text/html" }));
950
+ });
951
+ await t.mutation(internal.lib.recordAsset, {
952
+ path: "/index.html",
953
+ storageId,
954
+ contentType: "text/html; charset=utf-8",
955
+ deploymentId: "deploy-1",
956
+ });
957
+
958
+ const asset = await t.query(api.lib.resolveAssetForHttp, {
959
+ path: "/index.html",
960
+ });
961
+
962
+ expect(asset?.storageUrl).toContain("http");
963
+ expect(asset?.etag).toBe(`"${storageId}"`);
964
+ expect(asset?.contentType).toBe("text/html; charset=utf-8");
965
+ });
966
+
967
+ test("surfaces the app-owned storage id for inherited v1 rows", async () => {
968
+ // A same-name v1→v2 migration inherits rows whose files live in the app's
969
+ // storage; the component can't resolve a URL for them. convex-test can't
970
+ // distinguish that from a deleted file, so deleting one reproduces the
971
+ // condition: resolveAssetForHttp surfaces appStorageId (no storageUrl) so
972
+ // the app-side handler can serve it from its own storage during migration.
973
+ const t = initConvexTest();
974
+ const storageId = await t.run(async (ctx) => {
975
+ return await ctx.storage.store(new Blob(["legacy"]));
976
+ });
977
+ await t.mutation(internal.lib.recordAsset, {
978
+ path: "/index.html",
979
+ storageId,
980
+ contentType: "text/html; charset=utf-8",
981
+ deploymentId: "deploy-old",
982
+ });
983
+ await t.run(async (ctx) => {
984
+ await ctx.storage.delete(storageId);
985
+ });
986
+
987
+ const asset = await t.query(api.lib.resolveAssetForHttp, {
988
+ path: "/index.html",
989
+ });
990
+ expect(asset?.appStorageId).toBe(storageId);
991
+ expect(asset?.storageUrl).toBeUndefined();
992
+ expect(asset?.etag).toBe(`"${storageId}"`);
993
+ });
994
+
995
+ test("allows compatibility routes to override SPA fallback", async () => {
996
+ const t = initConvexTest();
997
+ await seedIndex(t);
998
+
999
+ const asset = await t.query(api.lib.resolveAssetForHttp, {
1000
+ path: "/dashboard",
1001
+ spaFallback: false,
1002
+ });
1003
+
1004
+ expect(asset).toBeNull();
1005
+ });
1006
+ });
1007
+ });