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