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.
- package/dist/server/d1/http.d.ts +19 -0
- package/dist/server/d1/http.js +37 -6
- package/package.json +1 -1
- package/src/server/d1/http.spec.ts +43 -0
- package/src/server/d1/http.ts +47 -6
package/dist/server/d1/http.d.ts
CHANGED
|
@@ -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;
|
package/dist/server/d1/http.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
+
});
|
package/src/server/d1/http.ts
CHANGED
|
@@ -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
|
|
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 =
|