cursedbelt-server 4.43.3 → 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.
@@ -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;
@@ -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
- /** One `wrangler d1 execute --remote --json` — every statement's result rows, in order. */
192
- function execute(wrangler, database, sql) {
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(wrangler, database, plan, log) {
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(wrangler, database, pageSql(plan, limit, after))[0] ?? [];
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, wrangler } = options;
283
- const log = options.log ?? ((line) => console.error(line));
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(wrangler, database, "select name, sql from sqlite_master where type = 'table'")[0] ?? []);
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(wrangler, database, tables.map((t) => `SELECT name, pk FROM pragma_table_info(${sqlText(t)});`).join(' '));
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(wrangler, database, plan, log) };
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.43.3",
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.",
@@ -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)';
@@ -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
- /** One `wrangler d1 execute --remote --json` — every statement's result rows, in order. */
238
- function execute(wrangler: Wrangler, database: string, sql: string): Array<Array<Record<string, unknown>>> {
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(wrangler, database, pageSql(plan, limit, after))[0] ?? [];
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, wrangler } = options;
342
- const log = options.log ?? ((line: string) => console.error(line));
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(wrangler, database, "select name, sql from sqlite_master where type = 'table'")[0] ?? []) as typeof rows;
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(wrangler, database, tables.map((t) => `SELECT name, pk FROM pragma_table_info(${sqlText(t)});`).join(' '));
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(wrangler, database, plan, log) };
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');