@samebase/convex-hosting 0.0.0-stage → 0.3.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 (122) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/INTEGRATION.md +399 -0
  3. package/LICENSE +201 -0
  4. package/NOTICE +7 -0
  5. package/README.md +471 -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/collectFiles.d.ts +10 -0
  15. package/dist/cli/collectFiles.d.ts.map +1 -0
  16. package/dist/cli/collectFiles.js +53 -0
  17. package/dist/cli/collectFiles.js.map +1 -0
  18. package/dist/cli/commands.d.ts +8 -0
  19. package/dist/cli/commands.d.ts.map +1 -0
  20. package/dist/cli/commands.js +56 -0
  21. package/dist/cli/commands.js.map +1 -0
  22. package/dist/cli/componentName.d.ts +32 -0
  23. package/dist/cli/componentName.d.ts.map +1 -0
  24. package/dist/cli/componentName.js +43 -0
  25. package/dist/cli/componentName.js.map +1 -0
  26. package/dist/cli/deploy.d.ts +16 -0
  27. package/dist/cli/deploy.d.ts.map +1 -0
  28. package/dist/cli/deploy.js +283 -0
  29. package/dist/cli/deploy.js.map +1 -0
  30. package/dist/cli/deployEnvironment.d.ts +10 -0
  31. package/dist/cli/deployEnvironment.d.ts.map +1 -0
  32. package/dist/cli/deployEnvironment.js +12 -0
  33. package/dist/cli/deployEnvironment.js.map +1 -0
  34. package/dist/cli/index.d.ts +12 -0
  35. package/dist/cli/index.d.ts.map +1 -0
  36. package/dist/cli/index.js +60 -0
  37. package/dist/cli/index.js.map +1 -0
  38. package/dist/cli/upload.d.ts +22 -0
  39. package/dist/cli/upload.d.ts.map +1 -0
  40. package/dist/cli/upload.js +630 -0
  41. package/dist/cli/upload.js.map +1 -0
  42. package/dist/client/_generated/_ignore.d.ts +1 -0
  43. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  44. package/dist/client/_generated/_ignore.js +3 -0
  45. package/dist/client/_generated/_ignore.js.map +1 -0
  46. package/dist/client/index.d.ts +119 -0
  47. package/dist/client/index.d.ts.map +1 -0
  48. package/dist/client/index.js +272 -0
  49. package/dist/client/index.js.map +1 -0
  50. package/dist/component/_generated/api.d.ts +38 -0
  51. package/dist/component/_generated/api.d.ts.map +1 -0
  52. package/dist/component/_generated/api.js +31 -0
  53. package/dist/component/_generated/api.js.map +1 -0
  54. package/dist/component/_generated/component.d.ts +43 -0
  55. package/dist/component/_generated/component.d.ts.map +1 -0
  56. package/dist/component/_generated/component.js +11 -0
  57. package/dist/component/_generated/component.js.map +1 -0
  58. package/dist/component/_generated/dataModel.d.ts +46 -0
  59. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  60. package/dist/component/_generated/dataModel.js +11 -0
  61. package/dist/component/_generated/dataModel.js.map +1 -0
  62. package/dist/component/_generated/server.d.ts +121 -0
  63. package/dist/component/_generated/server.d.ts.map +1 -0
  64. package/dist/component/_generated/server.js +78 -0
  65. package/dist/component/_generated/server.js.map +1 -0
  66. package/dist/component/convex.config.d.ts +3 -0
  67. package/dist/component/convex.config.d.ts.map +1 -0
  68. package/dist/component/convex.config.js +3 -0
  69. package/dist/component/convex.config.js.map +1 -0
  70. package/dist/component/http.d.ts +4 -0
  71. package/dist/component/http.d.ts.map +1 -0
  72. package/dist/component/http.js +125 -0
  73. package/dist/component/http.js.map +1 -0
  74. package/dist/component/lib.d.ts +164 -0
  75. package/dist/component/lib.d.ts.map +1 -0
  76. package/dist/component/lib.js +768 -0
  77. package/dist/component/lib.js.map +1 -0
  78. package/dist/component/schema.d.ts +84 -0
  79. package/dist/component/schema.d.ts.map +1 -0
  80. package/dist/component/schema.js +69 -0
  81. package/dist/component/schema.js.map +1 -0
  82. package/dist/component/serving.d.ts +17 -0
  83. package/dist/component/serving.d.ts.map +1 -0
  84. package/dist/component/serving.js +150 -0
  85. package/dist/component/serving.js.map +1 -0
  86. package/dist/react/index.d.ts +59 -0
  87. package/dist/react/index.d.ts.map +1 -0
  88. package/dist/react/index.js +137 -0
  89. package/dist/react/index.js.map +1 -0
  90. package/package.json +78 -3
  91. package/src/cli/args.test.ts +79 -0
  92. package/src/cli/args.ts +153 -0
  93. package/src/cli/argumentChunks.test.ts +27 -0
  94. package/src/cli/argumentChunks.ts +40 -0
  95. package/src/cli/collectFiles.test.ts +26 -0
  96. package/src/cli/collectFiles.ts +59 -0
  97. package/src/cli/commands.ts +68 -0
  98. package/src/cli/componentName.test.ts +41 -0
  99. package/src/cli/componentName.ts +46 -0
  100. package/src/cli/deploy.ts +336 -0
  101. package/src/cli/deployEnvironment.test.ts +27 -0
  102. package/src/cli/deployEnvironment.ts +17 -0
  103. package/src/cli/index.ts +65 -0
  104. package/src/cli/upload.ts +837 -0
  105. package/src/client/_generated/_ignore.ts +1 -0
  106. package/src/client/index.test.ts +510 -0
  107. package/src/client/index.ts +355 -0
  108. package/src/client/setup.test.ts +22 -0
  109. package/src/component/_generated/api.ts +54 -0
  110. package/src/component/_generated/component.ts +55 -0
  111. package/src/component/_generated/dataModel.ts +60 -0
  112. package/src/component/_generated/server.ts +156 -0
  113. package/src/component/convex.config.ts +3 -0
  114. package/src/component/http.test.ts +223 -0
  115. package/src/component/http.ts +144 -0
  116. package/src/component/lib.test.ts +1007 -0
  117. package/src/component/lib.ts +862 -0
  118. package/src/component/schema.ts +75 -0
  119. package/src/component/serving.ts +158 -0
  120. package/src/component/setup.test.ts +11 -0
  121. package/src/react/index.tsx +197 -0
  122. package/src/test.ts +23 -0
@@ -0,0 +1,768 @@
1
+ import { v } from "convex/values";
2
+ import { internalMutation, internalQuery, query, } from "./_generated/server.js";
3
+ import { hasFileExtension, isHtmlContentType } from "./serving.js";
4
+ const staticAssetValidator = v.object({
5
+ _id: v.id("staticAssets"),
6
+ _creationTime: v.number(),
7
+ path: v.string(),
8
+ storageId: v.optional(v.id("_storage")),
9
+ blobId: v.optional(v.string()),
10
+ contentType: v.string(),
11
+ deploymentId: v.string(),
12
+ });
13
+ const resolvedAssetValidator = v.union(staticAssetValidator, staticAssetValidator.omit("_id").extend({
14
+ _id: v.id("retiredAssets"),
15
+ storageId: v.id("_storage"),
16
+ expiresAt: v.number(),
17
+ }));
18
+ const deploymentInfoValidator = v.object({
19
+ _id: v.id("deploymentInfo"),
20
+ _creationTime: v.number(),
21
+ currentDeploymentId: v.string(),
22
+ deployedAt: v.number(),
23
+ spaFallback: v.optional(v.boolean()),
24
+ pendingBlobCleanupCount: v.optional(v.number()),
25
+ });
26
+ const MAX_ASSETS_PER_DEPLOYMENT = 1800;
27
+ const MAX_PUBLISH_MANIFEST_READS = 3800;
28
+ const MAX_PUBLISH_DOCUMENT_WRITES = 15500;
29
+ const MAX_MANIFEST_SERIALIZED_BYTES = 2 * 1024 * 1024;
30
+ const MAINTENANCE_PAGE_LIMIT = 256;
31
+ // The live manifest plus this scan is at most 3,800 documents. Remaining
32
+ // staged lookups use an explicit budget below Convex's 4,096 read limit.
33
+ const STORAGE_SCAN_LIMIT = 2000;
34
+ const ABANDONED_UPLOAD_AGE_MS = 24 * 60 * 60 * 1000;
35
+ const RETIRED_ASSET_AGE_MS = 7 * 24 * 60 * 60 * 1000;
36
+ const httpAssetValidator = v.object({
37
+ storageUrl: v.optional(v.string()),
38
+ blobId: v.optional(v.string()),
39
+ contentType: v.string(),
40
+ etag: v.optional(v.string()),
41
+ // TODO(remove in a future major): v1→v2 transitional only. Set when an
42
+ // inherited v1 row points at app-owned storage the component can't read, so
43
+ // the app-side `registerStaticRoutes` handler can serve it from its own
44
+ // storage during migration. Drop this field once v1 app-storage assets are
45
+ // no longer supported.
46
+ appStorageId: v.optional(v.string()),
47
+ });
48
+ async function resolveAssetDocument(ctx, path, spaFallbackOverride) {
49
+ const exact = await ctx.db
50
+ .query("staticAssets")
51
+ .withIndex("by_path", (q) => q.eq("path", path))
52
+ .unique();
53
+ if (exact)
54
+ return exact;
55
+ if (hasFileExtension(path)) {
56
+ // A file the current deployment replaced stays reachable for a while, so
57
+ // pages that are already open can still load their scripts. Old HTML never
58
+ // comes back.
59
+ const retired = await ctx.db
60
+ .query("retiredAssets")
61
+ .withIndex("by_path", (q) => q.eq("path", path))
62
+ .order("desc")
63
+ .first();
64
+ if (retired && !isHtmlContentType(retired.contentType))
65
+ return retired;
66
+ return null;
67
+ }
68
+ const info = await ctx.db.query("deploymentInfo").first();
69
+ const spaFallback = spaFallbackOverride ?? info?.spaFallback ?? true;
70
+ if (!spaFallback)
71
+ return null;
72
+ return await ctx.db
73
+ .query("staticAssets")
74
+ .withIndex("by_path", (q) => q.eq("path", "/index.html"))
75
+ .unique();
76
+ }
77
+ async function deleteStorageFile(ctx, storageId) {
78
+ const retiredReference = await ctx.db
79
+ .query("retiredAssets")
80
+ .withIndex("by_storageId", (q) => q.eq("storageId", storageId))
81
+ .first();
82
+ if (retiredReference)
83
+ return false;
84
+ // A v2 component mounted with the old `selfHosting` name can inherit v1 rows
85
+ // containing app-owned storage IDs. They are deliberately invisible in the
86
+ // component namespace. Checking metadata avoids relying on backend error text
87
+ // and treats both those foreign IDs and already-deleted files as absent.
88
+ const metadata = await ctx.db.system.get("_storage", storageId);
89
+ if (metadata === null)
90
+ return false;
91
+ await ctx.storage.delete(storageId);
92
+ return true;
93
+ }
94
+ async function adjustPendingBlobCleanupCount(ctx, delta) {
95
+ const state = await ctx.db.query("cleanupState").first();
96
+ if (state) {
97
+ const next = Math.max(0, state.pendingBlobCleanupCount + delta);
98
+ await ctx.db.patch("cleanupState", state._id, {
99
+ pendingBlobCleanupCount: next,
100
+ });
101
+ return next;
102
+ }
103
+ // Seed upgrades from the old denormalized field when present. On a fresh
104
+ // install this also works before deploymentInfo exists.
105
+ const info = await ctx.db.query("deploymentInfo").first();
106
+ const next = Math.max(0, (info?.pendingBlobCleanupCount ?? 0) + delta);
107
+ await ctx.db.insert("cleanupState", { pendingBlobCleanupCount: next });
108
+ return next;
109
+ }
110
+ export const getCurrentDeployment = query({
111
+ args: {},
112
+ returns: v.union(deploymentInfoValidator, v.null()),
113
+ handler: async (ctx) => {
114
+ const info = await ctx.db.query("deploymentInfo").first();
115
+ if (!info)
116
+ return null;
117
+ const state = await ctx.db.query("cleanupState").first();
118
+ return {
119
+ ...info,
120
+ pendingBlobCleanupCount: state?.pendingBlobCleanupCount ?? info.pendingBlobCleanupCount,
121
+ };
122
+ },
123
+ });
124
+ // Narrow serving API used by the app-owned `registerStaticRoutes` compatibility
125
+ // mode. The app can resolve a public asset, but all storage and deployment
126
+ // management stays encapsulated in the component.
127
+ export const resolveAssetForHttp = query({
128
+ args: {
129
+ path: v.string(),
130
+ spaFallback: v.optional(v.boolean()),
131
+ },
132
+ returns: v.union(httpAssetValidator, v.null()),
133
+ handler: async (ctx, { path, spaFallback }) => {
134
+ const asset = await resolveAssetDocument(ctx, path, spaFallback);
135
+ if (!asset)
136
+ return null;
137
+ const storageUrl = asset.storageId ? await ctx.storage.getUrl(asset.storageId) : null;
138
+ // TODO(remove in a future major): v1→v2 transitional path. During a
139
+ // same-name migration, inherited v1 rows point at app-owned storage the
140
+ // component can't fetch (storageId present but no resolvable URL and no
141
+ // blob). Rather than hide them (which shows the setup page until the first
142
+ // v2 upload), surface the raw storage id so the app-side
143
+ // `registerStaticRoutes` handler can serve the file from its own storage —
144
+ // keeping the live site up with zero downtime and no re-upload. The
145
+ // component-owned HTTP handler still can't reach app storage, so it
146
+ // continues to show its setup page for this case. Once v1 app-storage
147
+ // assets are no longer supported, delete `appStorageId` and return null
148
+ // here instead.
149
+ const appStorageId = asset.storageId && !storageUrl && !asset.blobId ? asset.storageId : undefined;
150
+ return {
151
+ ...(storageUrl ? { storageUrl } : {}),
152
+ ...(appStorageId ? { appStorageId } : {}),
153
+ ...(asset.blobId ? { blobId: asset.blobId } : {}),
154
+ contentType: asset.contentType,
155
+ ...(asset.storageId ? { etag: `"${asset.storageId}"` } : {}),
156
+ };
157
+ },
158
+ });
159
+ // Returns the deployment URLs visible to this component:
160
+ // siteUrl - CONVEX_SITE_URL (includes the component's mount prefix). Used
161
+ // by the CLI to derive CONVEX_HOSTING_BASE_PATH and to show
162
+ // where the deployed app lives.
163
+ // cloudUrl - CONVEX_CLOUD_URL. Used by the CLI as VITE_CONVEX_URL when
164
+ // building the frontend.
165
+ export const getUrls = internalQuery({
166
+ args: {},
167
+ returns: v.object({
168
+ siteUrl: v.string(),
169
+ cloudUrl: v.string(),
170
+ }),
171
+ handler: async () => ({
172
+ siteUrl: process.env.CONVEX_SITE_URL,
173
+ cloudUrl: process.env.CONVEX_CLOUD_URL,
174
+ }),
175
+ });
176
+ export const getByPath = internalQuery({
177
+ args: { path: v.string() },
178
+ returns: v.union(staticAssetValidator, v.null()),
179
+ handler: async (ctx, args) => {
180
+ return await ctx.db
181
+ .query("staticAssets")
182
+ .withIndex("by_path", (q) => q.eq("path", args.path))
183
+ .unique();
184
+ },
185
+ });
186
+ // Resolves the asset the HTTP handler should serve for a request path: the
187
+ // exact match, or, when SPA fallback is enabled for the current deployment
188
+ // and the path looks like a client-side route (no file extension), the
189
+ // index.html asset. Doing the fallback here keeps it to a single query.
190
+ export const resolveAsset = internalQuery({
191
+ args: { path: v.string() },
192
+ returns: v.union(resolvedAssetValidator, v.null()),
193
+ handler: async (ctx, { path }) => {
194
+ return await resolveAssetDocument(ctx, path);
195
+ },
196
+ });
197
+ export const listAssets = internalQuery({
198
+ args: { limit: v.optional(v.number()) },
199
+ returns: v.array(staticAssetValidator),
200
+ handler: async (ctx, args) => {
201
+ return await ctx.db
202
+ .query("staticAssets")
203
+ .order("asc")
204
+ .take(args.limit ?? 100);
205
+ },
206
+ });
207
+ export const generateUploadUrl = internalMutation({
208
+ args: {},
209
+ returns: v.string(),
210
+ handler: async (ctx) => {
211
+ return await ctx.storage.generateUploadUrl();
212
+ },
213
+ });
214
+ export const generateUploadUrls = internalMutation({
215
+ args: { count: v.number() },
216
+ returns: v.array(v.string()),
217
+ handler: async (ctx, { count }) => {
218
+ if (!Number.isInteger(count) || count < 1 || count > 100) {
219
+ throw new Error("Upload URL batch size must be an integer from 1 to 100");
220
+ }
221
+ const urls = [];
222
+ for (let i = 0; i < count; i++) {
223
+ urls.push(await ctx.storage.generateUploadUrl());
224
+ }
225
+ return urls;
226
+ },
227
+ });
228
+ export const listPendingBlobCleanup = internalQuery({
229
+ args: { limit: v.optional(v.number()) },
230
+ returns: v.array(v.string()),
231
+ handler: async (ctx, { limit }) => {
232
+ return (await ctx.db.query("pendingBlobCleanup").take(Math.min(limit ?? 250, 500))).map((entry) => entry.blobId);
233
+ },
234
+ });
235
+ export const acknowledgeBlobCleanup = internalMutation({
236
+ args: { blobIds: v.array(v.string()) },
237
+ returns: v.object({ acknowledged: v.number() }),
238
+ handler: async (ctx, { blobIds }) => {
239
+ let acknowledged = 0;
240
+ for (const blobId of new Set(blobIds)) {
241
+ const entries = await ctx.db
242
+ .query("pendingBlobCleanup")
243
+ .withIndex("by_blobId", (q) => q.eq("blobId", blobId))
244
+ .collect();
245
+ for (const entry of entries) {
246
+ await ctx.db.delete("pendingBlobCleanup", entry._id);
247
+ acknowledged++;
248
+ }
249
+ }
250
+ if (acknowledged > 0) {
251
+ await adjustPendingBlobCleanupCount(ctx, -acknowledged);
252
+ }
253
+ return { acknowledged };
254
+ },
255
+ });
256
+ export const queueBlobCleanup = internalMutation({
257
+ args: { blobIds: v.array(v.string()) },
258
+ returns: v.object({ queued: v.number() }),
259
+ handler: async (ctx, { blobIds }) => {
260
+ let queued = 0;
261
+ for (const blobId of new Set(blobIds)) {
262
+ const existing = await ctx.db
263
+ .query("pendingBlobCleanup")
264
+ .withIndex("by_blobId", (q) => q.eq("blobId", blobId))
265
+ .first();
266
+ if (existing)
267
+ continue;
268
+ await ctx.db.insert("pendingBlobCleanup", { blobId });
269
+ queued++;
270
+ }
271
+ if (queued > 0)
272
+ await adjustPendingBlobCleanupCount(ctx, queued);
273
+ return { queued };
274
+ },
275
+ });
276
+ export const cleanupPendingStorage = internalMutation({
277
+ args: { limit: v.optional(v.number()) },
278
+ returns: v.object({
279
+ processed: v.number(),
280
+ deleted: v.number(),
281
+ needsAnotherPass: v.boolean(),
282
+ }),
283
+ handler: async (ctx, { limit }) => {
284
+ const batchLimit = Math.min(limit ?? MAINTENANCE_PAGE_LIMIT, MAINTENANCE_PAGE_LIMIT);
285
+ const expired = await ctx.db
286
+ .query("retiredAssets")
287
+ .withIndex("by_expiresAt", (q) => q.lte("expiresAt", Date.now()))
288
+ .take(batchLimit);
289
+ for (const asset of expired) {
290
+ await ctx.db.insert("pendingStorageCleanup", {
291
+ storageId: asset.storageId,
292
+ });
293
+ await ctx.db.delete("retiredAssets", asset._id);
294
+ }
295
+ const entries = await ctx.db.query("pendingStorageCleanup").take(batchLimit);
296
+ let deleted = 0;
297
+ for (const entry of entries) {
298
+ const [liveReference, stagedReference] = await Promise.all([
299
+ ctx.db
300
+ .query("staticAssets")
301
+ .withIndex("by_storageId", (q) => q.eq("storageId", entry.storageId))
302
+ .first(),
303
+ ctx.db
304
+ .query("stagedAssets")
305
+ .withIndex("by_storageId", (q) => q.eq("storageId", entry.storageId))
306
+ .first(),
307
+ ]);
308
+ if (!liveReference && !stagedReference && (await deleteStorageFile(ctx, entry.storageId))) {
309
+ deleted++;
310
+ }
311
+ await ctx.db.delete("pendingStorageCleanup", entry._id);
312
+ }
313
+ return {
314
+ processed: entries.length,
315
+ deleted,
316
+ needsAnotherPass: entries.length === batchLimit || expired.length === batchLimit,
317
+ };
318
+ },
319
+ });
320
+ export const deleteUploadedFiles = internalMutation({
321
+ args: { storageIds: v.array(v.id("_storage")) },
322
+ returns: v.object({
323
+ deleted: v.number(),
324
+ alreadyMissing: v.number(),
325
+ stillReferenced: v.number(),
326
+ }),
327
+ handler: async (ctx, { storageIds }) => {
328
+ // A publish can commit even if the CLI loses its response. Never delete a
329
+ // file that the live manifest now references during failure cleanup.
330
+ const liveAssets = await ctx.db.query("staticAssets").collect();
331
+ const referencedStorageIds = new Set(liveAssets.flatMap((asset) => (asset.storageId === undefined ? [] : [asset.storageId])));
332
+ let deleted = 0;
333
+ let alreadyMissing = 0;
334
+ let stillReferenced = 0;
335
+ for (const storageId of storageIds) {
336
+ const retiredReference = await ctx.db
337
+ .query("retiredAssets")
338
+ .withIndex("by_storageId", (q) => q.eq("storageId", storageId))
339
+ .first();
340
+ if (referencedStorageIds.has(storageId) || retiredReference) {
341
+ stillReferenced++;
342
+ continue;
343
+ }
344
+ if (await deleteStorageFile(ctx, storageId)) {
345
+ deleted++;
346
+ }
347
+ else {
348
+ alreadyMissing++;
349
+ }
350
+ }
351
+ return { deleted, alreadyMissing, stillReferenced };
352
+ },
353
+ });
354
+ const recordAssetFields = {
355
+ path: v.string(),
356
+ storageId: v.optional(v.id("_storage")),
357
+ blobId: v.optional(v.string()),
358
+ contentType: v.string(),
359
+ deploymentId: v.string(),
360
+ };
361
+ function assertAssetLocation({ path, storageId, blobId, }) {
362
+ if ((storageId === undefined) === (blobId === undefined)) {
363
+ throw new Error("Asset " + path + " must have exactly one of storageId or blobId");
364
+ }
365
+ }
366
+ function assertAssetManifest(assets, deploymentId, requireIndex = false) {
367
+ if (requireIndex && assets.length === 0) {
368
+ throw new Error("A static asset manifest cannot be empty");
369
+ }
370
+ if (requireIndex && assets.length > MAX_ASSETS_PER_DEPLOYMENT) {
371
+ throw new Error(`A static asset manifest can contain at most ${MAX_ASSETS_PER_DEPLOYMENT} files`);
372
+ }
373
+ const paths = new Set();
374
+ for (const asset of assets) {
375
+ assertAssetLocation(asset);
376
+ if (paths.has(asset.path)) {
377
+ throw new Error("Duplicate asset path: " + asset.path);
378
+ }
379
+ if (deploymentId !== undefined && asset.deploymentId !== deploymentId) {
380
+ throw new Error("Asset " + asset.path + " has the wrong deploymentId");
381
+ }
382
+ paths.add(asset.path);
383
+ }
384
+ if (requireIndex && !paths.has("/index.html")) {
385
+ throw new Error("A static asset manifest must include /index.html");
386
+ }
387
+ }
388
+ export const stageAssets = internalMutation({
389
+ args: { assets: v.array(v.object(recordAssetFields)) },
390
+ returns: v.object({ staged: v.number() }),
391
+ handler: async (ctx, { assets }) => {
392
+ if (assets.length === 0) {
393
+ throw new Error("Cannot stage an empty asset chunk");
394
+ }
395
+ // Validate locations and deployment IDs within this chunk now. Duplicate
396
+ // paths across chunks are checked again when the full manifest publishes.
397
+ const deploymentId = assets[0].deploymentId;
398
+ for (const asset of assets) {
399
+ assertAssetLocation(asset);
400
+ if (asset.deploymentId !== deploymentId) {
401
+ throw new Error("A staged asset chunk must use one deploymentId");
402
+ }
403
+ // Validate component-storage IDs while the manifest is still split into
404
+ // small CLI chunks. Doing this for the complete manifest during publish
405
+ // would exceed Convex's index-range-read limit on large deployments.
406
+ if (asset.storageId && (await ctx.db.system.get("_storage", asset.storageId)) === null) {
407
+ throw new Error(`Staged storage file is missing for ${asset.path}`);
408
+ }
409
+ await ctx.db.insert("stagedAssets", asset);
410
+ }
411
+ return { staged: assets.length };
412
+ },
413
+ });
414
+ export const discardStagedDeployment = internalMutation({
415
+ args: { deploymentId: v.string() },
416
+ returns: v.object({ discarded: v.number() }),
417
+ handler: async (ctx, { deploymentId }) => {
418
+ const assets = await ctx.db
419
+ .query("stagedAssets")
420
+ .withIndex("by_deploymentId", (q) => q.eq("deploymentId", deploymentId))
421
+ .collect();
422
+ for (const asset of assets) {
423
+ await ctx.db.delete("stagedAssets", asset._id);
424
+ }
425
+ return { discarded: assets.length };
426
+ },
427
+ });
428
+ export const cleanupAbandonedStaging = internalMutation({
429
+ args: {
430
+ before: v.optional(v.number()),
431
+ limit: v.optional(v.number()),
432
+ },
433
+ returns: v.object({
434
+ discarded: v.number(),
435
+ deletedFiles: v.number(),
436
+ queuedBlobIds: v.number(),
437
+ }),
438
+ handler: async (ctx, { before, limit }) => {
439
+ const cutoff = before ?? Date.now() - ABANDONED_UPLOAD_AGE_MS;
440
+ const candidates = await ctx.db
441
+ .query("stagedAssets")
442
+ .order("asc")
443
+ .take(Math.min(limit ?? MAINTENANCE_PAGE_LIMIT, MAINTENANCE_PAGE_LIMIT));
444
+ const assets = candidates.filter((asset) => asset._creationTime < cutoff);
445
+ let deletedFiles = 0;
446
+ let queuedBlobIds = 0;
447
+ for (const asset of assets) {
448
+ if (asset.storageId) {
449
+ const liveReference = await ctx.db
450
+ .query("staticAssets")
451
+ .withIndex("by_storageId", (q) => q.eq("storageId", asset.storageId))
452
+ .first();
453
+ if (!liveReference && (await deleteStorageFile(ctx, asset.storageId))) {
454
+ deletedFiles++;
455
+ }
456
+ }
457
+ if (asset.blobId) {
458
+ await ctx.db.insert("pendingBlobCleanup", { blobId: asset.blobId });
459
+ queuedBlobIds++;
460
+ }
461
+ await ctx.db.delete("stagedAssets", asset._id);
462
+ }
463
+ if (queuedBlobIds > 0) {
464
+ await adjustPendingBlobCleanupCount(ctx, queuedBlobIds);
465
+ }
466
+ return {
467
+ discarded: assets.length,
468
+ deletedFiles,
469
+ queuedBlobIds,
470
+ };
471
+ },
472
+ });
473
+ export const cleanupUnreferencedStorage = internalMutation({
474
+ args: {
475
+ before: v.optional(v.number()),
476
+ limit: v.optional(v.number()),
477
+ order: v.optional(v.union(v.literal("asc"), v.literal("desc"))),
478
+ },
479
+ returns: v.object({
480
+ scanned: v.number(),
481
+ deleted: v.number(),
482
+ needsAnotherPass: v.boolean(),
483
+ }),
484
+ handler: async (ctx, { before, limit, order }) => {
485
+ const cutoff = before ?? Date.now() - ABANDONED_UPLOAD_AGE_MS;
486
+ const scanOrder = order ?? "asc";
487
+ // Read the live set once, then spend the remaining read budget checking
488
+ // staged references for non-live files. At the 1,800-file maximum this can
489
+ // inspect 2,000 storage rows while remaining below 4,096 document reads.
490
+ const [liveAssets, files] = await Promise.all([
491
+ ctx.db.query("staticAssets").collect(),
492
+ ctx.db.system.query("_storage").order(scanOrder).take(STORAGE_SCAN_LIMIT),
493
+ ]);
494
+ const liveStorageIds = new Set(liveAssets.flatMap((asset) => (asset.storageId === undefined ? [] : [asset.storageId])));
495
+ const stagedLookupBudget = Math.max(0, 3900 - liveAssets.length - files.length);
496
+ const deleteLimit = Math.min(limit ?? MAINTENANCE_PAGE_LIMIT, MAINTENANCE_PAGE_LIMIT);
497
+ let deleted = 0;
498
+ let scanned = 0;
499
+ let stagedLookups = 0;
500
+ let stoppedForReadBudget = false;
501
+ for (const file of files) {
502
+ if (deleted >= deleteLimit)
503
+ break;
504
+ if (file._creationTime >= cutoff) {
505
+ if (scanOrder === "asc")
506
+ break;
507
+ continue;
508
+ }
509
+ scanned++;
510
+ if (liveStorageIds.has(file._id))
511
+ continue;
512
+ if (stagedLookups + 2 > stagedLookupBudget) {
513
+ stoppedForReadBudget = true;
514
+ break;
515
+ }
516
+ stagedLookups += 2;
517
+ const stagedReference = await ctx.db
518
+ .query("stagedAssets")
519
+ .withIndex("by_storageId", (q) => q.eq("storageId", file._id))
520
+ .first();
521
+ const retiredReference = await ctx.db
522
+ .query("retiredAssets")
523
+ .withIndex("by_storageId", (q) => q.eq("storageId", file._id))
524
+ .first();
525
+ if (!stagedReference && !retiredReference) {
526
+ await ctx.storage.delete(file._id);
527
+ deleted++;
528
+ }
529
+ }
530
+ return {
531
+ scanned,
532
+ deleted,
533
+ needsAnotherPass: deleted === deleteLimit ||
534
+ // Without deletes, another pass would inspect the same protected prefix.
535
+ (deleted > 0 && (stoppedForReadBudget || files.length === STORAGE_SCAN_LIMIT)),
536
+ };
537
+ },
538
+ });
539
+ async function setCurrentDeployment(ctx, currentDeploymentId, spaFallback, pendingBlobCleanupCount) {
540
+ const existing = await ctx.db.query("deploymentInfo").first();
541
+ if (existing) {
542
+ await ctx.db.patch("deploymentInfo", existing._id, {
543
+ currentDeploymentId,
544
+ deployedAt: Date.now(),
545
+ spaFallback,
546
+ ...(pendingBlobCleanupCount === undefined ? {} : { pendingBlobCleanupCount }),
547
+ });
548
+ }
549
+ else {
550
+ await ctx.db.insert("deploymentInfo", {
551
+ currentDeploymentId,
552
+ deployedAt: Date.now(),
553
+ spaFallback,
554
+ ...(pendingBlobCleanupCount === undefined ? {} : { pendingBlobCleanupCount }),
555
+ });
556
+ }
557
+ }
558
+ export const recordAsset = internalMutation({
559
+ args: recordAssetFields,
560
+ returns: v.union(v.string(), v.null()),
561
+ handler: async (ctx, args) => {
562
+ assertAssetLocation(args);
563
+ const existing = await ctx.db
564
+ .query("staticAssets")
565
+ .withIndex("by_path", (q) => q.eq("path", args.path))
566
+ .unique();
567
+ if (existing) {
568
+ if (existing.storageId) {
569
+ await deleteStorageFile(ctx, existing.storageId);
570
+ }
571
+ await ctx.db.delete("staticAssets", existing._id);
572
+ }
573
+ await ctx.db.insert("staticAssets", {
574
+ path: args.path,
575
+ ...(args.storageId ? { storageId: args.storageId } : {}),
576
+ ...(args.blobId ? { blobId: args.blobId } : {}),
577
+ contentType: args.contentType,
578
+ deploymentId: args.deploymentId,
579
+ });
580
+ return existing?.blobId ?? null;
581
+ },
582
+ });
583
+ export const recordAssets = internalMutation({
584
+ args: { assets: v.array(v.object(recordAssetFields)) },
585
+ returns: v.object({ blobIds: v.array(v.string()) }),
586
+ handler: async (ctx, { assets }) => {
587
+ assertAssetManifest(assets);
588
+ const blobIds = [];
589
+ for (const asset of assets) {
590
+ const existing = await ctx.db
591
+ .query("staticAssets")
592
+ .withIndex("by_path", (q) => q.eq("path", asset.path))
593
+ .unique();
594
+ if (existing) {
595
+ if (existing.storageId) {
596
+ await deleteStorageFile(ctx, existing.storageId);
597
+ }
598
+ if (existing.blobId) {
599
+ blobIds.push(existing.blobId);
600
+ }
601
+ await ctx.db.delete("staticAssets", existing._id);
602
+ }
603
+ await ctx.db.insert("staticAssets", {
604
+ path: asset.path,
605
+ ...(asset.storageId ? { storageId: asset.storageId } : {}),
606
+ ...(asset.blobId ? { blobId: asset.blobId } : {}),
607
+ contentType: asset.contentType,
608
+ deploymentId: asset.deploymentId,
609
+ });
610
+ }
611
+ return { blobIds };
612
+ },
613
+ });
614
+ // Publish the complete manifest, deployment metadata, and old-asset cleanup in
615
+ // one mutation. Until this succeeds, the previous deployment remains wholly
616
+ // live. Failure cleanup separately checks the live manifest before deleting
617
+ // newly uploaded files in case this mutation committed but its response was
618
+ // lost.
619
+ export const publishDeployment = internalMutation({
620
+ args: {
621
+ currentDeploymentId: v.string(),
622
+ expectedAssetCount: v.number(),
623
+ spaFallback: v.optional(v.boolean()),
624
+ },
625
+ returns: v.object({
626
+ deleted: v.number(),
627
+ pendingBlobCleanup: v.number(),
628
+ }),
629
+ handler: async (ctx, { currentDeploymentId, expectedAssetCount, spaFallback }) => {
630
+ if (!Number.isInteger(expectedAssetCount) ||
631
+ expectedAssetCount < 1 ||
632
+ expectedAssetCount > MAX_ASSETS_PER_DEPLOYMENT) {
633
+ throw new Error(`expectedAssetCount must be an integer from 1 to ${MAX_ASSETS_PER_DEPLOYMENT}`);
634
+ }
635
+ const stagedAssets = await ctx.db
636
+ .query("stagedAssets")
637
+ .withIndex("by_deploymentId", (q) => q.eq("deploymentId", currentDeploymentId))
638
+ .take(expectedAssetCount + 1);
639
+ const assets = stagedAssets.map(({ path, storageId, blobId, contentType, deploymentId }) => ({
640
+ path,
641
+ storageId,
642
+ blobId,
643
+ contentType,
644
+ deploymentId,
645
+ }));
646
+ if (assets.length !== expectedAssetCount) {
647
+ throw new Error(`Staged manifest has ${assets.length} files; expected ${expectedAssetCount}`);
648
+ }
649
+ assertAssetManifest(assets, currentDeploymentId, true);
650
+ const manifestBytes = new TextEncoder().encode(JSON.stringify(assets)).byteLength;
651
+ if (manifestBytes > MAX_MANIFEST_SERIALIZED_BYTES) {
652
+ throw new Error(`The staged manifest is ${manifestBytes} bytes, above the safe limit of ${MAX_MANIFEST_SERIALIZED_BYTES} bytes. Shorten asset paths or reduce the number of files.`);
653
+ }
654
+ // Leave headroom below Convex's 4,096 document-read ceiling for deployment
655
+ // metadata and index bookkeeping. Taking one extra old row lets oversized
656
+ // v1-to-v2 migrations fail clearly instead of exceeding the platform limit.
657
+ const oldAssetReadLimit = MAX_PUBLISH_MANIFEST_READS - assets.length + 1;
658
+ const oldAssets = await ctx.db.query("staticAssets").take(oldAssetReadLimit);
659
+ if (assets.length + oldAssets.length > MAX_PUBLISH_MANIFEST_READS) {
660
+ throw new Error(`The old and new manifests contain more than ${MAX_PUBLISH_MANIFEST_READS} rows combined. Reduce the new build or clean up the legacy manifest before migrating.`);
661
+ }
662
+ const retiredAssets = oldAssets.flatMap((asset) => {
663
+ if (asset.storageId !== undefined && asset.blobId === undefined) {
664
+ return [
665
+ {
666
+ _id: asset._id,
667
+ path: asset.path,
668
+ storageId: asset.storageId,
669
+ contentType: asset.contentType,
670
+ deploymentId: asset.deploymentId,
671
+ },
672
+ ];
673
+ }
674
+ return [];
675
+ });
676
+ const retiredIds = new Set(retiredAssets.map((asset) => asset._id));
677
+ const blobIds = [];
678
+ const storageIds = new Set();
679
+ for (const asset of oldAssets) {
680
+ if (retiredIds.has(asset._id))
681
+ continue;
682
+ if (asset.blobId)
683
+ blobIds.push(asset.blobId);
684
+ if (asset.storageId)
685
+ storageIds.add(asset.storageId);
686
+ }
687
+ const plannedDocumentWrites = oldAssets.length +
688
+ storageIds.size +
689
+ blobIds.length +
690
+ assets.length +
691
+ stagedAssets.length +
692
+ retiredAssets.length +
693
+ 2;
694
+ if (plannedDocumentWrites > MAX_PUBLISH_DOCUMENT_WRITES) {
695
+ throw new Error(`Publishing this manifest would require ${plannedDocumentWrites} document writes, above the safe limit of ${MAX_PUBLISH_DOCUMENT_WRITES}. Reduce the number of files in the deployment.`);
696
+ }
697
+ for (const asset of oldAssets) {
698
+ await ctx.db.delete("staticAssets", asset._id);
699
+ }
700
+ for (const { _id, ...asset } of retiredAssets) {
701
+ await ctx.db.insert("retiredAssets", {
702
+ ...asset,
703
+ expiresAt: Date.now() + RETIRED_ASSET_AGE_MS,
704
+ });
705
+ }
706
+ for (const asset of assets) {
707
+ await ctx.db.insert("staticAssets", {
708
+ path: asset.path,
709
+ ...(asset.storageId ? { storageId: asset.storageId } : {}),
710
+ ...(asset.blobId ? { blobId: asset.blobId } : {}),
711
+ contentType: asset.contentType,
712
+ deploymentId: asset.deploymentId,
713
+ });
714
+ }
715
+ for (const asset of stagedAssets) {
716
+ await ctx.db.delete("stagedAssets", asset._id);
717
+ }
718
+ for (const blobId of blobIds) {
719
+ await ctx.db.insert("pendingBlobCleanup", { blobId });
720
+ }
721
+ for (const storageId of storageIds) {
722
+ await ctx.db.insert("pendingStorageCleanup", { storageId });
723
+ }
724
+ const pendingBlobCleanup = await adjustPendingBlobCleanupCount(ctx, blobIds.length);
725
+ await setCurrentDeployment(ctx, currentDeploymentId, spaFallback ?? true, pendingBlobCleanup);
726
+ // Old component-storage objects are collected in a separate bounded
727
+ // maintenance mutation. Keeping those deletes out of this transaction is
728
+ // what lets a 1,800-file manifest switch atomically below Convex's read
729
+ // and write limits.
730
+ return { deleted: 0, pendingBlobCleanup };
731
+ },
732
+ });
733
+ // Commits a finished upload as the current deployment: records the deployment
734
+ // id + SPA config, then garbage-collects assets left over from previous
735
+ // deployments. Returns the storage cleanup tally plus any CDN blobIds the
736
+ // caller should delete (component actions can't reach the /fs blobs endpoint).
737
+ export const commitDeployment = internalMutation({
738
+ args: {
739
+ currentDeploymentId: v.string(),
740
+ // Whether to serve SPA fallback for this deployment (default true).
741
+ spaFallback: v.optional(v.boolean()),
742
+ },
743
+ returns: v.object({
744
+ deleted: v.number(),
745
+ blobIds: v.array(v.string()),
746
+ }),
747
+ handler: async (ctx, args) => {
748
+ const oldAssets = await ctx.db.query("staticAssets").collect();
749
+ const blobIds = [];
750
+ let deleted = 0;
751
+ for (const asset of oldAssets) {
752
+ if (asset.deploymentId === args.currentDeploymentId)
753
+ continue;
754
+ if (asset.storageId) {
755
+ if (await deleteStorageFile(ctx, asset.storageId)) {
756
+ deleted++;
757
+ }
758
+ }
759
+ if (asset.blobId) {
760
+ blobIds.push(asset.blobId);
761
+ }
762
+ await ctx.db.delete("staticAssets", asset._id);
763
+ }
764
+ await setCurrentDeployment(ctx, args.currentDeploymentId, args.spaFallback ?? true);
765
+ return { deleted, blobIds };
766
+ },
767
+ });
768
+ //# sourceMappingURL=lib.js.map