@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.
Files changed (3) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/package.json +2 -2
  3. 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.2",
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.22.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 results = db.query<JsonObject, SqlParam[]>(sql).all(...values);
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, ...run(db, str(msg.sql), params(msg.params)) };
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 run(db, str(s.sql), params(s.params));
202
+ return exec1(db, str(s.sql), params(s.params));
92
203
  }),
93
204
  )(),
94
205
  };