@samebase/convex-hosting 0.3.2 → 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.
- package/CHANGELOG.md +10 -134
- package/README.md +136 -398
- package/dist/cli/convex.d.ts +63 -0
- package/dist/cli/convex.js +108 -0
- package/dist/cli/convex.js.map +1 -0
- package/dist/cli/files.d.ts +18 -0
- package/dist/cli/files.js +106 -0
- package/dist/cli/files.js.map +1 -0
- package/dist/cli/index.d.ts +1 -11
- package/dist/cli/index.js +3 -58
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/main.d.ts +5 -0
- package/dist/cli/main.js +346 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/client/index.d.ts +8 -115
- package/dist/client/index.js +65 -242
- package/dist/client/index.js.map +1 -1
- package/dist/client/types.d.ts +5 -0
- package/dist/client/version-module.d.ts +6 -0
- package/dist/component/_generated/api.d.ts +0 -5
- package/dist/component/_generated/api.js.map +1 -1
- package/dist/component/_generated/component.d.ts +20 -18
- package/dist/component/_generated/dataModel.d.ts +0 -1
- package/dist/component/_generated/server.d.ts +12 -1
- package/dist/component/_generated/server.js +2 -0
- package/dist/component/_generated/server.js.map +1 -1
- package/dist/component/convex.config.d.ts +0 -1
- package/dist/component/lib.d.ts +175 -156
- package/dist/component/lib.js +396 -710
- package/dist/component/lib.js.map +1 -1
- package/dist/component/schema.d.ts +76 -72
- package/dist/component/schema.js +35 -59
- package/dist/component/schema.js.map +1 -1
- package/dist/workers-shared/asset-worker/src/analytics.d.ts +39 -0
- package/dist/workers-shared/asset-worker/src/analytics.js +86 -0
- package/dist/workers-shared/asset-worker/src/analytics.js.map +1 -0
- package/dist/workers-shared/asset-worker/src/compatibility-flags.d.ts +19 -0
- package/dist/workers-shared/asset-worker/src/compatibility-flags.js +29 -0
- package/dist/workers-shared/asset-worker/src/compatibility-flags.js.map +1 -0
- package/dist/workers-shared/asset-worker/src/configuration.d.ts +3 -0
- package/dist/workers-shared/asset-worker/src/configuration.js +30 -0
- package/dist/workers-shared/asset-worker/src/configuration.js.map +1 -0
- package/dist/workers-shared/asset-worker/src/constants.d.ts +1 -0
- package/dist/workers-shared/asset-worker/src/constants.js +3 -0
- package/dist/workers-shared/asset-worker/src/constants.js.map +1 -0
- package/dist/workers-shared/asset-worker/src/handler.d.ts +28 -0
- package/dist/workers-shared/asset-worker/src/handler.js +717 -0
- package/dist/workers-shared/asset-worker/src/handler.js.map +1 -0
- package/dist/workers-shared/asset-worker/src/types.d.ts +13 -0
- package/dist/workers-shared/asset-worker/src/types.js +2 -0
- package/dist/workers-shared/asset-worker/src/types.js.map +1 -0
- package/dist/workers-shared/asset-worker/src/utils/base-path.d.ts +29 -0
- package/dist/workers-shared/asset-worker/src/utils/base-path.js +56 -0
- package/dist/workers-shared/asset-worker/src/utils/base-path.js.map +1 -0
- package/dist/workers-shared/asset-worker/src/utils/headers.d.ts +11 -0
- package/dist/workers-shared/asset-worker/src/utils/headers.js +77 -0
- package/dist/workers-shared/asset-worker/src/utils/headers.js.map +1 -0
- package/dist/workers-shared/asset-worker/src/utils/rules-engine.d.ts +23 -0
- package/dist/workers-shared/asset-worker/src/utils/rules-engine.js +133 -0
- package/dist/workers-shared/asset-worker/src/utils/rules-engine.js.map +1 -0
- package/dist/workers-shared/asset-worker/src/worker.d.ts +14 -0
- package/dist/workers-shared/asset-worker/src/worker.js +12 -0
- package/dist/workers-shared/asset-worker/src/worker.js.map +1 -0
- package/dist/workers-shared/utils/base-path.d.ts +32 -0
- package/dist/workers-shared/utils/base-path.js +94 -0
- package/dist/workers-shared/utils/base-path.js.map +1 -0
- package/dist/workers-shared/utils/configuration/constants.d.ts +15 -0
- package/dist/workers-shared/utils/configuration/constants.js +16 -0
- package/dist/workers-shared/utils/configuration/constants.js.map +1 -0
- package/dist/workers-shared/utils/configuration/constructConfiguration.d.ts +12 -0
- package/dist/workers-shared/utils/configuration/constructConfiguration.js +103 -0
- package/dist/workers-shared/utils/configuration/constructConfiguration.js.map +1 -0
- package/dist/workers-shared/utils/configuration/parseHeaders.d.ts +5 -0
- package/dist/workers-shared/utils/configuration/parseHeaders.js +181 -0
- package/dist/workers-shared/utils/configuration/parseHeaders.js.map +1 -0
- package/dist/workers-shared/utils/configuration/parseRedirects.d.ts +8 -0
- package/dist/workers-shared/utils/configuration/parseRedirects.js +129 -0
- package/dist/workers-shared/utils/configuration/parseRedirects.js.map +1 -0
- package/dist/workers-shared/utils/configuration/parseStaticRouting.d.ts +2 -0
- package/dist/workers-shared/utils/configuration/parseStaticRouting.js +94 -0
- package/dist/workers-shared/utils/configuration/parseStaticRouting.js.map +1 -0
- package/dist/workers-shared/utils/configuration/types.d.ts +38 -0
- package/dist/workers-shared/utils/configuration/types.js +2 -0
- package/dist/workers-shared/utils/configuration/types.js.map +1 -0
- package/dist/workers-shared/utils/configuration/validateURL.d.ts +3 -0
- package/dist/workers-shared/utils/configuration/validateURL.js +56 -0
- package/dist/workers-shared/utils/configuration/validateURL.js.map +1 -0
- package/dist/workers-shared/utils/constants.d.ts +28 -0
- package/dist/workers-shared/utils/constants.js +34 -0
- package/dist/workers-shared/utils/constants.js.map +1 -0
- package/dist/workers-shared/utils/helpers.d.ts +22 -0
- package/dist/workers-shared/utils/helpers.js +73 -0
- package/dist/workers-shared/utils/helpers.js.map +1 -0
- package/dist/workers-shared/utils/responses.d.ts +43 -0
- package/dist/workers-shared/utils/responses.js +125 -0
- package/dist/workers-shared/utils/responses.js.map +1 -0
- package/dist/workers-shared/utils/tracing.d.ts +3 -0
- package/dist/workers-shared/utils/tracing.js +29 -0
- package/dist/workers-shared/utils/tracing.js.map +1 -0
- package/dist/workers-shared/utils/types.d.ts +131 -0
- package/dist/workers-shared/utils/types.js +72 -0
- package/dist/workers-shared/utils/types.js.map +1 -0
- package/package.json +16 -32
- package/INTEGRATION.md +0 -399
- package/dist/cli/args.d.ts +0 -28
- package/dist/cli/args.d.ts.map +0 -1
- package/dist/cli/args.js +0 -148
- package/dist/cli/args.js.map +0 -1
- package/dist/cli/argumentChunks.d.ts +0 -3
- package/dist/cli/argumentChunks.d.ts.map +0 -1
- package/dist/cli/argumentChunks.js +0 -29
- package/dist/cli/argumentChunks.js.map +0 -1
- package/dist/cli/collectFiles.d.ts +0 -10
- package/dist/cli/collectFiles.d.ts.map +0 -1
- package/dist/cli/collectFiles.js +0 -53
- package/dist/cli/collectFiles.js.map +0 -1
- package/dist/cli/commands.d.ts +0 -8
- package/dist/cli/commands.d.ts.map +0 -1
- package/dist/cli/commands.js +0 -56
- package/dist/cli/commands.js.map +0 -1
- package/dist/cli/componentName.d.ts +0 -32
- package/dist/cli/componentName.d.ts.map +0 -1
- package/dist/cli/componentName.js +0 -43
- package/dist/cli/componentName.js.map +0 -1
- package/dist/cli/deploy.d.ts +0 -16
- package/dist/cli/deploy.d.ts.map +0 -1
- package/dist/cli/deploy.js +0 -283
- package/dist/cli/deploy.js.map +0 -1
- package/dist/cli/deployEnvironment.d.ts +0 -10
- package/dist/cli/deployEnvironment.d.ts.map +0 -1
- package/dist/cli/deployEnvironment.js +0 -12
- package/dist/cli/deployEnvironment.js.map +0 -1
- package/dist/cli/index.d.ts.map +0 -1
- package/dist/cli/upload.d.ts +0 -22
- package/dist/cli/upload.d.ts.map +0 -1
- package/dist/cli/upload.js +0 -630
- package/dist/cli/upload.js.map +0 -1
- package/dist/client/_generated/_ignore.d.ts +0 -1
- package/dist/client/_generated/_ignore.d.ts.map +0 -1
- package/dist/client/_generated/_ignore.js +0 -3
- package/dist/client/_generated/_ignore.js.map +0 -1
- package/dist/client/index.d.ts.map +0 -1
- package/dist/component/_generated/api.d.ts.map +0 -1
- package/dist/component/_generated/component.d.ts.map +0 -1
- package/dist/component/_generated/dataModel.d.ts.map +0 -1
- package/dist/component/_generated/server.d.ts.map +0 -1
- package/dist/component/convex.config.d.ts.map +0 -1
- package/dist/component/http.d.ts +0 -4
- package/dist/component/http.d.ts.map +0 -1
- package/dist/component/http.js +0 -113
- package/dist/component/http.js.map +0 -1
- package/dist/component/lib.d.ts.map +0 -1
- package/dist/component/schema.d.ts.map +0 -1
- package/dist/component/serving.d.ts +0 -23
- package/dist/component/serving.d.ts.map +0 -1
- package/dist/component/serving.js +0 -161
- package/dist/component/serving.js.map +0 -1
- package/dist/react/index.d.ts +0 -59
- package/dist/react/index.d.ts.map +0 -1
- package/dist/react/index.js +0 -137
- package/dist/react/index.js.map +0 -1
- package/src/cli/args.test.ts +0 -79
- package/src/cli/args.ts +0 -153
- package/src/cli/argumentChunks.test.ts +0 -27
- package/src/cli/argumentChunks.ts +0 -40
- package/src/cli/collectFiles.test.ts +0 -26
- package/src/cli/collectFiles.ts +0 -59
- package/src/cli/commands.ts +0 -68
- package/src/cli/componentName.test.ts +0 -41
- package/src/cli/componentName.ts +0 -46
- package/src/cli/deploy.ts +0 -336
- package/src/cli/deployEnvironment.test.ts +0 -27
- package/src/cli/deployEnvironment.ts +0 -17
- package/src/cli/index.ts +0 -65
- package/src/cli/upload.ts +0 -837
- package/src/client/_generated/_ignore.ts +0 -1
- package/src/client/index.test.ts +0 -549
- package/src/client/index.ts +0 -344
- package/src/client/setup.test.ts +0 -22
- package/src/component/_generated/api.ts +0 -54
- package/src/component/_generated/component.ts +0 -55
- package/src/component/_generated/dataModel.ts +0 -60
- package/src/component/_generated/server.ts +0 -156
- package/src/component/convex.config.ts +0 -3
- package/src/component/http.test.ts +0 -254
- package/src/component/http.ts +0 -133
- package/src/component/lib.test.ts +0 -1007
- package/src/component/lib.ts +0 -862
- package/src/component/schema.ts +0 -75
- package/src/component/serving.ts +0 -170
- package/src/component/setup.test.ts +0 -11
- package/src/react/index.tsx +0 -197
- package/src/test.ts +0 -23
package/dist/component/lib.js
CHANGED
|
@@ -1,768 +1,454 @@
|
|
|
1
|
-
import { v } from "convex/values";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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("
|
|
74
|
-
.withIndex("
|
|
62
|
+
.query("entries")
|
|
63
|
+
.withIndex("by_version_path", (q) => q.eq("versionId", versionId).eq("path", path))
|
|
75
64
|
.unique();
|
|
76
65
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
.
|
|
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
|
-
|
|
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
|
|
95
|
-
const
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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 (
|
|
251
|
-
|
|
114
|
+
else if (version.config !== config) {
|
|
115
|
+
throw new ConvexError(`Version ${versionId} exists with a different config.`);
|
|
252
116
|
}
|
|
253
|
-
return
|
|
117
|
+
return null;
|
|
254
118
|
},
|
|
255
119
|
});
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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 (
|
|
267
|
-
|
|
268
|
-
await ctx.db.insert("pendingBlobCleanup", { blobId });
|
|
269
|
-
queued++;
|
|
138
|
+
if (entry !== null)
|
|
139
|
+
known.push({ sha256, storageId: entry.storageId });
|
|
270
140
|
}
|
|
271
|
-
|
|
272
|
-
await adjustPendingBlobCleanupCount(ctx, queued);
|
|
273
|
-
return { queued };
|
|
141
|
+
return known;
|
|
274
142
|
},
|
|
275
143
|
});
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
345
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
|
209
|
+
return null;
|
|
426
210
|
},
|
|
427
211
|
});
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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 (
|
|
464
|
-
|
|
231
|
+
if (version.state.kind === "deleting") {
|
|
232
|
+
throw new ConvexError(`Version ${versionId} is being deleted.`);
|
|
465
233
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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.
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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.
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
339
|
+
budget -= entries.length;
|
|
340
|
+
await ctx.db.delete("versions", version._id);
|
|
602
341
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
|
349
|
+
return null;
|
|
612
350
|
},
|
|
613
351
|
});
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
|
|
627
|
-
|
|
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, {
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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
|
|
636
|
-
|
|
637
|
-
.
|
|
638
|
-
|
|
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
|
|
677
|
-
const
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
if (
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
|
|
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
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
|
|
765
|
-
|
|
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
|