@sproutboat/wire 0.14.3 → 0.14.5
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/CHANGELOG.md +43 -0
- package/package.json +2 -2
- package/src/d1.ts +134 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,48 @@
|
|
|
1
1
|
# @sproutboat/wire
|
|
2
2
|
|
|
3
|
+
## 0.14.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 9a5363e: D1 matches workerd from compatibility date `2026-10-24`:
|
|
8
|
+
|
|
9
|
+
- `all()`, `run()`, `first()`, `raw()`, `batch()` and `exec()` return Promises, and errors reject them. `bind()` stays synchronous and still throws `D1_TYPE_ERROR` at once.
|
|
10
|
+
- `meta` has D1's keys: `served_by`, `duration`, `changes`, `last_row_id`, `changed_db`, `size_after` (always 0 here), `rows_read` and `rows_written`.
|
|
11
|
+
- `raw({ columnNames: true })` puts the column names first, and keeps duplicate names.
|
|
12
|
+
- `first("name")` throws `D1_COLUMN_NOTFOUND` for a column the result doesn't have.
|
|
13
|
+
- Empty SQL and an empty `batch([])` throw `D1_ERROR: No SQL statements detected.`
|
|
14
|
+
- `BEGIN`, `COMMIT`, `ROLLBACK`, `SAVEPOINT` and `RELEASE` are refused, as in D1. A batch is already one transaction.
|
|
15
|
+
- `exec()` runs line by line, all or nothing, and returns the number of lines. A failing line throws `D1_EXEC_ERROR: Error in line N: ...`.
|
|
16
|
+
- `withSession()` returns a session with `prepare`, `batch` and `getBookmark()`, which returns `null`. `dump()` rejects; use `backup()` instead.
|
|
17
|
+
|
|
18
|
+
Apps with an earlier `compatibility_date` keep the synchronous API unchanged. To opt in, move the date to `2026-10-24` or later and rebuild. `await` code needs no other change, but code that reads results without `await` does.
|
|
19
|
+
|
|
20
|
+
Not yet like D1: `rows_read` counts the rows returned, not the rows scanned, and pragmas are not limited to D1's allow-list.
|
|
21
|
+
|
|
22
|
+
With LTX capture on (from `2026-10-22`), a capture failure during a D1 call now reports `SQLITE_IOERR` with a message saying the write is not durable in the replication log, instead of SQLite's "not an error". Writes stay refused until restart.
|
|
23
|
+
- Updated dependencies [9a5363e]
|
|
24
|
+
- @sproutboat/runtime@0.24.0
|
|
25
|
+
|
|
26
|
+
## 0.14.4
|
|
27
|
+
|
|
28
|
+
### Patch Changes
|
|
29
|
+
|
|
30
|
+
- 7a7fd34: D1 correctness fixes, in the standalone (embedded) transport and the broker:
|
|
31
|
+
|
|
32
|
+
- **Bytes are no longer lost.** Binding a `Uint8Array`, another typed array, a `DataView` or an `ArrayBuffer` stored NULL in a standalone sprout, along with every parameter after it. It now stores a BLOB, at every compatibility date. On earlier dates a BLOB still reads back as before: as one character per byte.
|
|
33
|
+
- **`batch()` is one transaction** in a standalone sprout, as it already was through the broker and is in D1: if a statement fails, the ones before it are rolled back. This applies at every compatibility date.
|
|
34
|
+
- **From compatibility date `2026-10-24`**, D1 follows workerd's rules:
|
|
35
|
+
- a parameter must be `null`, a number, a string, a boolean or bytes, or the call throws `D1_TYPE_ERROR` (before, `undefined` and objects became NULL);
|
|
36
|
+
- the number of parameters must match the statement (`D1_ERROR: Wrong number of parameter bindings for SQL query.`);
|
|
37
|
+
- foreign keys are enforced (`PRAGMA foreign_keys = ON`);
|
|
38
|
+
- errors read `D1_ERROR: <message>: SQLITE_<CODE>`;
|
|
39
|
+
- a BLOB reads back as an array of byte values.
|
|
40
|
+
|
|
41
|
+
Apps with an earlier `compatibility_date` keep the old behaviour. To opt in, move the date to `2026-10-24` or later and rebuild.
|
|
42
|
+
- `meta.changes` is 0 for a statement that only reads. It used to repeat the previous write's count.
|
|
43
|
+
- Updated dependencies [7a7fd34]
|
|
44
|
+
- @sproutboat/runtime@0.23.1
|
|
45
|
+
|
|
3
46
|
## 0.14.3
|
|
4
47
|
|
|
5
48
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sproutboat/wire",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.5",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -23,6 +23,6 @@
|
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@sproutboat/assets": "^0.3.0",
|
|
26
|
-
"@sproutboat/runtime": "^0.
|
|
26
|
+
"@sproutboat/runtime": "^0.24.0"
|
|
27
27
|
}
|
|
28
28
|
}
|
package/src/d1.ts
CHANGED
|
@@ -7,20 +7,126 @@ import { isBoolean, isString, jsonObject, type JsonObject, type JsonValue } from
|
|
|
7
7
|
import type { Frame } from "./broker";
|
|
8
8
|
|
|
9
9
|
const isNumber = (value: JsonValue): value is number => Number.isFinite(value);
|
|
10
|
-
type SqlParam = string | number | null;
|
|
10
|
+
type SqlParam = string | number | null | Uint8Array;
|
|
11
11
|
const str = (v: JsonValue | undefined) => (isString(v) ? v : String(v ?? ""));
|
|
12
|
+
// {"$blob":"HEX"} is the shim's encoding of bytes (an ArrayBuffer or a typed array).
|
|
13
|
+
const blobOf = (p: JsonValue): Uint8Array | undefined => {
|
|
14
|
+
const o = jsonObject(p);
|
|
15
|
+
return o && isString(o.$blob) ? Uint8Array.from(Buffer.from(o.$blob, "hex")) : undefined;
|
|
16
|
+
};
|
|
12
17
|
const params = (v: JsonValue | undefined): SqlParam[] =>
|
|
13
18
|
Array.isArray(v)
|
|
14
|
-
? v.map((p) => (p === null || isNumber(p) || isString(p) ? p : isBoolean(p) ? Number(p) : String(p)))
|
|
19
|
+
? v.map((p) => (p === null || isNumber(p) || isString(p) ? p : isBoolean(p) ? Number(p) : (blobOf(p) ?? String(p))))
|
|
15
20
|
: [];
|
|
21
|
+
// A BLOB read back: hex for strict calls (the shim makes it an array of byte values, as
|
|
22
|
+
// workerd does), one char per byte otherwise (what the embedded transport returns).
|
|
23
|
+
// SAFETY: bun:sqlite returns only null, numbers, strings, bigints (off by default) and
|
|
24
|
+
// Uint8Array for BLOBs; the Uint8Array case is converted here, the rest are JSON values.
|
|
25
|
+
type SqliteValue = JsonValue | Uint8Array | bigint;
|
|
26
|
+
// SAFETY: the two non-JSON cases (bytes, bigint) are converted first; what is left is a JSON value.
|
|
27
|
+
const cell = (v: SqliteValue, strict: boolean): JsonValue =>
|
|
28
|
+
v instanceof Uint8Array
|
|
29
|
+
? strict
|
|
30
|
+
? { $blob: Buffer.from(v).toString("hex") }
|
|
31
|
+
: String.fromCharCode(...v)
|
|
32
|
+
: Object(v) instanceof BigInt
|
|
33
|
+
? Number(v)
|
|
34
|
+
: (v as JsonValue);
|
|
35
|
+
type SqliteFailure = Error & { errno?: number; code?: string; byteOffset?: number };
|
|
36
|
+
const isSqliteFailure = (e: Error): e is SqliteFailure => "errno" in e;
|
|
37
|
+
const SQLITE_CODES = [
|
|
38
|
+
"",
|
|
39
|
+
"SQLITE_ERROR",
|
|
40
|
+
"SQLITE_INTERNAL",
|
|
41
|
+
"SQLITE_PERM",
|
|
42
|
+
"SQLITE_ABORT",
|
|
43
|
+
"SQLITE_BUSY",
|
|
44
|
+
"SQLITE_LOCKED",
|
|
45
|
+
"SQLITE_NOMEM",
|
|
46
|
+
"SQLITE_READONLY",
|
|
47
|
+
"SQLITE_INTERRUPT",
|
|
48
|
+
"SQLITE_IOERR",
|
|
49
|
+
"SQLITE_CORRUPT",
|
|
50
|
+
"SQLITE_NOTFOUND",
|
|
51
|
+
"SQLITE_FULL",
|
|
52
|
+
"SQLITE_CANTOPEN",
|
|
53
|
+
"SQLITE_PROTOCOL",
|
|
54
|
+
"SQLITE_EMPTY",
|
|
55
|
+
"SQLITE_SCHEMA",
|
|
56
|
+
"SQLITE_TOOBIG",
|
|
57
|
+
"SQLITE_CONSTRAINT",
|
|
58
|
+
"SQLITE_MISMATCH",
|
|
59
|
+
"SQLITE_MISUSE",
|
|
60
|
+
"SQLITE_NOLFS",
|
|
61
|
+
"SQLITE_AUTH",
|
|
62
|
+
"SQLITE_FORMAT",
|
|
63
|
+
"SQLITE_RANGE",
|
|
64
|
+
"SQLITE_NOTADB",
|
|
65
|
+
];
|
|
66
|
+
/** D1's wording for a SQLite error, as the embedded transport words it for strict calls. */
|
|
67
|
+
export function d1Error(error: Error): Error {
|
|
68
|
+
if (!isSqliteFailure(error)) return error;
|
|
69
|
+
let text = `D1_ERROR: ${error.message}`;
|
|
70
|
+
if (Number.isInteger(error.byteOffset) && Number(error.byteOffset) >= 0) text += ` at offset ${error.byteOffset}`;
|
|
71
|
+
const base = SQLITE_CODES[(Number(error.errno) || 0) & 255];
|
|
72
|
+
if (base) {
|
|
73
|
+
text += `: ${base}`;
|
|
74
|
+
if (base === "SQLITE_CONSTRAINT" && error.code && error.code !== base) text += ` (extended: ${error.code})`;
|
|
75
|
+
}
|
|
76
|
+
return new Error(text);
|
|
77
|
+
}
|
|
16
78
|
export type D1Request = { owner: string; key: string; path: string; name: string; msg: Frame };
|
|
17
79
|
|
|
18
80
|
export function createD1Store(executionMs = 0, capability = "") {
|
|
19
81
|
if (executionMs && !/^[a-f0-9]{64}$/.test(capability)) throw new Error("invalid host budget capability");
|
|
20
82
|
const connections = new Map<string, { db: Database; owners: Set<string> }>();
|
|
83
|
+
// Strict calls (the shim's compatibility date 2026-10-24): the parameter count must match
|
|
84
|
+
// and rows come back as arrays with their column names.
|
|
85
|
+
// A read changes nothing: changes() still holds the last write's count, total_changes() does not move.
|
|
86
|
+
const total = (db: Database) => db.query<{ n: number }, []>("SELECT total_changes() AS n").get()?.n ?? 0;
|
|
87
|
+
const runStrict = (db: Database, sql: string, values: SqlParam[]) => {
|
|
88
|
+
const started = performance.now();
|
|
89
|
+
const before = total(db);
|
|
90
|
+
let statement;
|
|
91
|
+
try {
|
|
92
|
+
statement = db.query<SqliteValue[], SqlParam[]>(sql);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw d1Error(error instanceof Error ? error : new Error(String(error)));
|
|
95
|
+
}
|
|
96
|
+
if (statement.paramsCount !== values.length)
|
|
97
|
+
throw new Error("D1_ERROR: Wrong number of parameter bindings for SQL query.");
|
|
98
|
+
let rows: SqliteValue[][];
|
|
99
|
+
try {
|
|
100
|
+
rows = statement.values(...values) ?? [];
|
|
101
|
+
} catch (error) {
|
|
102
|
+
throw d1Error(error instanceof Error ? error : new Error(String(error)));
|
|
103
|
+
}
|
|
104
|
+
const meta = db
|
|
105
|
+
.query<{ changes: number; last_row_id: number }, []>(
|
|
106
|
+
"SELECT changes() AS changes, last_insert_rowid() AS last_row_id",
|
|
107
|
+
)
|
|
108
|
+
.get();
|
|
109
|
+
return {
|
|
110
|
+
columns: statement.columnNames,
|
|
111
|
+
rows: rows.map((row) => row.map((v) => cell(v, true))),
|
|
112
|
+
meta: {
|
|
113
|
+
duration: performance.now() - started,
|
|
114
|
+
changes: total(db) > before ? (meta?.changes ?? 0) : 0,
|
|
115
|
+
last_row_id: meta?.last_row_id ?? 0,
|
|
116
|
+
rows_read: rows.length,
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
};
|
|
21
120
|
const run = (db: Database, sql: string, values: SqlParam[]) => {
|
|
22
121
|
const started = performance.now();
|
|
23
|
-
const
|
|
122
|
+
const before = total(db);
|
|
123
|
+
const results = db
|
|
124
|
+
.query<JsonObject, SqlParam[]>(sql)
|
|
125
|
+
.all(...values)
|
|
126
|
+
.map((row) => {
|
|
127
|
+
for (const k in row) row[k] = cell(row[k], false);
|
|
128
|
+
return row;
|
|
129
|
+
});
|
|
24
130
|
const meta = db
|
|
25
131
|
.query<{ changes: number; last_row_id: number }, []>(
|
|
26
132
|
"SELECT changes() AS changes, last_insert_rowid() AS last_row_id",
|
|
@@ -30,7 +136,7 @@ export function createD1Store(executionMs = 0, capability = "") {
|
|
|
30
136
|
results,
|
|
31
137
|
meta: {
|
|
32
138
|
duration: performance.now() - started,
|
|
33
|
-
changes: meta?.changes ?? 0,
|
|
139
|
+
changes: total(db) > before ? (meta?.changes ?? 0) : 0,
|
|
34
140
|
last_row_id: meta?.last_row_id ?? 0,
|
|
35
141
|
rows_read: results.length,
|
|
36
142
|
},
|
|
@@ -74,12 +180,32 @@ export function createD1Store(executionMs = 0, capability = "") {
|
|
|
74
180
|
}
|
|
75
181
|
};
|
|
76
182
|
try {
|
|
183
|
+
const strict = msg.strict === true;
|
|
184
|
+
// Foreign keys are enforced from compatibility date 2026-10-24, as in D1. The
|
|
185
|
+
// pragma is per connection and a no-op inside a transaction, so set it per call.
|
|
186
|
+
if (msg.op !== "d1.backup") db.exec(strict ? "PRAGMA foreign_keys = ON" : "PRAGMA foreign_keys = OFF");
|
|
187
|
+
const exec1 = strict ? runStrict : run;
|
|
77
188
|
switch (msg.op) {
|
|
78
189
|
case "d1.query":
|
|
79
|
-
return { ok: true, ...
|
|
80
|
-
case "d1.exec":
|
|
81
|
-
|
|
190
|
+
return { ok: true, ...exec1(db, str(msg.sql), params(msg.params)) };
|
|
191
|
+
case "d1.exec": {
|
|
192
|
+
const lines = Array.isArray(msg.lines) ? msg.lines.map(str) : null;
|
|
193
|
+
if (!strict || !lines) {
|
|
194
|
+
db.exec(str(msg.sql));
|
|
195
|
+
return { ok: true };
|
|
196
|
+
}
|
|
197
|
+
// From 2026-10-24: line by line as workerd runs it, all or nothing.
|
|
198
|
+
db.transaction(() =>
|
|
199
|
+
lines.forEach((line, i) => {
|
|
200
|
+
try {
|
|
201
|
+
db.exec(line);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
throw new Error(`D1_EXEC_ERROR: Error in line ${i + 1}: ${line}: ${error instanceof Error ? error.message : String(error)}`);
|
|
204
|
+
}
|
|
205
|
+
}),
|
|
206
|
+
)();
|
|
82
207
|
return { ok: true };
|
|
208
|
+
}
|
|
83
209
|
case "d1.batch": {
|
|
84
210
|
const statements = Array.isArray(msg.statements) ? msg.statements.map((s) => jsonObject(s) ?? {}) : [];
|
|
85
211
|
return {
|
|
@@ -88,7 +214,7 @@ export function createD1Store(executionMs = 0, capability = "") {
|
|
|
88
214
|
statements.map((s) => {
|
|
89
215
|
if (executionMs && performance.now() >= deadline)
|
|
90
216
|
throw new Error("D1 execution deadline exceeded; batch rolled back");
|
|
91
|
-
return
|
|
217
|
+
return exec1(db, str(s.sql), params(s.params));
|
|
92
218
|
}),
|
|
93
219
|
)(),
|
|
94
220
|
};
|