@sproutboat/wire 0.14.2 → 0.14.4
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 +27 -0
- package/package.json +2 -2
- package/src/d1.ts +117 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# @sproutboat/wire
|
|
2
2
|
|
|
3
|
+
## 0.14.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 7a7fd34: D1 correctness fixes, in the standalone (embedded) transport and the broker:
|
|
8
|
+
|
|
9
|
+
- **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.
|
|
10
|
+
- **`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.
|
|
11
|
+
- **From compatibility date `2026-10-24`**, D1 follows workerd's rules:
|
|
12
|
+
- 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);
|
|
13
|
+
- the number of parameters must match the statement (`D1_ERROR: Wrong number of parameter bindings for SQL query.`);
|
|
14
|
+
- foreign keys are enforced (`PRAGMA foreign_keys = ON`);
|
|
15
|
+
- errors read `D1_ERROR: <message>: SQLITE_<CODE>`;
|
|
16
|
+
- a BLOB reads back as an array of byte values.
|
|
17
|
+
|
|
18
|
+
Apps with an earlier `compatibility_date` keep the old behaviour. To opt in, move the date to `2026-10-24` or later and rebuild.
|
|
19
|
+
- `meta.changes` is 0 for a statement that only reads. It used to repeat the previous write's count.
|
|
20
|
+
- Updated dependencies [7a7fd34]
|
|
21
|
+
- @sproutboat/runtime@0.23.1
|
|
22
|
+
|
|
23
|
+
## 0.14.3
|
|
24
|
+
|
|
25
|
+
### Patch Changes
|
|
26
|
+
|
|
27
|
+
- Updated dependencies [df612fc]
|
|
28
|
+
- @sproutboat/runtime@0.23.0
|
|
29
|
+
|
|
3
30
|
## 0.14.2
|
|
4
31
|
|
|
5
32
|
### 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.4",
|
|
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.23.1"
|
|
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,9 +180,14 @@ 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, ...
|
|
190
|
+
return { ok: true, ...exec1(db, str(msg.sql), params(msg.params)) };
|
|
80
191
|
case "d1.exec":
|
|
81
192
|
db.exec(str(msg.sql));
|
|
82
193
|
return { ok: true };
|
|
@@ -88,7 +199,7 @@ export function createD1Store(executionMs = 0, capability = "") {
|
|
|
88
199
|
statements.map((s) => {
|
|
89
200
|
if (executionMs && performance.now() >= deadline)
|
|
90
201
|
throw new Error("D1 execution deadline exceeded; batch rolled back");
|
|
91
|
-
return
|
|
202
|
+
return exec1(db, str(s.sql), params(s.params));
|
|
92
203
|
}),
|
|
93
204
|
)(),
|
|
94
205
|
};
|