cursedbelt-server 2.0.0 → 2.1.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/bench/assert.d.ts +61 -0
- package/dist/server/bench/assert.js +117 -0
- package/dist/server/bench/budget.d.ts +130 -0
- package/dist/server/bench/budget.js +131 -0
- package/dist/server/bench/cpuBudget.d.ts +45 -0
- package/dist/server/bench/cpuBudget.js +34 -0
- package/dist/server/bench/cpuClock.d.ts +65 -0
- package/dist/server/bench/cpuClock.js +100 -0
- package/dist/server/bench/index.d.ts +40 -0
- package/dist/server/bench/index.js +40 -0
- package/dist/server/bench/recorder.d.ts +70 -0
- package/dist/server/bench/recorder.js +95 -0
- package/dist/server/bench/runBench.d.ts +61 -0
- package/dist/server/bench/runBench.js +61 -0
- package/dist/server/d1/backup.d.ts +110 -0
- package/dist/server/d1/backup.js +128 -0
- package/dist/server/d1/fakeD1.d.ts +41 -0
- package/dist/server/d1/fakeD1.js +185 -0
- package/dist/server/d1/index.d.ts +24 -0
- package/dist/server/d1/index.js +24 -0
- package/dist/server/d1/kysely.d.ts +56 -0
- package/dist/server/d1/kysely.js +138 -0
- package/dist/server/d1/limits.d.ts +56 -0
- package/dist/server/d1/limits.js +96 -0
- package/dist/server/d1/local.d.ts +31 -0
- package/dist/server/d1/local.js +135 -0
- package/dist/server/d1/remote.d.ts +59 -0
- package/dist/server/d1/remote.js +124 -0
- package/dist/server/d1/scheduling.d.ts +113 -0
- package/dist/server/d1/scheduling.js +164 -0
- package/dist/server/d1/types.d.ts +143 -0
- package/dist/server/d1/types.js +80 -0
- package/dist/server/d1/values.d.ts +50 -0
- package/dist/server/d1/values.js +124 -0
- package/package.json +21 -3
- package/src/leafSubpathsImportNothing.spec.ts +15 -3
- package/src/server/bench/assert.ts +192 -0
- package/src/server/bench/budget.spec.ts +126 -0
- package/src/server/bench/budget.ts +207 -0
- package/src/server/bench/cpuBudget.spec.ts +302 -0
- package/src/server/bench/cpuBudget.ts +81 -0
- package/src/server/bench/cpuClock.ts +119 -0
- package/src/server/bench/index.ts +81 -0
- package/src/server/bench/recorder.ts +163 -0
- package/src/server/bench/runBench.ts +110 -0
- package/src/server/d1/backup.spec.ts +121 -0
- package/src/server/d1/backup.ts +186 -0
- package/src/server/d1/fakeD1.ts +193 -0
- package/src/server/d1/index.ts +62 -0
- package/src/server/d1/kysely.spec.ts +145 -0
- package/src/server/d1/kysely.ts +169 -0
- package/src/server/d1/limits.spec.ts +90 -0
- package/src/server/d1/limits.ts +123 -0
- package/src/server/d1/local.ts +173 -0
- package/src/server/d1/remote.ts +182 -0
- package/src/server/d1/sameShape.spec.ts +279 -0
- package/src/server/d1/scheduling.spec.ts +120 -0
- package/src/server/d1/scheduling.ts +210 -0
- package/src/server/d1/types.ts +163 -0
- package/src/server/d1/values.ts +138 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D1's hard limits, and the helpers that keep a query inside them.
|
|
3
|
+
*
|
|
4
|
+
* 🔴 **These shape the schema, not just the bill.** Three of them change how code must be
|
|
5
|
+
* written, and all three are invisible on `bun:sqlite` — which has no such caps, so every
|
|
6
|
+
* one of them is a green-here / red-there defect waiting for the first real dataset:
|
|
7
|
+
*
|
|
8
|
+
* · **1,000 queries per Worker invocation.** An N+1 loop over `family`'s 489 people
|
|
9
|
+
* exceeds it. Join, or `batch()`; do not loop.
|
|
10
|
+
* · **100 bound parameters per query.** Any seed, import or bulk insert hits this. Use
|
|
11
|
+
* {@link chunkForBind}, which does the arithmetic rather than leaving it to a guess.
|
|
12
|
+
* · **2 MB per string / BLOB / row.** Nothing may store a media byte in D1.
|
|
13
|
+
*
|
|
14
|
+
* The limits are asserted by the LOCAL driver as well as the remote one, which is the
|
|
15
|
+
* whole point: a bulk insert that would fail in production fails on the laptop, at the
|
|
16
|
+
* call site, with the chunk size it should have used in the message.
|
|
17
|
+
*
|
|
18
|
+
* Values are the Workers **Paid** tier, which is what the owner already pays for.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { D1LimitError, type D1LikeBindable } from './types';
|
|
22
|
+
|
|
23
|
+
export const LIMITS = {
|
|
24
|
+
/** Bound parameters in a single statement. */
|
|
25
|
+
boundParams: 100,
|
|
26
|
+
/** Queries a single Worker invocation may issue, including every `batch()` member. */
|
|
27
|
+
queriesPerInvocation: 1_000,
|
|
28
|
+
/** Bytes of SQL text in one statement. A generated `IN (…)` list hits this first. */
|
|
29
|
+
sqlLength: 100_000,
|
|
30
|
+
/** Bytes in any single string or BLOB value, and in any single row. */
|
|
31
|
+
valueBytes: 2_000_000,
|
|
32
|
+
/** Columns in one table. The fleet's `data JSON` pattern stays far under it. */
|
|
33
|
+
columnsPerTable: 100,
|
|
34
|
+
/** Wall-clock milliseconds one query may take. */
|
|
35
|
+
queryDurationMs: 30_000,
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
const byteLength = (v: D1LikeBindable): number => {
|
|
39
|
+
if (typeof v === 'string') return Buffer.byteLength(v, 'utf8');
|
|
40
|
+
if (v instanceof Uint8Array) return v.byteLength;
|
|
41
|
+
if (v instanceof ArrayBuffer) return v.byteLength;
|
|
42
|
+
return 0;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Refuse a statement that D1 would refuse — checked on BOTH drivers, at `bind()` time, so
|
|
47
|
+
* the failure lands on the call site that built the query rather than in production.
|
|
48
|
+
*/
|
|
49
|
+
export function assertWithinLimits(sql: string, params: readonly D1LikeBindable[]): void {
|
|
50
|
+
if (params.length > LIMITS.boundParams) {
|
|
51
|
+
throw new D1LimitError(
|
|
52
|
+
'bound parameters per query',
|
|
53
|
+
params.length,
|
|
54
|
+
LIMITS.boundParams,
|
|
55
|
+
'Chunk the work — `chunkForBind(items, paramsPerItem)` sizes the batches for you, ' +
|
|
56
|
+
'or use `batch()` for independent statements.',
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const sqlBytes = Buffer.byteLength(sql, 'utf8');
|
|
60
|
+
if (sqlBytes > LIMITS.sqlLength) {
|
|
61
|
+
throw new D1LimitError(
|
|
62
|
+
'SQL statement length (bytes)',
|
|
63
|
+
sqlBytes,
|
|
64
|
+
LIMITS.sqlLength,
|
|
65
|
+
'A generated `IN (...)` list hits this before the parameter cap — bind parameters instead of inlining values.',
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
for (let i = 0; i < params.length; i++) {
|
|
69
|
+
const bytes = byteLength(params[i] as D1LikeBindable);
|
|
70
|
+
if (bytes > LIMITS.valueBytes) {
|
|
71
|
+
throw new D1LimitError(
|
|
72
|
+
`value size for parameter ${i + 1} (bytes)`,
|
|
73
|
+
bytes,
|
|
74
|
+
LIMITS.valueBytes,
|
|
75
|
+
'D1 is not a blob store. Media bytes belong in R2 behind the binary server — see task 193.',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Refuse a `batch()` bigger than one Worker invocation may issue. */
|
|
82
|
+
export function assertBatchSize(count: number): void {
|
|
83
|
+
if (count > LIMITS.queriesPerInvocation) {
|
|
84
|
+
throw new D1LimitError(
|
|
85
|
+
'queries per Worker invocation',
|
|
86
|
+
count,
|
|
87
|
+
LIMITS.queriesPerInvocation,
|
|
88
|
+
'Split the work across invocations — a Queue consumer or a Cron Trigger, not one giant batch.',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Split a list into chunks that fit under the 100-parameter cap, given how many bound
|
|
95
|
+
* parameters each item contributes.
|
|
96
|
+
*
|
|
97
|
+
* A bulk insert of `n` rows with `c` columns binds `n * c` parameters, so the batch size
|
|
98
|
+
* is `floor(100 / c)` — the arithmetic nobody does correctly by eye at 3 a.m., which is
|
|
99
|
+
* why it is a function and not a sentence in a doc.
|
|
100
|
+
*
|
|
101
|
+
* ```ts
|
|
102
|
+
* for (const rows of chunkForBind(people, 4)) { // 4 columns → 25 rows a chunk
|
|
103
|
+
* await db.prepare(insertSql(rows.length)).bind(...rows.flatMap(toParams)).run();
|
|
104
|
+
* }
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
export function chunkForBind<T>(items: readonly T[], paramsPerItem: number): T[][] {
|
|
108
|
+
if (!Number.isInteger(paramsPerItem) || paramsPerItem < 1) {
|
|
109
|
+
throw new RangeError(`paramsPerItem must be a positive integer, got ${paramsPerItem}`);
|
|
110
|
+
}
|
|
111
|
+
if (paramsPerItem > LIMITS.boundParams) {
|
|
112
|
+
throw new D1LimitError(
|
|
113
|
+
'bound parameters per query',
|
|
114
|
+
paramsPerItem,
|
|
115
|
+
LIMITS.boundParams,
|
|
116
|
+
'A single item already exceeds the cap — store fewer columns, or move the wide value out of D1.',
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const size = Math.floor(LIMITS.boundParams / paramsPerItem);
|
|
120
|
+
const out: T[][] = [];
|
|
121
|
+
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size) as T[]);
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The LOCAL implementation of {@link D1LikeDatabase}, over `bun:sqlite`.
|
|
3
|
+
*
|
|
4
|
+
* This is the half that keeps the gate, the dev loop and a Mac-hosted app working
|
|
5
|
+
* unchanged while the fleet ports. It is async because the seam is async — not because
|
|
6
|
+
* anything here awaits I/O. `bun:sqlite` answers synchronously and these methods resolve
|
|
7
|
+
* an already-computed value, so the cost is a microtask, not a round trip.
|
|
8
|
+
*
|
|
9
|
+
* 🔴 **It is deliberately no more permissive than D1.** Every bind goes through
|
|
10
|
+
* `normalizeBind`, which refuses the two values `bun:sqlite` would have accepted silently
|
|
11
|
+
* (`undefined`, and out-of-range `bigint`) and coerces the one both can agree on
|
|
12
|
+
* (`boolean`). D1's limits are asserted here too, and an interactive transaction is
|
|
13
|
+
* refused here exactly as it is refused remotely. The point of a local driver is to fail
|
|
14
|
+
* the same way, early and on a laptop.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Database } from 'bun:sqlite';
|
|
18
|
+
import { assertBatchSize, assertWithinLimits } from './limits';
|
|
19
|
+
import {
|
|
20
|
+
type D1LikeBindable,
|
|
21
|
+
type D1LikeDatabase,
|
|
22
|
+
type D1LikeResult,
|
|
23
|
+
type D1LikeRow,
|
|
24
|
+
type D1LikeStatement,
|
|
25
|
+
type D1LikeValue,
|
|
26
|
+
D1UnsupportedError,
|
|
27
|
+
} from './types';
|
|
28
|
+
import { makeMeta, normalizeBinds, normalizeRows, normalizeValue } from './values';
|
|
29
|
+
|
|
30
|
+
/** Shared clock, so `meta.duration` means the same thing on both sides of the seam. */
|
|
31
|
+
const now = (): number => performance.now();
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 🔴 The synchronous core, kept separate from the async surface on purpose.
|
|
35
|
+
*
|
|
36
|
+
* `batch()` has to run every statement inside one `db.transaction()` callback, and that
|
|
37
|
+
* callback is synchronous — a promise resolved inside it settles in a later microtask,
|
|
38
|
+
* long after `transaction()` has already committed. So the statements expose a real
|
|
39
|
+
* synchronous method that `batch()` calls directly, and the public `all()` merely wraps
|
|
40
|
+
* it. Reaching for `.then()` inside the transaction callback looks like it works and
|
|
41
|
+
* silently commits an empty transaction.
|
|
42
|
+
*/
|
|
43
|
+
interface SyncExecutable {
|
|
44
|
+
allSync<T>(): D1LikeResult<T>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class LocalStatement implements D1LikeStatement, SyncExecutable {
|
|
48
|
+
constructor(
|
|
49
|
+
private readonly db: Database,
|
|
50
|
+
private readonly sql: string,
|
|
51
|
+
private readonly params: D1LikeBindable[] = [],
|
|
52
|
+
) {}
|
|
53
|
+
|
|
54
|
+
bind(...values: D1LikeBindable[]): D1LikeStatement {
|
|
55
|
+
const params = normalizeBinds(values);
|
|
56
|
+
assertWithinLimits(this.sql, params);
|
|
57
|
+
return new LocalStatement(this.db, this.sql, params);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private stmt() {
|
|
61
|
+
return this.db.query(this.sql);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
allSync<T = D1LikeRow>(): D1LikeResult<T> {
|
|
65
|
+
const started = now();
|
|
66
|
+
const rows = this.stmt().all(...(this.params as never[])) as Record<string, unknown>[];
|
|
67
|
+
return {
|
|
68
|
+
results: normalizeRows<T>(rows),
|
|
69
|
+
success: true,
|
|
70
|
+
meta: makeMeta({ duration: now() - started }),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async first<T = D1LikeRow>(column?: string): Promise<T | null> {
|
|
75
|
+
const row = this.stmt().get(...(this.params as never[])) as Record<string, unknown> | null;
|
|
76
|
+
if (row === null || row === undefined) return null;
|
|
77
|
+
if (column === undefined) return normalizeRows<T>([row])[0] ?? null;
|
|
78
|
+
if (!(column in row)) {
|
|
79
|
+
throw new RangeError(`column '${column}' is not in the result of: ${this.sql.slice(0, 80)}`);
|
|
80
|
+
}
|
|
81
|
+
return normalizeValue(row[column]) as T;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async all<T = D1LikeRow>(): Promise<D1LikeResult<T>> {
|
|
85
|
+
return this.allSync<T>();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async run(): Promise<D1LikeResult<never>> {
|
|
89
|
+
const started = now();
|
|
90
|
+
const res = this.stmt().run(...(this.params as never[]));
|
|
91
|
+
return {
|
|
92
|
+
results: [],
|
|
93
|
+
success: true,
|
|
94
|
+
meta: makeMeta({
|
|
95
|
+
changes: res.changes,
|
|
96
|
+
last_row_id: res.lastInsertRowid,
|
|
97
|
+
duration: now() - started,
|
|
98
|
+
}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async raw<V = D1LikeValue>(): Promise<V[][]> {
|
|
103
|
+
const rows = this.stmt().values(...(this.params as never[])) as unknown[][];
|
|
104
|
+
return rows.map((r) => r.map((v) => normalizeValue(v) as V));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Wrap a `bun:sqlite` {@link Database} in the async seam.
|
|
110
|
+
*
|
|
111
|
+
* The handle is the SAME one a not-yet-ported app still uses directly, so a half-ported
|
|
112
|
+
* app has one connection, one WAL and one transaction space — the property `createKysely`
|
|
113
|
+
* was written for. That is what makes a file-at-a-time port possible instead of an
|
|
114
|
+
* app-at-a-time one, and it is why this takes a `Database` rather than a path.
|
|
115
|
+
*/
|
|
116
|
+
export function createLocalD1(db: Database): D1LikeDatabase {
|
|
117
|
+
return {
|
|
118
|
+
flavor: 'local',
|
|
119
|
+
|
|
120
|
+
prepare(sql: string): D1LikeStatement {
|
|
121
|
+
return new LocalStatement(db, sql);
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Atomic on both sides. Locally that is a real `bun:sqlite` transaction; on D1 it is
|
|
126
|
+
* one round trip the service commits or discards as a unit. The guarantee a caller
|
|
127
|
+
* can rely on is the same, which is the only reason `batch()` can be the fleet's
|
|
128
|
+
* transaction primitive — see the note on {@link SyncExecutable} for why the body
|
|
129
|
+
* below may not await.
|
|
130
|
+
*/
|
|
131
|
+
async batch<T = D1LikeRow>(statements: D1LikeStatement[]): Promise<D1LikeResult<T>[]> {
|
|
132
|
+
assertBatchSize(statements.length);
|
|
133
|
+
const run = db.transaction((stmts: D1LikeStatement[]): D1LikeResult<T>[] => {
|
|
134
|
+
const out: D1LikeResult<T>[] = [];
|
|
135
|
+
for (const s of stmts) {
|
|
136
|
+
const sync = s as Partial<SyncExecutable>;
|
|
137
|
+
if (typeof sync.allSync !== 'function') {
|
|
138
|
+
throw new TypeError(
|
|
139
|
+
'batch() accepts only statements from this same local database — ' +
|
|
140
|
+
'a remote statement cannot run inside a local transaction.',
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
out.push(sync.allSync<T>());
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
});
|
|
147
|
+
return run(statements);
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
async exec(sql: string): Promise<{ count: number; duration: number }> {
|
|
151
|
+
const started = now();
|
|
152
|
+
db.exec(sql);
|
|
153
|
+
// D1's `exec` reports how many statements it ran, and counts them by `;`.
|
|
154
|
+
// Matching that keeps the field meaning one thing on both sides.
|
|
155
|
+
const count = sql
|
|
156
|
+
.split(';')
|
|
157
|
+
.map((s) => s.trim())
|
|
158
|
+
.filter((s) => s.length > 0).length;
|
|
159
|
+
return { count, duration: now() - started };
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Refuse an interactive transaction, for the same reason D1 refuses it. Exported so the
|
|
166
|
+
* Kysely dialect and any app-level helper give one message instead of three.
|
|
167
|
+
*/
|
|
168
|
+
export const refuseInteractiveTransaction = (): never => {
|
|
169
|
+
throw new D1UnsupportedError(
|
|
170
|
+
'an interactive transaction (BEGIN/COMMIT)',
|
|
171
|
+
'collect the statements and pass them to `db.batch([...])`, which is atomic on both drivers',
|
|
172
|
+
);
|
|
173
|
+
};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The REMOTE implementation of {@link D1LikeDatabase}, over a Cloudflare D1 binding.
|
|
3
|
+
*
|
|
4
|
+
* It is thin on purpose. D1's own API is the shape `./types` mirrors, so nearly all of
|
|
5
|
+
* this file is the normalization that makes a D1 answer indistinguishable from a
|
|
6
|
+
* `bun:sqlite` one — the same `normalizeBind` / `normalizeValue` the local driver calls,
|
|
7
|
+
* so "the two drivers agree" is a property of one implementation rather than a
|
|
8
|
+
* coincidence between two.
|
|
9
|
+
*
|
|
10
|
+
* 🔴 **`workerd` is not imported here, and must not be.** This package is built and tested
|
|
11
|
+
* with Bun, and `@cloudflare/workers-types` would be a dependency the other 99 % of the
|
|
12
|
+
* library does not need. The binding is described structurally by {@link D1BindingLike}
|
|
13
|
+
* below, which is the subset of Cloudflare's `D1Database` this driver actually calls — so
|
|
14
|
+
* a real binding satisfies it without a cast, and the test suite can satisfy it with a
|
|
15
|
+
* fake that has no `workerd` in sight.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { assertBatchSize, assertWithinLimits } from './limits';
|
|
19
|
+
import {
|
|
20
|
+
type D1LikeBindable,
|
|
21
|
+
type D1LikeDatabase,
|
|
22
|
+
type D1LikeResult,
|
|
23
|
+
type D1LikeRow,
|
|
24
|
+
type D1LikeStatement,
|
|
25
|
+
type D1LikeValue,
|
|
26
|
+
D1UnsupportedError,
|
|
27
|
+
} from './types';
|
|
28
|
+
import { makeMeta, normalizeBinds, normalizeRows, normalizeValue } from './values';
|
|
29
|
+
|
|
30
|
+
/** What D1 reports back about a statement. Its `meta` is richer than the local driver's. */
|
|
31
|
+
export interface D1BindingMeta {
|
|
32
|
+
duration?: number;
|
|
33
|
+
changes?: number;
|
|
34
|
+
last_row_id?: number;
|
|
35
|
+
rows_read?: number;
|
|
36
|
+
rows_written?: number;
|
|
37
|
+
served_by?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface D1BindingResult {
|
|
41
|
+
results?: unknown[];
|
|
42
|
+
success?: boolean;
|
|
43
|
+
meta?: D1BindingMeta;
|
|
44
|
+
error?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The subset of Cloudflare's `D1PreparedStatement` this driver calls. */
|
|
48
|
+
export interface D1BindingStatement {
|
|
49
|
+
bind(...values: unknown[]): D1BindingStatement;
|
|
50
|
+
first(column?: string): Promise<unknown>;
|
|
51
|
+
all(): Promise<D1BindingResult>;
|
|
52
|
+
run(): Promise<D1BindingResult>;
|
|
53
|
+
raw(): Promise<unknown[][]>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The subset of Cloudflare's `D1Database` this driver calls. A real binding satisfies it. */
|
|
57
|
+
export interface D1BindingLike {
|
|
58
|
+
prepare(sql: string): D1BindingStatement;
|
|
59
|
+
batch(statements: D1BindingStatement[]): Promise<D1BindingResult[]>;
|
|
60
|
+
exec(sql: string): Promise<{ count: number; duration: number }>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* D1 reports a failed statement by RESOLVING with `success: false` in some paths rather
|
|
65
|
+
* than rejecting, so a driver that only catches rejections reports a silent no-op as a
|
|
66
|
+
* successful write. Every result goes through here.
|
|
67
|
+
*/
|
|
68
|
+
function assertSucceeded(res: D1BindingResult, sql: string): void {
|
|
69
|
+
if (res.success === false || res.error) {
|
|
70
|
+
throw new Error(`D1 refused the statement: ${res.error ?? 'success: false'}\n ${sql.slice(0, 160)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const toMeta = (res: D1BindingResult) =>
|
|
75
|
+
makeMeta({
|
|
76
|
+
changes: res.meta?.changes ?? 0,
|
|
77
|
+
last_row_id: res.meta?.last_row_id ?? null,
|
|
78
|
+
duration: res.meta?.duration ?? 0,
|
|
79
|
+
rows_read: res.meta?.rows_read ?? null,
|
|
80
|
+
rows_written: res.meta?.rows_written ?? null,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
class RemoteStatement implements D1LikeStatement {
|
|
84
|
+
constructor(
|
|
85
|
+
private readonly stmt: D1BindingStatement,
|
|
86
|
+
private readonly sql: string,
|
|
87
|
+
) {}
|
|
88
|
+
|
|
89
|
+
/** The underlying binding statement — how `batch()` reaches the real objects. */
|
|
90
|
+
get binding(): D1BindingStatement {
|
|
91
|
+
return this.stmt;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
bind(...values: D1LikeBindable[]): D1LikeStatement {
|
|
95
|
+
const params = normalizeBinds(values);
|
|
96
|
+
assertWithinLimits(this.sql, params);
|
|
97
|
+
// D1 accepts ArrayBuffer for BLOBs; `normalizeBind` has already reduced every blob
|
|
98
|
+
// to a `Uint8Array`, so this is the one place that converts back out.
|
|
99
|
+
const wire = params.map((p) => (p instanceof Uint8Array ? p.buffer.slice(p.byteOffset, p.byteOffset + p.byteLength) : p));
|
|
100
|
+
return new RemoteStatement(this.stmt.bind(...wire), this.sql);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async first<T = D1LikeRow>(column?: string): Promise<T | null> {
|
|
104
|
+
const row = await this.stmt.first(column);
|
|
105
|
+
if (row === null || row === undefined) return null;
|
|
106
|
+
if (column !== undefined) return normalizeValue(row) as T;
|
|
107
|
+
return normalizeRows<T>([row as Record<string, unknown>])[0] ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async all<T = D1LikeRow>(): Promise<D1LikeResult<T>> {
|
|
111
|
+
const res = await this.stmt.all();
|
|
112
|
+
assertSucceeded(res, this.sql);
|
|
113
|
+
return {
|
|
114
|
+
results: normalizeRows<T>((res.results ?? []) as Record<string, unknown>[]),
|
|
115
|
+
success: true,
|
|
116
|
+
meta: toMeta(res),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async run(): Promise<D1LikeResult<never>> {
|
|
121
|
+
const res = await this.stmt.run();
|
|
122
|
+
assertSucceeded(res, this.sql);
|
|
123
|
+
return { results: [], success: true, meta: toMeta(res) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async raw<V = D1LikeValue>(): Promise<V[][]> {
|
|
127
|
+
const rows = await this.stmt.raw();
|
|
128
|
+
return rows.map((r) => r.map((v) => normalizeValue(v) as V));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Wrap a Cloudflare D1 binding in the async seam.
|
|
134
|
+
*
|
|
135
|
+
* On a Worker this is constructed per request from `env.DB` — a binding is not a
|
|
136
|
+
* connection and holds no state worth caching, so there is nothing to pool and nothing to
|
|
137
|
+
* close.
|
|
138
|
+
*/
|
|
139
|
+
export function createRemoteD1(binding: D1BindingLike): D1LikeDatabase {
|
|
140
|
+
return {
|
|
141
|
+
flavor: 'd1',
|
|
142
|
+
|
|
143
|
+
prepare(sql: string): D1LikeStatement {
|
|
144
|
+
return new RemoteStatement(binding.prepare(sql), sql);
|
|
145
|
+
},
|
|
146
|
+
|
|
147
|
+
async batch<T = D1LikeRow>(statements: D1LikeStatement[]): Promise<D1LikeResult<T>[]> {
|
|
148
|
+
assertBatchSize(statements.length);
|
|
149
|
+
const bindings = statements.map((s) => {
|
|
150
|
+
const b = (s as Partial<RemoteStatement>).binding;
|
|
151
|
+
if (!b) {
|
|
152
|
+
throw new TypeError(
|
|
153
|
+
'batch() accepts only statements from this same D1 database — ' +
|
|
154
|
+
'a local statement cannot run inside a D1 batch.',
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return b;
|
|
158
|
+
});
|
|
159
|
+
const results = await binding.batch(bindings);
|
|
160
|
+
return results.map((res, i) => {
|
|
161
|
+
assertSucceeded(res, `batch statement ${i + 1}`);
|
|
162
|
+
return {
|
|
163
|
+
results: normalizeRows<T>((res.results ?? []) as Record<string, unknown>[]),
|
|
164
|
+
success: true as const,
|
|
165
|
+
meta: toMeta(res),
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
exec(sql: string): Promise<{ count: number; duration: number }> {
|
|
171
|
+
return binding.exec(sql);
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Re-exported so both drivers refuse an interactive transaction with one message. */
|
|
177
|
+
export const refuseInteractiveTransaction = (): never => {
|
|
178
|
+
throw new D1UnsupportedError(
|
|
179
|
+
'an interactive transaction (BEGIN/COMMIT)',
|
|
180
|
+
'collect the statements and pass them to `db.batch([...])`, which is atomic on both drivers',
|
|
181
|
+
);
|
|
182
|
+
};
|