cursedbelt-server 4.14.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 +2 -1
- package/dist/server/d1/index.js +2 -1
- package/dist/server/d1/limits.d.ts +31 -0
- package/dist/server/d1/limits.js +37 -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 +2 -1
- package/src/server/d1/limits.spec.ts +66 -1
- package/src/server/d1/limits.ts +36 -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,7 +16,8 @@
|
|
|
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 {
|
|
19
|
+
export { createHttpD1, D1HttpError, type HttpD1Options } from './http.js';
|
|
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';
|
|
22
23
|
export { classifySchedule, cronFieldCount, jobsForCron, type PortableJob, type PortableJobContext, type ScheduleVerdict, toCronTriggers, toFiveField, type TriggerShape, type WorkersPrimitive, } from './scheduling.js';
|
package/dist/server/d1/index.js
CHANGED
|
@@ -34,7 +34,8 @@ 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 {
|
|
37
|
+
export { createHttpD1, D1HttpError } from './http.js';
|
|
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';
|
|
40
41
|
export { classifySchedule, cronFieldCount, jobsForCron, toCronTriggers, toFiveField, } from './scheduling.js';
|
|
@@ -54,3 +54,34 @@ export declare function assertBatchSize(count: number): void;
|
|
|
54
54
|
* ```
|
|
55
55
|
*/
|
|
56
56
|
export declare function chunkForBind<T>(items: readonly T[], paramsPerItem: number): T[][];
|
|
57
|
+
/**
|
|
58
|
+
* `column IN (…)` over a list of ANY length, as ONE bound parameter.
|
|
59
|
+
*
|
|
60
|
+
* ```ts
|
|
61
|
+
* const ids = inArray(itemIds);
|
|
62
|
+
* await db.prepare(`UPDATE items SET deleted_at = ? WHERE id ${ids.sql}`).bind(now, ids.param).run();
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* 🔴 **Why this and not {@link chunkForBind} for a set of ids.** An `IN (?, ?, …)` built by
|
|
66
|
+
* `ids.map(() => '?')` binds one parameter per id, so it is green on every fixture and a 500 on
|
|
67
|
+
* the 101st id — the cap is asserted on both drivers, and real D1 enforces the same one.
|
|
68
|
+
* Chunking fixes a bulk INSERT, but it cannot fix a READ that must be one statement (a
|
|
69
|
+
* `WHERE … ORDER BY … LIMIT ? OFFSET ?` page and the `COUNT(*)` beside it), and on a write
|
|
70
|
+
* it trades one atomic statement for a batch that is atomic only per chunk. `json_each`
|
|
71
|
+
* over a JSON array is one parameter however long the list is, keeps the statement whole,
|
|
72
|
+
* and SQLite still drives the column's index through the materialised set.
|
|
73
|
+
*
|
|
74
|
+
* The array travels as one TEXT value, so the ceiling moves from 100 ids to
|
|
75
|
+
* `LIMITS.valueBytes` (2 MB) of JSON — about 70,000 of this fleet's 25-character ids — and
|
|
76
|
+
* past that `assertWithinLimits` refuses it at `bind()` with the byte count, on both drivers.
|
|
77
|
+
* An empty list is valid SQL and matches nothing, so no caller needs an `IN ()` special case.
|
|
78
|
+
*
|
|
79
|
+
* Only strings and finite numbers: `json_each` yields TEXT for a string and INTEGER/REAL for
|
|
80
|
+
* a number, which is what makes `id IN …` compare like `id = ?` would. Anything else would
|
|
81
|
+
* compare as something the caller did not write, so it is refused here rather than matched
|
|
82
|
+
* silently against nothing.
|
|
83
|
+
*/
|
|
84
|
+
export declare function inArray(values: readonly (string | number)[]): {
|
|
85
|
+
sql: string;
|
|
86
|
+
param: string;
|
|
87
|
+
};
|
package/dist/server/d1/limits.js
CHANGED
|
@@ -94,3 +94,40 @@ export function chunkForBind(items, paramsPerItem) {
|
|
|
94
94
|
out.push(items.slice(i, i + size));
|
|
95
95
|
return out;
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* `column IN (…)` over a list of ANY length, as ONE bound parameter.
|
|
99
|
+
*
|
|
100
|
+
* ```ts
|
|
101
|
+
* const ids = inArray(itemIds);
|
|
102
|
+
* await db.prepare(`UPDATE items SET deleted_at = ? WHERE id ${ids.sql}`).bind(now, ids.param).run();
|
|
103
|
+
* ```
|
|
104
|
+
*
|
|
105
|
+
* 🔴 **Why this and not {@link chunkForBind} for a set of ids.** An `IN (?, ?, …)` built by
|
|
106
|
+
* `ids.map(() => '?')` binds one parameter per id, so it is green on every fixture and a 500 on
|
|
107
|
+
* the 101st id — the cap is asserted on both drivers, and real D1 enforces the same one.
|
|
108
|
+
* Chunking fixes a bulk INSERT, but it cannot fix a READ that must be one statement (a
|
|
109
|
+
* `WHERE … ORDER BY … LIMIT ? OFFSET ?` page and the `COUNT(*)` beside it), and on a write
|
|
110
|
+
* it trades one atomic statement for a batch that is atomic only per chunk. `json_each`
|
|
111
|
+
* over a JSON array is one parameter however long the list is, keeps the statement whole,
|
|
112
|
+
* and SQLite still drives the column's index through the materialised set.
|
|
113
|
+
*
|
|
114
|
+
* The array travels as one TEXT value, so the ceiling moves from 100 ids to
|
|
115
|
+
* `LIMITS.valueBytes` (2 MB) of JSON — about 70,000 of this fleet's 25-character ids — and
|
|
116
|
+
* past that `assertWithinLimits` refuses it at `bind()` with the byte count, on both drivers.
|
|
117
|
+
* An empty list is valid SQL and matches nothing, so no caller needs an `IN ()` special case.
|
|
118
|
+
*
|
|
119
|
+
* Only strings and finite numbers: `json_each` yields TEXT for a string and INTEGER/REAL for
|
|
120
|
+
* a number, which is what makes `id IN …` compare like `id = ?` would. Anything else would
|
|
121
|
+
* compare as something the caller did not write, so it is refused here rather than matched
|
|
122
|
+
* silently against nothing.
|
|
123
|
+
*/
|
|
124
|
+
export function inArray(values) {
|
|
125
|
+
for (const v of values) {
|
|
126
|
+
if (typeof v === 'string')
|
|
127
|
+
continue;
|
|
128
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
129
|
+
continue;
|
|
130
|
+
throw new TypeError(`inArray takes strings and finite numbers, got ${typeof v}: ${String(v)}`);
|
|
131
|
+
}
|
|
132
|
+
return { sql: 'IN (SELECT value FROM json_each(?))', param: JSON.stringify(values) };
|
|
133
|
+
}
|
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,7 +40,8 @@ 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 {
|
|
43
|
+
export { createHttpD1, D1HttpError, type HttpD1Options } from './http.js';
|
|
44
|
+
export { assertBatchSize, assertWithinLimits, chunkForBind, inArray, LIMITS } from './limits.js';
|
|
44
45
|
export { createLocalD1, refuseInteractiveTransaction } from './local.js';
|
|
45
46
|
export {
|
|
46
47
|
createRemoteD1,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Database } from 'bun:sqlite';
|
|
2
2
|
import { describe, expect, test } from 'bun:test';
|
|
3
|
-
import { assertBatchSize, assertWithinLimits, chunkForBind, LIMITS } from './limits.js';
|
|
3
|
+
import { assertBatchSize, assertWithinLimits, chunkForBind, inArray, LIMITS } from './limits.js';
|
|
4
4
|
import { createLocalD1 } from './local.js';
|
|
5
5
|
import { D1LimitError } from './types.js';
|
|
6
6
|
|
|
@@ -87,4 +87,69 @@ describe('D1 limits are enforced on the LOCAL driver too', () => {
|
|
|
87
87
|
expect(() => chunkForBind([1], 1.5)).toThrow(RangeError);
|
|
88
88
|
});
|
|
89
89
|
});
|
|
90
|
+
|
|
91
|
+
describe('inArray binds a list of any length as ONE parameter', () => {
|
|
92
|
+
const seeded = (n: number) => {
|
|
93
|
+
const db = new Database(':memory:');
|
|
94
|
+
db.run('CREATE TABLE items (id TEXT PRIMARY KEY, n INTEGER, deleted_at INTEGER)');
|
|
95
|
+
const insert = db.prepare('INSERT INTO items (id, n) VALUES (?, ?)');
|
|
96
|
+
for (let i = 0; i < n; i++) insert.run(`itm_${String(i).padStart(4, '0')}`, i);
|
|
97
|
+
return { raw: db, seam: createLocalD1(db) };
|
|
98
|
+
};
|
|
99
|
+
const ids = (n: number) => Array.from({ length: n }, (_, i) => `itm_${String(i).padStart(4, '0')}`);
|
|
100
|
+
|
|
101
|
+
test('the shape it replaces is refused at 150 ids — the red this exists for', () => {
|
|
102
|
+
const { seam } = seeded(150);
|
|
103
|
+
const holes = ids(150).map(() => '?').join(', ');
|
|
104
|
+
expect(() => seam.prepare(`SELECT id FROM items WHERE id IN (${holes})`).bind(...ids(150))).toThrow(
|
|
105
|
+
D1LimitError,
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a 150-id read under ORDER BY / LIMIT / OFFSET selects exactly those rows', async () => {
|
|
110
|
+
const { seam } = seeded(400);
|
|
111
|
+
const set = inArray(ids(150));
|
|
112
|
+
const page = await seam
|
|
113
|
+
.prepare(`SELECT id FROM items WHERE id ${set.sql} ORDER BY id LIMIT ? OFFSET ?`)
|
|
114
|
+
.bind(set.param, 100, 20)
|
|
115
|
+
.all<{ id: string }>();
|
|
116
|
+
expect(page.results.map((r) => r.id)).toEqual(ids(150).slice(20, 120));
|
|
117
|
+
const count = await seam
|
|
118
|
+
.prepare(`SELECT COUNT(*) AS n FROM items WHERE id ${set.sql}`)
|
|
119
|
+
.bind(set.param)
|
|
120
|
+
.first<{ n: number }>();
|
|
121
|
+
expect(count?.n).toBe(150);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('a 1,000-id UPDATE is one statement and touches only those rows', async () => {
|
|
125
|
+
const { raw, seam } = seeded(1_200);
|
|
126
|
+
const set = inArray(ids(1_000));
|
|
127
|
+
await seam.prepare(`UPDATE items SET deleted_at = ? WHERE id ${set.sql}`).bind(7, set.param).run();
|
|
128
|
+
expect(raw.query('SELECT COUNT(*) AS n FROM items WHERE deleted_at = 7').get()).toEqual({ n: 1_000 });
|
|
129
|
+
expect(raw.query('SELECT COUNT(*) AS n FROM items WHERE deleted_at IS NULL').get()).toEqual({ n: 200 });
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('numbers compare as numbers, and an empty list is valid SQL that matches nothing', async () => {
|
|
133
|
+
const { seam } = seeded(10);
|
|
134
|
+
const nums = inArray([2, 4, 6]);
|
|
135
|
+
const hit = await seam.prepare(`SELECT id FROM items WHERE n ${nums.sql} ORDER BY n`).bind(nums.param).all<{ id: string }>();
|
|
136
|
+
expect(hit.results.map((r) => r.id)).toEqual(['itm_0002', 'itm_0004', 'itm_0006']);
|
|
137
|
+
const none = inArray([]);
|
|
138
|
+
const empty = await seam.prepare(`SELECT id FROM items WHERE id ${none.sql}`).bind(none.param).all();
|
|
139
|
+
expect(empty.results).toEqual([]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('a value it cannot compare honestly is refused, not matched against nothing', () => {
|
|
143
|
+
expect(() => inArray([null as unknown as string])).toThrow(TypeError);
|
|
144
|
+
expect(() => inArray([Number.NaN])).toThrow(TypeError);
|
|
145
|
+
expect(() => inArray([{} as unknown as string])).toThrow(TypeError);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('past 2 MB of JSON the seam refuses it at bind(), naming the byte count', () => {
|
|
149
|
+
const { seam } = seeded(1);
|
|
150
|
+
const huge = inArray(Array.from({ length: 90_000 }, (_, i) => `itm_${String(i).padStart(20, '0')}`));
|
|
151
|
+
expect(() => seam.prepare(`SELECT id FROM items WHERE id ${huge.sql}`).bind(huge.param)).toThrow(/value size/);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
90
154
|
});
|
|
155
|
+
|
package/src/server/d1/limits.ts
CHANGED
|
@@ -121,3 +121,39 @@ export function chunkForBind<T>(items: readonly T[], paramsPerItem: number): T[]
|
|
|
121
121
|
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size) as T[]);
|
|
122
122
|
return out;
|
|
123
123
|
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* `column IN (…)` over a list of ANY length, as ONE bound parameter.
|
|
127
|
+
*
|
|
128
|
+
* ```ts
|
|
129
|
+
* const ids = inArray(itemIds);
|
|
130
|
+
* await db.prepare(`UPDATE items SET deleted_at = ? WHERE id ${ids.sql}`).bind(now, ids.param).run();
|
|
131
|
+
* ```
|
|
132
|
+
*
|
|
133
|
+
* 🔴 **Why this and not {@link chunkForBind} for a set of ids.** An `IN (?, ?, …)` built by
|
|
134
|
+
* `ids.map(() => '?')` binds one parameter per id, so it is green on every fixture and a 500 on
|
|
135
|
+
* the 101st id — the cap is asserted on both drivers, and real D1 enforces the same one.
|
|
136
|
+
* Chunking fixes a bulk INSERT, but it cannot fix a READ that must be one statement (a
|
|
137
|
+
* `WHERE … ORDER BY … LIMIT ? OFFSET ?` page and the `COUNT(*)` beside it), and on a write
|
|
138
|
+
* it trades one atomic statement for a batch that is atomic only per chunk. `json_each`
|
|
139
|
+
* over a JSON array is one parameter however long the list is, keeps the statement whole,
|
|
140
|
+
* and SQLite still drives the column's index through the materialised set.
|
|
141
|
+
*
|
|
142
|
+
* The array travels as one TEXT value, so the ceiling moves from 100 ids to
|
|
143
|
+
* `LIMITS.valueBytes` (2 MB) of JSON — about 70,000 of this fleet's 25-character ids — and
|
|
144
|
+
* past that `assertWithinLimits` refuses it at `bind()` with the byte count, on both drivers.
|
|
145
|
+
* An empty list is valid SQL and matches nothing, so no caller needs an `IN ()` special case.
|
|
146
|
+
*
|
|
147
|
+
* Only strings and finite numbers: `json_each` yields TEXT for a string and INTEGER/REAL for
|
|
148
|
+
* a number, which is what makes `id IN …` compare like `id = ?` would. Anything else would
|
|
149
|
+
* compare as something the caller did not write, so it is refused here rather than matched
|
|
150
|
+
* silently against nothing.
|
|
151
|
+
*/
|
|
152
|
+
export function inArray(values: readonly (string | number)[]): { sql: string; param: string } {
|
|
153
|
+
for (const v of values) {
|
|
154
|
+
if (typeof v === 'string') continue;
|
|
155
|
+
if (typeof v === 'number' && Number.isFinite(v)) continue;
|
|
156
|
+
throw new TypeError(`inArray takes strings and finite numbers, got ${typeof v}: ${String(v)}`);
|
|
157
|
+
}
|
|
158
|
+
return { sql: 'IN (SELECT value FROM json_each(?))', param: JSON.stringify(values) };
|
|
159
|
+
}
|