@sproutboat/runtime 0.34.2 → 0.34.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 CHANGED
@@ -1,5 +1,25 @@
1
1
  # @sproutboat/runtime
2
2
 
3
+ ## 0.34.4
4
+
5
+ ### Patch Changes
6
+
7
+ - 5bd4a25: Cheaper JWT verification. `atob` decodes in C for the usual input (no whitespace, valid characters, correct padding) and falls back to the existing JavaScript for everything else, so what throws and what it throws is unchanged. `TextEncoder.encode` sizes and fills the Uint8Array in two C passes instead of two `charCodeAt` loops (a lone surrogate is still U+FFFD). The ArrayBuffer returned by `crypto.subtle.sign` and `digest` is copied in with one `memcpy`, and the constant-time compare behind `verify` runs in C. A typical `authenticate()` (three base64url decodes, two `JSON.parse`, one `TextEncoder.encode`, one HMAC verify) goes from 21.5 to 7 us on an M-series host. New native test `test:native-jwt`: RFC 4231 HMAC vectors, the RFC 7515 and jwt.io tokens, wrong key, tampered header, payload and signature, short, long and missing signatures, bad base64url, typed-array windows and ArrayBuffers as verify input, and seeded random `atob`, `TextEncoder` and HMAC (SHA-256, 384, 512) inputs compared with Bun and node:crypto.
8
+
9
+ ## 0.34.3
10
+
11
+ ### Patch Changes
12
+
13
+ - 49ac7df: Faster `atob`: each character is looked up in a 128-entry table instead of `indexOf` over the alphabet, and the same pass validates, so the regex test over the whole input is gone. Same output and same `InvalidCharacterError` cases (checked against the platform `atob` on 9,000 random inputs and edge cases).
14
+
15
+ A JWT check decodes three base64url parts. On one pinned x86 core at a fixed rate, CPU per request: like 854 to 761 us, create 922 to 866 us; feed and post reads do not call `atob`.
16
+ - 745f94c: Faster D1 in standalone builds: a D1 call no longer goes through `JSON.stringify` and `JSON.parse` on both sides of the in-process dispatch. The request object is handed straight to the dispatcher and the reply object comes straight back (the C layer still returns its own JSON, parsed once). Same results, same error messages, no change under the broker transport.
17
+
18
+ On one pinned x86 core at a fixed rate, CPU per request against a 500,000-row feed API: feed (20 rows) 1,418 to 1,133 us, post read 394 to 332 us, like 854 to 728 us, create 922 to 857 us, the realistic mix 670 to 580 us.
19
+ - 88c7bff: Faster strict D1 queries in standalone builds (compatibility date 2026-10-24 and later): the rows of a result are built as JS arrays in C, straight from the SQLite cells, instead of being written as JSON text in C and parsed again in JS. NULL, numbers, text (UTF-8 decoded exactly as the JSON path decoded it, including invalid sequences), BLOBs (byte arrays) and empty results are unchanged. Only a small header (columns, changes, last row id) still goes through JSON. Older compatibility dates and non-D1 operations keep the JSON path.
20
+
21
+ On one pinned x86 core at a fixed rate, CPU per request against a 500,000-row feed API, see the PR for numbers.
22
+
3
23
  ## 0.34.2
4
24
 
5
25
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/runtime",
3
- "version": "0.34.2",
3
+ "version": "0.34.4",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -3639,16 +3639,29 @@ if (globalThis.crypto.getRandomValues == null) {
3639
3639
  // so a string held as UTF-16 (one from JSON.parse or TextDecoder, say) decoded to the
3640
3640
  // wrong bytes, and btoa encoded its UTF-16 units.
3641
3641
  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3642
+ // Sextet per character code (255 = not base64): a table read rather than an indexOf over the
3643
+ // alphabet, and one pass that also validates, so no regex test over the whole input.
3644
+ const sextet = [];
3645
+ for (let i = 0; i < 128; i++) sextet.push(255);
3646
+ for (let i = 0; i < alphabet.length; i++) sextet[alphabet.charCodeAt(i)] = i;
3642
3647
  globalThis.atob = function (data) {
3643
- let text = String(data).replace(/[\t\n\f\r ]/g, "");
3648
+ // One C pass for the usual input (no whitespace, valid characters); the JS below
3649
+ // takes everything else, so it alone decides what throws.
3650
+ const input = String(data);
3651
+ const fast = __sbAtobNative(input);
3652
+ if (fast !== "") return fast;
3653
+ let text = input.replace(/[\t\n\f\r ]/g, "");
3644
3654
  if (text.length % 4 === 0) text = text.replace(/==?$/, "");
3645
- if (text.length % 4 === 1 || !/^[A-Za-z0-9+/]*$/.test(text)) throw invalidCharacter("atob: the string is not valid base64");
3655
+ if (text.length % 4 === 1) throw invalidCharacter("atob: the string is not valid base64");
3646
3656
  const parts = [];
3647
3657
  let chunk = "";
3648
3658
  let bits = 0;
3649
3659
  let count = 0;
3650
3660
  for (let i = 0; i < text.length; i++) {
3651
- bits = (bits << 6) | alphabet.indexOf(text[i]);
3661
+ const code = text.charCodeAt(i);
3662
+ const value = code < 128 ? sextet[code] : 255;
3663
+ if (value === 255) throw invalidCharacter("atob: the string is not valid base64");
3664
+ bits = (bits << 6) | value;
3652
3665
  count += 6;
3653
3666
  if (count >= 8) {
3654
3667
  count -= 8;
@@ -4050,6 +4063,7 @@ function __sbToBytes(input) {
4050
4063
  }
4051
4064
  function __sbBufFrom(latin1) {
4052
4065
  const b = new Uint8Array(latin1.length);
4066
+ if (__sbFillBytes(latin1, b)) return b.buffer;
4053
4067
  for (let i = 0; i < latin1.length; i++) b[i] = latin1.charCodeAt(i) & 0xff;
4054
4068
  return b.buffer;
4055
4069
  }
@@ -4182,6 +4196,8 @@ function __sbRawBodyResponse(body, init) {
4182
4196
  // Constant-time compare of two latin1 byte strings.
4183
4197
  function __sbEqCt(a, b) {
4184
4198
  if (a.length !== b.length) return false;
4199
+ const native = __sbEqCtNative(a, b);
4200
+ if (native >= 0) return native === 1;
4185
4201
  let diff = 0;
4186
4202
  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
4187
4203
  return diff === 0;
@@ -4797,6 +4813,21 @@ static size_t sb_utf8_encode_units(const uint16_t* s, size_t len, unsigned char*
4797
4813
  return k;
4798
4814
  }
4799
4815
 
4816
+ // Unit I of a string held as UTF-16 (WIDE) or one unit per byte.
4817
+ static unsigned sb_unit(const unsigned char* s, int wide, size_t i) {
4818
+ return wide ? (unsigned)((const uint16_t*)s)[i] : (unsigned)s[i];
4819
+ }
4820
+
4821
+ // Base64 sextet of a character, or -1.
4822
+ static int sb_b64_val(unsigned c) {
4823
+ if (c >= 'A' && c <= 'Z') return (int)(c - 'A');
4824
+ if (c >= 'a' && c <= 'z') return (int)(c - 'a') + 26;
4825
+ if (c >= '0' && c <= '9') return (int)(c - '0') + 52;
4826
+ if (c == '+') return 62;
4827
+ if (c == '/') return 63;
4828
+ return -1;
4829
+ }
4830
+
4800
4831
  `;
4801
4832
 
4802
4833
  // sproutd #22: __sbFromUtf8 for a string of bytes (one byte per unit) or a Uint8Array, in C.
@@ -4897,6 +4928,91 @@ function __sbToBytesNative(input) {
4897
4928
  return out;
4898
4929
  }
4899
4930
 
4931
+ // Constant-time equality of two equal-length bytestrings in C: 1 equal, 0 different, -1 when
4932
+ // either is not a bytestring (the caller then compares in JS).
4933
+ // oxlint-disable-next-line no-unused-vars -- `a` and `b` are read inside the RawC block below, not by JS.
4934
+ function __sbEqCtNative(a, b) {
4935
+ let result = -1;
4936
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
4937
+ Porffor.c`
4938
+ if (a.type == 195 && b.type == 195) {
4939
+ const size_t __n = (size_t)*(u32*)(MEM + (u32)a.val);
4940
+ if (__n == (size_t)*(u32*)(MEM + (u32)b.val)) {
4941
+ const unsigned char* __x = (const unsigned char*)(MEM + (u32)a.val + 4);
4942
+ const unsigned char* __y = (const unsigned char*)(MEM + (u32)b.val + 4);
4943
+ unsigned char __diff = 0;
4944
+ for (size_t __i = 0; __i < __n; __i++) __diff |= __x[__i] ^ __y[__i];
4945
+ result = (f64)(__diff == 0 ? 1 : 0);
4946
+ }
4947
+ }
4948
+ `;
4949
+ return result;
4950
+ }
4951
+
4952
+ // Copies a bytestring (one unit per byte) into the front of a Uint8Array.
4953
+ // Returns false (writing nothing) when it cannot.
4954
+ // oxlint-disable-next-line no-unused-vars -- `bytes` and `view` are read inside the RawC block below, not by JS.
4955
+ function __sbFillBytes(bytes, view) {
4956
+ let ok = 0;
4957
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
4958
+ Porffor.c`
4959
+ if (bytes.type == 195 && view.type == 81 && *(int32_t*)(MEM + (u32)view.val) >= 0) {
4960
+ const u32 __p = (u32)bytes.val;
4961
+ const u32 __v = (u32)view.val;
4962
+ const size_t __n = (size_t)*(u32*)(MEM + __p);
4963
+ if (__n <= (size_t)*(int32_t*)(MEM + __v)) {
4964
+ memcpy(MEM + *(u32*)(MEM + __v + 4) + 4, MEM + __p + 4, __n);
4965
+ ok = (f64)1;
4966
+ }
4967
+ }
4968
+ `;
4969
+ return ok === 1;
4970
+ }
4971
+
4972
+ // atob for the common case: no whitespace, only base64 characters, `=` padding
4973
+ // only where the spec allows it. Returns the decoded bytestring, or "" for
4974
+ // anything else (including an empty result), so the caller's JS path decides
4975
+ // and throws.
4976
+ // oxlint-disable-next-line no-unused-vars -- `text` is read inside the RawC block below, not by JS.
4977
+ function __sbAtobNative(text) {
4978
+ let out = "";
4979
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
4980
+ Porffor.c`
4981
+ if (text.type == 195 || text.type == 67) {
4982
+ const u32 __p = (u32)text.val;
4983
+ const int __wide = text.type == 67;
4984
+ size_t __n = (size_t)*(u32*)(MEM + __p);
4985
+ const unsigned char* __s = (const unsigned char*)(MEM + __p + 4);
4986
+ if (__n % 4 == 0) {
4987
+ if (__n > 0 && sb_unit(__s, __wide, __n - 1) == '=') __n--;
4988
+ if (__n > 0 && sb_unit(__s, __wide, __n - 1) == '=' && __n % 4 != 0) __n--;
4989
+ }
4990
+ if (__n > 0 && __n % 4 != 1) {
4991
+ const size_t __cap = __n / 4 * 3 + 3;
4992
+ unsigned char __stack[768];
4993
+ unsigned char* __o = __cap <= sizeof(__stack) ? __stack : (unsigned char*)malloc(__cap);
4994
+ if (__o) {
4995
+ size_t __k = 0, __i = 0;
4996
+ int __bad = 0;
4997
+ uint32_t __acc = 0;
4998
+ int __bits = 0;
4999
+ for (; __i < __n; __i++) {
5000
+ const unsigned __c = sb_unit(__s, __wide, __i);
5001
+ const int __v = __c < 128 ? sb_b64_val(__c) : -1;
5002
+ if (__v < 0) { __bad = 1; break; }
5003
+ __acc = (__acc << 6) | (uint32_t)__v;
5004
+ __bits += 6;
5005
+ if (__bits >= 8) { __bits -= 8; __o[__k++] = (unsigned char)((__acc >> __bits) & 0xff); }
5006
+ }
5007
+ if (!__bad) out = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__o, __k), 195);
5008
+ if (__o != __stack) free(__o);
5009
+ }
5010
+ }
5011
+ }
5012
+ `;
5013
+ return out;
5014
+ }
5015
+
4900
5016
  // TRANSPORT: wrap.ts splices one of transport-broker.js / transport-embedded.js here.
4901
5017
 
4902
5018
  // `nStr` is the decimal byte count as a string (same string-param pattern as
@@ -5173,11 +5289,30 @@ function __sbD1Param(value, strict) {
5173
5289
  // oxlint-disable-next-line anti-slop/no-runtime-typeof -- workerd's D1_TYPE_ERROR names the value's typeof
5174
5290
  throw new Error("D1_TYPE_ERROR: Type '" + typeof value + "' not supported for value '" + String(value) + "'");
5175
5291
  }
5292
+ // An embedded (standalone) build sets this to hand the request object straight to its
5293
+ // dispatcher. D1 replies are the biggest ones a handler sees (20 rows is 100 cells), and
5294
+ // the JSON.stringify and JSON.parse around the in-process call cost more than SQLite did.
5295
+ // The broker transport leaves it unset and keeps the string hop. (No initializer: the transport is placed ahead of the prelude, and `= null` would run after its assignment.)
5296
+ // oxlint-disable-next-line no-unassigned-vars -- the embedded transport assigns it; the broker transport does not.
5297
+ var __sbCallDirect;
5298
+ function __sbRpcDirect(op, extra) {
5299
+ if (!__sbCallDirect) return __sbRpc(op, extra);
5300
+ const req = { v: 1, id: ++__sbReqId, op };
5301
+ if (extra) for (const k in extra) req[k] = extra[k];
5302
+ let reply;
5303
+ try {
5304
+ reply = __sbCallDirect(req);
5305
+ } catch (err) {
5306
+ throw new Error(`sproutboat ${op}: ${String((err && err.message) || err) || "failed"}`);
5307
+ }
5308
+ if (reply && reply.ok === false) throw new Error(`sproutboat ${op}: ${reply.error || "failed"}`);
5309
+ return reply;
5310
+ }
5176
5311
  function __sbD1Rpc(op, extra, strict) {
5177
- if (!strict) return __sbRpc(op, extra);
5312
+ if (!strict) return __sbRpcDirect(op, extra);
5178
5313
  extra.strict = true;
5179
5314
  try {
5180
- return __sbRpc(op, extra);
5315
+ return __sbRpcDirect(op, extra);
5181
5316
  } catch (err) {
5182
5317
  // "sproutboat d1.query: D1_ERROR: ..." -> "D1_ERROR: ...", as workerd words it.
5183
5318
  const message = String((err && err.message) || err);
@@ -5240,7 +5375,8 @@ function __sbD1Meta(meta, started) {
5240
5375
  }
5241
5376
  // Columns and rows of a strict reply (an older broker answers with objects only).
5242
5377
  function __sbD1Table(r) {
5243
- if (r.columns) return { columns: r.columns, rows: __sbD1Rows(r) };
5378
+ // rows built in C (r.native) already hold BLOBs as byte arrays
5379
+ if (r.columns) return { columns: r.columns, rows: r.native === true ? r.rows : __sbD1Rows(r) };
5244
5380
  const objects = r.results || [];
5245
5381
  const columns = objects.length ? Object.keys(objects[0]) : [];
5246
5382
  const rows = [];
@@ -31,7 +31,7 @@ const load = new Function(
31
31
  "__sbRpc",
32
32
  "__sbR2MultipartPut",
33
33
  // __sbIsStr and __sbToBytes (for uploadPart's values), with the C fast paths stubbed out.
34
- `function __sbFromUtf8Native() { return ""; }\nfunction __sbToBytesNative() { return ""; }\n${src.slice(isStrStart, isStrEnd)}\n${src.slice(bytesStart, utf8Start)}\n${src.slice(utf8Start, utf8End)}\n${src.slice(rawStart, rawEnd)}\n${src.slice(r2Start, r2End)}\nreturn { __sbR2Object, __sbR2Multipart };`,
34
+ `function __sbFromUtf8Native() { return ""; }\nfunction __sbToBytesNative() { return ""; }\nfunction __sbFillBytes() { return false; }\n${src.slice(isStrStart, isStrEnd)}\n${src.slice(bytesStart, utf8Start)}\n${src.slice(utf8Start, utf8End)}\n${src.slice(rawStart, rawEnd)}\n${src.slice(r2Start, r2End)}\nreturn { __sbR2Object, __sbR2Multipart };`,
35
35
  );
36
36
  const calls = [];
37
37
  const rpc = (op, value) => {
@@ -1352,6 +1352,43 @@ static void sb_putjson(sb_buf* b, const char* s, size_t n) {
1352
1352
  // doesn't start a valid sequence passes through as \u00XX, the same rule as
1353
1353
  // the prelude's __sbFromUtf8. Raw byte producers (the HTTP client's body,
1354
1354
  // BLOB columns) keep sb_putjson.
1355
+ // The UTF-16 units sb_putjson_text would write as u-escapes, for a TEXT cell handed over as
1356
+ // a JS string: same validity rules (an invalid lead byte is its own unit, an overlong form, a
1357
+ // surrogate or a value above U+10FFFF is not decoded). OUT has room for n units. *wide is set
1358
+ // when a unit is above 0xff.
1359
+ static size_t sb_text_units(const unsigned char* s, size_t n, uint16_t* out, int* wide) {
1360
+ size_t k = 0;
1361
+ *wide = 0;
1362
+ for (size_t i = 0; i < n; i++) {
1363
+ unsigned char c = s[i];
1364
+ if (c < 0x80) { out[k++] = c; continue; }
1365
+ unsigned int cp = 0, min = 0;
1366
+ size_t len = 0;
1367
+ if ((c & 0xe0) == 0xc0) { cp = c & 0x1f; len = 2; min = 0x80; }
1368
+ else if ((c & 0xf0) == 0xe0) { cp = c & 0x0f; len = 3; min = 0x800; }
1369
+ else if ((c & 0xf8) == 0xf0) { cp = c & 0x07; len = 4; min = 0x10000; }
1370
+ int ok = len > 0 && i + len <= n;
1371
+ for (size_t j = 1; ok && j < len; j++) {
1372
+ unsigned char cc = s[i + j];
1373
+ if ((cc & 0xc0) != 0x80) ok = 0;
1374
+ else cp = (cp << 6) | (cc & 0x3f);
1375
+ }
1376
+ if (ok && (cp < min || cp > 0x10ffff || (cp >= 0xd800 && cp <= 0xdfff))) ok = 0;
1377
+ if (!ok) { out[k++] = c; if (c > 0xff) *wide = 1; continue; }
1378
+ if (cp >= 0x10000) {
1379
+ cp -= 0x10000;
1380
+ out[k++] = (uint16_t)(0xd800 + (cp >> 10));
1381
+ out[k++] = (uint16_t)(0xdc00 + (cp & 0x3ff));
1382
+ *wide = 1;
1383
+ } else {
1384
+ out[k++] = (uint16_t)cp;
1385
+ if (cp > 0xff) *wide = 1;
1386
+ }
1387
+ i += len - 1;
1388
+ }
1389
+ return k;
1390
+ }
1391
+
1355
1392
  static void sb_putjson_text(sb_buf* b, const char* s, size_t n) {
1356
1393
  sb_put(b, "\"", 1);
1357
1394
  for (size_t i = 0; i < n; i++) {
@@ -2122,7 +2159,29 @@ static char* sb_sql_error(sqlite3* db, const char* message, int code, int skip)
2122
2159
  // {"ok":true,"cols":[...],"rows":[[...]],"changes":n,"rowid":n}
2123
2160
  // or {"ok":false,"error":"...","code":n,"offset":n}. In a D1 database (a path under
2124
2161
  // /d1/) a BLOB comes back as {"$blob":"HEX"}; elsewhere as one char per byte.
2125
- static char* sb_sql_run(const char* path, const char* sql, const char* params) {
2162
+ // A strict D1 query can hand its rows back as cells instead of JSON text: the caller turns
2163
+ // them into JS arrays, with no JSON written here and none parsed there. t is 0 NULL,
2164
+ // 1 number, 2 TEXT (UTF-8 in p), 3 BLOB (bytes in p).
2165
+ typedef struct { unsigned char t; double d; char* p; int n; } sb_cell;
2166
+ typedef struct { sb_cell* c; size_t n; size_t cap; int ncol; int used; } sb_cells;
2167
+ static void sb_cells_free(sb_cells* cs) {
2168
+ if (!cs) return;
2169
+ for (size_t i = 0; i < cs->n; i++) free(cs->c[i].p);
2170
+ free(cs->c);
2171
+ cs->c = 0; cs->n = cs->cap = 0;
2172
+ }
2173
+ static sb_cell* sb_cells_add(sb_cells* cs) {
2174
+ if (cs->n == cs->cap) {
2175
+ size_t cap = cs->cap ? cs->cap * 2 : 64;
2176
+ sb_cell* grown = (sb_cell*)realloc(cs->c, cap * sizeof(sb_cell));
2177
+ if (!grown) return 0;
2178
+ cs->c = grown; cs->cap = cap;
2179
+ }
2180
+ sb_cell* cell = &cs->c[cs->n++];
2181
+ cell->t = 0; cell->d = 0; cell->p = 0; cell->n = 0;
2182
+ return cell;
2183
+ }
2184
+ static char* sb_sql_run_x(const char* path, const char* sql, const char* params, sb_cells* cells) {
2126
2185
  sb_buf b = { 0, 0, 0 };
2127
2186
  int idx = sb_db_for(path);
2128
2187
  if (idx < 0 && sb_db_capture_failed(path)) return sb_sql_error(0, SB_LTX_FAILED, 10, 0);
@@ -2151,8 +2210,30 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
2151
2210
  sb_putjson_text(&b, name ? name : "", name ? strlen(name) : 0);
2152
2211
  }
2153
2212
  sb_puts(&b, "],\"rows\":[");
2213
+ // Cells only for a strict D1 statement: its BLOBs are byte arrays either way.
2214
+ const int native = cells && strict && d1;
2215
+ if (native) { cells->ncol = ncol; cells->used = 1; }
2154
2216
  int rc, first = 1;
2155
2217
  while ((rc = sqlite3_step(st)) == SB_SQLITE_ROW) {
2218
+ if (native) {
2219
+ for (int i = 0; i < ncol; i++) {
2220
+ sb_cell* cell = sb_cells_add(cells);
2221
+ if (!cell) { cells->used = -1; break; }
2222
+ int t = sqlite3_column_type(st, i);
2223
+ if (t == 5) continue;
2224
+ if (t == 1 || t == 2) { cell->t = 1; cell->d = sqlite3_column_double(st, i); continue; }
2225
+ const unsigned char* txt = t == 4 ? (const unsigned char*)sqlite3_column_blob(st, i) : sqlite3_column_text(st, i);
2226
+ int n = sqlite3_column_bytes(st, i);
2227
+ cell->t = t == 4 ? 3 : 2;
2228
+ cell->n = txt ? n : 0;
2229
+ cell->p = (char*)malloc((size_t)cell->n + 1);
2230
+ if (!cell->p) { cells->used = -1; break; }
2231
+ if (cell->n) memcpy(cell->p, txt, (size_t)cell->n);
2232
+ cell->p[cell->n] = 0;
2233
+ }
2234
+ if (cells->used < 0) break;
2235
+ continue;
2236
+ }
2156
2237
  if (!first) sb_puts(&b, ",");
2157
2238
  first = 0;
2158
2239
  sb_puts(&b, "[");
@@ -2188,6 +2269,7 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
2188
2269
  // Reset, don't finalize: the statement stays compiled in sb_stmt_cache. Reset
2189
2270
  // now (not just on next reuse) so a SELECT stops holding its read transaction.
2190
2271
  sqlite3_reset(st);
2272
+ if (cells && cells->used < 0) { free(b.p); sb_cells_free(cells); return sb_sql_error(db, "out of memory", 7, 0); }
2191
2273
  // The statement's own error, kept before LTX capture runs: capture executes statements
2192
2274
  // of its own, after which SQLite reports "not an error".
2193
2275
  char failure[512] = { 0 };
@@ -2200,11 +2282,15 @@ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
2200
2282
  if (capture_failed) rc = 10;
2201
2283
  if (rc != SB_SQLITE_DONE && rc != SB_SQLITE_ROW) {
2202
2284
  free(b.p);
2285
+ if (cells) sb_cells_free(cells);
2203
2286
  if (capture_failed) return sb_sql_error(db, SB_LTX_FAILED, 10, 0);
2204
2287
  return sb_sql_error(db, failure, failure_code, 0);
2205
2288
  }
2206
2289
  return b.p;
2207
2290
  }
2291
+ static char* sb_sql_run(const char* path, const char* sql, const char* params) {
2292
+ return sb_sql_run_x(path, sql, params, 0);
2293
+ }
2208
2294
 
2209
2295
  // The storage quota (docs: an app's data directory is capped): bytes in regular files under
2210
2296
  // dir, symlinks not followed. Bounded by depth; an unreadable entry counts as 0.
@@ -4131,6 +4217,85 @@ function __sbSqlRaw(path, sql, paramsJson) {
4131
4217
  return res;
4132
4218
  }
4133
4219
 
4220
+ // One strict D1 statement: [header JSON, rows]. The header is {"ok":true,"cols":[...],"changes":n,
4221
+ // "rowid":n} (or the error reply) and rows is an array of row arrays built here from the
4222
+ // cells, BLOBs as byte arrays, so no JSON for the rows is written or parsed. rows is
4223
+ // undefined when the reply is not a result.
4224
+ // oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
4225
+ function __sbSqlRowsRaw(path, sql, paramsJson) {
4226
+ let res = "";
4227
+ // oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
4228
+ Porffor.c`
4229
+ const char* __p; size_t __pl; char* __po = 0;
4230
+ porf_native_fetch_read_value(path, &__p, &__pl, &__po);
4231
+ char* __path = (char*)malloc(__pl + 1); memcpy(__path, __p, __pl); __path[__pl] = 0;
4232
+ if (__po) free(__po);
4233
+
4234
+ const char* __s; size_t __sl; char* __so = 0;
4235
+ porf_native_fetch_read_value(sql, &__s, &__sl, &__so);
4236
+ char* __sql = (char*)malloc(__sl + 1); memcpy(__sql, __s, __sl); __sql[__sl] = 0;
4237
+ if (__so) free(__so);
4238
+
4239
+ const char* __a; size_t __al; char* __ao = 0;
4240
+ porf_native_fetch_read_value(paramsJson, &__a, &__al, &__ao);
4241
+ char* __args = (char*)malloc(__al + 1); memcpy(__args, __a, __al); __args[__al] = 0;
4242
+ if (__ao) free(__ao);
4243
+
4244
+ sb_cells __cells = { 0, 0, 0, 0, 0 };
4245
+ char* __out = sb_sql_run_x(__path, __sql, __args, &__cells);
4246
+ free(__path); free(__sql); free(__args);
4247
+ const u32 __pair = porf_arr_new(2, 2);
4248
+ res = porf_box((f64)__pair, 72);
4249
+ if (!__out) __out = strdup("{\"ok\":false,\"error\":\"no result\"}");
4250
+ porf_arr_set(__pair, 0, porf_box((f64)porf_native_fetch_alloc_bytestring(__out, strlen(__out)), 195));
4251
+ free(__out);
4252
+ if (__cells.used == 1) {
4253
+ const size_t __ncol = (size_t)__cells.ncol;
4254
+ const size_t __nrow = __ncol ? __cells.n / __ncol : 0;
4255
+ const u32 __rows = porf_arr_new(0, (i32)(__nrow ? __nrow : 1));
4256
+ porf_arr_set(__pair, 1, porf_box((f64)__rows, 72));
4257
+ for (size_t __r = 0; __r < __nrow; __r++) {
4258
+ const u32 __row = porf_arr_new((i32)__ncol, (i32)__ncol);
4259
+ porf_arr_set(__rows, (u32)__r, porf_box((f64)__row, 72));
4260
+ for (size_t __c = 0; __c < __ncol; __c++) {
4261
+ const sb_cell* __cell = &__cells.c[__r * __ncol + __c];
4262
+ jsval __v = porf_box(0.0, 7);
4263
+ if (__cell->t == 1) __v = porf_box_num(__cell->d);
4264
+ else if (__cell->t == 2) {
4265
+ const size_t __n = (size_t)__cell->n;
4266
+ uint16_t* __u = (uint16_t*)malloc(__n * 2 + 2);
4267
+ if (__u) {
4268
+ int __wide = 0;
4269
+ const size_t __k = sb_text_units((const unsigned char*)__cell->p, __n, __u, &__wide);
4270
+ if (__wide) {
4271
+ const u32 __str = porf_native_fetch_alloc((u32)(4 + __k * 2), 67);
4272
+ *(u32*)(MEM + __str) = (u32)__k;
4273
+ memcpy(MEM + __str + 4, __u, __k * 2);
4274
+ __v = porf_box((f64)__str, 67);
4275
+ } else {
4276
+ unsigned char* __b = (unsigned char*)malloc(__k + 1);
4277
+ if (__b) {
4278
+ for (size_t __i = 0; __i < __k; __i++) __b[__i] = (unsigned char)__u[__i];
4279
+ __v = porf_box((f64)porf_native_fetch_alloc_bytestring((const char*)__b, __k), 195);
4280
+ free(__b);
4281
+ }
4282
+ }
4283
+ free(__u);
4284
+ }
4285
+ } else if (__cell->t == 3) {
4286
+ const u32 __bytes = porf_arr_new((i32)__cell->n, (i32)__cell->n);
4287
+ for (int __i = 0; __i < __cell->n; __i++) porf_arr_set(__bytes, (u32)__i, porf_box_num((f64)(unsigned char)__cell->p[__i]));
4288
+ __v = porf_box((f64)__bytes, 72);
4289
+ }
4290
+ porf_arr_set(__row, (u32)__c, __v);
4291
+ }
4292
+ }
4293
+ }
4294
+ sb_cells_free(&__cells);
4295
+ `;
4296
+ return res;
4297
+ }
4298
+
4134
4299
  // --- the op dispatch, in JS -------------------------------------------------
4135
4300
  // Deliberately the same SQL and the same partition keys as broker.ts. When one
4136
4301
  // changes the other has to, and the conformance suite is what says so.
@@ -4621,7 +4786,7 @@ function __sbEmbeddedDispatch(msg) {
4621
4786
  __sbD1Quota(path);
4622
4787
  if (op === "d1.batch") return { ok: true, results: __sbD1Batch(path, msg.statements || [], strict) };
4623
4788
  const one = __sbD1Run(path, msg.sql, msg.params, strict);
4624
- return { ok: true, results: one.results, columns: one.columns, rows: one.rows, meta: one.meta, success: true };
4789
+ return { ok: true, results: one.results, columns: one.columns, rows: one.rows, native: one.native, meta: one.meta, success: true };
4625
4790
  }
4626
4791
 
4627
4792
  // Durable Objects storage.sql (compatibility date 2026-10-29): one database per object,
@@ -5168,6 +5333,14 @@ function __sbD1Bytes(cell) {
5168
5333
  return out;
5169
5334
  }
5170
5335
  function __sbD1Run(path, sql, params, strict) {
5336
+ if (strict) {
5337
+ // Rows come back as arrays, built in C (see __sbSqlRowsRaw); the header is small JSON.
5338
+ const pair = __sbSqlRowsRaw(path, "/*sb-d1-strict*/" + String(sql), JSON.stringify(params || []));
5339
+ const head = JSON.parse(pair[0]);
5340
+ if (head.ok === false) throw __sbD1Error(head);
5341
+ const rows = pair[1];
5342
+ return { columns: head.cols, rows, native: true, meta: { changes: head.changes, last_row_id: head.rowid, rows_read: rows.length } };
5343
+ }
5171
5344
  const r = JSON.parse(__sbSqlRaw(path, (strict ? "/*sb-d1-strict*/" : "") + String(sql), JSON.stringify(params || [])));
5172
5345
  if (r.ok === false) throw strict ? __sbD1Error(r) : new Error("sqlite: " + r.error);
5173
5346
  const meta = { changes: r.changes, last_row_id: r.rowid, rows_read: r.rows.length };
@@ -5331,6 +5504,12 @@ function __sbCall(reqJson) {
5331
5504
  }
5332
5505
  }
5333
5506
 
5507
+ // The same dispatch without the JSON text either side: D1 calls take this (see __sbRpcDirect).
5508
+ __sbCallDirect = function (req) {
5509
+ if (__sbCompatDate() >= "2026-10-22") __sbLtxConfigure();
5510
+ return __sbEmbeddedDispatch(req);
5511
+ };
5512
+
5334
5513
  // --- local triggers ---------------------------------------------------------
5335
5514
  // A deployed sprout is driven by the broker: it POSTs x-sb-trigger for cron
5336
5515
  // ticks, queue batches and DO alarms. An embedded binary has no broker, so the
package/test/native-d1.ts CHANGED
@@ -51,6 +51,33 @@ int main(void) {
51
51
  char* params = strchr(sql, '\\t'); if (!params) return 2; *params++ = 0;
52
52
  if (!strncmp(line, ":db ", 4)) { int r = sql[0] == 'p' ? sb_db_page_size(line + 4) : sb_db_quota_freeze(line + 4, sql[0] == 'c' ? -1 : sql[0] == 'f'); printf("{\\"n\\":%d}\\n", r); fflush(stdout); continue; }
53
53
  if (!strncmp(line, ":bytes ", 7)) { line[strcspn(line, "\\t")] = 0; printf("{\\"n\\":%lld}\\n", sb_dir_bytes(line + 7, 0)); fflush(stdout); continue; }
54
+ // ":cells PATH": the same statement through sb_sql_run_x with cells, printed as JSON the test
55
+ // can compare with sb_sql_run's rows: TEXT as \\uXXXX units (sb_text_units), BLOB as {"$blob":hex}.
56
+ if (!strncmp(line, ":cells ", 7)) {
57
+ sb_cells cs = { 0, 0, 0, 0, 0 };
58
+ char* head = sb_sql_run_x(line + 7, sql, params, &cs);
59
+ printf("{\\"head\\":%s,\\"rows\\":[", head);
60
+ size_t nrow = cs.ncol ? cs.n / (size_t)cs.ncol : 0;
61
+ for (size_t r = 0; r < nrow; r++) {
62
+ printf(r ? ",[" : "[");
63
+ for (int c = 0; c < cs.ncol; c++) {
64
+ sb_cell* x = &cs.c[r * (size_t)cs.ncol + (size_t)c];
65
+ if (c) printf(",");
66
+ if (x->t == 0) printf("null");
67
+ else if (x->t == 1) printf("%.17g", x->d);
68
+ else if (x->t == 2) {
69
+ uint16_t* u = (uint16_t*)malloc((size_t)x->n * 2 + 2); int wide = 0;
70
+ size_t k = sb_text_units((const unsigned char*)x->p, (size_t)x->n, u, &wide);
71
+ printf("\\"");
72
+ for (size_t i = 0; i < k; i++) printf("\\\\u%04x", u[i]);
73
+ printf("\\"");
74
+ free(u);
75
+ } else { printf("{\\"$blob\\":\\""); for (int i = 0; i < x->n; i++) printf("%02x", (unsigned char)x->p[i]); printf("\\"}"); }
76
+ }
77
+ printf("]");
78
+ }
79
+ printf("]}\\n"); fflush(stdout); sb_cells_free(&cs); free(head); continue;
80
+ }
54
81
  // ":script PATH" runs SQL as a script (sb_sql_script, what exec() uses).
55
82
  char* out = !strncmp(line, ":script ", 8) ? sb_sql_script(line + 8, sql) : sb_sql_run(line, sql, params); puts(out); fflush(stdout); free(out);
56
83
  }
@@ -91,6 +118,59 @@ int main(void) {
91
118
  );
92
119
  assert.equal(other.rows[0][0], String(other.rows[0][0]), "outside D1 a BLOB is still one char per byte, not hex");
93
120
 
121
+ // Strict rows handed over as cells (the JS arrays are built from them in C) hold exactly what
122
+ // the JSON rows hold: NULL, integers, floats, ASCII, Latin-1, BMP and astral text, every kind
123
+ // of invalid UTF-8, a BLOB, and no rows at all.
124
+ const cellRun = (sql: string, params: unknown[] = []) => {
125
+ const input = `:cells ${d1}\t/*sb-d1-strict*/${sql}\t${JSON.stringify(params)}\n${d1}\t/*sb-d1-strict*/${sql}\t${JSON.stringify(params)}\n`;
126
+ const out = Bun.spawnSync([binary], { stdin: Buffer.from(input) });
127
+ assert.equal(out.exitCode, 0, out.stderr.toString());
128
+ const [cells, json] = out.stdout
129
+ .toString()
130
+ .trim()
131
+ .split("\n")
132
+ .map((l) => JSON.parse(l));
133
+ return { cells, json };
134
+ };
135
+ const bad = [
136
+ "x'80'",
137
+ "x'c0af'",
138
+ "x'c3'",
139
+ "x'e282'",
140
+ "x'f09f9880'",
141
+ "x'f0'",
142
+ "x'eda080'",
143
+ "x'f4908080'",
144
+ "x'e0809a'",
145
+ "x'41c342'",
146
+ "x'ffff'",
147
+ "x'c3a9e282'",
148
+ ];
149
+ const texts = [
150
+ "''",
151
+ "'plain ascii'",
152
+ "'caf\u00e9'",
153
+ "'\u4e1c\u4eac'",
154
+ "'x\u{1F680}y'",
155
+ "'\u00ff\u0100'",
156
+ "char(113, 34, 92, 10, 9, 1, 127)",
157
+ ...bad.map((b) => `CAST(${b} AS TEXT)`),
158
+ ];
159
+ for (const t of texts) {
160
+ const { cells, json } = cellRun(`SELECT ${t} AS t, 7 AS i, 2.5 AS f, NULL AS n, x'00ff7f' AS b, ${t} || 'z' AS u`);
161
+ assert.equal(cells.head.ok, true);
162
+ assert.deepEqual(cells.rows, json.rows, `cells equal the JSON rows for ${t}`);
163
+ assert.deepEqual(cells.head.cols, json.cols);
164
+ assert.equal(cells.head.changes, json.changes);
165
+ }
166
+ const empty = cellRun("SELECT a FROM t WHERE 0");
167
+ assert.deepEqual(empty.cells.rows, []);
168
+ assert.deepEqual(empty.json.rows, []);
169
+ const wide = cellRun("SELECT * FROM t");
170
+ assert.deepEqual(wide.cells.rows, wide.json.rows, "a table with a BLOB and NULLs");
171
+ const failed = cellRun("SELECT nope FROM nowhere");
172
+ assert.equal(failed.cells.head.ok, false);
173
+
94
174
  // An object that is not a blob binds NULL and the parse carries on (a "}" in a string included).
95
175
  const [, kept] = run([
96
176
  [d1, "INSERT INTO t VALUES (?1, ?2, ?3)", [{ a: "}{" }, 5, "x"]],
@@ -172,12 +252,23 @@ int main(void) {
172
252
  };
173
253
  // oxlint-disable-next-line no-function-constructor -- lifting a block out of the text-only transport
174
254
  const scriptRaw = (path: string, text: string) => sqlRaw(":script " + path, text, "[]");
255
+ // __sbSqlRowsRaw as the transport's C block gives it: [header JSON, rows], BLOBs as byte arrays.
256
+ const rowsOf =
257
+ (raw: (path: string, text: string, params: string) => string) => (path: string, text: string, params: string) => {
258
+ const r = JSON.parse(raw(":cells " + path, text, params));
259
+ if (r.head.ok === false) return [JSON.stringify(r.head), undefined];
260
+ const rows = r.rows.map((row: unknown[]) =>
261
+ row.map((c) => (c instanceof Object && "$blob" in c ? [...Buffer.from(String(c.$blob), "hex")] : c)),
262
+ );
263
+ return [JSON.stringify(r.head), rows];
264
+ };
175
265
  const ops = new Function(
176
266
  "__sbSqlRaw",
177
267
  "__sbSql",
178
268
  "__sbSqlScriptRaw",
269
+ "__sbSqlRowsRaw",
179
270
  `${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch, __sbD1Open, __sbD1ExecLines };`,
180
- )(sqlRaw, sql, scriptRaw);
271
+ )(sqlRaw, sql, scriptRaw, rowsOf(sqlRaw));
181
272
  // The storage quota, lifted from "The storage quota" to __sbStore, for DIR and LIMIT.
182
273
  const quotaStart = transport.indexOf("// The storage quota: an app's data directory");
183
274
  const quotaEnd = transport.indexOf("function __sbStore() {");
@@ -233,8 +324,9 @@ int main(void) {
233
324
  "__sbSqlRaw",
234
325
  "__sbSql",
235
326
  "__sbSqlScriptRaw",
327
+ "__sbSqlRowsRaw",
236
328
  `${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch, __sbD1Open, __sbD1ExecLines };`,
237
- )(raw, run, (path: string, text: string) => raw(":script " + path, text, "[]"));
329
+ )(raw, run, (path: string, text: string) => raw(":script " + path, text, "[]"), rowsOf(raw));
238
330
  const lift = (dir: string, limit: number) =>
239
331
  // oxlint-disable-next-line no-function-constructor -- lifting a block out of the text-only transport
240
332
  new Function(
@@ -312,7 +404,7 @@ int main(void) {
312
404
  "D1_ERROR: FOREIGN KEY constraint failed: SQLITE_CONSTRAINT (extended: SQLITE_CONSTRAINT_FOREIGNKEY)",
313
405
  );
314
406
  const strictRow = ops.__sbD1Run(db2, "SELECT 1 AS a, ?1 AS a", [{ $blob: "0a" }], true);
315
- assert.deepEqual([strictRow.columns, strictRow.rows], [["a", "a"], [[1, { $blob: "0a" }]]]);
407
+ assert.deepEqual([strictRow.columns, strictRow.rows], [["a", "a"], [[1, [10]]]]);
316
408
  assert.throws(
317
409
  () => ops.__sbD1Run(db2, "SELEC 1", [], true),
318
410
  (e: Error) => e.message === 'D1_ERROR: near "SELEC": syntax error at offset 0: SQLITE_ERROR',
@@ -413,6 +505,7 @@ int main(void) {
413
505
  "__sbSqlRaw",
414
506
  "__sbSql",
415
507
  "__sbSqlScriptRaw",
508
+ "__sbSqlRowsRaw",
416
509
  `${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch };`,
417
510
  )(
418
511
  (path: string, text: string, params: string) => {
@@ -421,6 +514,7 @@ int main(void) {
421
514
  },
422
515
  sql,
423
516
  scriptRaw,
517
+ rowsOf(sqlRaw),
424
518
  );
425
519
  const LTX_FAILED =
426
520
  "D1_ERROR: LTX capture failed: the transaction may be committed locally but is not durable in the replication log; writes are refused until restart: SQLITE_IOERR";