@coywolf/emdash 0.0.0-stage → 0.35.14
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/LICENSE +21 -0
- package/README.md +203 -2
- package/docs/README.md +45 -0
- package/docs/ai.md +39 -0
- package/docs/backups.md +88 -0
- package/docs/breadcrumbs.md +59 -0
- package/docs/code-blocks.md +21 -0
- package/docs/content-urls.md +41 -0
- package/docs/custom-blocks.md +27 -0
- package/docs/discovery.md +32 -0
- package/docs/files.md +44 -0
- package/docs/form-uploads.md +59 -0
- package/docs/headings.md +37 -0
- package/docs/images.md +141 -0
- package/docs/internals.md +13 -0
- package/docs/links.md +34 -0
- package/docs/performance.md +72 -0
- package/docs/redirects.md +84 -0
- package/docs/releasing.md +21 -0
- package/docs/reviews.md +89 -0
- package/docs/robots.md +81 -0
- package/docs/schema.md +79 -0
- package/docs/search.md +114 -0
- package/docs/videos.md +48 -0
- package/docs/wordpress-import.md +198 -0
- package/package.json +79 -5
- package/src/admin/admin-css.ts +57 -0
- package/src/admin/ai.tsx +915 -0
- package/src/admin/backups.tsx +613 -0
- package/src/admin/breadcrumbs.tsx +542 -0
- package/src/admin/codeBlocks.tsx +204 -0
- package/src/admin/customBlocks.tsx +321 -0
- package/src/admin/dirty.ts +20 -0
- package/src/admin/discovery.tsx +463 -0
- package/src/admin/download.ts +28 -0
- package/src/admin/feature-switches.tsx +124 -0
- package/src/admin/features.tsx +142 -0
- package/src/admin/files.tsx +791 -0
- package/src/admin/formUploads.tsx +445 -0
- package/src/admin/guides.tsx +18 -0
- package/src/admin/headings.tsx +262 -0
- package/src/admin/images.tsx +825 -0
- package/src/admin/index.tsx +62 -0
- package/src/admin/links.tsx +980 -0
- package/src/admin/performance.tsx +330 -0
- package/src/admin/preview.tsx +63 -0
- package/src/admin/redirects-removed.tsx +266 -0
- package/src/admin/redirects.tsx +470 -0
- package/src/admin/reviews.tsx +232 -0
- package/src/admin/robots-bots.tsx +647 -0
- package/src/admin/robots-shared.tsx +109 -0
- package/src/admin/robots-wizard.tsx +748 -0
- package/src/admin/robots.tsx +911 -0
- package/src/admin/save-bar.tsx +134 -0
- package/src/admin/schema.tsx +1187 -0
- package/src/admin/search.tsx +355 -0
- package/src/admin/settings-ui.tsx +87 -0
- package/src/admin/trash-prompt-core.ts +135 -0
- package/src/admin/trash-prompt.tsx +156 -0
- package/src/admin/usage.tsx +301 -0
- package/src/admin/videos-display.tsx +248 -0
- package/src/admin/videos.tsx +1202 -0
- package/src/admin/wpImport.tsx +1516 -0
- package/src/ai/entities.ts +59 -0
- package/src/ai/export.ts +61 -0
- package/src/ai/logic.ts +606 -0
- package/src/ai/pack.ts +121 -0
- package/src/ai/prompts.ts +87 -0
- package/src/ai/providers.ts +276 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.ts +87 -0
- package/src/ai/worker.ts +530 -0
- package/src/astro/astro-modules.d.ts +6 -0
- package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
- package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
- package/src/astro/codeBlocks/CodeBlock.astro +62 -0
- package/src/astro/codeBlocks/EmDashCode.astro +61 -0
- package/src/astro/customBlocks/CustomBlock.astro +64 -0
- package/src/astro/customBlocks/Details.astro +8 -0
- package/src/astro/customBlocks/Disclosure.astro +8 -0
- package/src/astro/customBlocks/Note.astro +8 -0
- package/src/astro/customBlocks/Podcast.astro +8 -0
- package/src/astro/customBlocks/Quote.astro +8 -0
- package/src/astro/customBlocks/Testimonial.astro +8 -0
- package/src/astro/files/FileCard.astro +103 -0
- package/src/astro/headings/HeadingBlock.astro +129 -0
- package/src/astro/headings/TableOfContents.astro +45 -0
- package/src/astro/headings/request.ts +19 -0
- package/src/astro/images/ResponsiveImage.astro +60 -0
- package/src/astro/index.ts +84 -0
- package/src/astro/reviews/CoywolfReview.astro +27 -0
- package/src/astro/reviews/Review.astro +49 -0
- package/src/astro/search/SearchBox.astro +61 -0
- package/src/astro/search/SearchBoxForm.astro +509 -0
- package/src/astro/tables/CaptionedTable.astro +72 -0
- package/src/astro/videos/CoywolfVideo.astro +448 -0
- package/src/backups/download.ts +112 -0
- package/src/backups/dump.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -0
- package/src/backups/restore.ts +209 -0
- package/src/backups/store.ts +409 -0
- package/src/breadcrumbs/pack.ts +113 -0
- package/src/breadcrumbs/settings.ts +102 -0
- package/src/breadcrumbs/stamp.ts +40 -0
- package/src/breadcrumbs/trail.ts +123 -0
- package/src/codeBlocks/contrast.ts +179 -0
- package/src/codeBlocks/hljs.d.ts +5 -0
- package/src/codeBlocks/pack.ts +110 -0
- package/src/codeBlocks/render.ts +304 -0
- package/src/codeBlocks/settings.ts +18 -0
- package/src/codeBlocks/themes.generated.ts +304 -0
- package/src/codeBlocks/themes.ts +114 -0
- package/src/core/compose.ts +244 -0
- package/src/core/content-url.ts +888 -0
- package/src/core/csv.ts +119 -0
- package/src/core/d1-batch.ts +65 -0
- package/src/core/entry-docs.ts +175 -0
- package/src/core/features-module.ts +48 -0
- package/src/core/features.ts +392 -0
- package/src/core/generation.ts +88 -0
- package/src/core/guide-content.ts +166 -0
- package/src/core/module.ts +104 -0
- package/src/core/read-failures.ts +57 -0
- package/src/core/request-memo.ts +71 -0
- package/src/core/secrets.ts +83 -0
- package/src/core/settings-enhance.ts +74 -0
- package/src/core/site.ts +36 -0
- package/src/core/storage.ts +27 -0
- package/src/customBlocks/pack.ts +260 -0
- package/src/customBlocks/render.ts +424 -0
- package/src/customBlocks/rich.ts +313 -0
- package/src/customBlocks/settings.ts +46 -0
- package/src/discovery/build.ts +252 -0
- package/src/discovery/feed-build.ts +217 -0
- package/src/discovery/feed.ts +172 -0
- package/src/discovery/indexnow.ts +114 -0
- package/src/discovery/llms.ts +79 -0
- package/src/discovery/markdown.ts +294 -0
- package/src/discovery/memory-cache.ts +97 -0
- package/src/discovery/middleware.ts +186 -0
- package/src/discovery/module.ts +353 -0
- package/src/discovery/news.ts +99 -0
- package/src/discovery/pack.ts +58 -0
- package/src/discovery/settings.ts +91 -0
- package/src/discovery/urls.ts +35 -0
- package/src/env.d.ts +10 -0
- package/src/files/card.ts +231 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.ts +163 -0
- package/src/files/module.ts +596 -0
- package/src/files/pack.ts +92 -0
- package/src/files/r2-rules.ts +41 -0
- package/src/files/range.ts +50 -0
- package/src/files/s3.ts +114 -0
- package/src/files/serve.ts +141 -0
- package/src/files/sigv4.ts +160 -0
- package/src/files/site.ts +137 -0
- package/src/files/walker.ts +58 -0
- package/src/formUploads/lib.ts +266 -0
- package/src/formUploads/pack.ts +55 -0
- package/src/formUploads/wrap.ts +592 -0
- package/src/forms.ts +16 -0
- package/src/headings/pack.ts +154 -0
- package/src/headings/render.ts +139 -0
- package/src/headings/settings.ts +117 -0
- package/src/headings/slug.ts +68 -0
- package/src/headings/stamp.ts +142 -0
- package/src/headings/test-hooks.mjs +14 -0
- package/src/headings/toc.ts +48 -0
- package/src/images/cloudflare.ts +329 -0
- package/src/images/lib.ts +221 -0
- package/src/images/module.ts +372 -0
- package/src/images/pack.ts +804 -0
- package/src/images/pending.ts +37 -0
- package/src/images/settings.ts +48 -0
- package/src/images/setup.ts +192 -0
- package/src/images/variants-estimate.ts +162 -0
- package/src/images/variants-store.ts +475 -0
- package/src/images/variants.ts +311 -0
- package/src/index.ts +143 -0
- package/src/links/check.ts +101 -0
- package/src/links/classify.ts +270 -0
- package/src/links/export.ts +59 -0
- package/src/links/module.ts +652 -0
- package/src/links/pack.ts +43 -0
- package/src/links/pt.ts +325 -0
- package/src/links/store.ts +704 -0
- package/src/middleware.ts +287 -0
- package/src/modules.ts +53 -0
- package/src/options.ts +66 -0
- package/src/pageCache/lib.ts +274 -0
- package/src/pageCache/pack.ts +240 -0
- package/src/pageCache/usage-core.ts +152 -0
- package/src/pageCache/usage.ts +262 -0
- package/src/pageCache/warm.ts +722 -0
- package/src/redirects/middleware.ts +169 -0
- package/src/redirects/module.ts +124 -0
- package/src/redirects/pack.ts +89 -0
- package/src/redirects/removed-core.ts +151 -0
- package/src/redirects/removed.ts +293 -0
- package/src/redirects/rules.ts +355 -0
- package/src/redirects/slug-changes.ts +207 -0
- package/src/redirects/slug-core.ts +49 -0
- package/src/redirects/transfer.ts +92 -0
- package/src/reviews/lib.ts +624 -0
- package/src/reviews/pack.ts +135 -0
- package/src/reviews/schema.ts +128 -0
- package/src/reviews/settings.ts +26 -0
- package/src/robots/bots.ts +321 -0
- package/src/robots/data/bots.json +696 -0
- package/src/robots/data/verified.json +582 -0
- package/src/robots/directory.ts +26 -0
- package/src/robots/explain.ts +191 -0
- package/src/robots/guided.ts +300 -0
- package/src/robots/importer.ts +263 -0
- package/src/robots/middleware.ts +151 -0
- package/src/robots/module.ts +446 -0
- package/src/robots/pack.ts +69 -0
- package/src/robots/radar.ts +129 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.ts +883 -0
- package/src/robots/transfer.ts +45 -0
- package/src/robots/validate.ts +524 -0
- package/src/schema/catalog.ts +146 -0
- package/src/schema/graph.ts +791 -0
- package/src/schema/module.ts +1138 -0
- package/src/schema/og-image.ts +100 -0
- package/src/schema/pack.ts +58 -0
- package/src/search/engine.ts +578 -0
- package/src/search/fallback.ts +80 -0
- package/src/search/live-cache.ts +116 -0
- package/src/search/live-client.ts +770 -0
- package/src/search/live-serve.ts +282 -0
- package/src/search/module.ts +261 -0
- package/src/search/pack.ts +57 -0
- package/src/search/query.ts +132 -0
- package/src/search/ratelimit-core.ts +118 -0
- package/src/search/ratelimit.ts +79 -0
- package/src/search/snippet.ts +135 -0
- package/src/search/title-index.ts +139 -0
- package/src/search/title-match.ts +192 -0
- package/src/shared.ts +71 -0
- package/src/version.ts +2 -0
- package/src/videos/date.ts +40 -0
- package/src/videos/engagement.ts +75 -0
- package/src/videos/lib.ts +679 -0
- package/src/videos/module.ts +1160 -0
- package/src/videos/pack.ts +187 -0
- package/src/videos/pause.ts +42 -0
- package/src/videos/poster.ts +324 -0
- package/src/videos/render.ts +336 -0
- package/src/videos/store.ts +551 -0
- package/src/videos/stream.ts +134 -0
- package/src/wpImport/convert.ts +702 -0
- package/src/wpImport/guests.ts +329 -0
- package/src/wpImport/gutenberg.ts +157 -0
- package/src/wpImport/markers.ts +66 -0
- package/src/wpImport/pack.ts +451 -0
- package/src/wpImport/parents.ts +202 -0
- package/src/wpImport/prepare.ts +592 -0
- package/src/wpImport/redirects.ts +358 -0
- package/src/wpImport/sources.ts +67 -0
- package/src/wpImport/stream.ts +160 -0
- package/src/wpImport/urls.ts +354 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restores, through the Cloudflare API (a Worker can't create databases or
|
|
3
|
+
* time-travel through its D1 binding).
|
|
4
|
+
*
|
|
5
|
+
* Rewinding rolls the live database back, including this plugin's own
|
|
6
|
+
* settings and storage, so everything a restore depends on lives outside the
|
|
7
|
+
* database: the API token is a Worker secret, the account and database IDs are
|
|
8
|
+
* plugin options, and undo points are written to the backup bucket.
|
|
9
|
+
*/
|
|
10
|
+
import { PluginRouteError } from "emdash";
|
|
11
|
+
|
|
12
|
+
import { stampToDate } from "./store.js";
|
|
13
|
+
|
|
14
|
+
const API = "https://api.cloudflare.com/client/v4";
|
|
15
|
+
/** One file per rewind, newest last: undo pops the most recent, so earlier states stay reachable. */
|
|
16
|
+
const UNDO_PREFIX = "restore/undo/";
|
|
17
|
+
|
|
18
|
+
export interface RestoreConfig {
|
|
19
|
+
token: string;
|
|
20
|
+
accountId: string;
|
|
21
|
+
databaseId: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UndoPoint {
|
|
25
|
+
/** Bookmark of the database just before the rewind. */
|
|
26
|
+
bookmark: string;
|
|
27
|
+
rewoundAt: string;
|
|
28
|
+
rewoundTo: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function cf<T>(config: RestoreConfig, path: string, init: RequestInit = {}): Promise<T> {
|
|
32
|
+
const res = await fetch(`${API}/accounts/${config.accountId}${path}`, {
|
|
33
|
+
...init,
|
|
34
|
+
headers: { Authorization: `Bearer ${config.token}`, "Content-Type": "application/json", ...init.headers },
|
|
35
|
+
});
|
|
36
|
+
const body = (await res.json().catch(() => ({}))) as { success?: boolean; result?: T; errors?: Array<{ message: string }> };
|
|
37
|
+
if (!res.ok || body.success === false) {
|
|
38
|
+
// Surface the API's message in the admin (plain Errors become a generic 500).
|
|
39
|
+
throw PluginRouteError.badRequest(
|
|
40
|
+
`Cloudflare API ${res.status}: ${body.errors?.map((e) => e.message).join("; ") || res.statusText}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return body.result as T;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const db = (config: RestoreConfig) => `/d1/database/${config.databaseId}`;
|
|
47
|
+
|
|
48
|
+
export async function currentBookmark(config: RestoreConfig): Promise<string> {
|
|
49
|
+
const result = await cf<{ bookmark: string }>(config, `${db(config)}/time_travel/bookmark`);
|
|
50
|
+
return result.bookmark;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Roll the live database back to a backup's moment, saving an undo point first. */
|
|
54
|
+
export async function rewind(
|
|
55
|
+
config: RestoreConfig,
|
|
56
|
+
backups: R2Bucket,
|
|
57
|
+
target: { stamp: string; bookmark?: string },
|
|
58
|
+
): Promise<UndoPoint> {
|
|
59
|
+
const query = target.bookmark
|
|
60
|
+
? `bookmark=${encodeURIComponent(target.bookmark)}`
|
|
61
|
+
: `timestamp=${encodeURIComponent(stampToDate(target.stamp).toISOString())}`;
|
|
62
|
+
const before = await currentBookmark(config);
|
|
63
|
+
const undo: UndoPoint = { bookmark: before, rewoundAt: new Date().toISOString(), rewoundTo: target.stamp };
|
|
64
|
+
await backups.put(`${UNDO_PREFIX}${undo.rewoundAt}.json`, JSON.stringify(undo), {
|
|
65
|
+
httpMetadata: { contentType: "application/json" },
|
|
66
|
+
});
|
|
67
|
+
await cf(config, `${db(config)}/time_travel/restore?${query}`, { method: "POST" });
|
|
68
|
+
return undo;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function latestUndoKey(backups: R2Bucket): Promise<string | null> {
|
|
72
|
+
const keys: string[] = [];
|
|
73
|
+
let cursor: string | undefined;
|
|
74
|
+
do {
|
|
75
|
+
const page = await backups.list({ prefix: UNDO_PREFIX, cursor });
|
|
76
|
+
for (const o of page.objects) keys.push(o.key);
|
|
77
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
78
|
+
} while (cursor);
|
|
79
|
+
return keys.sort().at(-1) ?? null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The most recent rewind that hasn't been undone. */
|
|
83
|
+
export async function getUndo(backups: R2Bucket): Promise<UndoPoint | null> {
|
|
84
|
+
const key = await latestUndoKey(backups);
|
|
85
|
+
const object = key ? await backups.get(key) : null;
|
|
86
|
+
return object ? ((await object.json()) as UndoPoint) : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Return the database to where it was before the most recent rewind. */
|
|
90
|
+
export async function undoRewind(config: RestoreConfig, backups: R2Bucket): Promise<UndoPoint> {
|
|
91
|
+
const key = await latestUndoKey(backups);
|
|
92
|
+
const object = key ? await backups.get(key) : null;
|
|
93
|
+
if (!key || !object) throw PluginRouteError.badRequest("There is no rewind to undo.");
|
|
94
|
+
const undo = (await object.json()) as UndoPoint;
|
|
95
|
+
await cf(config, `${db(config)}/time_travel/restore?bookmark=${encodeURIComponent(undo.bookmark)}`, { method: "POST" });
|
|
96
|
+
await backups.delete(key);
|
|
97
|
+
return undo;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function md5(bytes: Uint8Array<ArrayBuffer>): Promise<string> {
|
|
101
|
+
// MD5 is what D1's import API uses as the upload ETag (Workers support it in WebCrypto).
|
|
102
|
+
const digest = await crypto.subtle.digest("MD5", bytes);
|
|
103
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface ImportStatus {
|
|
107
|
+
status?: "active" | "complete" | "error";
|
|
108
|
+
at_bookmark?: string;
|
|
109
|
+
upload_url?: string;
|
|
110
|
+
filename?: string;
|
|
111
|
+
error?: string;
|
|
112
|
+
success?: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface NewDatabaseRestore {
|
|
116
|
+
databaseId: string;
|
|
117
|
+
databaseName: string;
|
|
118
|
+
tables: number;
|
|
119
|
+
rows: number;
|
|
120
|
+
mismatches: Record<string, [number, number | undefined]>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Import a dump into a brand-new D1 database and verify row counts. The live
|
|
125
|
+
* site keeps running on its current database until its binding is switched.
|
|
126
|
+
*/
|
|
127
|
+
export async function restoreToNewDatabase(
|
|
128
|
+
config: RestoreConfig,
|
|
129
|
+
databaseName: string,
|
|
130
|
+
sql: string,
|
|
131
|
+
): Promise<NewDatabaseRestore> {
|
|
132
|
+
const suffix = crypto.randomUUID().slice(0, 6);
|
|
133
|
+
const created = await cf<{ uuid: string; name: string }>(config, "/d1/database", {
|
|
134
|
+
method: "POST",
|
|
135
|
+
body: JSON.stringify({ name: `${databaseName}-${suffix}` }),
|
|
136
|
+
});
|
|
137
|
+
const target: RestoreConfig = { ...config, databaseId: created.uuid };
|
|
138
|
+
try {
|
|
139
|
+
return await importAndVerify(target, created, sql);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
// Don't leave a half-imported database behind.
|
|
142
|
+
await cf(config, `/d1/database/${created.uuid}`, { method: "DELETE" }).catch(() => undefined);
|
|
143
|
+
throw error;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function importAndVerify(
|
|
148
|
+
target: RestoreConfig,
|
|
149
|
+
created: { uuid: string; name: string },
|
|
150
|
+
sql: string,
|
|
151
|
+
): Promise<NewDatabaseRestore> {
|
|
152
|
+
const path = `${db(target)}/import`;
|
|
153
|
+
|
|
154
|
+
const bytes = new TextEncoder().encode(sql) as Uint8Array<ArrayBuffer>;
|
|
155
|
+
const etag = await md5(bytes);
|
|
156
|
+
const init = await cf<ImportStatus>(target, path, { method: "POST", body: JSON.stringify({ action: "init", etag }) });
|
|
157
|
+
if (init.upload_url) {
|
|
158
|
+
const put = await fetch(init.upload_url, { method: "PUT", body: bytes });
|
|
159
|
+
if (!put.ok) throw PluginRouteError.badRequest(`Uploading the dump failed (${put.status})`);
|
|
160
|
+
}
|
|
161
|
+
let status = await cf<ImportStatus>(target, path, {
|
|
162
|
+
method: "POST",
|
|
163
|
+
body: JSON.stringify({ action: "ingest", etag, filename: init.filename }),
|
|
164
|
+
});
|
|
165
|
+
const bookmark = status.at_bookmark;
|
|
166
|
+
for (let i = 0; status.status !== "complete" && status.status !== "error" && i < 60; i++) {
|
|
167
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
168
|
+
status = await cf<ImportStatus>(target, path, {
|
|
169
|
+
method: "POST",
|
|
170
|
+
body: JSON.stringify({ action: "poll", current_bookmark: bookmark }),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (status.status !== "complete") {
|
|
174
|
+
throw PluginRouteError.badRequest(`Import ${status.status ?? "timed out"}: ${status.error ?? "no details"}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Verify: rows per table in the dump vs. the new database.
|
|
178
|
+
const expected = new Map<string, number>();
|
|
179
|
+
for (const match of sql.matchAll(/^INSERT INTO "((?:[^"]|"")+)"/gm)) {
|
|
180
|
+
const table = match[1].replace(/""/g, '"');
|
|
181
|
+
expected.set(table, (expected.get(table) ?? 0) + 1);
|
|
182
|
+
}
|
|
183
|
+
const tables = [...expected.keys()];
|
|
184
|
+
const actual = new Map<string, number>();
|
|
185
|
+
for (let i = 0; i < tables.length; i += 5) {
|
|
186
|
+
// D1 allows at most 5 terms in a compound SELECT.
|
|
187
|
+
const sqlCounts = tables
|
|
188
|
+
.slice(i, i + 5)
|
|
189
|
+
.map((t) => `SELECT '${t.replace(/'/g, "''")}' AS t, count(*) AS n FROM "${t.replace(/"/g, '""')}"`)
|
|
190
|
+
.join(" UNION ALL ");
|
|
191
|
+
const result = await cf<Array<{ results: Array<{ t: string; n: number }> }>>(target, `${db(target)}/query`, {
|
|
192
|
+
method: "POST",
|
|
193
|
+
body: JSON.stringify({ sql: sqlCounts }),
|
|
194
|
+
});
|
|
195
|
+
for (const row of result[0]?.results ?? []) actual.set(row.t, row.n);
|
|
196
|
+
}
|
|
197
|
+
const mismatches: NewDatabaseRestore["mismatches"] = {};
|
|
198
|
+
for (const [table, n] of expected) if (actual.get(table) !== n) mismatches[table] = [n, actual.get(table)];
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
databaseId: created.uuid,
|
|
202
|
+
databaseName: created.name,
|
|
203
|
+
tables: tables.length,
|
|
204
|
+
rows: [...expected.values()].reduce((a, b) => a + b, 0),
|
|
205
|
+
mismatches,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export { restoreMissingMedia } from "./store.js";
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backup storage layout (shared with .github/workflows/backup.yml):
|
|
3
|
+
*
|
|
4
|
+
* d1/<stamp>/<file>.sql.gz database dump
|
|
5
|
+
* d1/<stamp>/manifest.json what the dump is and where it came from
|
|
6
|
+
* media/current/<key> mirror of the media bucket
|
|
7
|
+
* media/changed/<stamp>/<key> media replaced or deleted since the previous run
|
|
8
|
+
* uploads/current/<key> mirror of the private form uploads bucket (when bound)
|
|
9
|
+
* uploads/changed/<stamp>/<key> uploads replaced or deleted since the previous run
|
|
10
|
+
*
|
|
11
|
+
* The backup bucket must stay private: uploads/ holds files people sent
|
|
12
|
+
* through forms, which aren't public on the site.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface Manifest {
|
|
16
|
+
stamp: string;
|
|
17
|
+
database?: string;
|
|
18
|
+
file: string;
|
|
19
|
+
bytes: number;
|
|
20
|
+
sha256: string;
|
|
21
|
+
/** "github-actions" (external nightly job), "scheduled" (plugin), or "admin" (Back up now). */
|
|
22
|
+
source?: string;
|
|
23
|
+
timeTravelBookmark?: string;
|
|
24
|
+
gitSha?: string;
|
|
25
|
+
tables?: number;
|
|
26
|
+
rows?: number;
|
|
27
|
+
media?: MirrorResult;
|
|
28
|
+
/** Private form uploads, when the site has an uploads bucket. */
|
|
29
|
+
uploads?: MirrorResult;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MirrorResult {
|
|
33
|
+
copied: number;
|
|
34
|
+
preserved: number;
|
|
35
|
+
total: number;
|
|
36
|
+
pending?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Folder in the backup bucket for each mirrored bucket. */
|
|
40
|
+
export type MirrorPrefix = "media" | "uploads";
|
|
41
|
+
|
|
42
|
+
export interface BackupEntry extends Manifest {
|
|
43
|
+
createdAt: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const STAMP = /^\d{4}-\d{2}-\d{2}T\d{4}Z$/;
|
|
47
|
+
export const FILE = /^[a-z0-9._-]+$/i;
|
|
48
|
+
|
|
49
|
+
/** 2026-10-03T1802Z — sortable, safe in object keys and URLs. */
|
|
50
|
+
export function makeStamp(date = new Date()): string {
|
|
51
|
+
const iso = date.toISOString();
|
|
52
|
+
return `${iso.slice(0, 13)}${iso.slice(14, 16)}Z`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function stampToDate(stamp: string): Date {
|
|
56
|
+
return new Date(`${stamp.slice(0, 13)}:${stamp.slice(13, 15)}:00Z`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function sha256(bytes: Uint8Array<ArrayBuffer>): Promise<string> {
|
|
60
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
61
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** R2's smallest multipart part. Every part but the last must be the same size. */
|
|
65
|
+
export const PART_BYTES = 5 * 1024 * 1024;
|
|
66
|
+
/** Text handed to the gzip stream at a time. */
|
|
67
|
+
const FEED_BYTES = 64 * 1024;
|
|
68
|
+
const utf8 = new TextEncoder();
|
|
69
|
+
|
|
70
|
+
/** SHA-256 over a stream of chunks: Workers' DigestStream, else (Node tests) the chunks kept and hashed at the end. */
|
|
71
|
+
function digester(): { update(chunk: Uint8Array<ArrayBuffer>): Promise<void>; digest(): Promise<string> } {
|
|
72
|
+
const hex = (digest: ArrayBuffer) => [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
73
|
+
const Stream = (crypto as unknown as { DigestStream?: new (algorithm: string) => WritableStream<Uint8Array> & { digest: Promise<ArrayBuffer> } }).DigestStream;
|
|
74
|
+
if (Stream) {
|
|
75
|
+
const stream = new Stream("SHA-256");
|
|
76
|
+
const writer = stream.getWriter();
|
|
77
|
+
return {
|
|
78
|
+
update: (chunk) => writer.write(chunk),
|
|
79
|
+
digest: async () => {
|
|
80
|
+
await writer.close();
|
|
81
|
+
return hex(await stream.digest);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const chunks: Uint8Array<ArrayBuffer>[] = [];
|
|
86
|
+
return {
|
|
87
|
+
update: async (chunk) => {
|
|
88
|
+
chunks.push(chunk);
|
|
89
|
+
},
|
|
90
|
+
digest: () => sha256(concat(chunks, chunks.reduce((n, c) => n + c.byteLength, 0))),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function concat(chunks: Uint8Array<ArrayBuffer>[], size: number): Uint8Array<ArrayBuffer> {
|
|
95
|
+
const out = new Uint8Array(size);
|
|
96
|
+
let at = 0;
|
|
97
|
+
for (const chunk of chunks) {
|
|
98
|
+
out.set(chunk, at);
|
|
99
|
+
at += chunk.byteLength;
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Gzip the dump's statements (one per line) straight into the backup bucket:
|
|
106
|
+
* one put when it's under a part, else an R2 multipart upload in PART_BYTES
|
|
107
|
+
* parts. Memory stays around one part plus a page of rows, however big the
|
|
108
|
+
* database. Returns the stored size and SHA-256 of the gzip file.
|
|
109
|
+
*/
|
|
110
|
+
export async function writeDumpStream(
|
|
111
|
+
bucket: R2Bucket,
|
|
112
|
+
stamp: string,
|
|
113
|
+
file: string,
|
|
114
|
+
lines: AsyncIterable<string>,
|
|
115
|
+
partBytes = PART_BYTES,
|
|
116
|
+
): Promise<{ bytes: number; sha256: string }> {
|
|
117
|
+
const key = `d1/${stamp}/${file}`;
|
|
118
|
+
const httpMetadata = { contentType: "application/gzip" };
|
|
119
|
+
const gzip = new CompressionStream("gzip");
|
|
120
|
+
const writer = gzip.writable.getWriter();
|
|
121
|
+
// Feed the compressor while the loop below drains it (each waits on the other).
|
|
122
|
+
/** An error from the dump itself (a failed query), which explains more than the stream errors it causes. */
|
|
123
|
+
let dumpError: { error: unknown } | null = null;
|
|
124
|
+
const feed = (async () => {
|
|
125
|
+
let batch = "";
|
|
126
|
+
const iterator = lines[Symbol.asyncIterator]();
|
|
127
|
+
for (;;) {
|
|
128
|
+
let next: IteratorResult<string>;
|
|
129
|
+
try {
|
|
130
|
+
next = await iterator.next();
|
|
131
|
+
} catch (error) {
|
|
132
|
+
dumpError = { error };
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
if (next.done) break;
|
|
136
|
+
batch += `${next.value}\n`;
|
|
137
|
+
if (batch.length >= FEED_BYTES) {
|
|
138
|
+
await writer.write(utf8.encode(batch));
|
|
139
|
+
batch = "";
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (batch) await writer.write(utf8.encode(batch));
|
|
143
|
+
await writer.close();
|
|
144
|
+
})();
|
|
145
|
+
feed.catch((error) => writer.abort(error).catch(() => undefined));
|
|
146
|
+
|
|
147
|
+
const hash = digester();
|
|
148
|
+
let pending: Uint8Array<ArrayBuffer>[] = [];
|
|
149
|
+
let pendingBytes = 0;
|
|
150
|
+
let bytes = 0;
|
|
151
|
+
/** The first `size` pending bytes as one buffer; the rest stay pending. */
|
|
152
|
+
const take = (size: number): Uint8Array<ArrayBuffer> => {
|
|
153
|
+
const all = concat(pending, pendingBytes);
|
|
154
|
+
pending = size < pendingBytes ? [all.slice(size)] : [];
|
|
155
|
+
pendingBytes -= size;
|
|
156
|
+
return size < all.byteLength ? all.slice(0, size) : all;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
let upload: R2MultipartUpload | null = null;
|
|
160
|
+
const parts: R2UploadedPart[] = [];
|
|
161
|
+
const reader = gzip.readable.getReader();
|
|
162
|
+
try {
|
|
163
|
+
for (;;) {
|
|
164
|
+
const { value, done } = await reader.read();
|
|
165
|
+
if (done) break;
|
|
166
|
+
const chunk = value as Uint8Array<ArrayBuffer>;
|
|
167
|
+
bytes += chunk.byteLength;
|
|
168
|
+
await hash.update(chunk);
|
|
169
|
+
pending.push(chunk);
|
|
170
|
+
pendingBytes += chunk.byteLength;
|
|
171
|
+
while (pendingBytes >= partBytes) {
|
|
172
|
+
upload ??= await bucket.createMultipartUpload(key, { httpMetadata });
|
|
173
|
+
parts.push(await upload.uploadPart(parts.length + 1, take(partBytes)));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
await feed;
|
|
177
|
+
const rest = take(pendingBytes);
|
|
178
|
+
if (upload) {
|
|
179
|
+
if (rest.byteLength) parts.push(await upload.uploadPart(parts.length + 1, rest));
|
|
180
|
+
await upload.complete(parts);
|
|
181
|
+
} else {
|
|
182
|
+
await bucket.put(key, rest, { httpMetadata });
|
|
183
|
+
}
|
|
184
|
+
} catch (error) {
|
|
185
|
+
await upload?.abort().catch(() => undefined);
|
|
186
|
+
// Unblock the feed (it may be waiting for room in the stream) and let it finish.
|
|
187
|
+
await reader.cancel(error).catch(() => undefined);
|
|
188
|
+
await feed.catch(() => undefined);
|
|
189
|
+
throw dumpError ? (dumpError as { error: unknown }).error : error;
|
|
190
|
+
}
|
|
191
|
+
return { bytes, sha256: await hash.digest() };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Write (or rewrite) a backup's manifest for a dump already stored. */
|
|
195
|
+
export async function writeManifest(
|
|
196
|
+
bucket: R2Bucket,
|
|
197
|
+
stamp: string,
|
|
198
|
+
file: string,
|
|
199
|
+
written: { bytes: number; sha256: string },
|
|
200
|
+
extra: Partial<Manifest>,
|
|
201
|
+
): Promise<Manifest> {
|
|
202
|
+
const manifest: Manifest = { ...extra, stamp, file, bytes: written.bytes, sha256: written.sha256 };
|
|
203
|
+
await bucket.put(`d1/${stamp}/manifest.json`, JSON.stringify(manifest, null, 1), {
|
|
204
|
+
httpMetadata: { contentType: "application/json" },
|
|
205
|
+
});
|
|
206
|
+
return manifest;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
/** Every backup with a manifest, newest first. */
|
|
211
|
+
export async function listBackups(bucket: R2Bucket): Promise<BackupEntry[]> {
|
|
212
|
+
const stamps: string[] = [];
|
|
213
|
+
let cursor: string | undefined;
|
|
214
|
+
do {
|
|
215
|
+
const page = await bucket.list({ prefix: "d1/", delimiter: "/", cursor });
|
|
216
|
+
for (const prefix of page.delimitedPrefixes) {
|
|
217
|
+
const stamp = prefix.slice(3, -1);
|
|
218
|
+
if (STAMP.test(stamp)) stamps.push(stamp);
|
|
219
|
+
}
|
|
220
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
221
|
+
} while (cursor);
|
|
222
|
+
|
|
223
|
+
const entries = await Promise.all(
|
|
224
|
+
stamps.map(async (stamp) => {
|
|
225
|
+
const object = await bucket.get(`d1/${stamp}/manifest.json`);
|
|
226
|
+
if (!object) return null;
|
|
227
|
+
try {
|
|
228
|
+
const manifest = (await object.json()) as Manifest;
|
|
229
|
+
return { ...manifest, stamp, createdAt: stampToDate(stamp).toISOString() };
|
|
230
|
+
} catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
}),
|
|
234
|
+
);
|
|
235
|
+
return entries.filter((e): e is BackupEntry => e !== null).sort((a, b) => b.stamp.localeCompare(a.stamp));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Mirror the media bucket into media/current/, moving anything replaced or
|
|
240
|
+
* deleted since the last run to media/changed/<stamp>/ (same as the nightly
|
|
241
|
+
* job's `rclone sync --backup-dir`). Compares by ETag.
|
|
242
|
+
*
|
|
243
|
+
* Copies at most `maxChanges` files per run so a large first mirror stays under
|
|
244
|
+
* the Workers subrequest limit; the rest are reported as `pending` and copied
|
|
245
|
+
* on the next run.
|
|
246
|
+
*/
|
|
247
|
+
export async function mirrorMedia(media: R2Bucket, backups: R2Bucket, stamp: string, maxChanges = 300): Promise<MirrorResult> {
|
|
248
|
+
return mirrorBucket(media, backups, stamp, "media", maxChanges);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** mirrorMedia for any bucket, into <prefix>/current/ and <prefix>/changed/<stamp>/. */
|
|
252
|
+
export async function mirrorBucket(
|
|
253
|
+
media: R2Bucket,
|
|
254
|
+
backups: R2Bucket,
|
|
255
|
+
stamp: string,
|
|
256
|
+
prefix: MirrorPrefix,
|
|
257
|
+
maxChanges = 300,
|
|
258
|
+
): Promise<MirrorResult> {
|
|
259
|
+
const current = `${prefix}/current/`;
|
|
260
|
+
const source = new Map<string, string>();
|
|
261
|
+
let cursor: string | undefined;
|
|
262
|
+
do {
|
|
263
|
+
const page = await media.list({ cursor });
|
|
264
|
+
for (const object of page.objects) source.set(object.key, object.etag);
|
|
265
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
266
|
+
} while (cursor);
|
|
267
|
+
|
|
268
|
+
const mirror = new Map<string, string>();
|
|
269
|
+
do {
|
|
270
|
+
const page = await backups.list({ prefix: current, cursor });
|
|
271
|
+
for (const object of page.objects) mirror.set(object.key.slice(current.length), object.etag);
|
|
272
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
273
|
+
} while (cursor);
|
|
274
|
+
|
|
275
|
+
let copied = 0;
|
|
276
|
+
let preserved = 0;
|
|
277
|
+
const keep = async (key: string) => {
|
|
278
|
+
const old = await backups.get(`${current}${key}`);
|
|
279
|
+
if (!old) return;
|
|
280
|
+
await backups.put(`${prefix}/changed/${stamp}/${key}`, old.body, { httpMetadata: old.httpMetadata });
|
|
281
|
+
preserved++;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// Decide the changes first, then make them a few files at a time.
|
|
285
|
+
const tasks: Array<() => Promise<void>> = [];
|
|
286
|
+
let pending = 0;
|
|
287
|
+
for (const [key, etag] of source) {
|
|
288
|
+
if (mirror.get(key) === etag) continue;
|
|
289
|
+
if (tasks.length >= maxChanges) {
|
|
290
|
+
pending++;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
tasks.push(async () => {
|
|
294
|
+
if (mirror.has(key)) await keep(key);
|
|
295
|
+
const object = await media.get(key);
|
|
296
|
+
if (!object) return;
|
|
297
|
+
await backups.put(`${current}${key}`, object.body, { httpMetadata: object.httpMetadata });
|
|
298
|
+
copied++;
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
for (const key of mirror.keys()) {
|
|
302
|
+
if (source.has(key)) continue;
|
|
303
|
+
if (tasks.length >= maxChanges) {
|
|
304
|
+
pending++;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
tasks.push(async () => {
|
|
308
|
+
await keep(key);
|
|
309
|
+
await backups.delete(`${current}${key}`);
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
await inPool(tasks, MIRROR_CONCURRENCY);
|
|
313
|
+
return { copied, preserved, total: source.size, pending };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Files copied at once when mirroring (each copy is a get and a put, streamed). */
|
|
317
|
+
export const MIRROR_CONCURRENCY = 8;
|
|
318
|
+
|
|
319
|
+
/** Run tasks with at most `limit` in flight; the first failure is thrown once the others settle. */
|
|
320
|
+
async function inPool(tasks: Array<() => Promise<void>>, limit: number): Promise<void> {
|
|
321
|
+
let next = 0;
|
|
322
|
+
let failure: { error: unknown } | null = null;
|
|
323
|
+
const worker = async () => {
|
|
324
|
+
while (next < tasks.length && !failure) {
|
|
325
|
+
const task = tasks[next++];
|
|
326
|
+
try {
|
|
327
|
+
await task();
|
|
328
|
+
} catch (error) {
|
|
329
|
+
failure ??= { error };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
|
|
334
|
+
if (failure) throw (failure as { error: unknown }).error;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Copy media files that are in the backup mirror but missing from the media
|
|
339
|
+
* bucket. Never overwrites. Copies at most `max` files per call so one request
|
|
340
|
+
* stays well inside Worker limits; `pending` is what's left for the next call
|
|
341
|
+
* (a coywolf.com restore trial copied 2,021 files in one request and took
|
|
342
|
+
* over five minutes).
|
|
343
|
+
*/
|
|
344
|
+
export async function restoreMissingMedia(
|
|
345
|
+
media: R2Bucket,
|
|
346
|
+
backups: R2Bucket,
|
|
347
|
+
max = 200,
|
|
348
|
+
prefix: MirrorPrefix = "media",
|
|
349
|
+
): Promise<{ restored: number; checked: number; pending: number }> {
|
|
350
|
+
const current = `${prefix}/current/`;
|
|
351
|
+
const present = new Set<string>();
|
|
352
|
+
let cursor: string | undefined;
|
|
353
|
+
do {
|
|
354
|
+
const page = await media.list({ cursor });
|
|
355
|
+
for (const o of page.objects) present.add(o.key);
|
|
356
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
357
|
+
} while (cursor);
|
|
358
|
+
|
|
359
|
+
let restored = 0;
|
|
360
|
+
let checked = 0;
|
|
361
|
+
let pending = 0;
|
|
362
|
+
do {
|
|
363
|
+
const page = await backups.list({ prefix: current, cursor });
|
|
364
|
+
for (const o of page.objects) {
|
|
365
|
+
checked++;
|
|
366
|
+
const key = o.key.slice(current.length);
|
|
367
|
+
if (present.has(key)) continue;
|
|
368
|
+
if (restored >= max) {
|
|
369
|
+
pending++;
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const object = await backups.get(o.key);
|
|
373
|
+
if (!object) continue;
|
|
374
|
+
await media.put(key, object.body, { httpMetadata: object.httpMetadata });
|
|
375
|
+
restored++;
|
|
376
|
+
}
|
|
377
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
378
|
+
} while (cursor);
|
|
379
|
+
return { restored, checked, pending };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Delete database backups and replaced media and upload copies older than `days`. Returns the number of stamps pruned. */
|
|
383
|
+
export async function pruneBackups(bucket: R2Bucket, days: number): Promise<number> {
|
|
384
|
+
const cutoff = makeStamp(new Date(Date.now() - days * 86_400_000));
|
|
385
|
+
let pruned = 0;
|
|
386
|
+
for (const prefix of ["d1/", "media/changed/", "uploads/changed/"]) {
|
|
387
|
+
let cursor: string | undefined;
|
|
388
|
+
do {
|
|
389
|
+
const page = await bucket.list({ prefix, delimiter: "/", cursor });
|
|
390
|
+
for (const dir of page.delimitedPrefixes) {
|
|
391
|
+
const stamp = dir.slice(prefix.length, -1);
|
|
392
|
+
if (!STAMP.test(stamp) || stamp >= cutoff) continue;
|
|
393
|
+
await deletePrefix(bucket, dir);
|
|
394
|
+
if (prefix === "d1/") pruned++;
|
|
395
|
+
}
|
|
396
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
397
|
+
} while (cursor);
|
|
398
|
+
}
|
|
399
|
+
return pruned;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function deletePrefix(bucket: R2Bucket, prefix: string) {
|
|
403
|
+
let cursor: string | undefined;
|
|
404
|
+
do {
|
|
405
|
+
const page = await bucket.list({ prefix, cursor });
|
|
406
|
+
if (page.objects.length) await bucket.delete(page.objects.map((o) => o.key));
|
|
407
|
+
cursor = page.truncated ? page.cursor : undefined;
|
|
408
|
+
} while (cursor);
|
|
409
|
+
}
|