cursedbelt-server 4.43.2 → 4.44.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/dist/server/analytics/webAnalytics.d.ts +13 -0
- package/dist/server/analytics/webAnalytics.js +20 -1
- package/dist/server/d1/pullD1.d.ts +17 -0
- package/dist/server/d1/pullD1.js +47 -10
- package/package.json +1 -1
- package/src/server/analytics/webAnalytics.spec.ts +45 -2
- package/src/server/analytics/webAnalytics.ts +20 -1
- package/src/server/d1/pullD1.spec.ts +74 -0
- package/src/server/d1/pullD1.ts +60 -15
|
@@ -116,6 +116,19 @@ export declare const WEB_ANALYTICS_PUBLIC_HOST = "cursedalchemy.com";
|
|
|
116
116
|
* production `dist` served from `127.0.0.1`, so a production-only injection is still a console
|
|
117
117
|
* error there. The hostname is the honest discriminator, not the build mode.
|
|
118
118
|
*
|
|
119
|
+
* ## 🔴 Why it is skipped on a GET-only (ctgr) device
|
|
120
|
+
*
|
|
121
|
+
* A device in GET-only mode (`cursedbelt-core/ctgr-client`) sits behind something that drops
|
|
122
|
+
* every non-GET, and the beacon reports with a POST to the page's `/cdn-cgi/rum`. It cannot be
|
|
123
|
+
* tunnelled the way our own API is — the script is Cloudflare's — so there it only ever failed,
|
|
124
|
+
* a red line per page view, and recorded nothing. Not loading it loses no data (2026-10-08).
|
|
125
|
+
*
|
|
126
|
+
* The guard reads the URL as well as `localStorage.ctgr`: this script runs before the app's
|
|
127
|
+
* `applyCtgrFromLocation()` has stored a first `?ctgr=t`, so the stored flag alone would still
|
|
128
|
+
* fire on the visit that turns the mode on — and `?ctgr=f` must win over a flag about to be
|
|
129
|
+
* cleared. The spellings are `ctgrConfig.ts`'s; `tools/check-web-analytics.ts` EXECUTES every
|
|
130
|
+
* shell's copy against them.
|
|
131
|
+
*
|
|
119
132
|
* 🔴 It needs inline script to be permitted on the page. Every shell that carries it already
|
|
120
133
|
* ships one; `apps/vault` does not, serves `script-src 'self'`, and is exempt from the beacon
|
|
121
134
|
* for that reason — see `tools/check-web-analytics.ts`.
|
|
@@ -123,6 +123,19 @@ export const WEB_ANALYTICS_PUBLIC_HOST = "cursedalchemy.com";
|
|
|
123
123
|
* production `dist` served from `127.0.0.1`, so a production-only injection is still a console
|
|
124
124
|
* error there. The hostname is the honest discriminator, not the build mode.
|
|
125
125
|
*
|
|
126
|
+
* ## 🔴 Why it is skipped on a GET-only (ctgr) device
|
|
127
|
+
*
|
|
128
|
+
* A device in GET-only mode (`cursedbelt-core/ctgr-client`) sits behind something that drops
|
|
129
|
+
* every non-GET, and the beacon reports with a POST to the page's `/cdn-cgi/rum`. It cannot be
|
|
130
|
+
* tunnelled the way our own API is — the script is Cloudflare's — so there it only ever failed,
|
|
131
|
+
* a red line per page view, and recorded nothing. Not loading it loses no data (2026-10-08).
|
|
132
|
+
*
|
|
133
|
+
* The guard reads the URL as well as `localStorage.ctgr`: this script runs before the app's
|
|
134
|
+
* `applyCtgrFromLocation()` has stored a first `?ctgr=t`, so the stored flag alone would still
|
|
135
|
+
* fire on the visit that turns the mode on — and `?ctgr=f` must win over a flag about to be
|
|
136
|
+
* cleared. The spellings are `ctgrConfig.ts`'s; `tools/check-web-analytics.ts` EXECUTES every
|
|
137
|
+
* shell's copy against them.
|
|
138
|
+
*
|
|
126
139
|
* 🔴 It needs inline script to be permitted on the page. Every shell that carries it already
|
|
127
140
|
* ships one; `apps/vault` does not, serves `script-src 'self'`, and is exempt from the beacon
|
|
128
141
|
* for that reason — see `tools/check-web-analytics.ts`.
|
|
@@ -131,7 +144,13 @@ export function webAnalyticsBootstrap(token = WEB_ANALYTICS_SITE_TOKEN) {
|
|
|
131
144
|
return [
|
|
132
145
|
"<script>",
|
|
133
146
|
`\tvar host = location.hostname;`,
|
|
134
|
-
`\
|
|
147
|
+
`\tvar asked = "";`,
|
|
148
|
+
"\tvar getOnly = false;",
|
|
149
|
+
"\ttry {",
|
|
150
|
+
`\t\tasked = ((/[?&]ctgr=([^&]*)/.exec(location.search) || [])[1] || "").toLowerCase();`,
|
|
151
|
+
`\t\tgetOnly = /^(t|on|1|yes|true)$/.test(asked) || (!/^(f|off|0|no|false)$/.test(asked) && localStorage.getItem("ctgr") === "on");`,
|
|
152
|
+
"\t} catch (_e) {}",
|
|
153
|
+
`\tif (!getOnly && (host === "${WEB_ANALYTICS_PUBLIC_HOST}" || host.endsWith(".${WEB_ANALYTICS_PUBLIC_HOST}"))) {`,
|
|
135
154
|
`\t\tvar beacon = document.createElement("script");`,
|
|
136
155
|
`\t\tbeacon.type = "module";`,
|
|
137
156
|
`\t\tbeacon.src = "${WEB_ANALYTICS_BEACON_SRC}";`,
|
|
@@ -155,7 +155,24 @@ export interface PullD1Options {
|
|
|
155
155
|
* so the only symptom of a slow spell was a backup that took hours.
|
|
156
156
|
*/
|
|
157
157
|
log?: (line: string) => void;
|
|
158
|
+
/** How a {@link TRANSIENT_WAITS_MS} wait is slept (default `Bun.sleepSync`). Injected so a spec waits for nothing. */
|
|
159
|
+
sleep?: (ms: number) => void;
|
|
158
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* The waits before asking again after a {@link isTransient} failure — ~1.5 minutes in all, then
|
|
163
|
+
* the failure is real. Measured 2026-10-09T15:35Z: this Mac's network dropped for under a minute
|
|
164
|
+
* just as collections' and flix's nightly backups ran (both at :35), wrangler answered the ONE
|
|
165
|
+
* columns call with `{"error":{"text":"fetch failed"}}`, and both nights were refused with no
|
|
166
|
+
* snapshot (tasks 2985–2987). patterns' log holds the same `fetch failed` from September.
|
|
167
|
+
*/
|
|
168
|
+
export declare const TRANSIENT_WAITS_MS: readonly number[];
|
|
169
|
+
/**
|
|
170
|
+
* A failure of the WIRE between this Mac and Cloudflare, not an answer from D1 — which asking
|
|
171
|
+
* again a little later fixes. Only these are waited out — a refusal D1 actually made (a page too
|
|
172
|
+
* large, bad SQL, auth) is never retried here, so a page refused for its size still halves at
|
|
173
|
+
* once instead of sleeping ~1.5 minutes per halving.
|
|
174
|
+
*/
|
|
175
|
+
export declare const isTransient: (message: string) => boolean;
|
|
159
176
|
/** How a table is walked in pages: by a rowid keyset, or — a `WITHOUT ROWID` table — by offset. */
|
|
160
177
|
export interface TablePlan {
|
|
161
178
|
table: string;
|
package/dist/server/d1/pullD1.js
CHANGED
|
@@ -186,10 +186,43 @@ export function backupTables(rows) {
|
|
|
186
186
|
.filter((name) => name === 'sqlite_sequence' || !name.startsWith('sqlite_'))
|
|
187
187
|
.sort();
|
|
188
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* The waits before asking again after a {@link isTransient} failure — ~1.5 minutes in all, then
|
|
191
|
+
* the failure is real. Measured 2026-10-09T15:35Z: this Mac's network dropped for under a minute
|
|
192
|
+
* just as collections' and flix's nightly backups ran (both at :35), wrangler answered the ONE
|
|
193
|
+
* columns call with `{"error":{"text":"fetch failed"}}`, and both nights were refused with no
|
|
194
|
+
* snapshot (tasks 2985–2987). patterns' log holds the same `fetch failed` from September.
|
|
195
|
+
*/
|
|
196
|
+
export const TRANSIENT_WAITS_MS = [5_000, 20_000, 60_000];
|
|
197
|
+
/**
|
|
198
|
+
* A failure of the WIRE between this Mac and Cloudflare, not an answer from D1 — which asking
|
|
199
|
+
* again a little later fixes. Only these are waited out — a refusal D1 actually made (a page too
|
|
200
|
+
* large, bad SQL, auth) is never retried here, so a page refused for its size still halves at
|
|
201
|
+
* once instead of sleeping ~1.5 minutes per halving.
|
|
202
|
+
*/
|
|
203
|
+
export const isTransient = (message) => /fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|network connection was lost/i.test(message);
|
|
189
204
|
const sqlIdent = (name) => `"${name.replaceAll('"', '""')}"`;
|
|
190
205
|
const sqlText = (text) => `'${text.replaceAll("'", "''")}'`;
|
|
191
|
-
/**
|
|
192
|
-
|
|
206
|
+
/**
|
|
207
|
+
* One `wrangler d1 execute --remote --json` — every statement's result rows, in order — asked
|
|
208
|
+
* again after each of {@link TRANSIENT_WAITS_MS} while the failure {@link isTransient}.
|
|
209
|
+
*/
|
|
210
|
+
function execute(caller, database, sql) {
|
|
211
|
+
for (let attempt = 0;; attempt++) {
|
|
212
|
+
try {
|
|
213
|
+
return executeOnce(caller.wrangler, database, sql);
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
const message = error.message;
|
|
217
|
+
const wait = TRANSIENT_WAITS_MS[attempt];
|
|
218
|
+
if (wait === undefined || !isTransient(message))
|
|
219
|
+
throw error;
|
|
220
|
+
caller.log(`pull D1: the wire failed (${message.replace(/\s+/g, ' ').slice(0, 120)}) — asking again in ${wait / 1000}s`);
|
|
221
|
+
caller.sleep(wait);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function executeOnce(wrangler, database, sql) {
|
|
193
226
|
const r = wrangler(['d1', 'execute', database, '--remote', '--env=', '--json', '--command', sql]);
|
|
194
227
|
if (r.code !== 0)
|
|
195
228
|
throw new Error(r.stderr.trim() || r.stdout.trim() || `wrangler exited ${r.code}`);
|
|
@@ -236,7 +269,7 @@ export function pageSql(plan, limit, after) {
|
|
|
236
269
|
* backup (2026-09-25) hit a few slow calls early and read its 3,381-row table 15 rows per call —
|
|
237
270
|
* 225 two-minute calls, on course for hours. Only a refusal at ONE row is fatal.
|
|
238
271
|
*/
|
|
239
|
-
function dumpTable(
|
|
272
|
+
function dumpTable(caller, database, plan) {
|
|
240
273
|
const into = `INSERT INTO ${sqlIdent(plan.table)} (${plan.columns.map(sqlIdent).join(', ')}) VALUES (`;
|
|
241
274
|
const lines = [];
|
|
242
275
|
let limit = PAGE_ROWS;
|
|
@@ -244,13 +277,13 @@ function dumpTable(wrangler, database, plan, log) {
|
|
|
244
277
|
for (;;) {
|
|
245
278
|
let page;
|
|
246
279
|
try {
|
|
247
|
-
page = execute(
|
|
280
|
+
page = execute(caller, database, pageSql(plan, limit, after))[0] ?? [];
|
|
248
281
|
}
|
|
249
282
|
catch (error) {
|
|
250
283
|
const message = error.message;
|
|
251
284
|
if (limit > 1) {
|
|
252
285
|
const next = Math.max(1, Math.floor(limit / 2));
|
|
253
|
-
log(`pull D1: \`${plan.table}\` refused a page of ${limit} — asking for ${next}: ${message}`);
|
|
286
|
+
caller.log(`pull D1: \`${plan.table}\` refused a page of ${limit} — asking for ${next}: ${message}`);
|
|
254
287
|
limit = next;
|
|
255
288
|
continue;
|
|
256
289
|
}
|
|
@@ -279,11 +312,15 @@ function dumpTable(wrangler, database, plan, log) {
|
|
|
279
312
|
* magic-byte check and `integrity_check` and land on the shelf with today's date on it.
|
|
280
313
|
*/
|
|
281
314
|
export async function pullD1ToSqlite(options) {
|
|
282
|
-
const { destination, database
|
|
283
|
-
const
|
|
315
|
+
const { destination, database } = options;
|
|
316
|
+
const caller = {
|
|
317
|
+
wrangler: options.wrangler,
|
|
318
|
+
log: options.log ?? ((line) => console.error(line)),
|
|
319
|
+
sleep: options.sleep ?? ((ms) => Bun.sleepSync(ms)),
|
|
320
|
+
};
|
|
284
321
|
let rows;
|
|
285
322
|
try {
|
|
286
|
-
rows = (execute(
|
|
323
|
+
rows = (execute(caller, database, "select name, sql from sqlite_master where type = 'table'")[0] ?? []);
|
|
287
324
|
}
|
|
288
325
|
catch (error) {
|
|
289
326
|
throw new Error(`could not list D1's tables: ${error.message}`);
|
|
@@ -295,7 +332,7 @@ export async function pullD1ToSqlite(options) {
|
|
|
295
332
|
// Every table's columns in ONE call: one statement each, answered in order.
|
|
296
333
|
let columnSets;
|
|
297
334
|
try {
|
|
298
|
-
columnSets = execute(
|
|
335
|
+
columnSets = execute(caller, database, tables.map((t) => `SELECT name, pk FROM pragma_table_info(${sqlText(t)});`).join(' '));
|
|
299
336
|
}
|
|
300
337
|
catch (error) {
|
|
301
338
|
throw new Error(`could not read D1's columns: ${error.message}`);
|
|
@@ -303,7 +340,7 @@ export async function pullD1ToSqlite(options) {
|
|
|
303
340
|
const dumps = tables.map((table, i) => {
|
|
304
341
|
const cols = (columnSets[i] ?? []);
|
|
305
342
|
const plan = tablePlan(table, rows.find((r) => r.name === table)?.sql ?? null, cols);
|
|
306
|
-
return { table, ...dumpTable(
|
|
343
|
+
return { table, ...dumpTable(caller, database, plan) };
|
|
307
344
|
});
|
|
308
345
|
if (dumps.every((d) => d.rows === 0)) {
|
|
309
346
|
throw new Error('the D1 pull contains no rows — refusing to snapshot an empty database as if it were the library');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.44.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The app-facing Bun/Hono server tier of the cursedbelt split — storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
|
|
@@ -70,7 +70,10 @@ describe("the fleet's beacon", () => {
|
|
|
70
70
|
* only shows up at a particular hostname — so a test that merely asserted the string contains
|
|
71
71
|
* "cursedalchemy.com" would pass on a guard with the condition inverted.
|
|
72
72
|
*/
|
|
73
|
-
function runBootstrap(
|
|
73
|
+
function runBootstrap(
|
|
74
|
+
hostname: string,
|
|
75
|
+
device: { search?: string; stored?: string | null; storageThrows?: boolean } = {},
|
|
76
|
+
): { appended: string[]; attrs: Record<string, string> } {
|
|
74
77
|
const body = /<script>([\s\S]*)<\/script>/.exec(webAnalyticsBootstrap())?.[1] ?? "";
|
|
75
78
|
expect(body.trim().length).toBeGreaterThan(0);
|
|
76
79
|
const appended: string[] = [];
|
|
@@ -89,7 +92,13 @@ function runBootstrap(hostname: string): { appended: string[]; attrs: Record<str
|
|
|
89
92
|
},
|
|
90
93
|
},
|
|
91
94
|
};
|
|
92
|
-
|
|
95
|
+
const fakeStorage = {
|
|
96
|
+
getItem(key: string): string | null {
|
|
97
|
+
if (device.storageThrows) throw new Error("SecurityError");
|
|
98
|
+
return key === "ctgr" ? (device.stored ?? null) : null;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
new Function("location", "document", "localStorage", body)({ hostname, search: device.search ?? "" }, fakeDocument, fakeStorage);
|
|
93
102
|
return { appended, attrs };
|
|
94
103
|
}
|
|
95
104
|
|
|
@@ -121,6 +130,40 @@ describe("the hostname guard", () => {
|
|
|
121
130
|
});
|
|
122
131
|
});
|
|
123
132
|
|
|
133
|
+
/**
|
|
134
|
+
* 🔴 A GET-only device drops the beacon's POST, so the beacon is not loaded there at all — and
|
|
135
|
+
* the URL counts as well as the stored flag, because this script runs before the app stores it.
|
|
136
|
+
*/
|
|
137
|
+
describe("the GET-only (ctgr) guard", () => {
|
|
138
|
+
const zone = "desk.cursedalchemy.com";
|
|
139
|
+
|
|
140
|
+
test("loads NOTHING on a device whose stored mode is on", () => {
|
|
141
|
+
expect(runBootstrap(zone, { stored: "on" }).appended).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("loads NOTHING on the very visit that turns the mode on — before anything is stored", () => {
|
|
145
|
+
for (const search of ["?ctgr=t", "?ctgr=on", "?ctgr=1", "?ctgr=yes", "?ctgr=TRUE", "?open=sheet:1&ctgr=t"]) {
|
|
146
|
+
expect(runBootstrap(zone, { search }).appended, search).toEqual([]);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("`?ctgr=f` wins over a stored flag the app is about to clear", () => {
|
|
151
|
+
for (const search of ["?ctgr=f", "?ctgr=off", "?ctgr=0", "?a=1&ctgr=no"]) {
|
|
152
|
+
expect(runBootstrap(zone, { search, stored: "on" }).appended, search).toEqual([WEB_ANALYTICS_BEACON_SRC]);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("an ordinary visitor still gets the beacon — an unknown value, another key, or storage that throws", () => {
|
|
157
|
+
for (const device of [{ search: "?ctgr=maybe" }, { search: "?notctgr=t" }, { stored: "off" }, { storageThrows: true }, {}]) {
|
|
158
|
+
expect(runBootstrap(zone, device).appended, JSON.stringify(device)).toEqual([WEB_ANALYTICS_BEACON_SRC]);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("turned on by the URL, it holds even where storage throws", () => {
|
|
163
|
+
expect(runBootstrap(zone, { search: "?ctgr=t", storageThrows: true }).appended).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
124
167
|
describe("htmlCarriesWebAnalytics", () => {
|
|
125
168
|
test("true for the bootstrap block a shell actually pastes", () => {
|
|
126
169
|
expect(htmlCarriesWebAnalytics(webAnalyticsBootstrap())).toBe(true);
|
|
@@ -135,6 +135,19 @@ export const WEB_ANALYTICS_PUBLIC_HOST = "cursedalchemy.com";
|
|
|
135
135
|
* production `dist` served from `127.0.0.1`, so a production-only injection is still a console
|
|
136
136
|
* error there. The hostname is the honest discriminator, not the build mode.
|
|
137
137
|
*
|
|
138
|
+
* ## 🔴 Why it is skipped on a GET-only (ctgr) device
|
|
139
|
+
*
|
|
140
|
+
* A device in GET-only mode (`cursedbelt-core/ctgr-client`) sits behind something that drops
|
|
141
|
+
* every non-GET, and the beacon reports with a POST to the page's `/cdn-cgi/rum`. It cannot be
|
|
142
|
+
* tunnelled the way our own API is — the script is Cloudflare's — so there it only ever failed,
|
|
143
|
+
* a red line per page view, and recorded nothing. Not loading it loses no data (2026-10-08).
|
|
144
|
+
*
|
|
145
|
+
* The guard reads the URL as well as `localStorage.ctgr`: this script runs before the app's
|
|
146
|
+
* `applyCtgrFromLocation()` has stored a first `?ctgr=t`, so the stored flag alone would still
|
|
147
|
+
* fire on the visit that turns the mode on — and `?ctgr=f` must win over a flag about to be
|
|
148
|
+
* cleared. The spellings are `ctgrConfig.ts`'s; `tools/check-web-analytics.ts` EXECUTES every
|
|
149
|
+
* shell's copy against them.
|
|
150
|
+
*
|
|
138
151
|
* 🔴 It needs inline script to be permitted on the page. Every shell that carries it already
|
|
139
152
|
* ships one; `apps/vault` does not, serves `script-src 'self'`, and is exempt from the beacon
|
|
140
153
|
* for that reason — see `tools/check-web-analytics.ts`.
|
|
@@ -143,7 +156,13 @@ export function webAnalyticsBootstrap(token: string = WEB_ANALYTICS_SITE_TOKEN):
|
|
|
143
156
|
return [
|
|
144
157
|
"<script>",
|
|
145
158
|
`\tvar host = location.hostname;`,
|
|
146
|
-
`\
|
|
159
|
+
`\tvar asked = "";`,
|
|
160
|
+
"\tvar getOnly = false;",
|
|
161
|
+
"\ttry {",
|
|
162
|
+
`\t\tasked = ((/[?&]ctgr=([^&]*)/.exec(location.search) || [])[1] || "").toLowerCase();`,
|
|
163
|
+
`\t\tgetOnly = /^(t|on|1|yes|true)$/.test(asked) || (!/^(f|off|0|no|false)$/.test(asked) && localStorage.getItem("ctgr") === "on");`,
|
|
164
|
+
"\t} catch (_e) {}",
|
|
165
|
+
`\tif (!getOnly && (host === "${WEB_ANALYTICS_PUBLIC_HOST}" || host.endsWith(".${WEB_ANALYTICS_PUBLIC_HOST}"))) {`,
|
|
147
166
|
`\t\tvar beacon = document.createElement("script");`,
|
|
148
167
|
`\t\tbeacon.type = "module";`,
|
|
149
168
|
`\t\tbeacon.src = "${WEB_ANALYTICS_BEACON_SRC}";`,
|
|
@@ -279,6 +279,80 @@ describe('pulling D1 down — paged SELECTs, table by table, never an export', (
|
|
|
279
279
|
expect(logged).toHaveLength(8);
|
|
280
280
|
});
|
|
281
281
|
|
|
282
|
+
describe('🔴 a blip on the wire is waited out, not a lost night (collections + flix, 2026-10-09T15:35Z)', () => {
|
|
283
|
+
const FETCH_FAILED = '{\n "error": {\n "text": "fetch failed"\n }\n}';
|
|
284
|
+
/** `d1`, but the first `blips` calls whose SQL `when` matches fail the way wrangler did that night. */
|
|
285
|
+
const blipping = (source: Database, when: (sql: string) => boolean, blips: number, said = FETCH_FAILED) => {
|
|
286
|
+
const real = d1(source);
|
|
287
|
+
let left = blips;
|
|
288
|
+
const run: Wrangler = (args) => {
|
|
289
|
+
const sql = args[args.indexOf('--command') + 1] as string;
|
|
290
|
+
if (args[1] === 'execute' && left > 0 && when(sql)) {
|
|
291
|
+
left--;
|
|
292
|
+
return { code: 1, stdout: said, stderr: '' };
|
|
293
|
+
}
|
|
294
|
+
return real.run(args);
|
|
295
|
+
};
|
|
296
|
+
return { run, seen: real.seen };
|
|
297
|
+
};
|
|
298
|
+
const pullWaiting = (dir: string, run: Wrangler, slept: number[], logged: string[] = []) =>
|
|
299
|
+
pullD1ToSqlite({
|
|
300
|
+
destination: join(dir, 'pulled.sqlite'),
|
|
301
|
+
database: 'collections',
|
|
302
|
+
schemaSql: SCHEMA,
|
|
303
|
+
requiredTable: 'items',
|
|
304
|
+
wrangler: run,
|
|
305
|
+
log: (line) => logged.push(line),
|
|
306
|
+
sleep: (ms) => slept.push(ms),
|
|
307
|
+
});
|
|
308
|
+
const columns = (sql: string) => sql.includes('pragma_table_info');
|
|
309
|
+
/** `TRANSIENT_WAITS_MS`, written out: the spec pins the numbers rather than importing them. */
|
|
310
|
+
const WAITS = [5_000, 20_000, 60_000];
|
|
311
|
+
|
|
312
|
+
test('the columns call that failed that night is asked again after a wait, and the snapshot lands', async () => {
|
|
313
|
+
const dir = scratch();
|
|
314
|
+
const source = live("INSERT INTO items VALUES ('i1', 'a');");
|
|
315
|
+
const slept: number[] = [];
|
|
316
|
+
const logged: string[] = [];
|
|
317
|
+
await pullWaiting(dir, blipping(source, columns, 2).run, slept, logged);
|
|
318
|
+
expect(slept).toEqual(WAITS.slice(0, 2));
|
|
319
|
+
expect(logged[0]).toContain('fetch failed');
|
|
320
|
+
const db = opened(dir);
|
|
321
|
+
expect((db.query('select name from items').get() as { name: string }).name).toBe('a');
|
|
322
|
+
db.close();
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('a page that blips is asked again at the SAME size — a wire failure is not a page too large', async () => {
|
|
326
|
+
const source = live("INSERT INTO items VALUES ('i1', 'a');");
|
|
327
|
+
// wrangler's coloured form, as patterns' log has it.
|
|
328
|
+
const { run, seen } = blipping(source, (sql) => sql.includes('FROM "items"'), 1, '✘ \u001b[1mfetch failed\u001b[0m');
|
|
329
|
+
const slept: number[] = [];
|
|
330
|
+
await pullWaiting(scratch(), run, slept);
|
|
331
|
+
const limits = seen
|
|
332
|
+
.map((a) => a[a.indexOf('--command') + 1] as string)
|
|
333
|
+
.filter((sql) => sql.includes('FROM "items"'))
|
|
334
|
+
.map((sql) => Number(/LIMIT (\d+)/.exec(sql)?.[1]));
|
|
335
|
+
expect(limits).toEqual([PAGE_ROWS]);
|
|
336
|
+
expect(slept).toEqual([WAITS[0] as number]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('a wire that stays down still REFUSES, with its own words, after every wait', async () => {
|
|
340
|
+
const source = live("INSERT INTO items VALUES ('i1', 'a');");
|
|
341
|
+
const slept: number[] = [];
|
|
342
|
+
await expect(pullWaiting(scratch(), blipping(source, columns, 99).run, slept)).rejects.toThrow(
|
|
343
|
+
/could not read D1's columns: .*fetch failed/s,
|
|
344
|
+
);
|
|
345
|
+
expect(slept).toEqual(WAITS);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('a refusal D1 itself made is never waited on', async () => {
|
|
349
|
+
const source = live("INSERT INTO items VALUES ('i1', 'a');");
|
|
350
|
+
const slept: number[] = [];
|
|
351
|
+
await expect(pullWaiting(scratch(), d1(source, { failWhen: columns }).run, slept)).rejects.toThrow(/D1 said no/);
|
|
352
|
+
expect(slept).toEqual([]);
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
282
356
|
test('🔴 a table D1 holds that the schema does not declare is created from D1\'s own DDL', async () => {
|
|
283
357
|
const dir = scratch();
|
|
284
358
|
const ddl = 'CREATE TABLE art_lookups (key TEXT PRIMARY KEY, art_key TEXT)';
|
package/src/server/d1/pullD1.ts
CHANGED
|
@@ -229,13 +229,59 @@ export interface PullD1Options {
|
|
|
229
229
|
* so the only symptom of a slow spell was a backup that took hours.
|
|
230
230
|
*/
|
|
231
231
|
log?: (line: string) => void;
|
|
232
|
+
/** How a {@link TRANSIENT_WAITS_MS} wait is slept (default `Bun.sleepSync`). Injected so a spec waits for nothing. */
|
|
233
|
+
sleep?: (ms: number) => void;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The waits before asking again after a {@link isTransient} failure — ~1.5 minutes in all, then
|
|
238
|
+
* the failure is real. Measured 2026-10-09T15:35Z: this Mac's network dropped for under a minute
|
|
239
|
+
* just as collections' and flix's nightly backups ran (both at :35), wrangler answered the ONE
|
|
240
|
+
* columns call with `{"error":{"text":"fetch failed"}}`, and both nights were refused with no
|
|
241
|
+
* snapshot (tasks 2985–2987). patterns' log holds the same `fetch failed` from September.
|
|
242
|
+
*/
|
|
243
|
+
export const TRANSIENT_WAITS_MS: readonly number[] = [5_000, 20_000, 60_000];
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* A failure of the WIRE between this Mac and Cloudflare, not an answer from D1 — which asking
|
|
247
|
+
* again a little later fixes. Only these are waited out — a refusal D1 actually made (a page too
|
|
248
|
+
* large, bad SQL, auth) is never retried here, so a page refused for its size still halves at
|
|
249
|
+
* once instead of sleeping ~1.5 minutes per halving.
|
|
250
|
+
*/
|
|
251
|
+
export const isTransient = (message: string): boolean =>
|
|
252
|
+
/fetch failed|ECONNRESET|ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|network connection was lost/i.test(
|
|
253
|
+
message,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
/** What every {@link execute} needs: the wrangler to run, where to log, and how to wait. */
|
|
257
|
+
interface Caller {
|
|
258
|
+
wrangler: Wrangler;
|
|
259
|
+
log: (line: string) => void;
|
|
260
|
+
sleep: (ms: number) => void;
|
|
232
261
|
}
|
|
233
262
|
|
|
234
263
|
const sqlIdent = (name: string): string => `"${name.replaceAll('"', '""')}"`;
|
|
235
264
|
const sqlText = (text: string): string => `'${text.replaceAll("'", "''")}'`;
|
|
236
265
|
|
|
237
|
-
/**
|
|
238
|
-
|
|
266
|
+
/**
|
|
267
|
+
* One `wrangler d1 execute --remote --json` — every statement's result rows, in order — asked
|
|
268
|
+
* again after each of {@link TRANSIENT_WAITS_MS} while the failure {@link isTransient}.
|
|
269
|
+
*/
|
|
270
|
+
function execute(caller: Caller, database: string, sql: string): Array<Array<Record<string, unknown>>> {
|
|
271
|
+
for (let attempt = 0; ; attempt++) {
|
|
272
|
+
try {
|
|
273
|
+
return executeOnce(caller.wrangler, database, sql);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
const message = (error as Error).message;
|
|
276
|
+
const wait = TRANSIENT_WAITS_MS[attempt];
|
|
277
|
+
if (wait === undefined || !isTransient(message)) throw error;
|
|
278
|
+
caller.log(`pull D1: the wire failed (${message.replace(/\s+/g, ' ').slice(0, 120)}) — asking again in ${wait / 1000}s`);
|
|
279
|
+
caller.sleep(wait);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function executeOnce(wrangler: Wrangler, database: string, sql: string): Array<Array<Record<string, unknown>>> {
|
|
239
285
|
const r = wrangler(['d1', 'execute', database, '--remote', '--env=', '--json', '--command', sql]);
|
|
240
286
|
if (r.code !== 0) throw new Error(r.stderr.trim() || r.stdout.trim() || `wrangler exited ${r.code}`);
|
|
241
287
|
let parsed: Array<{ results?: Array<Record<string, unknown>> }>;
|
|
@@ -291,12 +337,7 @@ export function pageSql(plan: TablePlan, limit: number, after: { key?: number; o
|
|
|
291
337
|
* backup (2026-09-25) hit a few slow calls early and read its 3,381-row table 15 rows per call —
|
|
292
338
|
* 225 two-minute calls, on course for hours. Only a refusal at ONE row is fatal.
|
|
293
339
|
*/
|
|
294
|
-
function dumpTable(
|
|
295
|
-
wrangler: Wrangler,
|
|
296
|
-
database: string,
|
|
297
|
-
plan: TablePlan,
|
|
298
|
-
log: (line: string) => void,
|
|
299
|
-
): { text: string; rows: number } {
|
|
340
|
+
function dumpTable(caller: Caller, database: string, plan: TablePlan): { text: string; rows: number } {
|
|
300
341
|
const into = `INSERT INTO ${sqlIdent(plan.table)} (${plan.columns.map(sqlIdent).join(', ')}) VALUES (`;
|
|
301
342
|
const lines: string[] = [];
|
|
302
343
|
let limit = PAGE_ROWS;
|
|
@@ -304,12 +345,12 @@ function dumpTable(
|
|
|
304
345
|
for (;;) {
|
|
305
346
|
let page: Array<Record<string, unknown>>;
|
|
306
347
|
try {
|
|
307
|
-
page = execute(
|
|
348
|
+
page = execute(caller, database, pageSql(plan, limit, after))[0] ?? [];
|
|
308
349
|
} catch (error) {
|
|
309
350
|
const message = (error as Error).message;
|
|
310
351
|
if (limit > 1) {
|
|
311
352
|
const next = Math.max(1, Math.floor(limit / 2));
|
|
312
|
-
log(`pull D1: \`${plan.table}\` refused a page of ${limit} — asking for ${next}: ${message}`);
|
|
353
|
+
caller.log(`pull D1: \`${plan.table}\` refused a page of ${limit} — asking for ${next}: ${message}`);
|
|
313
354
|
limit = next;
|
|
314
355
|
continue;
|
|
315
356
|
}
|
|
@@ -338,11 +379,15 @@ function dumpTable(
|
|
|
338
379
|
* magic-byte check and `integrity_check` and land on the shelf with today's date on it.
|
|
339
380
|
*/
|
|
340
381
|
export async function pullD1ToSqlite(options: PullD1Options): Promise<void> {
|
|
341
|
-
const { destination, database
|
|
342
|
-
const
|
|
382
|
+
const { destination, database } = options;
|
|
383
|
+
const caller: Caller = {
|
|
384
|
+
wrangler: options.wrangler,
|
|
385
|
+
log: options.log ?? ((line: string) => console.error(line)),
|
|
386
|
+
sleep: options.sleep ?? ((ms: number) => Bun.sleepSync(ms)),
|
|
387
|
+
};
|
|
343
388
|
let rows: Array<{ name: string; sql: string | null }>;
|
|
344
389
|
try {
|
|
345
|
-
rows = (execute(
|
|
390
|
+
rows = (execute(caller, database, "select name, sql from sqlite_master where type = 'table'")[0] ?? []) as typeof rows;
|
|
346
391
|
} catch (error) {
|
|
347
392
|
throw new Error(`could not list D1's tables: ${(error as Error).message}`);
|
|
348
393
|
}
|
|
@@ -356,14 +401,14 @@ export async function pullD1ToSqlite(options: PullD1Options): Promise<void> {
|
|
|
356
401
|
// Every table's columns in ONE call: one statement each, answered in order.
|
|
357
402
|
let columnSets: Array<Array<Record<string, unknown>>>;
|
|
358
403
|
try {
|
|
359
|
-
columnSets = execute(
|
|
404
|
+
columnSets = execute(caller, database, tables.map((t) => `SELECT name, pk FROM pragma_table_info(${sqlText(t)});`).join(' '));
|
|
360
405
|
} catch (error) {
|
|
361
406
|
throw new Error(`could not read D1's columns: ${(error as Error).message}`);
|
|
362
407
|
}
|
|
363
408
|
const dumps = tables.map((table, i) => {
|
|
364
409
|
const cols = (columnSets[i] ?? []) as Array<{ name: string; pk: number }>;
|
|
365
410
|
const plan = tablePlan(table, rows.find((r) => r.name === table)?.sql ?? null, cols);
|
|
366
|
-
return { table, ...dumpTable(
|
|
411
|
+
return { table, ...dumpTable(caller, database, plan) };
|
|
367
412
|
});
|
|
368
413
|
if (dumps.every((d) => d.rows === 0)) {
|
|
369
414
|
throw new Error('the D1 pull contains no rows — refusing to snapshot an empty database as if it were the library');
|