cursedbelt-server 4.41.0 → 4.42.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.
@@ -51,7 +51,26 @@ export interface HttpD1Options {
51
51
  fetch?: typeof fetch;
52
52
  /** Defaults to `https://api.cloudflare.com/client/v4`. */
53
53
  apiBase?: string;
54
+ /**
55
+ * The waits, in ms, before each retry of a request that never CONNECTED — see
56
+ * {@link neverConnected}. Default {@link CONNECT_RETRY_DELAYS_MS}; `[]` fails on the first.
57
+ */
58
+ connectRetryDelaysMs?: readonly number[];
59
+ /** Injected for tests. Defaults to a `setTimeout` promise. */
60
+ sleep?: (ms: number) => Promise<void>;
54
61
  }
62
+ /**
63
+ * ~1m45s of waiting, which outlasts a Mac's network coming back after a wake. Measured
64
+ * 2026-10-02 16:15Z: family's once-a-day `dangling-media` job fired as the Mac woke, its first
65
+ * D1 read was `ConnectionRefused`, and the job exited 1 — red for a whole day over one blip.
66
+ */
67
+ export declare const CONNECT_RETRY_DELAYS_MS: readonly number[];
68
+ /**
69
+ * 🔴 `true` only for a failure that happened BEFORE the request reached Cloudflare — refused,
70
+ * unresolvable, unroutable — so retrying it cannot run a write twice. A reset or a timeout
71
+ * after connecting may have executed the statement, and is thrown as it is.
72
+ */
73
+ export declare function neverConnected(err: unknown): boolean;
55
74
  /** Thrown when the REST API itself refuses — auth, a missing database, a rate limit. */
56
75
  export declare class D1HttpError extends Error {
57
76
  readonly status: number;
@@ -40,6 +40,22 @@
40
40
  */
41
41
  import { createRemoteD1 } from './remote.js';
42
42
  import { D1UnsupportedError } from './types.js';
43
+ /**
44
+ * ~1m45s of waiting, which outlasts a Mac's network coming back after a wake. Measured
45
+ * 2026-10-02 16:15Z: family's once-a-day `dangling-media` job fired as the Mac woke, its first
46
+ * D1 read was `ConnectionRefused`, and the job exited 1 — red for a whole day over one blip.
47
+ */
48
+ export const CONNECT_RETRY_DELAYS_MS = [2_000, 10_000, 30_000, 60_000];
49
+ /**
50
+ * 🔴 `true` only for a failure that happened BEFORE the request reached Cloudflare — refused,
51
+ * unresolvable, unroutable — so retrying it cannot run a write twice. A reset or a timeout
52
+ * after connecting may have executed the statement, and is thrown as it is.
53
+ */
54
+ export function neverConnected(err) {
55
+ const code = err?.code;
56
+ return (typeof code === 'string' &&
57
+ ['ConnectionRefused', 'ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'FailedToOpenSocket', 'ENETUNREACH', 'EHOSTUNREACH', 'ENETDOWN'].includes(code));
58
+ }
43
59
  /** Thrown when the REST API itself refuses — auth, a missing database, a rate limit. */
44
60
  export class D1HttpError extends Error {
45
61
  status;
@@ -65,13 +81,28 @@ export function httpD1Binding(options) {
65
81
  const doFetch = options.fetch ?? fetch;
66
82
  const base = `${options.apiBase ?? 'https://api.cloudflare.com/client/v4'}/accounts/${options.accountId}/d1/database/${options.databaseId}`;
67
83
  const timeoutMs = options.timeoutMs ?? 30_000;
84
+ const retryDelays = options.connectRetryDelaysMs ?? CONNECT_RETRY_DELAYS_MS;
85
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
86
+ const send = async (path, body) => {
87
+ for (let attempt = 0;; attempt++) {
88
+ try {
89
+ return await doFetch(`${base}${path}`, {
90
+ method: 'POST',
91
+ headers: { authorization: `Bearer ${options.apiToken}`, 'content-type': 'application/json' },
92
+ body: JSON.stringify(body),
93
+ signal: AbortSignal.timeout(timeoutMs),
94
+ });
95
+ }
96
+ catch (err) {
97
+ const wait = retryDelays[attempt];
98
+ if (wait === undefined || !neverConnected(err))
99
+ throw err;
100
+ await sleep(wait);
101
+ }
102
+ }
103
+ };
68
104
  const post = async (path, body) => {
69
- const response = await doFetch(`${base}${path}`, {
70
- method: 'POST',
71
- headers: { authorization: `Bearer ${options.apiToken}`, 'content-type': 'application/json' },
72
- body: JSON.stringify(body),
73
- signal: AbortSignal.timeout(timeoutMs),
74
- });
105
+ const response = await send(path, body);
75
106
  const envelope = (await response.json().catch(() => null));
76
107
  if (!response.ok || !envelope || envelope.success === false) {
77
108
  const message = envelope?.errors
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "4.41.0",
3
+ "version": "4.42.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.",
@@ -124,3 +124,46 @@ describe('createHttpD1', () => {
124
124
  expect(sent[0]?.body).toEqual({ sql: 'CREATE TABLE u (x); INSERT INTO u VALUES (1); INSERT INTO u VALUES (2)' });
125
125
  });
126
126
  });
127
+
128
+ describe('🔴 a request that never connected is retried; one that may have run is not', () => {
129
+ const refused = () => Object.assign(new TypeError('Unable to connect. Is the computer able to access the url?'), { code: 'ConnectionRefused' });
130
+ const flaky = (failures: Error[]) => {
131
+ let calls = 0;
132
+ const waits: number[] = [];
133
+ const fetchImpl = (async () => {
134
+ const fail = failures[calls++];
135
+ if (fail) throw fail;
136
+ return new Response(JSON.stringify({ success: true, errors: [], result: [{ results: [{ n: 1 }], success: true }] }));
137
+ }) as unknown as typeof fetch;
138
+ const db = createHttpD1({ accountId: 'a', databaseId: 'd', apiToken: 't', fetch: fetchImpl, sleep: async (ms) => void waits.push(ms) });
139
+ return { db, waits, calls: () => calls };
140
+ };
141
+
142
+ test('2026-10-02, family dangling-media: ConnectionRefused on a wake, then the network is back — the read answers', async () => {
143
+ const { db, waits, calls } = flaky([refused(), refused()]);
144
+ expect(await db.prepare('SELECT 1 n').first('n')).toBe(1);
145
+ expect(calls()).toBe(3);
146
+ expect(waits).toEqual([2_000, 10_000]);
147
+ });
148
+
149
+ test('still refused after every wait: the original error, not a swallowed one', async () => {
150
+ const { db, waits } = flaky(Array.from({ length: 5 }, refused));
151
+ await expect(db.prepare('SELECT 1').all()).rejects.toThrow('Unable to connect');
152
+ expect(waits).toEqual([2_000, 10_000, 30_000, 60_000]);
153
+ });
154
+
155
+ test('a reset after connecting may have run the write — thrown at once, never retried', async () => {
156
+ const reset = Object.assign(new TypeError('socket closed'), { code: 'ECONNRESET' });
157
+ const { db, calls } = flaky([reset]);
158
+ await expect(db.prepare('INSERT INTO t VALUES (1)').run()).rejects.toThrow('socket closed');
159
+ expect(calls()).toBe(1);
160
+ });
161
+
162
+ test('an unresolvable host is a never-connected failure too, and a timeout is not', async () => {
163
+ const dns = Object.assign(new TypeError('getaddrinfo ENOTFOUND api.cloudflare.com'), { code: 'ENOTFOUND' });
164
+ expect(await flaky([dns]).db.prepare('SELECT 1 n').first('n')).toBe(1);
165
+ const timeout = flaky([new DOMException('The operation timed out.', 'TimeoutError')]);
166
+ await expect(timeout.db.prepare('SELECT 1').all()).rejects.toThrow('timed out');
167
+ expect(timeout.calls()).toBe(1);
168
+ });
169
+ });
@@ -53,6 +53,33 @@ export interface HttpD1Options {
53
53
  fetch?: typeof fetch;
54
54
  /** Defaults to `https://api.cloudflare.com/client/v4`. */
55
55
  apiBase?: string;
56
+ /**
57
+ * The waits, in ms, before each retry of a request that never CONNECTED — see
58
+ * {@link neverConnected}. Default {@link CONNECT_RETRY_DELAYS_MS}; `[]` fails on the first.
59
+ */
60
+ connectRetryDelaysMs?: readonly number[];
61
+ /** Injected for tests. Defaults to a `setTimeout` promise. */
62
+ sleep?: (ms: number) => Promise<void>;
63
+ }
64
+
65
+ /**
66
+ * ~1m45s of waiting, which outlasts a Mac's network coming back after a wake. Measured
67
+ * 2026-10-02 16:15Z: family's once-a-day `dangling-media` job fired as the Mac woke, its first
68
+ * D1 read was `ConnectionRefused`, and the job exited 1 — red for a whole day over one blip.
69
+ */
70
+ export const CONNECT_RETRY_DELAYS_MS: readonly number[] = [2_000, 10_000, 30_000, 60_000];
71
+
72
+ /**
73
+ * 🔴 `true` only for a failure that happened BEFORE the request reached Cloudflare — refused,
74
+ * unresolvable, unroutable — so retrying it cannot run a write twice. A reset or a timeout
75
+ * after connecting may have executed the statement, and is thrown as it is.
76
+ */
77
+ export function neverConnected(err: unknown): boolean {
78
+ const code = (err as { code?: unknown } | null)?.code;
79
+ return (
80
+ typeof code === 'string' &&
81
+ ['ConnectionRefused', 'ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'FailedToOpenSocket', 'ENETUNREACH', 'EHOSTUNREACH', 'ENETDOWN'].includes(code)
82
+ );
56
83
  }
57
84
 
58
85
  interface WireResult {
@@ -97,14 +124,28 @@ export function httpD1Binding(options: HttpD1Options): D1BindingLike {
97
124
  const doFetch = options.fetch ?? fetch;
98
125
  const base = `${options.apiBase ?? 'https://api.cloudflare.com/client/v4'}/accounts/${options.accountId}/d1/database/${options.databaseId}`;
99
126
  const timeoutMs = options.timeoutMs ?? 30_000;
127
+ const retryDelays = options.connectRetryDelaysMs ?? CONNECT_RETRY_DELAYS_MS;
128
+ const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
129
+
130
+ const send = async (path: '/query' | '/raw', body: unknown): Promise<Response> => {
131
+ for (let attempt = 0; ; attempt++) {
132
+ try {
133
+ return await doFetch(`${base}${path}`, {
134
+ method: 'POST',
135
+ headers: { authorization: `Bearer ${options.apiToken}`, 'content-type': 'application/json' },
136
+ body: JSON.stringify(body),
137
+ signal: AbortSignal.timeout(timeoutMs),
138
+ });
139
+ } catch (err) {
140
+ const wait = retryDelays[attempt];
141
+ if (wait === undefined || !neverConnected(err)) throw err;
142
+ await sleep(wait);
143
+ }
144
+ }
145
+ };
100
146
 
101
147
  const post = async (path: '/query' | '/raw', body: unknown): Promise<WireResult[]> => {
102
- const response = await doFetch(`${base}${path}`, {
103
- method: 'POST',
104
- headers: { authorization: `Bearer ${options.apiToken}`, 'content-type': 'application/json' },
105
- body: JSON.stringify(body),
106
- signal: AbortSignal.timeout(timeoutMs),
107
- });
148
+ const response = await send(path, body);
108
149
  const envelope = (await response.json().catch(() => null)) as Envelope | null;
109
150
  if (!response.ok || !envelope || envelope.success === false) {
110
151
  const message =