@coywolf/emdash 0.0.0-stage → 0.35.13
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 +201 -2
- package/package.json +70 -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.test.ts +187 -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/queue.test.ts +97 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.test.ts +184 -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.test.mjs +287 -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.test.mjs +103 -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.test.ts +152 -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.test.ts +114 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.test.ts +93 -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.test.ts +30 -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.test.ts +64 -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/headings.test.ts +159 -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.test.mjs +224 -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.test.mjs +670 -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.test.ts +50 -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.test.ts +335 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.test.ts +251 -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.test.mjs +522 -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,287 @@
|
|
|
1
|
+
// Unit tests for the D1 dump: rows bigger than D1's statement limit restore intact.
|
|
2
|
+
// Run: node --test src/backups/dump.test.mjs (Node 22.6+ strips the types from dump.ts)
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { DatabaseSync } from "node:sqlite";
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
|
|
7
|
+
const { dumpDatabase, splitText, MAX_STATEMENT_BYTES, nextPageSize, PAGE_TARGET_BYTES } = await import("./dump.ts");
|
|
8
|
+
|
|
9
|
+
/** The slice of the D1 binding that dumpDatabase uses, over node:sqlite. */
|
|
10
|
+
function d1(db) {
|
|
11
|
+
return {
|
|
12
|
+
prepare(sql) {
|
|
13
|
+
let params = [];
|
|
14
|
+
const stmt = {
|
|
15
|
+
bind(...args) {
|
|
16
|
+
params = args;
|
|
17
|
+
return stmt;
|
|
18
|
+
},
|
|
19
|
+
async all() {
|
|
20
|
+
return { results: db.prepare(sql).all(...params) };
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
return stmt;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function seed() {
|
|
29
|
+
const db = new DatabaseSync(":memory:");
|
|
30
|
+
db.exec(`CREATE TABLE options (name TEXT PRIMARY KEY, value TEXT, revision INTEGER);
|
|
31
|
+
CREATE TABLE ec_posts (id TEXT PRIMARY KEY, slug TEXT, content TEXT, excerpt TEXT);
|
|
32
|
+
CREATE TABLE log (line TEXT);`);
|
|
33
|
+
const insert = (sql, ...v) => db.prepare(sql).run(...v);
|
|
34
|
+
// 250 KB with quotes, CR/LF, emoji (surrogate pairs) and accents, so pieces split on awkward characters.
|
|
35
|
+
const big = Array.from({ length: 5000 }, (_, i) => `Line ${i}: it's "quoted" — café 🦊\r\n`).join("");
|
|
36
|
+
insert("INSERT INTO ec_posts VALUES (?, ?, ?, ?)", "p1", "long", big, big.slice(0, 120_000));
|
|
37
|
+
insert("INSERT INTO ec_posts VALUES (?, ?, ?, ?)", "p2", "short", "hello\nworld", null);
|
|
38
|
+
insert("INSERT INTO options VALUES (?, ?, ?)", "site:title", "Coywolf", 1);
|
|
39
|
+
insert("INSERT INTO options VALUES (?, ?, ?)", "plugin:coywolf-pack:cache:discovery:llms", big, 1);
|
|
40
|
+
insert("INSERT INTO log VALUES (?)", big);
|
|
41
|
+
return { db, big };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test("oversized rows are split into statements under the limit and restore intact", async () => {
|
|
45
|
+
const { db, big } = seed();
|
|
46
|
+
const { sql } = await dumpDatabase(d1(db));
|
|
47
|
+
const statements = sql.trim().split("\n");
|
|
48
|
+
for (const s of statements) assert.ok(new TextEncoder().encode(s).length <= MAX_STATEMENT_BYTES, `statement of ${s.length} chars`);
|
|
49
|
+
assert.ok(statements.some((s) => s.startsWith('UPDATE "ec_posts"')), "long post written in pieces");
|
|
50
|
+
|
|
51
|
+
const restored = new DatabaseSync(":memory:");
|
|
52
|
+
for (const s of statements) restored.exec(s);
|
|
53
|
+
const post = restored.prepare("SELECT * FROM ec_posts WHERE id = 'p1'").get();
|
|
54
|
+
assert.equal(post.content, big);
|
|
55
|
+
assert.equal(post.excerpt, big.slice(0, 120_000));
|
|
56
|
+
assert.equal(restored.prepare("SELECT content FROM ec_posts WHERE id = 'p2'").get().content, "hello\nworld");
|
|
57
|
+
assert.equal(restored.prepare("SELECT line FROM log").get().line, big, "table without a primary key uses rowid");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("plugin caches are left out; other options are kept", async () => {
|
|
61
|
+
const { db } = seed();
|
|
62
|
+
const { sql, rows } = await dumpDatabase(d1(db));
|
|
63
|
+
assert.ok(!sql.includes("cache:discovery:llms"));
|
|
64
|
+
assert.ok(sql.includes("site:title"));
|
|
65
|
+
assert.equal(rows, 4);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("splitText never cuts a surrogate pair and rejoins exactly", () => {
|
|
69
|
+
const text = "🦊".repeat(40_000);
|
|
70
|
+
const pieces = splitText(text);
|
|
71
|
+
assert.ok(pieces.length > 1);
|
|
72
|
+
assert.equal(pieces.join(""), text);
|
|
73
|
+
for (const p of pieces) assert.ok(!/^[\udc00-\udfff]/.test(p));
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("rows are paged by rowid across pages, in rowid order, and restore intact", async () => {
|
|
77
|
+
const db = new DatabaseSync(":memory:");
|
|
78
|
+
db.exec(`CREATE TABLE items (id TEXT PRIMARY KEY, n INTEGER);
|
|
79
|
+
CREATE TABLE plain (line TEXT);
|
|
80
|
+
CREATE TABLE odd (oid TEXT, v INTEGER);
|
|
81
|
+
CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT) WITHOUT ROWID;`);
|
|
82
|
+
for (let i = 0; i < 1234; i++) {
|
|
83
|
+
db.prepare("INSERT INTO items VALUES (?, ?)").run(`id-${String(1234 - i).padStart(4, "0")}`, i);
|
|
84
|
+
db.prepare("INSERT INTO plain VALUES (?)").run(`line ${i}`);
|
|
85
|
+
db.prepare("INSERT INTO odd VALUES (?, ?)").run(`o${i}`, i);
|
|
86
|
+
db.prepare("INSERT INTO kv VALUES (?, ?)").run(`k${i}`, `v${i}`);
|
|
87
|
+
}
|
|
88
|
+
// Gaps in the rowids, as deletes leave them.
|
|
89
|
+
db.exec("DELETE FROM items WHERE n % 7 = 0; DELETE FROM plain WHERE rowid BETWEEN 400 AND 650;");
|
|
90
|
+
const queries = [];
|
|
91
|
+
const binding = d1(db);
|
|
92
|
+
const spy = { prepare: (sql) => (queries.push(sql), binding.prepare(sql)) };
|
|
93
|
+
const { sql, rows } = await dumpDatabase(spy);
|
|
94
|
+
assert.ok(queries.some((q) => /FROM "items" WHERE rowid > \? ORDER BY rowid LIMIT \?/.test(q)), "paged by rowid");
|
|
95
|
+
assert.ok(queries.some((q) => /FROM "odd" ORDER BY rowid LIMIT \? OFFSET \?/.test(q)), "a column named oid falls back to OFFSET");
|
|
96
|
+
assert.ok(queries.some((q) => /FROM "kv" LIMIT \? OFFSET \?/.test(q)), "WITHOUT ROWID uses OFFSET");
|
|
97
|
+
assert.ok(!sql.includes("__coywolf_rowid"), "the paging cursor is never written");
|
|
98
|
+
|
|
99
|
+
const restored = new DatabaseSync(":memory:");
|
|
100
|
+
for (const s of sql.trim().split("\n")) restored.exec(s);
|
|
101
|
+
for (const table of ["items", "plain", "odd", "kv"]) {
|
|
102
|
+
assert.deepEqual(restored.prepare(`SELECT * FROM ${table} ORDER BY 1`).all(), db.prepare(`SELECT * FROM ${table} ORDER BY 1`).all(), table);
|
|
103
|
+
}
|
|
104
|
+
// Same order as before (rowid order): the n column of items rises.
|
|
105
|
+
const ns = [...sql.matchAll(/INSERT INTO "items" \("id","n"\) VALUES\('[^']+',(\d+)\);/g)].map((m) => Number(m[1]));
|
|
106
|
+
assert.deepEqual(ns, [...ns].sort((a, b) => a - b));
|
|
107
|
+
assert.equal(rows, db.prepare("SELECT (SELECT count(*) FROM items) + (SELECT count(*) FROM plain) + 1234 + 1234 AS n").get().n);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
/** An R2 bucket that keeps what's put and records multipart uploads. */
|
|
111
|
+
function fakeBucket() {
|
|
112
|
+
const store = new Map();
|
|
113
|
+
const uploads = [];
|
|
114
|
+
return {
|
|
115
|
+
store,
|
|
116
|
+
uploads,
|
|
117
|
+
async put(key, body) {
|
|
118
|
+
store.set(key, Buffer.from(body));
|
|
119
|
+
},
|
|
120
|
+
async createMultipartUpload(key) {
|
|
121
|
+
const parts = [];
|
|
122
|
+
const upload = { key, parts, completed: false, aborted: false };
|
|
123
|
+
uploads.push(upload);
|
|
124
|
+
return {
|
|
125
|
+
async uploadPart(n, body) {
|
|
126
|
+
parts.push({ n, size: body.byteLength, body: Buffer.from(body) });
|
|
127
|
+
return { partNumber: n, etag: `e${n}` };
|
|
128
|
+
},
|
|
129
|
+
async complete(list) {
|
|
130
|
+
upload.completed = true;
|
|
131
|
+
store.set(key, Buffer.concat(list.map((p) => parts.find((x) => x.n === p.partNumber).body)));
|
|
132
|
+
},
|
|
133
|
+
async abort() {
|
|
134
|
+
upload.aborted = true;
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
test("the dump streams into the bucket: multipart parts of one size, same bytes as the whole", async () => {
|
|
142
|
+
const { writeDumpStream } = await import("./store.ts");
|
|
143
|
+
const { dumpLines } = await import("./dump.ts");
|
|
144
|
+
const { gunzipSync } = await import("node:zlib");
|
|
145
|
+
const { createHash } = await import("node:crypto");
|
|
146
|
+
const { db } = seed();
|
|
147
|
+
const lines = [];
|
|
148
|
+
for await (const line of dumpLines(d1(db))) lines.push(line);
|
|
149
|
+
async function* replay() {
|
|
150
|
+
yield* lines;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const big = fakeBucket();
|
|
154
|
+
const result = await writeDumpStream(big, "2026-10-05T1200Z", "site.sql.gz", replay(), 4096);
|
|
155
|
+
const stored = big.store.get("d1/2026-10-05T1200Z/site.sql.gz");
|
|
156
|
+
assert.equal(big.uploads.length, 1);
|
|
157
|
+
const sizes = big.uploads[0].parts.map((p) => p.size);
|
|
158
|
+
assert.ok(sizes.length > 2, `${sizes.length} parts`);
|
|
159
|
+
assert.ok(sizes.slice(0, -1).every((s) => s === 4096), "every part but the last is exactly one part size");
|
|
160
|
+
assert.equal(result.bytes, stored.length);
|
|
161
|
+
assert.equal(result.sha256, createHash("sha256").update(stored).digest("hex"));
|
|
162
|
+
assert.equal(gunzipSync(stored).toString("utf8"), `${lines.join("\n")}\n`);
|
|
163
|
+
|
|
164
|
+
const small = fakeBucket();
|
|
165
|
+
await writeDumpStream(small, "2026-10-05T1200Z", "site.sql.gz", replay());
|
|
166
|
+
assert.equal(small.uploads.length, 0, "under one part: a single put");
|
|
167
|
+
assert.equal(gunzipSync(small.store.get("d1/2026-10-05T1200Z/site.sql.gz")).toString("utf8"), (await dumpDatabase(d1(db))).sql);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("a failed dump aborts the upload and reports the dump's error", async () => {
|
|
171
|
+
const { writeDumpStream } = await import("./store.ts");
|
|
172
|
+
const bucket = fakeBucket();
|
|
173
|
+
async function* failing() {
|
|
174
|
+
for (let i = 0; i < 4000; i++) yield `${Math.random().toString(36)}${Math.random().toString(36)}`;
|
|
175
|
+
throw new Error("D1 went away");
|
|
176
|
+
}
|
|
177
|
+
await assert.rejects(() => writeDumpStream(bucket, "2026-10-05T1200Z", "x.sql.gz", failing(), 1024), /D1 went away/);
|
|
178
|
+
assert.equal(bucket.uploads.length, 1);
|
|
179
|
+
assert.equal(bucket.uploads[0].aborted, true);
|
|
180
|
+
assert.equal(bucket.store.size, 0);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a failed upload stops the dump instead of hanging", async () => {
|
|
184
|
+
const { writeDumpStream } = await import("./store.ts");
|
|
185
|
+
let aborted = false;
|
|
186
|
+
const bucket = {
|
|
187
|
+
async put() {},
|
|
188
|
+
async createMultipartUpload() {
|
|
189
|
+
return {
|
|
190
|
+
uploadPart: async () => {
|
|
191
|
+
throw new Error("R2 unavailable");
|
|
192
|
+
},
|
|
193
|
+
complete: async () => {},
|
|
194
|
+
abort: async () => {
|
|
195
|
+
aborted = true;
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
let produced = 0;
|
|
201
|
+
async function* endless() {
|
|
202
|
+
for (;;) {
|
|
203
|
+
produced++;
|
|
204
|
+
yield `${Math.random().toString(36)}${Math.random().toString(36)}${Math.random().toString(36)}`;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
await assert.rejects(() => writeDumpStream(bucket, "2026-10-05T1200Z", "x.sql.gz", endless(), 1024), /R2 unavailable/);
|
|
208
|
+
assert.equal(aborted, true);
|
|
209
|
+
const stopped = produced;
|
|
210
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
211
|
+
assert.equal(produced, stopped, "the dump stops reading rows");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("reads are sized by bytes: big rows mean fewer rows per read, never more than 500", () => {
|
|
215
|
+
assert.equal(nextPageSize(0, 0), 500);
|
|
216
|
+
assert.equal(nextPageSize(500 * 200, 500), 500, "small rows: the full page");
|
|
217
|
+
assert.equal(nextPageSize(10 * 1024 * 1024, 10), Math.floor(PAGE_TARGET_BYTES / (1024 * 1024)), "1 MB rows: about 8 a read");
|
|
218
|
+
assert.equal(nextPageSize(50 * 1024 * 1024, 1), 1, "never below one row");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("a read D1 refuses as too large is asked again for fewer rows; offset paging skips nothing", async () => {
|
|
222
|
+
// 600 rows of ~40 KB each in a table paged by OFFSET (a column named rowid shadows the real one),
|
|
223
|
+
// with a fake D1 that refuses any result over 5 MB, as D1 does over 50 MB.
|
|
224
|
+
const db = new DatabaseSync(":memory:");
|
|
225
|
+
db.exec("CREATE TABLE odd (rowid TEXT, body TEXT)");
|
|
226
|
+
const body = "x".repeat(40_000);
|
|
227
|
+
for (let i = 0; i < 600; i++) db.prepare("INSERT INTO odd VALUES (?, ?)").run(`r${i}`, `${i}:${body}`);
|
|
228
|
+
const base = d1(db);
|
|
229
|
+
let refused = 0;
|
|
230
|
+
const limited = {
|
|
231
|
+
prepare(sql) {
|
|
232
|
+
const stmt = base.prepare(sql);
|
|
233
|
+
let params = [];
|
|
234
|
+
return {
|
|
235
|
+
bind(...args) {
|
|
236
|
+
params = args;
|
|
237
|
+
stmt.bind(...args);
|
|
238
|
+
return this;
|
|
239
|
+
},
|
|
240
|
+
async all() {
|
|
241
|
+
const out = await stmt.all();
|
|
242
|
+
const size = JSON.stringify(out.results).length;
|
|
243
|
+
if (/FROM "odd"/.test(sql) && size > 5_000_000) {
|
|
244
|
+
refused++;
|
|
245
|
+
throw new Error("D1_ERROR: result too large");
|
|
246
|
+
}
|
|
247
|
+
return out;
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
const { sql, rows } = await dumpDatabase(limited);
|
|
253
|
+
assert.ok(refused >= 1, "the first 500-row read was refused");
|
|
254
|
+
assert.equal(rows, 600);
|
|
255
|
+
const restored = new DatabaseSync(":memory:");
|
|
256
|
+
for (const s of sql.trim().split("\n")) restored.exec(s);
|
|
257
|
+
assert.equal(restored.prepare("SELECT count(*) AS n FROM odd").get().n, 600, "every row once");
|
|
258
|
+
assert.equal(restored.prepare("SELECT count(DISTINCT rowid) AS n FROM odd").get().n, 600, "no row twice");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("other read errors aren't retried with fewer rows: they fail the backup at once", async () => {
|
|
262
|
+
const db = new DatabaseSync(":memory:");
|
|
263
|
+
db.exec("CREATE TABLE t (id TEXT PRIMARY KEY, v TEXT)");
|
|
264
|
+
db.prepare("INSERT INTO t VALUES ('a', 'b')").run();
|
|
265
|
+
const base = d1(db);
|
|
266
|
+
let reads = 0;
|
|
267
|
+
const failing = {
|
|
268
|
+
prepare(sql) {
|
|
269
|
+
const stmt = base.prepare(sql);
|
|
270
|
+
return {
|
|
271
|
+
bind(...args) {
|
|
272
|
+
stmt.bind(...args);
|
|
273
|
+
return this;
|
|
274
|
+
},
|
|
275
|
+
async all() {
|
|
276
|
+
if (/FROM "t"/.test(sql)) {
|
|
277
|
+
reads++;
|
|
278
|
+
throw new Error("D1_ERROR: Network connection lost.");
|
|
279
|
+
}
|
|
280
|
+
return stmt.all();
|
|
281
|
+
},
|
|
282
|
+
};
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
await assert.rejects(dumpDatabase(failing), /Network connection lost/);
|
|
286
|
+
assert.equal(reads, 1, "asked once, not again with fewer rows");
|
|
287
|
+
});
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restorable SQL dump of a D1 database, built from inside the Worker.
|
|
3
|
+
*
|
|
4
|
+
* Same output order as scripts/backup/d1-backup.sh, so either kind of dump
|
|
5
|
+
* restores with scripts/backup/d1-restore.sh:
|
|
6
|
+
*
|
|
7
|
+
* 1. CREATE TABLE for every regular table
|
|
8
|
+
* 2. FTS5 search tables and the triggers that maintain them
|
|
9
|
+
* 3. INSERT rows, one statement per line (the search triggers re-index
|
|
10
|
+
* content rows as they load)
|
|
11
|
+
* 4. indexes and the remaining triggers
|
|
12
|
+
*
|
|
13
|
+
* FTS5 shadow tables are skipped: SQLite recreates them with the virtual table.
|
|
14
|
+
*
|
|
15
|
+
* D1 rejects any statement over 100 KB (SQLITE_TOOBIG), but the site writes
|
|
16
|
+
* rows with bound parameters, so a long post can be bigger than that. A row
|
|
17
|
+
* whose INSERT would be too long is written with its long text values cut
|
|
18
|
+
* short, followed by UPDATEs that append the rest in pieces under the limit.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
interface MasterRow {
|
|
22
|
+
type: string;
|
|
23
|
+
name: string;
|
|
24
|
+
tbl_name: string;
|
|
25
|
+
sql: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const PAGE_SIZE = 500;
|
|
29
|
+
/**
|
|
30
|
+
* About how much a read of rows should return. D1 refuses query results over 50 MB (from
|
|
31
|
+
* November 4, 2026); 500 rows of a table with very large rows (long posts, revisions) could
|
|
32
|
+
* pass that, so each read is sized from the bytes the previous one returned.
|
|
33
|
+
*/
|
|
34
|
+
export const PAGE_TARGET_BYTES = 8 * 1024 * 1024;
|
|
35
|
+
/** How D1 words a result over its size limit (its message has said "too large", "exceeds" or "limit" with a size). */
|
|
36
|
+
const RESULT_TOO_LARGE = /too large|too big|exceed|size limit|limit.*(bytes|MB)|SQLITE_TOOBIG/i;
|
|
37
|
+
|
|
38
|
+
/** Rows to ask for next, from the size of the rows just read. */
|
|
39
|
+
export function nextPageSize(pageBytes: number, rows: number): number {
|
|
40
|
+
if (rows <= 0) return PAGE_SIZE;
|
|
41
|
+
const perRow = Math.max(1, pageBytes / rows);
|
|
42
|
+
return Math.max(1, Math.min(PAGE_SIZE, Math.floor(PAGE_TARGET_BYTES / perRow)));
|
|
43
|
+
}
|
|
44
|
+
const SEARCH_PREFIX = "_emdash_fts_";
|
|
45
|
+
/** Longest statement written, in UTF-8 bytes; D1's limit is 100,000. */
|
|
46
|
+
export const MAX_STATEMENT_BYTES = 90_000;
|
|
47
|
+
/** Most UTF-8 bytes of escaped text per piece of a split value. */
|
|
48
|
+
const PIECE_BYTES = 60_000;
|
|
49
|
+
/** In a row too long for one INSERT, text values longer than this are appended separately. */
|
|
50
|
+
const SHORT_BYTES = 2_000;
|
|
51
|
+
/**
|
|
52
|
+
* Line breaks are written as '||char(10)||', two operators each, and SQLite
|
|
53
|
+
* refuses expressions deeper than 1,000: at most this many per statement.
|
|
54
|
+
*/
|
|
55
|
+
const MAX_BREAKS = 400;
|
|
56
|
+
const breaks = (s: string) => s.match(/[\r\n]/g)?.length ?? 0;
|
|
57
|
+
/** Option rows that the plugin rebuilds on demand; not worth backing up. */
|
|
58
|
+
const SKIP_OPTION = /^plugin:coywolf-pack:cache:/;
|
|
59
|
+
|
|
60
|
+
function isInternal(name: string): boolean {
|
|
61
|
+
return name.startsWith("sqlite_") || name.startsWith("_cf_");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function quoteIdent(name: string): string {
|
|
65
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** SQL literal for a D1 value, keeping every statement on one line (like sqlite3 .dump). */
|
|
69
|
+
export function sqlLiteral(value: unknown): string {
|
|
70
|
+
if (value === null || value === undefined) return "NULL";
|
|
71
|
+
if (typeof value === "number") return Number.isFinite(value) ? String(value) : "NULL";
|
|
72
|
+
if (typeof value === "bigint") return value.toString();
|
|
73
|
+
if (typeof value === "boolean") return value ? "1" : "0";
|
|
74
|
+
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value) || Array.isArray(value)) {
|
|
75
|
+
const bytes =
|
|
76
|
+
value instanceof ArrayBuffer
|
|
77
|
+
? new Uint8Array(value)
|
|
78
|
+
: Array.isArray(value)
|
|
79
|
+
? Uint8Array.from(value as number[])
|
|
80
|
+
: new Uint8Array((value as ArrayBufferView).buffer, (value as ArrayBufferView).byteOffset, (value as ArrayBufferView).byteLength);
|
|
81
|
+
let hex = "";
|
|
82
|
+
for (const b of bytes) hex += b.toString(16).padStart(2, "0");
|
|
83
|
+
return `X'${hex}'`;
|
|
84
|
+
}
|
|
85
|
+
// Newlines become ||char(10)|| so each INSERT stays on one line.
|
|
86
|
+
return `'${escapeText(String(value))}'`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function escapeText(text: string): string {
|
|
90
|
+
return text.replace(/'/g, "''").replace(/\r/g, "'||char(13)||'").replace(/\n/g, "'||char(10)||'");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const utf8 = new TextEncoder();
|
|
94
|
+
const bytes = (s: string) => utf8.encode(s).length;
|
|
95
|
+
|
|
96
|
+
/** Split text into pieces whose escaped form stays under PIECE_BYTES (never inside a surrogate pair). */
|
|
97
|
+
export function splitText(text: string): string[] {
|
|
98
|
+
const pieces: string[] = [];
|
|
99
|
+
let start = 0;
|
|
100
|
+
let size = 0;
|
|
101
|
+
let lines = 0;
|
|
102
|
+
for (let i = 0; i < text.length; i++) {
|
|
103
|
+
const code = text.charCodeAt(i);
|
|
104
|
+
const pair = code >= 0xd800 && code <= 0xdbff && i + 1 < text.length;
|
|
105
|
+
const char = pair ? text.slice(i, i + 2) : text[i];
|
|
106
|
+
const cost = bytes(escapeText(char));
|
|
107
|
+
const isBreak = char === "\n" || char === "\r";
|
|
108
|
+
if ((size + cost > PIECE_BYTES || (isBreak && lines >= MAX_BREAKS)) && i > start) {
|
|
109
|
+
pieces.push(text.slice(start, i));
|
|
110
|
+
start = i;
|
|
111
|
+
size = 0;
|
|
112
|
+
lines = 0;
|
|
113
|
+
}
|
|
114
|
+
size += cost;
|
|
115
|
+
if (isBreak) lines++;
|
|
116
|
+
if (pair) i++;
|
|
117
|
+
}
|
|
118
|
+
pieces.push(text.slice(start));
|
|
119
|
+
return pieces;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* One row as statements: a single INSERT when it fits, otherwise an INSERT
|
|
124
|
+
* with each long text value's first piece, then UPDATEs appending the rest.
|
|
125
|
+
* `key` names the columns that identify the row (primary key, or rowid).
|
|
126
|
+
*/
|
|
127
|
+
export function rowStatements(table: string, row: Record<string, unknown>, key: Record<string, unknown>): string[] {
|
|
128
|
+
const columns = Object.keys(row).filter((c) => c !== ROWID);
|
|
129
|
+
const insert = (values: Record<string, unknown>) =>
|
|
130
|
+
`INSERT INTO ${table} (${columns.map(quoteIdent).join(",")}) VALUES(${columns.map((c) => sqlLiteral(values[c])).join(",")});`;
|
|
131
|
+
const whole = insert(row);
|
|
132
|
+
const lineCount = columns.reduce((n, c) => n + (typeof row[c] === "string" ? breaks(row[c] as string) : 0), 0);
|
|
133
|
+
if (bytes(whole) <= MAX_STATEMENT_BYTES && lineCount <= MAX_BREAKS) return [whole];
|
|
134
|
+
|
|
135
|
+
// Long text values start empty and are appended piece by piece, so the
|
|
136
|
+
// INSERT holds only short values however many long columns the row has.
|
|
137
|
+
const first: Record<string, unknown> = { ...row };
|
|
138
|
+
const rest: Array<[string, string]> = [];
|
|
139
|
+
for (const c of columns) {
|
|
140
|
+
const value = row[c];
|
|
141
|
+
if (typeof value !== "string" || (bytes(sqlLiteral(value)) <= SHORT_BYTES && breaks(value) <= 20)) continue;
|
|
142
|
+
first[c] = "";
|
|
143
|
+
for (const piece of splitText(value)) rest.push([c, piece]);
|
|
144
|
+
}
|
|
145
|
+
const where = Object.entries(key)
|
|
146
|
+
.map(([c, v]) => `${c === ROWID ? "rowid" : quoteIdent(c)}=${sqlLiteral(v)}`)
|
|
147
|
+
.join(" AND ");
|
|
148
|
+
return [
|
|
149
|
+
insert(first),
|
|
150
|
+
...rest.map(([c, piece]) => `UPDATE ${table} SET ${quoteIdent(c)}=${quoteIdent(c)}||${sqlLiteral(piece)} WHERE ${where};`),
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Alias for rowid in dump queries of tables without a primary key. */
|
|
155
|
+
const ROWID = "__coywolf_rowid";
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Parents before children. D1 may apply a large import in several batches, so
|
|
159
|
+
* PRAGMA defer_foreign_keys can't be relied on to cover forward references.
|
|
160
|
+
*/
|
|
161
|
+
async function orderByForeignKeys(db: D1Database, tables: MasterRow[]): Promise<MasterRow[]> {
|
|
162
|
+
const names = new Set(tables.map((t) => t.name));
|
|
163
|
+
const parents = new Map<string, Set<string>>();
|
|
164
|
+
for (const t of tables) {
|
|
165
|
+
const { results } = await db.prepare(`PRAGMA foreign_key_list(${quoteIdent(t.name)})`).all<{ table: string }>();
|
|
166
|
+
parents.set(t.name, new Set(results.map((r) => r.table).filter((p) => p !== t.name && names.has(p))));
|
|
167
|
+
}
|
|
168
|
+
const ordered: MasterRow[] = [];
|
|
169
|
+
const done = new Set<string>();
|
|
170
|
+
const visiting = new Set<string>();
|
|
171
|
+
const byName = new Map(tables.map((t) => [t.name, t]));
|
|
172
|
+
const visit = (name: string) => {
|
|
173
|
+
if (done.has(name) || visiting.has(name)) return; // a cycle falls back to name order
|
|
174
|
+
visiting.add(name);
|
|
175
|
+
for (const parent of parents.get(name) ?? []) visit(parent);
|
|
176
|
+
visiting.delete(name);
|
|
177
|
+
done.add(name);
|
|
178
|
+
ordered.push(byName.get(name)!);
|
|
179
|
+
};
|
|
180
|
+
for (const t of tables) visit(t.name);
|
|
181
|
+
return ordered;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface DumpResult {
|
|
185
|
+
sql: string;
|
|
186
|
+
tables: number;
|
|
187
|
+
rows: number;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** The whole dump as one string (tests, small databases). Backups stream dumpLines instead. */
|
|
191
|
+
export async function dumpDatabase(db: D1Database): Promise<DumpResult> {
|
|
192
|
+
const counts = { tables: 0, rows: 0 };
|
|
193
|
+
const out: string[] = [];
|
|
194
|
+
for await (const line of dumpLines(db, counts)) out.push(line);
|
|
195
|
+
return { sql: `${out.join("\n")}\n`, ...counts };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Columns that, when a table declares them, hide the real rowid from `rowid` in queries. */
|
|
199
|
+
const ROWID_NAMES = new Set(["rowid", "_rowid_", "oid"]);
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The dump, one statement per item (no trailing newline), read a page of rows
|
|
203
|
+
* at a time so memory stays bounded however big the database is. `counts` is
|
|
204
|
+
* filled in as it goes (complete once the iteration ends). Tables with a
|
|
205
|
+
* rowid are paged by rowid (`WHERE rowid > ?`), which stays fast on big
|
|
206
|
+
* tables where OFFSET rescans every earlier row; WITHOUT ROWID tables use OFFSET.
|
|
207
|
+
*/
|
|
208
|
+
export async function* dumpLines(db: D1Database, counts: { tables: number; rows: number } = { tables: 0, rows: 0 }): AsyncGenerator<string> {
|
|
209
|
+
const { results: master } = await db
|
|
210
|
+
.prepare("SELECT type, name, tbl_name, sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY name")
|
|
211
|
+
.all<MasterRow>();
|
|
212
|
+
|
|
213
|
+
const isSearch = (row: MasterRow) =>
|
|
214
|
+
row.name.startsWith(SEARCH_PREFIX) || row.tbl_name.startsWith(SEARCH_PREFIX) || (row.sql ?? "").includes(SEARCH_PREFIX);
|
|
215
|
+
|
|
216
|
+
const tables = master.filter((r) => r.type === "table" && !isInternal(r.name) && !r.name.startsWith(SEARCH_PREFIX));
|
|
217
|
+
const searchTables = master.filter(
|
|
218
|
+
(r) => r.type === "table" && r.name.startsWith(SEARCH_PREFIX) && /^CREATE VIRTUAL TABLE/i.test(r.sql ?? ""),
|
|
219
|
+
);
|
|
220
|
+
const searchTriggers = master.filter((r) => r.type === "trigger" && r.name.startsWith(SEARCH_PREFIX));
|
|
221
|
+
const indexes = master.filter((r) => r.type === "index" && !isInternal(r.name) && !isSearch(r));
|
|
222
|
+
const triggers = master.filter((r) => r.type === "trigger" && !isSearch(r));
|
|
223
|
+
|
|
224
|
+
const ordered = await orderByForeignKeys(db, tables);
|
|
225
|
+
|
|
226
|
+
const statement = (sql: string) => `${sql.trim().replace(/;$/, "")};`;
|
|
227
|
+
counts.tables = tables.length;
|
|
228
|
+
yield "PRAGMA defer_foreign_keys=TRUE;";
|
|
229
|
+
|
|
230
|
+
for (const t of tables) yield statement((t.sql ?? "").replace(/^CREATE TABLE /i, "CREATE TABLE IF NOT EXISTS "));
|
|
231
|
+
for (const t of searchTables) yield statement(t.sql ?? "");
|
|
232
|
+
for (const t of searchTriggers) yield statement(t.sql ?? "");
|
|
233
|
+
|
|
234
|
+
for (const t of ordered) {
|
|
235
|
+
const table = quoteIdent(t.name);
|
|
236
|
+
const { results: info } = await db.prepare(`PRAGMA table_info(${table})`).all<{ name: string; pk: number }>();
|
|
237
|
+
const withRowid = !/WITHOUT\s+ROWID/i.test(t.sql ?? "");
|
|
238
|
+
// A column named rowid (or oid) shadows the real one: page that table by OFFSET.
|
|
239
|
+
const byRowid = withRowid && !info.some((c) => ROWID_NAMES.has(c.name.toLowerCase()));
|
|
240
|
+
const pk = info.filter((c) => c.pk > 0).map((c) => c.name);
|
|
241
|
+
// Rows are identified by primary key for split values; tables without one by rowid.
|
|
242
|
+
const keyColumns = pk.length || !withRowid ? pk : [ROWID];
|
|
243
|
+
// The rowid alias is never written out (rowStatements skips it); it's the paging cursor.
|
|
244
|
+
const select = byRowid || (withRowid && !pk.length) ? `rowid AS ${ROWID}, *` : "*";
|
|
245
|
+
const order = withRowid ? " ORDER BY rowid" : "";
|
|
246
|
+
let after: unknown = null;
|
|
247
|
+
let offset = 0;
|
|
248
|
+
let limit = PAGE_SIZE;
|
|
249
|
+
for (;;) {
|
|
250
|
+
let results: Record<string, unknown>[];
|
|
251
|
+
try {
|
|
252
|
+
({ results } = byRowid
|
|
253
|
+
? await db
|
|
254
|
+
.prepare(`SELECT ${select} FROM ${table}${after === null ? "" : ` WHERE rowid > ?`}${order} LIMIT ?`)
|
|
255
|
+
.bind(...(after === null ? [limit] : [after, limit]))
|
|
256
|
+
.all<Record<string, unknown>>()
|
|
257
|
+
: await db
|
|
258
|
+
.prepare(`SELECT ${select} FROM ${table}${order} LIMIT ? OFFSET ?`)
|
|
259
|
+
.bind(limit, offset)
|
|
260
|
+
.all<Record<string, unknown>>());
|
|
261
|
+
} catch (error) {
|
|
262
|
+
// D1 refuses results over 50 MB (from November 4, 2026). Ask again for fewer rows, down to one.
|
|
263
|
+
// Any other error (an outage, a bad query) goes straight up instead of being retried.
|
|
264
|
+
if (limit === 1 || !RESULT_TOO_LARGE.test(String(error))) throw error;
|
|
265
|
+
limit = Math.max(1, Math.floor(limit / 4));
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
let pageBytes = 0;
|
|
269
|
+
for (const row of results) {
|
|
270
|
+
if (t.name === "options" && typeof row.name === "string" && SKIP_OPTION.test(row.name)) continue;
|
|
271
|
+
const key = Object.fromEntries(keyColumns.map((c) => [c, row[c]]));
|
|
272
|
+
for (const sql of rowStatements(table, row, key)) {
|
|
273
|
+
pageBytes += sql.length;
|
|
274
|
+
yield sql;
|
|
275
|
+
}
|
|
276
|
+
counts.rows++;
|
|
277
|
+
}
|
|
278
|
+
if (results.length < limit) break;
|
|
279
|
+
offset += results.length;
|
|
280
|
+
if (byRowid) after = results[results.length - 1][ROWID];
|
|
281
|
+
// Size the next read from this one: about PAGE_TARGET_BYTES, never more than PAGE_SIZE rows.
|
|
282
|
+
limit = nextPageSize(pageBytes, results.length);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const i of indexes) yield statement(i.sql ?? "");
|
|
287
|
+
for (const t of triggers) yield statement(t.sql ?? "");
|
|
288
|
+
}
|