@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,768 +1,454 @@
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(),
1
+ import { ConvexError, v } from "convex/values";
2
+ import { z } from "zod";
3
+ import { Analytics } from "../workers-shared/asset-worker/src/analytics.js";
4
+ import { normalizeConfiguration } from "../workers-shared/asset-worker/src/configuration.js";
5
+ import { canFetch, handleRequest } from "../workers-shared/asset-worker/src/handler.js";
6
+ import { mockJaegerBinding } from "../workers-shared/utils/tracing.js";
7
+ import { AssetConfigSchema } from "../workers-shared/utils/types.js";
8
+ import { internal } from "./_generated/api.js";
9
+ import { internalAction, internalMutation, internalQuery, mutation, query, } from "./_generated/server.js";
10
+ /**
11
+ * The compatibility date of Cloudflare's handler. From 2025-04-01 on, Cloudflare applies
12
+ * `not_found_handling` to a request with `Sec-Fetch-Mode: navigate` when a Worker exists.
13
+ */
14
+ const COMPATIBILITY_DATE = "2025-04-01";
15
+ /**
16
+ * A version that never became live, and a stored file that no entry references, are garbage once
17
+ * they are a day old: a failed deploy leaves them, and an upload in progress is younger. This age
18
+ * bounds the garbage of failed deploys; it decides nothing about what is served.
19
+ */
20
+ const GARBAGE_AGE_MS = 24 * 60 * 60 * 1000;
21
+ /**
22
+ * Versions that one `collect` call reads in its page. A version document holds its config, at
23
+ * most 1 MiB (the CLI keeps the config at or below 900 KiB), so a page reads at most 8 MiB besides
24
+ * the versions its cursor has already seen at its time (normally none), and the current live
25
+ * version, read through `by_live`, 1 MiB more. A page cannot hold every version: failed deploys of
26
+ * the last day are not deleted yet, and each one is a version.
27
+ */
28
+ const VERSION_PAGE = 8;
29
+ /**
30
+ * Entries that one `collect` call deletes, without their stored files, across the versions of its
31
+ * page. Entries below 4 KiB read and delete at most 4 MiB, which, with the 9 MiB of version
32
+ * documents, keeps a call at 13 MiB of the 16 MiB read limit and, with the 8 version rows it may
33
+ * delete and the one it may mark `deleting`, at 13 MiB written. Documents read: at most 1,009 of
34
+ * 32,000. Index ranges: `by_live`, the page, and one range of entries per version, at most 10 of
35
+ * 4,096. Writes: at most 1,000 entries, 8 versions, and the next call, 1,009 of 16,000.
36
+ */
37
+ const DELETE_BATCH = 1000;
38
+ /**
39
+ * `_storage` documents that one `collect` call reads. Each one reads one index range
40
+ * (`by_storageId`) of at most one entry and may delete the file: 1,000 files are 1,000 index ranges,
41
+ * at most 2,000 documents read, and 1,000 writes.
42
+ */
43
+ const SWEEP_BATCH = 1000;
44
+ /**
45
+ * The file that `upload` stores and `createVersion` reads: Cloudflare's `AssetConfig` for the
46
+ * version, without the fields that are package code.
47
+ */
48
+ const configSchema = AssetConfigSchema.pick({
49
+ html_handling: true,
50
+ not_found_handling: true,
51
+ headers: true,
52
+ redirects: true,
12
53
  });
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))
54
+ async function getVersion(ctx, versionId) {
55
+ return await ctx.db
56
+ .query("versions")
57
+ .withIndex("by_versionId", (q) => q.eq("versionId", versionId))
52
58
  .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;
59
+ }
60
+ async function getEntry(ctx, versionId, path) {
72
61
  return await ctx.db
73
- .query("staticAssets")
74
- .withIndex("by_path", (q) => q.eq("path", "/index.html"))
62
+ .query("entries")
63
+ .withIndex("by_version_path", (q) => q.eq("versionId", versionId).eq("path", path))
75
64
  .unique();
76
65
  }
77
- async function deleteStorageFile(ctx, storageId) {
78
- const retiredReference = await ctx.db
79
- .query("retiredAssets")
80
- .withIndex("by_storageId", (q) => q.eq("storageId", storageId))
66
+ /** The `at` of the newest live version, or `null` when no version is live. */
67
+ async function getNewestLiveAt(ctx) {
68
+ const newest = await ctx.db
69
+ .query("versions")
70
+ .withIndex("by_live", (q) => q.eq("state.kind", "live"))
71
+ .order("desc")
81
72
  .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;
73
+ return newest?.state.kind === "live" ? newest.state.at : null;
93
74
  }
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;
75
+ async function getVersionOrThrow(ctx, versionId) {
76
+ const version = await getVersion(ctx, versionId);
77
+ if (version === null)
78
+ throw new ConvexError(`Version ${versionId} does not exist.`);
79
+ return version;
109
80
  }
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
81
  export const generateUploadUrls = internalMutation({
215
82
  args: { count: v.number() },
216
83
  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
- },
84
+ handler: async (ctx, { count }) => await Promise.all(Array.from({ length: count }, () => ctx.storage.generateUploadUrl())),
227
85
  });
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);
86
+ /**
87
+ * Starts a version: reads its config from storage and creates the version in state `uploading`.
88
+ * Idempotent for the same id and config.
89
+ */
90
+ export const createVersion = internalAction({
91
+ args: { versionId: v.string(), configId: v.id("_storage") },
92
+ returns: v.null(),
93
+ handler: async (ctx, { versionId, configId }) => {
94
+ const blob = await ctx.storage.get(configId);
95
+ if (blob === null)
96
+ throw new ConvexError(`The config ${configId} is not in storage.`);
97
+ const config = configSchema.parse(JSON.parse(await blob.text()));
98
+ await ctx.runMutation(internal.lib.insertVersion, {
99
+ versionId,
100
+ config: JSON.stringify(config),
101
+ });
102
+ return null;
233
103
  },
234
104
  });
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
- }
105
+ /** The write of `createVersion`: creates the version, unless it exists with the same config. */
106
+ export const insertVersion = internalMutation({
107
+ args: { versionId: v.string(), config: v.string() },
108
+ returns: v.null(),
109
+ handler: async (ctx, { versionId, config }) => {
110
+ const version = await getVersion(ctx, versionId);
111
+ if (version === null) {
112
+ await ctx.db.insert("versions", { versionId, state: { kind: "uploading" }, config });
249
113
  }
250
- if (acknowledged > 0) {
251
- await adjustPendingBlobCleanupCount(ctx, -acknowledged);
114
+ else if (version.config !== config) {
115
+ throw new ConvexError(`Version ${versionId} exists with a different config.`);
252
116
  }
253
- return { acknowledged };
117
+ return null;
254
118
  },
255
119
  });
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))
120
+ /**
121
+ * The stored file of each hash that an entry of any version already has, in the order of
122
+ * `sha256s`.
123
+ *
124
+ * Each hash reads one index range (`by_sha256`) of at most one document. The CLI sends at most
125
+ * 8,000 characters of arguments, which hold at most 119 hashes of 67 characters: 119 index ranges
126
+ * of 4,096 and 119 documents read of 32,000.
127
+ */
128
+ export const knownFiles = internalQuery({
129
+ args: { sha256s: v.array(v.string()) },
130
+ returns: v.array(v.object({ sha256: v.string(), storageId: v.id("_storage") })),
131
+ handler: async (ctx, { sha256s }) => {
132
+ const known = [];
133
+ for (const sha256 of sha256s) {
134
+ const entry = await ctx.db
135
+ .query("entries")
136
+ .withIndex("by_sha256", (q) => q.eq("sha256", sha256))
265
137
  .first();
266
- if (existing)
267
- continue;
268
- await ctx.db.insert("pendingBlobCleanup", { blobId });
269
- queued++;
138
+ if (entry !== null)
139
+ known.push({ sha256, storageId: entry.storageId });
270
140
  }
271
- if (queued > 0)
272
- await adjustPendingBlobCleanupCount(ctx, queued);
273
- return { queued };
141
+ return known;
274
142
  },
275
143
  });
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);
144
+ /**
145
+ * Inserts complete entries into an `uploading` version, after checking that each stored file has
146
+ * the entry's sha256. An entry that exists with the same sha256 is skipped, so a retried batch
147
+ * passes; one that exists with another sha256 is refused.
148
+ *
149
+ * Each entry reads one index range (`by_version_path`) of at most one document and one `_storage`
150
+ * document, and writes at most one document. The CLI sends at most 8,000 characters of arguments,
151
+ * and an entry takes at least 135 of them (its sha256 and storage id alone are 96), so a call has
152
+ * at most 60 entries: 60 index ranges of 4,096, at most 121 documents read of 32,000 with the
153
+ * version, and 60 writes of 16,000.
154
+ */
155
+ export const recordFiles = internalMutation({
156
+ args: {
157
+ versionId: v.string(),
158
+ entries: v.array(v.object({
159
+ path: v.string(),
160
+ sha256: v.string(),
161
+ contentType: v.optional(v.string()),
162
+ storageId: v.id("_storage"),
163
+ })),
164
+ },
165
+ returns: v.null(),
166
+ handler: async (ctx, { versionId, entries }) => {
167
+ const version = await getVersionOrThrow(ctx, versionId);
168
+ if (version.state.kind !== "uploading") {
169
+ throw new ConvexError(`Version ${versionId} is ${version.state.kind}, not uploading.`);
294
170
  }
295
- const entries = await ctx.db.query("pendingStorageCleanup").take(batchLimit);
296
- let deleted = 0;
297
171
  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++;
172
+ const existing = await getEntry(ctx, versionId, entry.path);
173
+ if (existing !== null) {
174
+ if (existing.sha256 !== entry.sha256) {
175
+ throw new ConvexError(`Version ${versionId} has other contents at ${entry.path}.`);
176
+ }
342
177
  continue;
343
178
  }
344
- if (await deleteStorageFile(ctx, storageId)) {
345
- deleted++;
179
+ const stored = await ctx.db.system.get("_storage", entry.storageId);
180
+ if (stored === null) {
181
+ throw new ConvexError(`The stored file of ${entry.path} is gone, deleted by the age rule; run the deploy again.`);
346
182
  }
347
- else {
348
- alreadyMissing++;
183
+ if (base64ToHex(stored.sha256) !== entry.sha256) {
184
+ throw new ConvexError(`The file stored for ${entry.path} does not match its sha256.`);
349
185
  }
186
+ await ctx.db.insert("entries", {
187
+ versionId,
188
+ path: entry.path,
189
+ sha256: entry.sha256,
190
+ contentType: entry.contentType,
191
+ storageId: entry.storageId,
192
+ });
350
193
  }
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 };
194
+ return null;
412
195
  },
413
196
  });
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);
197
+ /**
198
+ * Marks an `uploading` version `ready`. The CLI calls it after every `recordFiles` batch succeeded.
199
+ * A version that is already `ready` or `live` stays as it is.
200
+ */
201
+ export const finishVersion = internalMutation({
202
+ args: { versionId: v.string() },
203
+ returns: v.null(),
204
+ handler: async (ctx, { versionId }) => {
205
+ const version = await getVersionOrThrow(ctx, versionId);
206
+ if (version.state.kind === "uploading") {
207
+ await ctx.db.patch("versions", version._id, { state: { kind: "ready" } });
424
208
  }
425
- return { discarded: assets.length };
209
+ return null;
426
210
  },
427
211
  });
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);
212
+ /**
213
+ * Marks a `ready` version `live` and schedules `collect`. `serveAsset` calls it at the first
214
+ * request that the version's pushed code serves, with the `at` of the newest live version that
215
+ * `route` saw for that request. A mark succeeds only when the state it was based on is still the
216
+ * state: when the newest live version changed between the request's lookup and this mark, it does
217
+ * nothing, so a request whose code was replaced between its lookup and its mark cannot make its
218
+ * version newest. A version that is already live stays as it is, so concurrent first requests are
219
+ * fine.
220
+ */
221
+ export const markLive = mutation({
222
+ args: { versionId: v.string(), expectedNewestLiveAt: v.union(v.number(), v.null()) },
223
+ returns: v.null(),
224
+ handler: async (ctx, { versionId, expectedNewestLiveAt }) => {
225
+ const version = await getVersionOrThrow(ctx, versionId);
226
+ if (version.state.kind === "live")
227
+ return null;
228
+ if (version.state.kind === "uploading") {
229
+ throw new ConvexError(`Version ${versionId} is still uploading.`);
462
230
  }
463
- if (queuedBlobIds > 0) {
464
- await adjustPendingBlobCleanupCount(ctx, queuedBlobIds);
231
+ if (version.state.kind === "deleting") {
232
+ throw new ConvexError(`Version ${versionId} is being deleted.`);
465
233
  }
466
- return {
467
- discarded: assets.length,
468
- deletedFiles,
469
- queuedBlobIds,
470
- };
234
+ const newestLiveAt = await getNewestLiveAt(ctx);
235
+ if (newestLiveAt !== expectedNewestLiveAt)
236
+ return null;
237
+ // Strictly newer than the newest, also within one millisecond, so that `collect` deletes it.
238
+ const at = newestLiveAt === null ? Date.now() : Math.max(Date.now(), newestLiveAt + 1);
239
+ await ctx.db.patch("versions", version._id, { state: { kind: "live", at } });
240
+ await ctx.scheduler.runAfter(0, internal.lib.collect, { step: { kind: "files" } });
241
+ return null;
471
242
  },
472
243
  });
473
- export const cleanupUnreferencedStorage = internalMutation({
244
+ /**
245
+ * One chain of calls, one batch per call, each scheduling the next with `runAfter(0)` while work
246
+ * remains. First the `_storage` files that no entry references and that are older than
247
+ * `GARBAGE_AGE_MS`; then the versions, a page of `VERSION_PAGE` at a time in creation order: the
248
+ * live versions older than the current live one, whose code is gone because the current one has
249
+ * served since, and the versions that are not live and older than `GARBAGE_AGE_MS`, with their
250
+ * entries.
251
+ *
252
+ * A stored file is deleted when no entry references it, it is a day old, and it was already
253
+ * unreferenced when the chain started: the files that this chain's version deletion frees wait for
254
+ * a later chain, at a later switch. An upload in progress may have taken a stored file from
255
+ * `knownFiles` and not recorded its entry yet; it therefore fails only if two switches happen
256
+ * during that one upload, and a retry works.
257
+ *
258
+ * Both steps walk `by_creation_time` with a cursor: the creation time of the last document handled
259
+ * and the ids already handled at that time. Convex gives every document of one transaction the
260
+ * same creation time, and creation times are not unique across transactions either, so a cursor of
261
+ * a time alone would skip the other documents at the boundary. The next call reads from that time
262
+ * on and skips the ids in `seen`, which hold the documents of one time, normally one; it reads
263
+ * that many more documents, so a page still holds a full batch of new ones when they exist, and it
264
+ * keeps at most a batch, because a seen document may be gone.
265
+ */
266
+ export const collect = internalMutation({
474
267
  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"))),
268
+ /** The step of the chain: the file sweep, then the versions, each after its cursor. */
269
+ step: v.union(v.object({
270
+ kind: v.literal("files"),
271
+ after: v.optional(v.object({ at: v.number(), seen: v.array(v.id("_storage")) })),
272
+ }), v.object({
273
+ kind: v.literal("versions"),
274
+ after: v.optional(v.object({ at: v.number(), seen: v.array(v.id("versions")) })),
275
+ })),
478
276
  },
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);
277
+ returns: v.null(),
278
+ handler: async (ctx, { step }) => {
279
+ const garbageBefore = Date.now() - GARBAGE_AGE_MS;
280
+ if (step.kind === "files") {
281
+ const after = step.after;
282
+ const files = (await ctx.db.system
283
+ .query("_storage")
284
+ .withIndex("by_creation_time", (q) => after === undefined
285
+ ? q.lt("_creationTime", garbageBefore)
286
+ : q.gte("_creationTime", after.at).lt("_creationTime", garbageBefore))
287
+ .take(SWEEP_BATCH + (after?.seen.length ?? 0)))
288
+ .filter((file) => !after?.seen.includes(file._id))
289
+ .slice(0, SWEEP_BATCH);
290
+ let finished = after;
291
+ for (const file of files) {
292
+ const entry = await ctx.db
293
+ .query("entries")
294
+ .withIndex("by_storageId", (q) => q.eq("storageId", file._id))
295
+ .first();
296
+ if (entry === null)
297
+ await ctx.storage.delete(file._id);
298
+ finished = advance(finished, file);
570
299
  }
571
- await ctx.db.delete("staticAssets", existing._id);
300
+ await ctx.scheduler.runAfter(0, internal.lib.collect, {
301
+ step: files.length === SWEEP_BATCH ? { kind: "files", after: finished } : { kind: "versions" },
302
+ });
303
+ return null;
572
304
  }
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);
305
+ const after = step.after;
306
+ const currentAt = await getNewestLiveAt(ctx);
307
+ const page = (await ctx.db
308
+ .query("versions")
309
+ .withIndex("by_creation_time", (q) => after === undefined ? q : q.gte("_creationTime", after.at))
310
+ .take(VERSION_PAGE + (after?.seen.length ?? 0)))
311
+ .filter((version) => !after?.seen.includes(version._id))
312
+ .slice(0, VERSION_PAGE);
313
+ let budget = DELETE_BATCH;
314
+ let finished = after;
315
+ for (const version of page) {
316
+ const { state } = version;
317
+ const garbage = state.kind === "deleting" ||
318
+ (state.kind === "live"
319
+ ? currentAt !== null && state.at < currentAt
320
+ : version._creationTime < garbageBefore);
321
+ if (garbage) {
322
+ const entries = await ctx.db
323
+ .query("entries")
324
+ .withIndex("by_version_path", (q) => q.eq("versionId", version.versionId))
325
+ .take(budget);
326
+ for (const entry of entries)
327
+ await ctx.db.delete("entries", entry._id);
328
+ if (entries.length === budget) {
329
+ // The version may have more entries: the next call reads it again. Until its row goes,
330
+ // it is `deleting`, so that no request serves it or marks it live between the pages.
331
+ if (state.kind !== "deleting") {
332
+ await ctx.db.patch("versions", version._id, { state: { kind: "deleting" } });
333
+ }
334
+ await ctx.scheduler.runAfter(0, internal.lib.collect, {
335
+ step: { kind: "versions", after: finished },
336
+ });
337
+ return null;
600
338
  }
601
- await ctx.db.delete("staticAssets", existing._id);
339
+ budget -= entries.length;
340
+ await ctx.db.delete("versions", version._id);
602
341
  }
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,
342
+ finished = advance(finished, version);
343
+ }
344
+ if (page.length === VERSION_PAGE) {
345
+ await ctx.scheduler.runAfter(0, internal.lib.collect, {
346
+ step: { kind: "versions", after: finished },
609
347
  });
610
348
  }
611
- return { blobIds };
349
+ return null;
612
350
  },
613
351
  });
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
- },
352
+ /** The cursor of `collect` after `document`: its creation time and the ids handled at that time. */
353
+ function advance(cursor, document) {
354
+ return cursor !== undefined && cursor.at === document._creationTime
355
+ ? { at: cursor.at, seen: [...cursor.seen, document._id] }
356
+ : { at: document._creationTime, seen: [document._id] };
357
+ }
358
+ const routeHeaders = v.record(v.string(), v.string());
359
+ /**
360
+ * Runs Cloudflare's asset handler for one request against one version. `state` is the kind of the
361
+ * version's state, or `null` when the version does not exist. `newestLiveAt` is, for a `ready`
362
+ * version, the `at` of the newest live version that this query saw, which `serveAsset` passes to
363
+ * `markLive`; otherwise `null`, and a live version's requests read nothing more. `asset` is
364
+ * `null` when the version is uploading, being deleted, or does not exist, or when no asset answers.
365
+ */
366
+ export const route = query({
367
+ args: { versionId: v.string(), method: v.string(), url: v.string(), headers: routeHeaders },
625
368
  returns: v.object({
626
- deleted: v.number(),
627
- pendingBlobCleanup: v.number(),
369
+ state: v.union(v.literal("uploading"), v.literal("ready"), v.literal("live"), v.literal("deleting"), v.null()),
370
+ newestLiveAt: v.union(v.number(), v.null()),
371
+ asset: v.union(v.null(), v.object({
372
+ status: v.number(),
373
+ headers: v.array(v.object({ name: v.string(), value: v.string() })),
374
+ /** The storage URL of the body, when the response has one. */
375
+ bodyUrl: v.union(v.string(), v.null()),
376
+ })),
628
377
  }),
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}`);
378
+ handler: async (ctx, { versionId, method, url, headers }) => {
379
+ const version = await getVersion(ctx, versionId);
380
+ if (version === null)
381
+ return { state: null, newestLiveAt: null, asset: null };
382
+ const state = version.state.kind;
383
+ if (state === "uploading" || state === "deleting") {
384
+ return { state, newestLiveAt: null, asset: null };
634
385
  }
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 [];
386
+ const newestLiveAt = state === "ready" ? await getNewestLiveAt(ctx) : null;
387
+ const configuration = normalizeConfiguration({
388
+ ...AssetConfigSchema.parse(JSON.parse(version.config)),
389
+ compatibility_date: COMPATIBILITY_DATE,
675
390
  });
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 });
391
+ const request = new Request(url, { method, headers });
392
+ const env = { JAEGER: mockJaegerBinding() };
393
+ // `exists` returns an ETag; `getByETag` gets the entry back for that ETag.
394
+ const found = new Map();
395
+ const exists = async (pathname) => {
396
+ const entry = await getEntry(ctx, versionId, pathname);
397
+ if (entry === null)
398
+ return null;
399
+ const eTag = entry.sha256 + (await shortHash(entry.contentType ?? ""));
400
+ found.set(eTag, entry);
401
+ return eTag;
402
+ };
403
+ let bodyEntry;
404
+ const getByETag = async (eTag) => {
405
+ bodyEntry = found.get(eTag);
406
+ if (bodyEntry === undefined)
407
+ throw new Error(`No entry has the ETag ${eTag}.`);
408
+ return {
409
+ // `serveAsset` replaces this placeholder with the stored file.
410
+ readableStream: new ReadableStream(),
411
+ contentType: bodyEntry.contentType,
412
+ cacheStatus: "MISS",
413
+ };
414
+ };
415
+ if (!(await canFetch(request, env, configuration, exists))) {
416
+ return { state, newestLiveAt, asset: null };
723
417
  }
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);
418
+ const response = await handleRequest(request, env, configuration, exists, getByETag, new Analytics());
419
+ // It describes Cloudflare's cache, which does not exist here.
420
+ response.headers.delete("CF-Cache-Status");
421
+ let bodyUrl = null;
422
+ if (response.body !== null && bodyEntry !== undefined) {
423
+ bodyUrl = await ctx.storage.getUrl(bodyEntry.storageId);
424
+ // The file is gone from storage. As in `serveAsset` when storage answers 404, the answer must
425
+ // not stay in a cache.
426
+ if (bodyUrl === null) {
427
+ return {
428
+ state,
429
+ newestLiveAt,
430
+ asset: { status: 404, headers: [{ name: "cache-control", value: "no-store" }], bodyUrl },
431
+ };
761
432
  }
762
- await ctx.db.delete("staticAssets", asset._id);
763
433
  }
764
- await setCurrentDeployment(ctx, args.currentDeploymentId, args.spaFallback ?? true);
765
- return { deleted, blobIds };
434
+ return {
435
+ state,
436
+ newestLiveAt,
437
+ asset: {
438
+ status: response.status,
439
+ headers: [...response.headers].map(([name, value]) => ({ name, value })),
440
+ bodyUrl,
441
+ },
442
+ };
766
443
  },
767
444
  });
445
+ /** Convex's `_storage` table holds the sha256 in base64; entries hold it in hex. */
446
+ function base64ToHex(base64) {
447
+ return Array.from(atob(base64), (char) => char.charCodeAt(0).toString(16).padStart(2, "0")).join("");
448
+ }
449
+ /** The first 8 hex digits of the SHA-256 of `text`. */
450
+ async function shortHash(text) {
451
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
452
+ return Array.from(new Uint8Array(digest, 0, 4), (byte) => byte.toString(16).padStart(2, "0")).join("");
453
+ }
768
454
  //# sourceMappingURL=lib.js.map