@sproutboat/runtime 0.34.5 → 0.36.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/CHANGELOG.md +23 -0
- package/package.json +1 -1
- package/src/d1-shim.test.js +120 -16
- package/src/native-fetch-prelude.js +313 -86
- package/src/transport-embedded.js +128 -31
- package/test/concurrent-driver.ts +291 -0
- package/test/fixtures/als-app.js +136 -0
- package/test/fixtures/concurrent-app.js +229 -0
- package/test/native-async-local-storage.ts +97 -0
- package/test/native-concurrent-requests.ts +87 -0
- package/test/native-cpu-requests.ts +156 -0
- package/test/native-d1.ts +36 -6
- package/test/native-headers.ts +182 -0
- package/test/native-sqlite-lock.ts +94 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.36.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 7a9c1f9: `AsyncLocalStorage` (`import { AsyncLocalStorage } from "node:async_hooks"`, once the CLI maps the import), as Workers has it: `run`, `exit`, `getStore` and the static `snapshot`. `enterWith` and `disable` throw, as there. The static `bind` is not provided: Porffor compiles a `.bind(` call on a function value to `Function.prototype.bind`, so use `snapshot()`. The toolchain patches Porffor's job queue and timers to carry one current-context value: a promise reaction (then, await, a coroutine resume, a native response callback) and a timer record it when they are created and set it when they run, so the store follows a request across awaits, microtasks, timers, intervals, async fetch and WebSocket callbacks and `ctx.waitUntil`, and requests interleaved in one process never see each other's. Cost: a reaction is 56 bytes, not 40, and each job does one load. The context is only ever set by `AsyncLocalStorage`. Nothing an existing app observes changes, so there is no new compatibility date: `node:async_hooks` did not work at any date before (the import built and `new AsyncLocalStorage()` threw). New native test `test:native-async-local-storage` (needs `SB_CLI`).
|
|
8
|
+
- a68d923: `x-sb-cpu-ms` is per request when requests interleave in one process. A request is charged the thread CPU time of the slices in which it ran (the toolchain's async context carries a request id through awaits, reactions and timers, and the clock is read only when the running request changes, so a request that runs alone costs two reads). Before, the stamp was the process CPU delta across the handler, which is right only for a handler that answers synchronously, so an async handler was never stamped. From compatibility date `2026-11-02` an async handler is stamped too (its response is a string with no Set-Cookie, as for a sync one); earlier dates still send no stamp for an async handler. A synchronous handler's stamp is now thread time, which leaves out the egress threads. New native test `test:native-cpu-requests`: a 200 ms spinner among eight waiters is charged its 200 ms and the waiters under 60 ms, two spinners are each charged their own, and the date gate.
|
|
9
|
+
|
|
10
|
+
## 0.35.0
|
|
11
|
+
|
|
12
|
+
### Minor Changes
|
|
13
|
+
|
|
14
|
+
- 7f5e87b: A standalone binary now opens its SQLite databases in `PRAGMA locking_mode=EXCLUSIVE` by default, with the page cache at 2 MB (`cache_size=-2000`, unchanged). The lock is taken once and held, so a transaction no longer takes and releases file locks around itself, and the WAL index lives in heap memory instead of a shared `-shm` file. The cost: no other process can open the database while the binary runs, so the `sqlite3` shell, `sproutboat kv/d1 --local --data-dir` against a running binary, and the several-copies-behind-`SO_REUSEPORT` recipe do not work on a live binary. Set `SB_SQLITE_EXCLUSIVE=0` to opt out and keep shared locking. A database under LTX replication is never opened exclusive.
|
|
15
|
+
|
|
16
|
+
The default belongs to the standalone binary only: the toolchain's native server shim sets `SB_SQLITE_EXCLUSIVE=1` at startup unless the variable is already set. The embedded transport itself reads only `SB_SQLITE_EXCLUSIVE` (`1` is exclusive, anything else is shared), so sproutd and the broker keep shared locking unless an operator sets it.
|
|
17
|
+
|
|
18
|
+
`SB_SQLITE_CACHE_MB=<n>` (1 to 4096) sets each database's page cache in MiB; unset it stays at 2 MB.
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- 2c9d885: A strict D1 query in a standalone binary does less around SQLite. It skips the generic dispatch (a request object, a walk down the op list, a result object), a statement is a class instead of an object with five closures, `raw()` and `first()` no longer build the `meta` they throw away (two clock reads), the transaction-keyword check reads character codes instead of compiling a regular expression on every query, and the column names, change count and row id come back as JavaScript values built in C instead of as JSON text that is written, parsed and discarded. Replies, errors and `meta` are unchanged.
|
|
23
|
+
- 004b8e7: A request's fixed cost in a native sprout drops five items. The CPU stamp and the streamed-body probe come back as numbers instead of formatted and parsed text (a `printf` and a `parseFloat` each, per request). The ingress reads of `x-sb-trigger` and `x-sb-remote-addr` scan the stored header names instead of validating and lowercasing a name for each, and the three `x-sb-` names the runtime strips are looked for in one pass. `SB_EDGE_TOKEN` is read once. `Headers` checks a name and a value in C and only falls back to its regular expression for a name it cannot judge there, so `get`, `has`, `set`, `append` and `new Response(body, { headers })` stop compiling that expression on every call. Behavior is unchanged.
|
|
24
|
+
- 4213ed7: A standalone binary stops mapping and unmapping 88 KiB for every statement that builds a temporary table. SQLite allocates 20 pages in one block for each page cache it creates, and `INSERT ... RETURNING` (and any sort that spills to a temporary table) creates one, so each such statement cost an `mmap` and an `munmap`. The runtime now sets the initial page-cache allocation to 0 before SQLite starts, so a cache takes its pages one at a time as it grows. Query results and the main database's cache size are unchanged.
|
|
25
|
+
|
|
3
26
|
## 0.34.5
|
|
4
27
|
|
|
5
28
|
### Patch Changes
|
package/package.json
CHANGED
package/src/d1-shim.test.js
CHANGED
|
@@ -64,7 +64,9 @@ for (const date of ["2026-09-28", "2026-10-24"]) {
|
|
|
64
64
|
test(`${date}: batch() is one transaction`, async () => {
|
|
65
65
|
const DB = fresh(date);
|
|
66
66
|
await DB.exec("CREATE TABLE t (id INTEGER PRIMARY KEY)");
|
|
67
|
-
expect(
|
|
67
|
+
expect(
|
|
68
|
+
await failure(() => DB.batch([DB.prepare("INSERT INTO t VALUES (1)"), DB.prepare("INSERT INTO t VALUES (1)")])),
|
|
69
|
+
).toMatch(/UNIQUE/);
|
|
68
70
|
expect(await DB.prepare("SELECT count(*) AS n FROM t").first("n")).toBe(0);
|
|
69
71
|
});
|
|
70
72
|
}
|
|
@@ -85,13 +87,23 @@ test("before 2026-10-24: synchronous, lenient, no foreign keys, BLOBs as one cha
|
|
|
85
87
|
|
|
86
88
|
test("from 2026-10-24: D1's parameter rules", async () => {
|
|
87
89
|
const DB = fresh("2026-10-24");
|
|
88
|
-
expect(caught(() => DB.prepare("SELECT ?1").bind(undefined))).toBe(
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
expect(caught(() => DB.prepare("SELECT ?1").bind(undefined))).toBe(
|
|
91
|
+
"D1_TYPE_ERROR: Type 'undefined' not supported for value 'undefined'",
|
|
92
|
+
);
|
|
93
|
+
expect(caught(() => DB.prepare("SELECT ?1").bind({ a: 1 }))).toBe(
|
|
94
|
+
"D1_TYPE_ERROR: Type 'object' not supported for value '[object Object]'",
|
|
95
|
+
);
|
|
96
|
+
expect(caught(() => DB.prepare("SELECT ?1").bind(10n))).toBe(
|
|
97
|
+
"D1_TYPE_ERROR: Type 'bigint' not supported for value '10'",
|
|
98
|
+
);
|
|
91
99
|
expect(await failure(() => DB.prepare("SELECT ?1, ?2").bind(1).all())).toBe(WRONG_COUNT);
|
|
92
100
|
expect(await failure(() => DB.prepare("SELECT ?1").bind(1, 2).all())).toBe(WRONG_COUNT);
|
|
93
101
|
expect(await failure(() => DB.prepare("SELECT ?1").all())).toBe(WRONG_COUNT);
|
|
94
|
-
expect(await DB.prepare("SELECT ?1 AS a, ?1 AS b, ?2 AS c").bind("x", true).first()).toEqual({
|
|
102
|
+
expect(await DB.prepare("SELECT ?1 AS a, ?1 AS b, ?2 AS c").bind("x", true).first()).toEqual({
|
|
103
|
+
a: "x",
|
|
104
|
+
b: "x",
|
|
105
|
+
c: 1,
|
|
106
|
+
});
|
|
95
107
|
});
|
|
96
108
|
|
|
97
109
|
test("from 2026-10-24: foreign keys, BLOBs as byte arrays, D1's error text", async () => {
|
|
@@ -104,18 +116,25 @@ test("from 2026-10-24: foreign keys, BLOBs as byte arrays, D1's error text", asy
|
|
|
104
116
|
expect(await failure(() => DB.prepare("INSERT INTO p VALUES (1)").run())).toBe(
|
|
105
117
|
"D1_ERROR: UNIQUE constraint failed: p.id: SQLITE_CONSTRAINT (extended: SQLITE_CONSTRAINT_PRIMARYKEY)",
|
|
106
118
|
);
|
|
107
|
-
expect(await failure(() => DB.prepare("SELECT * FROM nope").all())).toBe(
|
|
119
|
+
expect(await failure(() => DB.prepare("SELECT * FROM nope").all())).toBe(
|
|
120
|
+
"D1_ERROR: no such table: nope: SQLITE_ERROR",
|
|
121
|
+
);
|
|
108
122
|
expect(await DB.prepare("SELECT x'00ff' AS b").first("b")).toEqual([0, 255]);
|
|
109
123
|
});
|
|
110
124
|
|
|
111
125
|
test("from 2026-10-24: every query method returns a Promise", async () => {
|
|
112
126
|
const DB = fresh("2026-10-24");
|
|
113
127
|
const s = DB.prepare("SELECT 41 AS a");
|
|
114
|
-
for (const p of [s.all(), s.run(), s.first(), s.raw(), DB.batch([s]), DB.exec("SELECT 1"), DB.dump().catch(() => 0)])
|
|
128
|
+
for (const p of [s.all(), s.run(), s.first(), s.raw(), DB.batch([s]), DB.exec("SELECT 1"), DB.dump().catch(() => 0)])
|
|
129
|
+
expect(p).toBeInstanceOf(Promise);
|
|
115
130
|
expect(await s.first("a").then((a) => a + 1)).toBe(42);
|
|
116
131
|
expect(await Promise.all([s.first("a"), DB.prepare("SELECT 2 AS a").first("a")])).toEqual([41, 2]);
|
|
117
132
|
// Errors reject rather than throw: .catch() sees them.
|
|
118
|
-
expect(
|
|
133
|
+
expect(
|
|
134
|
+
await DB.prepare("SELEC 1")
|
|
135
|
+
.all()
|
|
136
|
+
.catch((e) => e.message),
|
|
137
|
+
).toMatch(/^D1_ERROR: near "SELEC": syntax error/);
|
|
119
138
|
});
|
|
120
139
|
|
|
121
140
|
test("from 2026-10-24: results, meta, raw() and first() as in workerd", async () => {
|
|
@@ -123,16 +142,42 @@ test("from 2026-10-24: results, meta, raw() and first() as in workerd", async ()
|
|
|
123
142
|
await DB.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)");
|
|
124
143
|
const inserted = await DB.prepare("INSERT INTO t (name) VALUES (?), (?)").bind("one", "two").run();
|
|
125
144
|
expect(Object.keys(inserted).sort()).toEqual(["meta", "results", "success"]);
|
|
126
|
-
expect(Object.keys(inserted.meta).sort()).toEqual([
|
|
127
|
-
|
|
145
|
+
expect(Object.keys(inserted.meta).sort()).toEqual([
|
|
146
|
+
"changed_db",
|
|
147
|
+
"changes",
|
|
148
|
+
"duration",
|
|
149
|
+
"last_row_id",
|
|
150
|
+
"rows_read",
|
|
151
|
+
"rows_written",
|
|
152
|
+
"served_by",
|
|
153
|
+
"size_after",
|
|
154
|
+
]);
|
|
155
|
+
expect([
|
|
156
|
+
inserted.meta.changes,
|
|
157
|
+
inserted.meta.last_row_id,
|
|
158
|
+
inserted.meta.changed_db,
|
|
159
|
+
inserted.meta.rows_written,
|
|
160
|
+
]).toEqual([2, 2, true, 2]);
|
|
128
161
|
const read = await DB.prepare("SELECT id, name FROM t ORDER BY id").all();
|
|
129
162
|
expect([read.meta.changes, read.meta.changed_db, read.meta.rows_written]).toEqual([0, false, 0]);
|
|
130
|
-
expect(await DB.prepare("SELECT id, name FROM t ORDER BY id").raw()).toEqual([
|
|
131
|
-
|
|
163
|
+
expect(await DB.prepare("SELECT id, name FROM t ORDER BY id").raw()).toEqual([
|
|
164
|
+
[1, "one"],
|
|
165
|
+
[2, "two"],
|
|
166
|
+
]);
|
|
167
|
+
expect(await DB.prepare("SELECT id, name FROM t ORDER BY id").raw({ columnNames: true })).toEqual([
|
|
168
|
+
["id", "name"],
|
|
169
|
+
[1, "one"],
|
|
170
|
+
[2, "two"],
|
|
171
|
+
]);
|
|
132
172
|
expect(await DB.prepare("SELECT id FROM t WHERE id > 9").raw({ columnNames: true })).toEqual([["id"]]);
|
|
133
|
-
expect(await DB.prepare("SELECT 1 AS a, 2 AS a").raw({ columnNames: true })).toEqual([
|
|
173
|
+
expect(await DB.prepare("SELECT 1 AS a, 2 AS a").raw({ columnNames: true })).toEqual([
|
|
174
|
+
["a", "a"],
|
|
175
|
+
[1, 2],
|
|
176
|
+
]);
|
|
134
177
|
expect((await DB.prepare("SELECT 1 AS a, 2 AS a").all()).results).toEqual([{ a: 2 }]);
|
|
135
|
-
expect(await failure(() => DB.prepare("SELECT id FROM t").first("nope"))).toBe(
|
|
178
|
+
expect(await failure(() => DB.prepare("SELECT id FROM t").first("nope"))).toBe(
|
|
179
|
+
"D1_COLUMN_NOTFOUND: Column not found (nope)",
|
|
180
|
+
);
|
|
136
181
|
expect(await DB.prepare("SELECT id FROM t WHERE id > 9").first("nope")).toBeNull();
|
|
137
182
|
});
|
|
138
183
|
|
|
@@ -140,15 +185,74 @@ test("from 2026-10-24: empty SQL, transactions, exec() and sessions as in worker
|
|
|
140
185
|
const DB = fresh("2026-10-24");
|
|
141
186
|
expect(await failure(() => DB.prepare("").all())).toBe(NO_STATEMENTS);
|
|
142
187
|
expect(await failure(() => DB.batch([]))).toBe(NO_STATEMENTS);
|
|
143
|
-
expect(await failure(() => DB.prepare("BEGIN TRANSACTION").run())).toMatch(
|
|
188
|
+
expect(await failure(() => DB.prepare("BEGIN TRANSACTION").run())).toMatch(
|
|
189
|
+
/^D1_ERROR: To execute a transaction, please use the state\.storage\.transaction\(\)/,
|
|
190
|
+
);
|
|
144
191
|
expect(await failure(() => DB.prepare("savepoint x").run())).toMatch(/^D1_ERROR: To execute a transaction/);
|
|
145
192
|
const done = await DB.exec("CREATE TABLE t (id INTEGER)\nINSERT INTO t VALUES (1);\n\nINSERT INTO t VALUES (2);\n");
|
|
146
193
|
expect([done.count, Number.isFinite(done.duration)]).toEqual([3, true]);
|
|
147
194
|
expect(await DB.exec("SELECT 1").then((r) => r.count)).toBe(1);
|
|
148
|
-
expect(await failure(() => DB.exec("INSERT INTO t VALUES (3);\nSELEC 2"))).toMatch(
|
|
195
|
+
expect(await failure(() => DB.exec("INSERT INTO t VALUES (3);\nSELEC 2"))).toMatch(
|
|
196
|
+
/^D1_EXEC_ERROR: Error in line 2: SELEC 2: near "SELEC": syntax error/,
|
|
197
|
+
);
|
|
149
198
|
expect(await DB.prepare("SELECT count(*) AS n FROM t").first("n")).toBe(2);
|
|
150
199
|
const session = DB.withSession("first-unconstrained");
|
|
151
200
|
expect(await session.prepare("SELECT count(*) AS n FROM t").first("n")).toBe(2);
|
|
152
201
|
expect(session.getBookmark()).toBeNull();
|
|
153
202
|
expect(await session.batch([session.prepare("SELECT 1 AS a")]).then((r) => r[0].results)).toEqual([{ a: 1 }]);
|
|
154
203
|
});
|
|
204
|
+
|
|
205
|
+
// __sbD1IsTransaction replaced `/^(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text)`.
|
|
206
|
+
test("a statement is a transaction keyword exactly as the regular expression it replaced said", () => {
|
|
207
|
+
// oxlint-disable-next-line no-function-constructor -- the prelude is prepended as text in production too
|
|
208
|
+
const { isTx } = new Function("Porffor", `${source}\nreturn { isTx: __sbD1IsTransaction };`)({ c: () => "" });
|
|
209
|
+
const old = (text) => /^(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text);
|
|
210
|
+
const cases = [
|
|
211
|
+
"BEGIN",
|
|
212
|
+
"begin",
|
|
213
|
+
"Begin TRANSACTION",
|
|
214
|
+
"begin;",
|
|
215
|
+
"BEGIN\n",
|
|
216
|
+
"commit",
|
|
217
|
+
"COMMIT;",
|
|
218
|
+
"end",
|
|
219
|
+
"END TRANSACTION",
|
|
220
|
+
"rollback to x",
|
|
221
|
+
"ROLLBACK",
|
|
222
|
+
"savepoint x",
|
|
223
|
+
"SAVEPOINT\tx",
|
|
224
|
+
"release x",
|
|
225
|
+
"RELEASE",
|
|
226
|
+
"beginning",
|
|
227
|
+
"begin_x",
|
|
228
|
+
"begin1",
|
|
229
|
+
"ending",
|
|
230
|
+
"endx",
|
|
231
|
+
"ender",
|
|
232
|
+
"committed",
|
|
233
|
+
"rollbacks",
|
|
234
|
+
"savepoints",
|
|
235
|
+
"released",
|
|
236
|
+
"select 1",
|
|
237
|
+
"SELECT begin FROM t",
|
|
238
|
+
"insert into t values (1)",
|
|
239
|
+
"be",
|
|
240
|
+
"en",
|
|
241
|
+
"e",
|
|
242
|
+
"",
|
|
243
|
+
"BEGINX",
|
|
244
|
+
"begin-",
|
|
245
|
+
"end(",
|
|
246
|
+
"end.",
|
|
247
|
+
"release@",
|
|
248
|
+
"commité",
|
|
249
|
+
"xbegin",
|
|
250
|
+
" begin",
|
|
251
|
+
"savepoint",
|
|
252
|
+
"savepointx",
|
|
253
|
+
"rollback_",
|
|
254
|
+
"ROLLBACKĀ",
|
|
255
|
+
"bégin",
|
|
256
|
+
];
|
|
257
|
+
for (const text of cases) expect([text, isTx(text)]).toEqual([text, old(text)]);
|
|
258
|
+
});
|