cursedbelt-server 4.15.0 → 4.16.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 +66 -0
- package/dist/server/d1/http.js +143 -0
- package/dist/server/d1/index.d.ts +1 -0
- package/dist/server/d1/index.js +1 -0
- package/package.json +1 -1
- package/src/server/d1/http.spec.ts +126 -0
- package/src/server/d1/http.ts +182 -0
- package/src/server/d1/index.ts +1 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A D1 database reached over Cloudflare's REST API — for a process that is NOT a Worker.
|
|
3
|
+
*
|
|
4
|
+
* ── Why this exists ─────────────────────────────────────────────────────────────────────
|
|
5
|
+
* An app that moves onto a Worker keeps some work on the Mac: a job that drives the owner's
|
|
6
|
+
* own Chrome, a bulk importer reading a local folder, a backfill, a backup. Before the move
|
|
7
|
+
* those wrote the Mac's `bun:sqlite` file; after it, that file is a frozen copy nothing reads,
|
|
8
|
+
* and a script writing into it reports success while the library it meant to change never
|
|
9
|
+
* hears about it. Measured on `music`'s cutover plan, 2026-09-22: four Mac-side writers, and
|
|
10
|
+
* the only other door into D1 was a per-app bearer route on the Worker for each one.
|
|
11
|
+
*
|
|
12
|
+
* So the Mac gets the same {@link D1LikeDatabase} a Worker holds, pointed at the same
|
|
13
|
+
* database, and the app's own catalogue code runs unchanged against production — ONE writer
|
|
14
|
+
* of the rows, one schema, one set of statements.
|
|
15
|
+
*
|
|
16
|
+
* ── How ──────────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
* It is a {@link D1BindingLike} over `POST /accounts/:account/d1/database/:db/query` (and
|
|
18
|
+
* `/raw`), handed to {@link createRemoteD1} — so every value normalisation, limit check and
|
|
19
|
+
* `success: false` guard is the binding driver's own, not a second copy that could drift.
|
|
20
|
+
* `batch()` sends `{ batch: [...] }`, which D1 runs as one unit exactly as a binding's batch
|
|
21
|
+
* does: measured 2026-09-23 on a throwaway database, a batch whose second statement violated
|
|
22
|
+
* a UNIQUE constraint left the first one's row absent.
|
|
23
|
+
*
|
|
24
|
+
* 🔴 **Not for a Worker.** A Worker has the binding, which is faster, free of an API token,
|
|
25
|
+
* and not subject to the REST API's rate limit. This is for a Mac process, with the account
|
|
26
|
+
* token read from the generation's secrets — never shipped to a browser or a Worker.
|
|
27
|
+
*
|
|
28
|
+
* 🔴 **Every number arrives as a REAL.** The REST API's parameters are JSON, and measured
|
|
29
|
+
* 2026-09-23 it binds a JSON `5` as the REAL `5.0` (`typeof(?)` answers `real`). Everywhere a
|
|
30
|
+
* number is compared, added, used as a `LIMIT`, or stored in an INTEGER column, that is
|
|
31
|
+
* indistinguishable from an integer — SQLite compares `5.0 = 5` true and INTEGER affinity
|
|
32
|
+
* stores `5`. The one place it differs: a number stored into a TEXT column is `'5.0'`, where
|
|
33
|
+
* the binding stores `'5'`. Bind a string into a TEXT column. (Sending integers as strings
|
|
34
|
+
* instead was measured too, and is worse: it breaks every comparison that has no column
|
|
35
|
+
* affinity, such as `CASE WHEN ? = 1`.)
|
|
36
|
+
*
|
|
37
|
+
* 🔴 **A BLOB parameter is refused.** The REST API's parameters are JSON; there is no
|
|
38
|
+
* lossless spelling of bytes in it that D1 documents, and guessing one would store the
|
|
39
|
+
* wrong thing silently. Encode bytes yourself (hex, base64) if a table genuinely needs them.
|
|
40
|
+
*/
|
|
41
|
+
import { type D1BindingLike } from './remote.js';
|
|
42
|
+
import { type D1LikeDatabase } from './types.js';
|
|
43
|
+
export interface HttpD1Options {
|
|
44
|
+
accountId: string;
|
|
45
|
+
databaseId: string;
|
|
46
|
+
/** An API token with D1 Edit on the account. */
|
|
47
|
+
apiToken: string;
|
|
48
|
+
/** Each request's ceiling, in ms. Default 30 s — a hung socket must not hang a job. */
|
|
49
|
+
timeoutMs?: number;
|
|
50
|
+
/** Injected for tests. Defaults to the global `fetch`. */
|
|
51
|
+
fetch?: typeof fetch;
|
|
52
|
+
/** Defaults to `https://api.cloudflare.com/client/v4`. */
|
|
53
|
+
apiBase?: string;
|
|
54
|
+
}
|
|
55
|
+
/** Thrown when the REST API itself refuses — auth, a missing database, a rate limit. */
|
|
56
|
+
export declare class D1HttpError extends Error {
|
|
57
|
+
readonly status: number;
|
|
58
|
+
constructor(status: number, message: string);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The {@link D1BindingLike} half — exported for a caller that wants to wrap it itself
|
|
62
|
+
* (a test, or `perInvocation`). Most callers want {@link createHttpD1}.
|
|
63
|
+
*/
|
|
64
|
+
export declare function httpD1Binding(options: HttpD1Options): D1BindingLike;
|
|
65
|
+
/** A {@link D1LikeDatabase} over the D1 REST API. See the header for when, and when not. */
|
|
66
|
+
export declare function createHttpD1(options: HttpD1Options): D1LikeDatabase;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A D1 database reached over Cloudflare's REST API — for a process that is NOT a Worker.
|
|
3
|
+
*
|
|
4
|
+
* ── Why this exists ─────────────────────────────────────────────────────────────────────
|
|
5
|
+
* An app that moves onto a Worker keeps some work on the Mac: a job that drives the owner's
|
|
6
|
+
* own Chrome, a bulk importer reading a local folder, a backfill, a backup. Before the move
|
|
7
|
+
* those wrote the Mac's `bun:sqlite` file; after it, that file is a frozen copy nothing reads,
|
|
8
|
+
* and a script writing into it reports success while the library it meant to change never
|
|
9
|
+
* hears about it. Measured on `music`'s cutover plan, 2026-09-22: four Mac-side writers, and
|
|
10
|
+
* the only other door into D1 was a per-app bearer route on the Worker for each one.
|
|
11
|
+
*
|
|
12
|
+
* So the Mac gets the same {@link D1LikeDatabase} a Worker holds, pointed at the same
|
|
13
|
+
* database, and the app's own catalogue code runs unchanged against production — ONE writer
|
|
14
|
+
* of the rows, one schema, one set of statements.
|
|
15
|
+
*
|
|
16
|
+
* ── How ──────────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
* It is a {@link D1BindingLike} over `POST /accounts/:account/d1/database/:db/query` (and
|
|
18
|
+
* `/raw`), handed to {@link createRemoteD1} — so every value normalisation, limit check and
|
|
19
|
+
* `success: false` guard is the binding driver's own, not a second copy that could drift.
|
|
20
|
+
* `batch()` sends `{ batch: [...] }`, which D1 runs as one unit exactly as a binding's batch
|
|
21
|
+
* does: measured 2026-09-23 on a throwaway database, a batch whose second statement violated
|
|
22
|
+
* a UNIQUE constraint left the first one's row absent.
|
|
23
|
+
*
|
|
24
|
+
* 🔴 **Not for a Worker.** A Worker has the binding, which is faster, free of an API token,
|
|
25
|
+
* and not subject to the REST API's rate limit. This is for a Mac process, with the account
|
|
26
|
+
* token read from the generation's secrets — never shipped to a browser or a Worker.
|
|
27
|
+
*
|
|
28
|
+
* 🔴 **Every number arrives as a REAL.** The REST API's parameters are JSON, and measured
|
|
29
|
+
* 2026-09-23 it binds a JSON `5` as the REAL `5.0` (`typeof(?)` answers `real`). Everywhere a
|
|
30
|
+
* number is compared, added, used as a `LIMIT`, or stored in an INTEGER column, that is
|
|
31
|
+
* indistinguishable from an integer — SQLite compares `5.0 = 5` true and INTEGER affinity
|
|
32
|
+
* stores `5`. The one place it differs: a number stored into a TEXT column is `'5.0'`, where
|
|
33
|
+
* the binding stores `'5'`. Bind a string into a TEXT column. (Sending integers as strings
|
|
34
|
+
* instead was measured too, and is worse: it breaks every comparison that has no column
|
|
35
|
+
* affinity, such as `CASE WHEN ? = 1`.)
|
|
36
|
+
*
|
|
37
|
+
* 🔴 **A BLOB parameter is refused.** The REST API's parameters are JSON; there is no
|
|
38
|
+
* lossless spelling of bytes in it that D1 documents, and guessing one would store the
|
|
39
|
+
* wrong thing silently. Encode bytes yourself (hex, base64) if a table genuinely needs them.
|
|
40
|
+
*/
|
|
41
|
+
import { createRemoteD1 } from './remote.js';
|
|
42
|
+
import { D1UnsupportedError } from './types.js';
|
|
43
|
+
/** Thrown when the REST API itself refuses — auth, a missing database, a rate limit. */
|
|
44
|
+
export class D1HttpError extends Error {
|
|
45
|
+
status;
|
|
46
|
+
constructor(status, message) {
|
|
47
|
+
super(`D1 over HTTP refused the request (${status}): ${message}`);
|
|
48
|
+
this.name = 'D1HttpError';
|
|
49
|
+
this.status = status;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function toWire(values) {
|
|
53
|
+
return values.map((value, index) => {
|
|
54
|
+
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
|
|
55
|
+
throw new D1UnsupportedError(`a BLOB parameter (parameter ${index + 1}) over the D1 REST API`, 'encode the bytes as text (hex or base64) — the REST API has no documented lossless BLOB spelling');
|
|
56
|
+
}
|
|
57
|
+
return value;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The {@link D1BindingLike} half — exported for a caller that wants to wrap it itself
|
|
62
|
+
* (a test, or `perInvocation`). Most callers want {@link createHttpD1}.
|
|
63
|
+
*/
|
|
64
|
+
export function httpD1Binding(options) {
|
|
65
|
+
const doFetch = options.fetch ?? fetch;
|
|
66
|
+
const base = `${options.apiBase ?? 'https://api.cloudflare.com/client/v4'}/accounts/${options.accountId}/d1/database/${options.databaseId}`;
|
|
67
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
68
|
+
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
|
+
});
|
|
75
|
+
const envelope = (await response.json().catch(() => null));
|
|
76
|
+
if (!response.ok || !envelope || envelope.success === false) {
|
|
77
|
+
const message = envelope?.errors
|
|
78
|
+
?.map((e) => e.message)
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.join('; ') || response.statusText || 'no body';
|
|
81
|
+
throw new D1HttpError(response.status, message);
|
|
82
|
+
}
|
|
83
|
+
return envelope.result ?? [];
|
|
84
|
+
};
|
|
85
|
+
const asResult = (wire) => ({
|
|
86
|
+
results: Array.isArray(wire?.results) ? wire?.results : [],
|
|
87
|
+
success: wire?.success !== false,
|
|
88
|
+
...(wire?.meta ? { meta: wire.meta } : {}),
|
|
89
|
+
});
|
|
90
|
+
class HttpStatement {
|
|
91
|
+
sql;
|
|
92
|
+
params;
|
|
93
|
+
constructor(sql, params = []) {
|
|
94
|
+
this.sql = sql;
|
|
95
|
+
this.params = params;
|
|
96
|
+
}
|
|
97
|
+
bind(...values) {
|
|
98
|
+
return new HttpStatement(this.sql, toWire(values));
|
|
99
|
+
}
|
|
100
|
+
async all() {
|
|
101
|
+
return asResult((await post('/query', { sql: this.sql, params: this.params }))[0]);
|
|
102
|
+
}
|
|
103
|
+
run() {
|
|
104
|
+
return this.all();
|
|
105
|
+
}
|
|
106
|
+
async first(column) {
|
|
107
|
+
const row = ((await this.all()).results?.[0] ?? null);
|
|
108
|
+
if (row === null)
|
|
109
|
+
return null;
|
|
110
|
+
return column === undefined ? row : (row[column] ?? null);
|
|
111
|
+
}
|
|
112
|
+
async raw() {
|
|
113
|
+
const wire = (await post('/raw', { sql: this.sql, params: this.params }))[0];
|
|
114
|
+
const results = wire?.results;
|
|
115
|
+
return results?.rows ?? [];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
prepare: (sql) => new HttpStatement(sql),
|
|
120
|
+
async batch(statements) {
|
|
121
|
+
const batch = statements.map((s) => {
|
|
122
|
+
if (!(s instanceof HttpStatement)) {
|
|
123
|
+
throw new TypeError('batch() accepts only statements from this same HTTP D1 handle.');
|
|
124
|
+
}
|
|
125
|
+
return { sql: s.sql, params: s.params };
|
|
126
|
+
});
|
|
127
|
+
if (batch.length === 0)
|
|
128
|
+
return [];
|
|
129
|
+
return (await post('/query', { batch })).map(asResult);
|
|
130
|
+
},
|
|
131
|
+
async exec(sql) {
|
|
132
|
+
const results = await post('/query', { sql });
|
|
133
|
+
return {
|
|
134
|
+
count: results.length,
|
|
135
|
+
duration: results.reduce((sum, r) => sum + (r.meta?.duration ?? 0), 0),
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/** A {@link D1LikeDatabase} over the D1 REST API. See the header for when, and when not. */
|
|
141
|
+
export function createHttpD1(options) {
|
|
142
|
+
return createRemoteD1(httpD1Binding(options));
|
|
143
|
+
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
export { type BackupPoint, createTimeTravelBackup, type DatabaseBackup, type TimeTravelOpts, } from './backup.js';
|
|
18
18
|
export { createPendingWrites, type InvocationD1, type PendingWrites, perInvocation, type WriteCollector } from './invocation.js';
|
|
19
|
+
export { createHttpD1, D1HttpError, type HttpD1Options } from './http.js';
|
|
19
20
|
export { assertBatchSize, assertWithinLimits, chunkForBind, inArray, LIMITS } from './limits.js';
|
|
20
21
|
export { createLocalD1, refuseInteractiveTransaction } from './local.js';
|
|
21
22
|
export { createRemoteD1, type D1BindingLike, type D1BindingMeta, type D1BindingResult, type D1BindingStatement, } from './remote.js';
|
package/dist/server/d1/index.js
CHANGED
|
@@ -34,6 +34,7 @@ export { createTimeTravelBackup, } from './backup.js';
|
|
|
34
34
|
// of them, since business tables stay on raw statements. Measured 2026-09-18 against the
|
|
35
35
|
// published 4.3.0 tarball. `barrelsReachNoOptionalPeer.spec.ts` is what keeps it out.
|
|
36
36
|
export { createPendingWrites, perInvocation } from './invocation.js';
|
|
37
|
+
export { createHttpD1, D1HttpError } from './http.js';
|
|
37
38
|
export { assertBatchSize, assertWithinLimits, chunkForBind, inArray, LIMITS } from './limits.js';
|
|
38
39
|
export { createLocalD1, refuseInteractiveTransaction } from './local.js';
|
|
39
40
|
export { createRemoteD1, } from './remote.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.16.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.",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { createHttpD1, D1HttpError } from './http.js';
|
|
3
|
+
import { D1UnsupportedError } from './types.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The HTTP driver against a fake of the D1 REST API's WIRE shapes — the envelopes below are
|
|
7
|
+
* copied from real responses measured 2026-09-23 on a throwaway database (see `http.ts`).
|
|
8
|
+
*/
|
|
9
|
+
interface Sent {
|
|
10
|
+
url: string;
|
|
11
|
+
body: Record<string, unknown>;
|
|
12
|
+
auth: string | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fakeApi(reply: (path: string, body: Record<string, unknown>) => { status?: number; envelope: unknown }) {
|
|
16
|
+
const sent: Sent[] = [];
|
|
17
|
+
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
18
|
+
const url = String(input);
|
|
19
|
+
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
|
20
|
+
sent.push({ url, body, auth: new Headers(init?.headers).get('authorization') });
|
|
21
|
+
const path = url.endsWith('/raw') ? '/raw' : '/query';
|
|
22
|
+
const { status = 200, envelope } = reply(path, body);
|
|
23
|
+
return new Response(JSON.stringify(envelope), { status });
|
|
24
|
+
}) as typeof fetch;
|
|
25
|
+
const db = createHttpD1({ accountId: 'acct', databaseId: 'db1', apiToken: 'tok', fetch: fetchImpl });
|
|
26
|
+
return { db, sent };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const ok = (result: unknown[]) => ({ envelope: { success: true, errors: [], result } });
|
|
30
|
+
const meta = { duration: 0.4, changes: 1, last_row_id: 7, rows_read: 1, rows_written: 1 };
|
|
31
|
+
|
|
32
|
+
describe('createHttpD1', () => {
|
|
33
|
+
test('a statement is one POST to /query with its sql, params and the bearer', async () => {
|
|
34
|
+
const { db, sent } = fakeApi(() => ok([{ results: [{ id: 1, v: 'a' }], success: true, meta }]));
|
|
35
|
+
const rows = await db.prepare('SELECT * FROM t WHERE id = ? AND v = ?').bind(1, 'a').all();
|
|
36
|
+
expect(rows.results).toEqual([{ id: 1, v: 'a' }]);
|
|
37
|
+
expect(sent).toEqual([
|
|
38
|
+
{
|
|
39
|
+
url: 'https://api.cloudflare.com/client/v4/accounts/acct/d1/database/db1/query',
|
|
40
|
+
body: { sql: 'SELECT * FROM t WHERE id = ? AND v = ?', params: [1, 'a'] },
|
|
41
|
+
auth: 'Bearer tok',
|
|
42
|
+
},
|
|
43
|
+
]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('run reports changes and last_row_id; first reads a row and a column', async () => {
|
|
47
|
+
const { db } = fakeApi(() => ok([{ results: [{ n: 3 }], success: true, meta }]));
|
|
48
|
+
const res = await db.prepare('INSERT INTO t (v) VALUES (?)').bind('x').run();
|
|
49
|
+
expect(res.meta.changes).toBe(1);
|
|
50
|
+
expect(res.meta.last_row_id).toBe(7);
|
|
51
|
+
expect(await db.prepare('SELECT COUNT(*) n FROM t').first()).toEqual({ n: 3 });
|
|
52
|
+
expect(await db.prepare('SELECT COUNT(*) n FROM t').first('n')).toBe(3);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('batch is ONE request carrying every statement, and answers one result each', async () => {
|
|
56
|
+
const { db, sent } = fakeApi(() =>
|
|
57
|
+
ok([
|
|
58
|
+
{ results: [], success: true, meta },
|
|
59
|
+
{ results: [{ id: 1 }], success: true, meta: { ...meta, changes: 0 } },
|
|
60
|
+
]),
|
|
61
|
+
);
|
|
62
|
+
const results = await db.batch([
|
|
63
|
+
db.prepare('INSERT INTO t (v) VALUES (?)').bind('a'),
|
|
64
|
+
db.prepare('SELECT id FROM t'),
|
|
65
|
+
]);
|
|
66
|
+
expect(sent).toHaveLength(1);
|
|
67
|
+
expect(sent[0]?.body).toEqual({
|
|
68
|
+
batch: [
|
|
69
|
+
{ sql: 'INSERT INTO t (v) VALUES (?)', params: ['a'] },
|
|
70
|
+
{ sql: 'SELECT id FROM t', params: [] },
|
|
71
|
+
],
|
|
72
|
+
});
|
|
73
|
+
expect(results.map((r) => r.results)).toEqual([[], [{ id: 1 }]]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('raw reads the /raw endpoint, so duplicate column names survive', async () => {
|
|
77
|
+
const { db, sent } = fakeApi(() =>
|
|
78
|
+
ok([{ results: { columns: ['id', 'v', 'v'], rows: [[1, 'a', 'a']] }, success: true, meta }]),
|
|
79
|
+
);
|
|
80
|
+
expect(await db.prepare('SELECT id, v, v AS v FROM t').raw()).toEqual([[1, 'a', 'a']]);
|
|
81
|
+
expect(sent[0]?.url.endsWith('/raw')).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a refused statement throws with D1 own message — never a silent empty result', async () => {
|
|
85
|
+
const { db } = fakeApi(() => ({
|
|
86
|
+
status: 400,
|
|
87
|
+
envelope: {
|
|
88
|
+
success: false,
|
|
89
|
+
result: [],
|
|
90
|
+
errors: [{ code: 7500, message: 'UNIQUE constraint failed: t.v: SQLITE_CONSTRAINT' }],
|
|
91
|
+
},
|
|
92
|
+
}));
|
|
93
|
+
const attempt = db.prepare('INSERT INTO t (v) VALUES (?)').bind('one').run();
|
|
94
|
+
await expect(attempt).rejects.toBeInstanceOf(D1HttpError);
|
|
95
|
+
await expect(db.prepare('INSERT INTO t (v) VALUES (?)').bind('one').run()).rejects.toThrow(
|
|
96
|
+
/400\): UNIQUE constraint failed/,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('a body that is not JSON (a proxy page, an outage) still throws with the status', async () => {
|
|
101
|
+
const fetchImpl = (async () => new Response('<html>bad gateway</html>', { status: 502 })) as unknown as typeof fetch;
|
|
102
|
+
const db = createHttpD1({ accountId: 'a', databaseId: 'd', apiToken: 't', fetch: fetchImpl });
|
|
103
|
+
await expect(db.prepare('SELECT 1').all()).rejects.toThrow(/\(502\)/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('a BLOB parameter is refused rather than sent in a spelling D1 does not document', () => {
|
|
107
|
+
const { db } = fakeApi(() => ok([]));
|
|
108
|
+
expect(() => db.prepare('INSERT INTO t (b) VALUES (?)').bind(new Uint8Array([1, 2]))).toThrow(D1UnsupportedError);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('the parameter cap is the binding driver own — asserted before anything is sent', () => {
|
|
112
|
+
const { db, sent } = fakeApi(() => ok([]));
|
|
113
|
+
const holes = Array.from({ length: 101 }, () => '?').join(', ');
|
|
114
|
+
expect(() => db.prepare(`SELECT ${holes}`).bind(...Array.from({ length: 101 }, (_, i) => i))).toThrow();
|
|
115
|
+
expect(sent).toHaveLength(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('exec sends the whole script and counts the statements D1 ran', async () => {
|
|
119
|
+
const { db, sent } = fakeApi(() => ok([{ meta }, { meta }, { meta }]));
|
|
120
|
+
expect(await db.exec('CREATE TABLE u (x); INSERT INTO u VALUES (1); INSERT INTO u VALUES (2)')).toEqual({
|
|
121
|
+
count: 3,
|
|
122
|
+
duration: 1.2000000000000002,
|
|
123
|
+
});
|
|
124
|
+
expect(sent[0]?.body).toEqual({ sql: 'CREATE TABLE u (x); INSERT INTO u VALUES (1); INSERT INTO u VALUES (2)' });
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A D1 database reached over Cloudflare's REST API — for a process that is NOT a Worker.
|
|
3
|
+
*
|
|
4
|
+
* ── Why this exists ─────────────────────────────────────────────────────────────────────
|
|
5
|
+
* An app that moves onto a Worker keeps some work on the Mac: a job that drives the owner's
|
|
6
|
+
* own Chrome, a bulk importer reading a local folder, a backfill, a backup. Before the move
|
|
7
|
+
* those wrote the Mac's `bun:sqlite` file; after it, that file is a frozen copy nothing reads,
|
|
8
|
+
* and a script writing into it reports success while the library it meant to change never
|
|
9
|
+
* hears about it. Measured on `music`'s cutover plan, 2026-09-22: four Mac-side writers, and
|
|
10
|
+
* the only other door into D1 was a per-app bearer route on the Worker for each one.
|
|
11
|
+
*
|
|
12
|
+
* So the Mac gets the same {@link D1LikeDatabase} a Worker holds, pointed at the same
|
|
13
|
+
* database, and the app's own catalogue code runs unchanged against production — ONE writer
|
|
14
|
+
* of the rows, one schema, one set of statements.
|
|
15
|
+
*
|
|
16
|
+
* ── How ──────────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
* It is a {@link D1BindingLike} over `POST /accounts/:account/d1/database/:db/query` (and
|
|
18
|
+
* `/raw`), handed to {@link createRemoteD1} — so every value normalisation, limit check and
|
|
19
|
+
* `success: false` guard is the binding driver's own, not a second copy that could drift.
|
|
20
|
+
* `batch()` sends `{ batch: [...] }`, which D1 runs as one unit exactly as a binding's batch
|
|
21
|
+
* does: measured 2026-09-23 on a throwaway database, a batch whose second statement violated
|
|
22
|
+
* a UNIQUE constraint left the first one's row absent.
|
|
23
|
+
*
|
|
24
|
+
* 🔴 **Not for a Worker.** A Worker has the binding, which is faster, free of an API token,
|
|
25
|
+
* and not subject to the REST API's rate limit. This is for a Mac process, with the account
|
|
26
|
+
* token read from the generation's secrets — never shipped to a browser or a Worker.
|
|
27
|
+
*
|
|
28
|
+
* 🔴 **Every number arrives as a REAL.** The REST API's parameters are JSON, and measured
|
|
29
|
+
* 2026-09-23 it binds a JSON `5` as the REAL `5.0` (`typeof(?)` answers `real`). Everywhere a
|
|
30
|
+
* number is compared, added, used as a `LIMIT`, or stored in an INTEGER column, that is
|
|
31
|
+
* indistinguishable from an integer — SQLite compares `5.0 = 5` true and INTEGER affinity
|
|
32
|
+
* stores `5`. The one place it differs: a number stored into a TEXT column is `'5.0'`, where
|
|
33
|
+
* the binding stores `'5'`. Bind a string into a TEXT column. (Sending integers as strings
|
|
34
|
+
* instead was measured too, and is worse: it breaks every comparison that has no column
|
|
35
|
+
* affinity, such as `CASE WHEN ? = 1`.)
|
|
36
|
+
*
|
|
37
|
+
* 🔴 **A BLOB parameter is refused.** The REST API's parameters are JSON; there is no
|
|
38
|
+
* lossless spelling of bytes in it that D1 documents, and guessing one would store the
|
|
39
|
+
* wrong thing silently. Encode bytes yourself (hex, base64) if a table genuinely needs them.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { createRemoteD1, type D1BindingLike, type D1BindingResult, type D1BindingStatement } from './remote.js';
|
|
43
|
+
import { D1UnsupportedError, type D1LikeDatabase } from './types.js';
|
|
44
|
+
|
|
45
|
+
export interface HttpD1Options {
|
|
46
|
+
accountId: string;
|
|
47
|
+
databaseId: string;
|
|
48
|
+
/** An API token with D1 Edit on the account. */
|
|
49
|
+
apiToken: string;
|
|
50
|
+
/** Each request's ceiling, in ms. Default 30 s — a hung socket must not hang a job. */
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
/** Injected for tests. Defaults to the global `fetch`. */
|
|
53
|
+
fetch?: typeof fetch;
|
|
54
|
+
/** Defaults to `https://api.cloudflare.com/client/v4`. */
|
|
55
|
+
apiBase?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface WireResult {
|
|
59
|
+
results?: unknown;
|
|
60
|
+
success?: boolean;
|
|
61
|
+
meta?: D1BindingResult['meta'];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface Envelope {
|
|
65
|
+
success?: boolean;
|
|
66
|
+
errors?: { code?: number; message?: string }[];
|
|
67
|
+
result?: WireResult[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Thrown when the REST API itself refuses — auth, a missing database, a rate limit. */
|
|
71
|
+
export class D1HttpError extends Error {
|
|
72
|
+
readonly status: number;
|
|
73
|
+
constructor(status: number, message: string) {
|
|
74
|
+
super(`D1 over HTTP refused the request (${status}): ${message}`);
|
|
75
|
+
this.name = 'D1HttpError';
|
|
76
|
+
this.status = status;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function toWire(values: readonly unknown[]): unknown[] {
|
|
81
|
+
return values.map((value, index) => {
|
|
82
|
+
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) {
|
|
83
|
+
throw new D1UnsupportedError(
|
|
84
|
+
`a BLOB parameter (parameter ${index + 1}) over the D1 REST API`,
|
|
85
|
+
'encode the bytes as text (hex or base64) — the REST API has no documented lossless BLOB spelling',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The {@link D1BindingLike} half — exported for a caller that wants to wrap it itself
|
|
94
|
+
* (a test, or `perInvocation`). Most callers want {@link createHttpD1}.
|
|
95
|
+
*/
|
|
96
|
+
export function httpD1Binding(options: HttpD1Options): D1BindingLike {
|
|
97
|
+
const doFetch = options.fetch ?? fetch;
|
|
98
|
+
const base = `${options.apiBase ?? 'https://api.cloudflare.com/client/v4'}/accounts/${options.accountId}/d1/database/${options.databaseId}`;
|
|
99
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
100
|
+
|
|
101
|
+
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
|
+
});
|
|
108
|
+
const envelope = (await response.json().catch(() => null)) as Envelope | null;
|
|
109
|
+
if (!response.ok || !envelope || envelope.success === false) {
|
|
110
|
+
const message =
|
|
111
|
+
envelope?.errors
|
|
112
|
+
?.map((e) => e.message)
|
|
113
|
+
.filter(Boolean)
|
|
114
|
+
.join('; ') || response.statusText || 'no body';
|
|
115
|
+
throw new D1HttpError(response.status, message);
|
|
116
|
+
}
|
|
117
|
+
return envelope.result ?? [];
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const asResult = (wire: WireResult | undefined): D1BindingResult => ({
|
|
121
|
+
results: Array.isArray(wire?.results) ? (wire?.results as unknown[]) : [],
|
|
122
|
+
success: wire?.success !== false,
|
|
123
|
+
...(wire?.meta ? { meta: wire.meta } : {}),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
class HttpStatement implements D1BindingStatement {
|
|
127
|
+
constructor(
|
|
128
|
+
readonly sql: string,
|
|
129
|
+
readonly params: unknown[] = [],
|
|
130
|
+
) {}
|
|
131
|
+
|
|
132
|
+
bind(...values: unknown[]): D1BindingStatement {
|
|
133
|
+
return new HttpStatement(this.sql, toWire(values));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async all(): Promise<D1BindingResult> {
|
|
137
|
+
return asResult((await post('/query', { sql: this.sql, params: this.params }))[0]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
run(): Promise<D1BindingResult> {
|
|
141
|
+
return this.all();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async first(column?: string): Promise<unknown> {
|
|
145
|
+
const row = ((await this.all()).results?.[0] ?? null) as Record<string, unknown> | null;
|
|
146
|
+
if (row === null) return null;
|
|
147
|
+
return column === undefined ? row : (row[column] ?? null);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async raw(): Promise<unknown[][]> {
|
|
151
|
+
const wire = (await post('/raw', { sql: this.sql, params: this.params }))[0];
|
|
152
|
+
const results = wire?.results as { rows?: unknown[][] } | undefined;
|
|
153
|
+
return results?.rows ?? [];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
prepare: (sql) => new HttpStatement(sql),
|
|
159
|
+
async batch(statements) {
|
|
160
|
+
const batch = statements.map((s) => {
|
|
161
|
+
if (!(s instanceof HttpStatement)) {
|
|
162
|
+
throw new TypeError('batch() accepts only statements from this same HTTP D1 handle.');
|
|
163
|
+
}
|
|
164
|
+
return { sql: s.sql, params: s.params };
|
|
165
|
+
});
|
|
166
|
+
if (batch.length === 0) return [];
|
|
167
|
+
return (await post('/query', { batch })).map(asResult);
|
|
168
|
+
},
|
|
169
|
+
async exec(sql) {
|
|
170
|
+
const results = await post('/query', { sql });
|
|
171
|
+
return {
|
|
172
|
+
count: results.length,
|
|
173
|
+
duration: results.reduce((sum, r) => sum + (r.meta?.duration ?? 0), 0),
|
|
174
|
+
};
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A {@link D1LikeDatabase} over the D1 REST API. See the header for when, and when not. */
|
|
180
|
+
export function createHttpD1(options: HttpD1Options): D1LikeDatabase {
|
|
181
|
+
return createRemoteD1(httpD1Binding(options));
|
|
182
|
+
}
|
package/src/server/d1/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ export {
|
|
|
40
40
|
// of them, since business tables stay on raw statements. Measured 2026-09-18 against the
|
|
41
41
|
// published 4.3.0 tarball. `barrelsReachNoOptionalPeer.spec.ts` is what keeps it out.
|
|
42
42
|
export { createPendingWrites, type InvocationD1, type PendingWrites, perInvocation, type WriteCollector } from './invocation.js';
|
|
43
|
+
export { createHttpD1, D1HttpError, type HttpD1Options } from './http.js';
|
|
43
44
|
export { assertBatchSize, assertWithinLimits, chunkForBind, inArray, LIMITS } from './limits.js';
|
|
44
45
|
export { createLocalD1, refuseInteractiveTransaction } from './local.js';
|
|
45
46
|
export {
|