@sproutboat/runtime 0.34.5 → 0.35.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 +16 -0
- package/package.json +1 -1
- package/src/d1-shim.test.js +120 -16
- package/src/native-fetch-prelude.js +187 -80
- package/src/transport-embedded.js +128 -31
- 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,21 @@
|
|
|
1
1
|
# @sproutboat/runtime
|
|
2
2
|
|
|
3
|
+
## 0.35.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
`SB_SQLITE_CACHE_MB=<n>` (1 to 4096) sets each database's page cache in MiB; unset it stays at 2 MB.
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 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.
|
|
16
|
+
- 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.
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
3
19
|
## 0.34.5
|
|
4
20
|
|
|
5
21
|
### 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
|
+
});
|
|
@@ -3092,18 +3092,34 @@ __sbStartupMark();
|
|
|
3092
3092
|
// string via the same primitives as __sbEnv / __sbRandomBytes (proven working),
|
|
3093
3093
|
// then parsed — Porffor's number boxing for a bare inline-C assignment is not
|
|
3094
3094
|
// relied on. `__sbEntry` samples it around the handler for per-invocation CPU.
|
|
3095
|
+
// The number comes back as a number: no text and no parse around it.
|
|
3095
3096
|
function __sbCpuMs() {
|
|
3096
|
-
let
|
|
3097
|
+
let ms = 0;
|
|
3097
3098
|
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
3098
3099
|
Porffor.c`
|
|
3099
3100
|
struct timespec __ts;
|
|
3100
3101
|
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &__ts);
|
|
3101
|
-
|
|
3102
|
+
ms = (f64)((double)__ts.tv_sec * 1000.0 + (double)__ts.tv_nsec / 1000000.0);
|
|
3103
|
+
`;
|
|
3104
|
+
return ms;
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
// The `x-sb-cpu-ms` value for the CPU time since `t0`, as three decimals, floored at zero: one
|
|
3108
|
+
// clock read and one format call in C, where it was a read, a format, a parse and a toFixed.
|
|
3109
|
+
// oxlint-disable-next-line no-unused-vars -- `t0` is read inside the RawC block below, not by JS.
|
|
3110
|
+
function __sbCpuStamp(t0) {
|
|
3111
|
+
let res = "0.000";
|
|
3112
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
3113
|
+
Porffor.c`
|
|
3114
|
+
struct timespec __ts;
|
|
3115
|
+
clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &__ts);
|
|
3116
|
+
double __d = ((double)__ts.tv_sec * 1000.0 + (double)__ts.tv_nsec / 1000000.0) - (t0.type == 1 ? (double)t0.val : 0.0);
|
|
3117
|
+
if (!(__d >= 0)) __d = 0;
|
|
3102
3118
|
char __b[32];
|
|
3103
|
-
int __n = snprintf(__b, sizeof(__b), "%.3f",
|
|
3119
|
+
int __n = snprintf(__b, sizeof(__b), "%.3f", __d);
|
|
3104
3120
|
if (__n > 0) res = porf_box((f64)porf_native_fetch_alloc_bytestring(__b, (size_t)__n), 195);
|
|
3105
3121
|
`;
|
|
3106
|
-
return res
|
|
3122
|
+
return res;
|
|
3107
3123
|
}
|
|
3108
3124
|
|
|
3109
3125
|
// #28 — stamp `x-sb-cpu-ms` onto a handler Response. Porffor alpha-10's
|
|
@@ -3114,7 +3130,6 @@ function __sbCpuMs() {
|
|
|
3114
3130
|
// http-sync-v0) with no Set-Cookie to comma-fold; anything else is returned
|
|
3115
3131
|
// untouched and the edge simply omits cpuMs for that request.
|
|
3116
3132
|
function __sbTagCpu(res, t0) {
|
|
3117
|
-
const cpu = __sbCpuMs() - t0;
|
|
3118
3133
|
try {
|
|
3119
3134
|
// A string body and no Set-Cookie, as before. The native writer reads Headers' flat `_entries`,
|
|
3120
3135
|
// so the stamp is appended to the handler's own Response instead of building a second one (a
|
|
@@ -3122,7 +3137,7 @@ function __sbTagCpu(res, t0) {
|
|
|
3122
3137
|
// the handler set itself is replaced by the measured one, as before.
|
|
3123
3138
|
const headers = res && res.headers;
|
|
3124
3139
|
if (headers && __sbIsStr(__sbRawBody(res)) && !headers.has("set-cookie")) {
|
|
3125
|
-
const stamp = (
|
|
3140
|
+
const stamp = __sbCpuStamp(t0);
|
|
3126
3141
|
const entries = headers._entries;
|
|
3127
3142
|
let set = false;
|
|
3128
3143
|
for (let i = 0; i < entries.length; i += 2) {
|
|
@@ -5295,6 +5310,11 @@ function __sbD1Param(value, strict) {
|
|
|
5295
5310
|
// 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
5311
|
// oxlint-disable-next-line no-unassigned-vars -- the embedded transport assigns it; the broker transport does not.
|
|
5297
5312
|
var __sbCallDirect;
|
|
5313
|
+
// The embedded transport also answers a strict D1 query without the generic dispatch around it
|
|
5314
|
+
// (a request object, a walk down the op list, a result object): (db, sql, params) -> the reply
|
|
5315
|
+
// __sbD1Run builds. Unset elsewhere, and the query goes through __sbD1Rpc as before.
|
|
5316
|
+
// oxlint-disable-next-line no-unassigned-vars -- the embedded transport assigns it; the broker transport does not.
|
|
5317
|
+
var __sbD1QueryDirect;
|
|
5298
5318
|
function __sbRpcDirect(op, extra) {
|
|
5299
5319
|
if (!__sbCallDirect) return __sbRpc(op, extra);
|
|
5300
5320
|
const req = { v: 1, id: ++__sbReqId, op };
|
|
@@ -5355,10 +5375,30 @@ function __sbD1Fail(message) {
|
|
|
5355
5375
|
// transaction statements are refused, exec() runs line by line, and withSession() exists.
|
|
5356
5376
|
var __sbD1NoTransactions =
|
|
5357
5377
|
"D1_ERROR: To execute a transaction, please use the state.storage.transaction() or state.storage.transactionSync() APIs instead of the SQL BEGIN TRANSACTION or SAVEPOINT statements. The JavaScript API is safer because it will automatically roll back on exceptions, and because it interacts correctly with Durable Objects' automatic atomic write coalescing.";
|
|
5378
|
+
// A statement's first word is one of the transaction keywords: read by character code, where the
|
|
5379
|
+
// regular expression this replaced was compiled on every query. The word ends at the first character
|
|
5380
|
+
// that is not a letter, digit or underscore (the regular expression's \b after a keyword).
|
|
5381
|
+
function __sbD1IsTransaction(text) {
|
|
5382
|
+
let end = 0;
|
|
5383
|
+
while (end < text.length && end < 10) {
|
|
5384
|
+
const c = text.charCodeAt(end) | 32;
|
|
5385
|
+
if (c < 97 || c > 122) break;
|
|
5386
|
+
end++;
|
|
5387
|
+
}
|
|
5388
|
+
if (end < 3 || end > 9) return false;
|
|
5389
|
+
if (end < text.length) {
|
|
5390
|
+
const c = text.charCodeAt(end);
|
|
5391
|
+
if ((c >= 48 && c <= 57) || (c >= 65 && c <= 90) || c === 95 || (c >= 97 && c <= 122)) return false;
|
|
5392
|
+
}
|
|
5393
|
+
const word = text.slice(0, end).toLowerCase();
|
|
5394
|
+
return (
|
|
5395
|
+
word === "begin" || word === "commit" || word === "end" || word === "rollback" || word === "savepoint" || word === "release"
|
|
5396
|
+
);
|
|
5397
|
+
}
|
|
5358
5398
|
function __sbD1Check(sql) {
|
|
5359
5399
|
const text = String(sql).trim();
|
|
5360
5400
|
if (!text) throw __sbD1Fail("D1_ERROR: No SQL statements detected.");
|
|
5361
|
-
if (
|
|
5401
|
+
if (__sbD1IsTransaction(text)) throw __sbD1Fail(__sbD1NoTransactions);
|
|
5362
5402
|
}
|
|
5363
5403
|
function __sbD1Meta(meta, started) {
|
|
5364
5404
|
const changes = Number(meta.changes) || 0;
|
|
@@ -5394,70 +5434,91 @@ function __sbD1Promise(run) {
|
|
|
5394
5434
|
return Promise.reject(err);
|
|
5395
5435
|
}
|
|
5396
5436
|
}
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
const
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
|
-
|
|
5416
|
-
|
|
5417
|
-
|
|
5418
|
-
|
|
5419
|
-
|
|
5420
|
-
|
|
5421
|
-
|
|
5422
|
-
|
|
5423
|
-
|
|
5424
|
-
|
|
5425
|
-
|
|
5426
|
-
},
|
|
5427
|
-
raw(options) {
|
|
5428
|
-
if (!__sbD1Strict()) {
|
|
5429
|
-
const rows = query(sql, params, false).results;
|
|
5430
|
-
const out = [];
|
|
5431
|
-
for (let i = 0; i < rows.length; i++) {
|
|
5432
|
-
const cols = [];
|
|
5433
|
-
for (const k in rows[i]) cols.push(rows[i][k]);
|
|
5434
|
-
out.push(cols);
|
|
5435
|
-
}
|
|
5436
|
-
return out;
|
|
5437
|
-
}
|
|
5438
|
-
return __sbD1Promise(() => {
|
|
5439
|
-
const t = query(sql, params, true);
|
|
5440
|
-
return options && options.columnNames ? [t.columns].concat(t.rows) : t.rows;
|
|
5441
|
-
});
|
|
5442
|
-
},
|
|
5443
|
-
first(column) {
|
|
5444
|
-
if (!__sbD1Strict()) {
|
|
5445
|
-
const rows = query(sql, params, false).results;
|
|
5446
|
-
if (rows.length === 0) return null;
|
|
5447
|
-
return column == null ? rows[0] : rows[0][column];
|
|
5448
|
-
}
|
|
5449
|
-
return __sbD1Promise(() => {
|
|
5450
|
-
const t = query(sql, params, true);
|
|
5451
|
-
if (column == null) return t.rows.length ? __sbD1Objects(t.columns, [t.rows[0]])[0] : null;
|
|
5452
|
-
const at = t.columns.indexOf(String(column));
|
|
5453
|
-
if (!t.rows.length) return null;
|
|
5454
|
-
if (at < 0) throw __sbD1Fail("D1_COLUMN_NOTFOUND: Column not found (" + column + ")");
|
|
5455
|
-
return t.rows[0][t.columns.lastIndexOf(String(column))];
|
|
5456
|
-
});
|
|
5457
|
-
},
|
|
5458
|
-
};
|
|
5459
|
-
return s;
|
|
5437
|
+
// A strict query's reply from the embedded transport's direct lane, or through the generic
|
|
5438
|
+
// dispatch. The errors read the same either way: "D1_ERROR: ..." for SQLite's, and the
|
|
5439
|
+
// transport's own prefix for anything else.
|
|
5440
|
+
function __sbD1Direct(name, sql, params) {
|
|
5441
|
+
try {
|
|
5442
|
+
return __sbD1QueryDirect(name, sql, params);
|
|
5443
|
+
} catch (err) {
|
|
5444
|
+
const message = "sproutboat d1.query: " + (String((err && err.message) || err) || "failed");
|
|
5445
|
+
const at = message.indexOf("D1_");
|
|
5446
|
+
throw new Error(at > 0 ? message.slice(at) : message);
|
|
5447
|
+
}
|
|
5448
|
+
}
|
|
5449
|
+
// The synchronous core: the answer to one statement as columns, rows and meta. meta is only built
|
|
5450
|
+
// when the caller will hand it out (all() and run()): raw() and first() drop it, and building it
|
|
5451
|
+
// reads the clock twice.
|
|
5452
|
+
function __sbD1Query(dbName, sql, params, strict, wantMeta) {
|
|
5453
|
+
if (strict) __sbD1Check(sql);
|
|
5454
|
+
const started = !strict || wantMeta ? Date.now() : 0;
|
|
5455
|
+
const r = strict && __sbD1QueryDirect ? __sbD1Direct(dbName, sql, params) : __sbD1Rpc("d1.query", { db: dbName, sql, params }, strict);
|
|
5456
|
+
if (!strict) return { results: r.results || [], success: true, meta: r.meta || {} };
|
|
5457
|
+
const table = __sbD1Table(r);
|
|
5458
|
+
return { columns: table.columns, rows: table.rows, meta: wantMeta ? __sbD1Meta(r.meta || {}, started) : null };
|
|
5459
|
+
}
|
|
5460
|
+
// A prepared statement. A class, so a prepare() or bind() does not allocate five closures.
|
|
5461
|
+
class __SbD1Statement {
|
|
5462
|
+
constructor(dbName, sql, params) {
|
|
5463
|
+
this.__name = dbName;
|
|
5464
|
+
this.__sql = sql;
|
|
5465
|
+
this.__params = params;
|
|
5460
5466
|
}
|
|
5467
|
+
bind() {
|
|
5468
|
+
const strict = __sbD1Strict();
|
|
5469
|
+
const list = [];
|
|
5470
|
+
for (let i = 0; i < arguments.length; i++) list.push(__sbD1Param(arguments[i], strict));
|
|
5471
|
+
return new __SbD1Statement(this.__name, this.__sql, list);
|
|
5472
|
+
}
|
|
5473
|
+
all() {
|
|
5474
|
+
if (!__sbD1Strict()) return __sbD1Query(this.__name, this.__sql, this.__params, false, true);
|
|
5475
|
+
try {
|
|
5476
|
+
const t = __sbD1Query(this.__name, this.__sql, this.__params, true, true);
|
|
5477
|
+
return Promise.resolve({ success: true, meta: t.meta, results: __sbD1Objects(t.columns, t.rows) });
|
|
5478
|
+
} catch (err) {
|
|
5479
|
+
return Promise.reject(err);
|
|
5480
|
+
}
|
|
5481
|
+
}
|
|
5482
|
+
run() {
|
|
5483
|
+
return this.all();
|
|
5484
|
+
}
|
|
5485
|
+
raw(options) {
|
|
5486
|
+
if (!__sbD1Strict()) {
|
|
5487
|
+
const rows = __sbD1Query(this.__name, this.__sql, this.__params, false, true).results;
|
|
5488
|
+
const out = [];
|
|
5489
|
+
for (let i = 0; i < rows.length; i++) {
|
|
5490
|
+
const cols = [];
|
|
5491
|
+
for (const k in rows[i]) cols.push(rows[i][k]);
|
|
5492
|
+
out.push(cols);
|
|
5493
|
+
}
|
|
5494
|
+
return out;
|
|
5495
|
+
}
|
|
5496
|
+
try {
|
|
5497
|
+
const t = __sbD1Query(this.__name, this.__sql, this.__params, true, false);
|
|
5498
|
+
return Promise.resolve(options && options.columnNames ? [t.columns].concat(t.rows) : t.rows);
|
|
5499
|
+
} catch (err) {
|
|
5500
|
+
return Promise.reject(err);
|
|
5501
|
+
}
|
|
5502
|
+
}
|
|
5503
|
+
first(column) {
|
|
5504
|
+
if (!__sbD1Strict()) {
|
|
5505
|
+
const rows = __sbD1Query(this.__name, this.__sql, this.__params, false, true).results;
|
|
5506
|
+
if (rows.length === 0) return null;
|
|
5507
|
+
return column == null ? rows[0] : rows[0][column];
|
|
5508
|
+
}
|
|
5509
|
+
try {
|
|
5510
|
+
const t = __sbD1Query(this.__name, this.__sql, this.__params, true, false);
|
|
5511
|
+
if (column == null) return Promise.resolve(t.rows.length ? __sbD1Objects(t.columns, [t.rows[0]])[0] : null);
|
|
5512
|
+
const at = t.columns.indexOf(String(column));
|
|
5513
|
+
if (!t.rows.length) return Promise.resolve(null);
|
|
5514
|
+
if (at < 0) throw __sbD1Fail("D1_COLUMN_NOTFOUND: Column not found (" + column + ")");
|
|
5515
|
+
return Promise.resolve(t.rows[0][t.columns.lastIndexOf(String(column))]);
|
|
5516
|
+
} catch (err) {
|
|
5517
|
+
return Promise.reject(err);
|
|
5518
|
+
}
|
|
5519
|
+
}
|
|
5520
|
+
}
|
|
5521
|
+
function __sbMakeD1(dbName) {
|
|
5461
5522
|
function batch(statements) {
|
|
5462
5523
|
const strict = __sbD1Strict();
|
|
5463
5524
|
const list = [];
|
|
@@ -5495,7 +5556,7 @@ function __sbMakeD1(dbName) {
|
|
|
5495
5556
|
}
|
|
5496
5557
|
const db = {
|
|
5497
5558
|
prepare(sql) {
|
|
5498
|
-
return
|
|
5559
|
+
return new __SbD1Statement(dbName, String(sql), []);
|
|
5499
5560
|
},
|
|
5500
5561
|
batch(statements) {
|
|
5501
5562
|
if (!__sbD1Strict()) return batch(statements);
|
|
@@ -9111,7 +9172,7 @@ async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top) {
|
|
|
9111
9172
|
} catch (error) {
|
|
9112
9173
|
// A Durable Object's stub.fetch() from 2026-10-29: the caller gets the exception.
|
|
9113
9174
|
if (rethrow) {
|
|
9114
|
-
__sbDrainWaitUntil(tasks);
|
|
9175
|
+
if (tasks.length) __sbDrainWaitUntil(tasks);
|
|
9115
9176
|
throw error;
|
|
9116
9177
|
}
|
|
9117
9178
|
// #179 — a rejected handler promise must not take the process down with it.
|
|
@@ -9119,8 +9180,8 @@ async function __sbFetchWithDrain(res, tasks, bindingCall, rethrow, top) {
|
|
|
9119
9180
|
}
|
|
9120
9181
|
// The response goes out now and ctx.waitUntil work keeps running after it, as in
|
|
9121
9182
|
// workerd; it was awaited first, so a 1.5 s background task delayed the response by
|
|
9122
|
-
// 1.5 s. The 25 s cap still bounds it.
|
|
9123
|
-
__sbDrainWaitUntil(tasks);
|
|
9183
|
+
// 1.5 s. The 25 s cap still bounds it. Not called for no tasks: an async call is a coroutine and a promise.
|
|
9184
|
+
if (tasks.length) __sbDrainWaitUntil(tasks);
|
|
9124
9185
|
return r;
|
|
9125
9186
|
}
|
|
9126
9187
|
|
|
@@ -9151,7 +9212,7 @@ function __sbErrorResponse(error, bindingCall) {
|
|
|
9151
9212
|
// sees it. Returns whether the edge vouched for this one.
|
|
9152
9213
|
function __sbBindingCallIngress(request) {
|
|
9153
9214
|
const headers = request.headers;
|
|
9154
|
-
const want =
|
|
9215
|
+
const want = __sbEdgeToken();
|
|
9155
9216
|
const vouched = !!want && headers.get("x-sb-binding-call") === "1" && __sbEqCt(headers.get("x-sb-edge-token") || "", want);
|
|
9156
9217
|
if (vouched) {
|
|
9157
9218
|
const url = headers.get("x-sb-url");
|
|
@@ -9159,12 +9220,46 @@ function __sbBindingCallIngress(request) {
|
|
|
9159
9220
|
if (url && URL.canParse(url) && __sbCompatDate() >= "2026-10-22") request.url = url;
|
|
9160
9221
|
headers.delete("x-sb-remote-addr");
|
|
9161
9222
|
}
|
|
9162
|
-
|
|
9163
|
-
|
|
9223
|
+
// Nearly every request carries none of the three: one look at the stored names tells, where
|
|
9224
|
+
// three lookups (each validating and lowercasing its name) and an array of them did.
|
|
9225
|
+
const stored = headers._entries;
|
|
9226
|
+
let present = stored === undefined;
|
|
9227
|
+
for (let i = 0; !present && i < stored.length; i += 2) {
|
|
9228
|
+
const key = stored[i];
|
|
9229
|
+
if (key === "x-sb-binding-call" || key === "x-sb-url" || key === "x-sb-edge-token") present = true;
|
|
9230
|
+
}
|
|
9231
|
+
if (present) {
|
|
9232
|
+
for (const name of ["x-sb-binding-call", "x-sb-url", "x-sb-edge-token"]) {
|
|
9233
|
+
if (headers.get(name) != null) headers.delete(name);
|
|
9234
|
+
}
|
|
9164
9235
|
}
|
|
9165
9236
|
return vouched;
|
|
9166
9237
|
}
|
|
9167
9238
|
|
|
9239
|
+
// SB_EDGE_TOKEN is fixed for the life of a process, like SB_TRUSTED_PROXIES above: read once.
|
|
9240
|
+
let __sbEdgeTokenCache;
|
|
9241
|
+
function __sbEdgeToken() {
|
|
9242
|
+
if (__sbEdgeTokenCache === undefined) __sbEdgeTokenCache = __sbEnv("SB_EDGE_TOKEN");
|
|
9243
|
+
return __sbEdgeTokenCache;
|
|
9244
|
+
}
|
|
9245
|
+
|
|
9246
|
+
// One header of a request the native server built, by a name that is already lowercase and valid:
|
|
9247
|
+
// its stored names are scanned directly, where get() validates and lowercases the name first. A
|
|
9248
|
+
// name stored twice, or a headers object that is not the runtime's, goes through get().
|
|
9249
|
+
function __sbHeader(headers, name) {
|
|
9250
|
+
const stored = headers._entries;
|
|
9251
|
+
if (stored === undefined) return headers.get(name);
|
|
9252
|
+
let found = null;
|
|
9253
|
+
let count = 0;
|
|
9254
|
+
for (let i = 0; i < stored.length; i += 2) {
|
|
9255
|
+
if (stored[i] === name) {
|
|
9256
|
+
found = stored[i + 1];
|
|
9257
|
+
count++;
|
|
9258
|
+
}
|
|
9259
|
+
}
|
|
9260
|
+
return count > 1 ? headers.get(name) : found;
|
|
9261
|
+
}
|
|
9262
|
+
|
|
9168
9263
|
function __sbLogUncaught(where, error) {
|
|
9169
9264
|
try {
|
|
9170
9265
|
let text = "";
|
|
@@ -9328,12 +9423,24 @@ function __sbIngressStream(request, id) {
|
|
|
9328
9423
|
request.bytes = async () => await all();
|
|
9329
9424
|
}
|
|
9330
9425
|
|
|
9426
|
+
// The id of the current request's streamed body, 0 when it has none (op "t" of __sbReqBodyOp),
|
|
9427
|
+
// as a number: every request asks, and the text round trip was a format and a parse.
|
|
9428
|
+
function __sbReqBodyTake() {
|
|
9429
|
+
let id = 0;
|
|
9430
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
9431
|
+
Porffor.c`
|
|
9432
|
+
extern unsigned int sb_reqbody_take_current(void);
|
|
9433
|
+
id = (f64)sb_reqbody_take_current();
|
|
9434
|
+
`;
|
|
9435
|
+
return id;
|
|
9436
|
+
}
|
|
9437
|
+
|
|
9331
9438
|
// Native HTTP ingress stores the body as one character per raw byte. Keep
|
|
9332
9439
|
// that representation for binary consumers, but decode text lazily before
|
|
9333
9440
|
// handing the request to user code. Request.text() in Porffor otherwise treats
|
|
9334
9441
|
// the raw bytes as already-decoded characters and corrupts Unicode/HMAC input.
|
|
9335
9442
|
function __sbDecodeIngress(request) {
|
|
9336
|
-
const streamed = __sbCompatDate() >= "2026-10-27" ?
|
|
9443
|
+
const streamed = __sbCompatDate() >= "2026-10-27" ? __sbReqBodyTake() : 0;
|
|
9337
9444
|
if (streamed > 0) {
|
|
9338
9445
|
__sbIngressStream(request, streamed);
|
|
9339
9446
|
return;
|
|
@@ -9369,7 +9476,7 @@ function __sbTrustedProxiesOnce() {
|
|
|
9369
9476
|
|
|
9370
9477
|
globalThis.__sbEntry = function (handlers, request) {
|
|
9371
9478
|
__sbDecodeIngress(request);
|
|
9372
|
-
const trigger = request.headers
|
|
9479
|
+
const trigger = __sbHeader(request.headers, "x-sb-trigger");
|
|
9373
9480
|
if (!trigger) {
|
|
9374
9481
|
const bindingCall = __sbBindingCallIngress(request);
|
|
9375
9482
|
// #163/#128 — expose the request metadata the edge already has, the
|
|
@@ -9379,7 +9486,7 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
9379
9486
|
const __cf = request.cf || {};
|
|
9380
9487
|
if (__sbTrustedProxiesOnce().length === 0) {
|
|
9381
9488
|
// No trusted proxy (the usual case): the peer is the client and no forwarded header counts.
|
|
9382
|
-
__cf.clientIp = __sbNormalizeIp(request.headers
|
|
9489
|
+
__cf.clientIp = __sbNormalizeIp(__sbHeader(request.headers, "x-sb-remote-addr") || "");
|
|
9383
9490
|
} else {
|
|
9384
9491
|
__cf.clientIp = __sbClientIp(request);
|
|
9385
9492
|
const __httpProtocol = __sbTrustedProxyHeader(request, "x-sb-http-protocol");
|
|
@@ -1195,6 +1195,17 @@ static unsigned long sb_db_tick = 0;
|
|
|
1195
1195
|
static int sb_is_do_sql(const char* path) { return strstr(path, "/do-sql/") != 0; }
|
|
1196
1196
|
static void sb_db_evict(int idx);
|
|
1197
1197
|
|
|
1198
|
+
// SQLite allocates 20 pages in one block for every page cache it creates, and a statement that
|
|
1199
|
+
// builds a temporary table (INSERT ... RETURNING, a sort) creates one: an 88 KiB mmap and munmap
|
|
1200
|
+
// per such statement. A page count of 0 turns the bulk allocation off; the cache then takes
|
|
1201
|
+
// its pages one at a time, as it always does past the first 20. Must run before SQLite starts.
|
|
1202
|
+
extern int sqlite3_config(int, ...);
|
|
1203
|
+
static int sb_sqlite_booted = 0;
|
|
1204
|
+
static void sb_sqlite_boot(void) {
|
|
1205
|
+
if (sb_sqlite_booted) return;
|
|
1206
|
+
sb_sqlite_booted = 1;
|
|
1207
|
+
sqlite3_config(7 /* SQLITE_CONFIG_PAGECACHE */, (void*)0, 0, 0);
|
|
1208
|
+
}
|
|
1198
1209
|
static int sb_db_for(const char* path) {
|
|
1199
1210
|
for (int i = 0; i < sb_db_count; i++) {
|
|
1200
1211
|
if (sb_dbs[i] && strcmp(sb_db_names[i], path) == 0) {
|
|
@@ -1202,6 +1213,7 @@ static int sb_db_for(const char* path) {
|
|
|
1202
1213
|
return sb_ltx_states[i].failed ? -1 : i;
|
|
1203
1214
|
}
|
|
1204
1215
|
}
|
|
1216
|
+
sb_sqlite_boot();
|
|
1205
1217
|
const int do_sql = sb_is_do_sql(path);
|
|
1206
1218
|
// A name that would not fit could never be found again.
|
|
1207
1219
|
if (do_sql && strlen(path) >= sizeof(sb_db_names[0])) return -1;
|
|
@@ -1240,9 +1252,30 @@ static int sb_db_for(const char* path) {
|
|
|
1240
1252
|
// copies of the binary behind SO_REUSEPORT (#154 scaling recipe): WAL takes
|
|
1241
1253
|
// concurrent readers plus one writer across processes, and this makes a
|
|
1242
1254
|
// writer wait for the lock instead of failing SQLITE_BUSY.
|
|
1255
|
+
// SB_SQLITE_EXCLUSIVE=1 holds the database's lock for the life of the process: no fcntl lock
|
|
1256
|
+
// and unlock around each transaction, and the WAL index lives in heap memory instead of a shared
|
|
1257
|
+
// -shm file. Set before journal_mode, because the heap index is only chosen when WAL starts in
|
|
1258
|
+
// exclusive mode. No other process can open the database while the binary runs, which also rules
|
|
1259
|
+
// out the several-copies-behind-SO_REUSEPORT recipe above. Off unless set (see below), and never for a
|
|
1260
|
+
// database under LTX replication: capture reads the WAL through its shared index, and in
|
|
1261
|
+
// exclusive mode (no -shm file) every capture failed (native-d1's LTX database).
|
|
1262
|
+
// The transport itself only reads the variable, so a host that embeds it (sproutd, the broker) stays
|
|
1263
|
+
// in shared mode unless it is set. A standalone binary sets it to 1 at startup (patch.ts, the shim's
|
|
1264
|
+
// constructor) unless the operator has set it, so SB_SQLITE_EXCLUSIVE=0 opts a standalone binary out.
|
|
1265
|
+
// SB_SQLITE_CACHE_MB sizes each database's page cache in MiB (default 2, about -2000 KiB).
|
|
1266
|
+
{
|
|
1267
|
+
const char* excl = getenv("SB_SQLITE_EXCLUSIVE");
|
|
1268
|
+
if (excl && excl[0] == '1' && !excl[1] && !replicated) sqlite3_exec(db, "PRAGMA locking_mode=EXCLUSIVE", 0, 0, 0);
|
|
1269
|
+
char cache[64];
|
|
1270
|
+
const char* mb = getenv("SB_SQLITE_CACHE_MB");
|
|
1271
|
+
long mib = mb && *mb ? strtol(mb, 0, 10) : 0;
|
|
1272
|
+
if (mib >= 1 && mib <= 4096) snprintf(cache, sizeof(cache), "PRAGMA cache_size=-%ld", mib * 1024);
|
|
1273
|
+
else snprintf(cache, sizeof(cache), "PRAGMA cache_size=-2000");
|
|
1274
|
+
sqlite3_exec(db, cache, 0, 0, 0);
|
|
1275
|
+
}
|
|
1243
1276
|
sqlite3_exec(db,
|
|
1244
1277
|
"PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000;"
|
|
1245
|
-
"PRAGMA
|
|
1278
|
+
"PRAGMA journal_size_limit=16777216;",
|
|
1246
1279
|
0, 0, 0);
|
|
1247
1280
|
// Durable Object SQLite enforces foreign keys, as in workerd.
|
|
1248
1281
|
if (do_sql) sqlite3_exec(db, "PRAGMA foreign_keys=ON", 0, 0, 0);
|
|
@@ -2165,7 +2198,9 @@ static char* sb_sql_error(sqlite3* db, const char* message, int code, int skip)
|
|
|
2165
2198
|
// The text and blob bytes of every cell share one arena (a cell holds an offset into it), and a
|
|
2166
2199
|
// TEXT cell says whether it is all ASCII: that one becomes a JS string with a single copy.
|
|
2167
2200
|
typedef struct { unsigned char t; unsigned char ascii; double d; size_t off; int n; } sb_cell;
|
|
2168
|
-
|
|
2201
|
+
// hdr: how many of the first cells are the column names, not row data; changes and rowid are the
|
|
2202
|
+
// statement's counts, so a native reply needs no JSON header at all.
|
|
2203
|
+
typedef struct { sb_cell* c; size_t n; size_t cap; int ncol; int used; char* a; size_t alen; size_t acap; int hdr; long long changes; long long rowid; } sb_cells;
|
|
2169
2204
|
static void sb_cells_free(sb_cells* cs) {
|
|
2170
2205
|
if (!cs) return;
|
|
2171
2206
|
free(cs->c);
|
|
@@ -2222,17 +2257,30 @@ static char* sb_sql_run_x(const char* path, const char* sql, const char* params,
|
|
|
2222
2257
|
return sb_sql_error(db, "Wrong number of parameter bindings for SQL query.", 0, 0);
|
|
2223
2258
|
}
|
|
2224
2259
|
|
|
2225
|
-
sb_puts(&b, "{\"ok\":true,\"cols\":[");
|
|
2226
2260
|
int ncol = sqlite3_column_count(st);
|
|
2227
|
-
for
|
|
2228
|
-
|
|
2229
|
-
const char* name = sqlite3_column_name(st, i);
|
|
2230
|
-
sb_putjson_text(&b, name ? name : "", name ? strlen(name) : 0);
|
|
2231
|
-
}
|
|
2232
|
-
sb_puts(&b, "],\"rows\":[");
|
|
2233
|
-
// Cells only for a strict D1 statement: its BLOBs are byte arrays either way.
|
|
2261
|
+
// Cells only for a strict D1 statement: its BLOBs are byte arrays either way. The column names
|
|
2262
|
+
// are its first cells and the reply is no JSON text: success returns NULL, with the counts in cells.
|
|
2234
2263
|
const int native = cells && strict && d1;
|
|
2235
|
-
if (native) {
|
|
2264
|
+
if (native) {
|
|
2265
|
+
cells->ncol = ncol; cells->used = 1; cells->hdr = ncol;
|
|
2266
|
+
for (int i = 0; i < ncol && cells->used > 0; i++) {
|
|
2267
|
+
sb_cell* cell = sb_cells_add(cells);
|
|
2268
|
+
if (!cell) { cells->used = -1; break; }
|
|
2269
|
+
const char* name = sqlite3_column_name(st, i);
|
|
2270
|
+
cell->t = 2;
|
|
2271
|
+
cell->n = name ? (int)strlen(name) : 0;
|
|
2272
|
+
cell->off = sb_cells_bytes(cells, (const unsigned char*)(name ? name : ""), (size_t)cell->n, &cell->ascii);
|
|
2273
|
+
if (cell->off == (size_t)-1) { cells->used = -1; break; }
|
|
2274
|
+
}
|
|
2275
|
+
} else {
|
|
2276
|
+
sb_puts(&b, "{\"ok\":true,\"cols\":[");
|
|
2277
|
+
for (int i = 0; i < ncol; i++) {
|
|
2278
|
+
if (i) sb_puts(&b, ",");
|
|
2279
|
+
const char* name = sqlite3_column_name(st, i);
|
|
2280
|
+
sb_putjson_text(&b, name ? name : "", name ? strlen(name) : 0);
|
|
2281
|
+
}
|
|
2282
|
+
sb_puts(&b, "],\"rows\":[");
|
|
2283
|
+
}
|
|
2236
2284
|
int rc, first = 1;
|
|
2237
2285
|
while ((rc = sqlite3_step(st)) == SB_SQLITE_ROW) {
|
|
2238
2286
|
if (native) {
|
|
@@ -2280,10 +2328,15 @@ static char* sb_sql_run_x(const char* path, const char* sql, const char* params,
|
|
|
2280
2328
|
}
|
|
2281
2329
|
sb_puts(&b, "]");
|
|
2282
2330
|
}
|
|
2283
|
-
char tail[96];
|
|
2284
2331
|
// A read-only statement changed nothing: sqlite3_changes still holds the last write's count.
|
|
2285
|
-
|
|
2286
|
-
|
|
2332
|
+
if (native) {
|
|
2333
|
+
cells->changes = sqlite3_stmt_readonly(st) ? 0 : sqlite3_changes(db);
|
|
2334
|
+
cells->rowid = (long long)sqlite3_last_insert_rowid(db);
|
|
2335
|
+
} else {
|
|
2336
|
+
char tail[96];
|
|
2337
|
+
int k = snprintf(tail, 96, "],\"changes\":%d,\"rowid\":%lld}", sqlite3_stmt_readonly(st) ? 0 : sqlite3_changes(db), (long long)sqlite3_last_insert_rowid(db));
|
|
2338
|
+
sb_put(&b, tail, (size_t)k);
|
|
2339
|
+
}
|
|
2287
2340
|
// Reset, don't finalize: the statement stays compiled in sb_stmt_cache. Reset
|
|
2288
2341
|
// now (not just on next reuse) so a SELECT stops holding its read transaction.
|
|
2289
2342
|
sqlite3_reset(st);
|
|
@@ -4029,6 +4082,22 @@ function __sbDirBytesRaw(dir) {
|
|
|
4029
4082
|
return res;
|
|
4030
4083
|
}
|
|
4031
4084
|
|
|
4085
|
+
// "captured" of __sbD1DbRaw as a number: no text made in C and parsed here, on every D1 query.
|
|
4086
|
+
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
|
|
4087
|
+
function __sbD1Captured_(path) {
|
|
4088
|
+
let status = -1;
|
|
4089
|
+
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
4090
|
+
Porffor.c`
|
|
4091
|
+
const char* __p; size_t __pl; char* __po = 0;
|
|
4092
|
+
porf_native_fetch_read_value(path, &__p, &__pl, &__po);
|
|
4093
|
+
char* __path = (char*)malloc(__pl + 1); memcpy(__path, __p, __pl); __path[__pl] = 0;
|
|
4094
|
+
if (__po) free(__po);
|
|
4095
|
+
status = (f64)sb_db_quota_freeze(__path, -1);
|
|
4096
|
+
free(__path);
|
|
4097
|
+
`;
|
|
4098
|
+
return status;
|
|
4099
|
+
}
|
|
4100
|
+
|
|
4032
4101
|
// sb_db_page_size and sb_db_quota_freeze, for the quota (a path and a "0"/"1" in, a number out).
|
|
4033
4102
|
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
|
|
4034
4103
|
function __sbD1DbRaw(path, what) {
|
|
@@ -4235,10 +4304,10 @@ function __sbSqlRaw(path, sql, paramsJson) {
|
|
|
4235
4304
|
return res;
|
|
4236
4305
|
}
|
|
4237
4306
|
|
|
4238
|
-
// One strict D1 statement: [header
|
|
4239
|
-
//
|
|
4240
|
-
//
|
|
4241
|
-
//
|
|
4307
|
+
// One strict D1 statement: [header, rows, columns, changes, rowid]. A result has the empty string
|
|
4308
|
+
// for its header, rows an array of row arrays built here from the cells (BLOBs as byte arrays),
|
|
4309
|
+
// columns the array of names, and changes and rowid numbers: no JSON is written or parsed. Any
|
|
4310
|
+
// other reply is the error JSON in the header, with nothing after it.
|
|
4242
4311
|
// oxlint-disable-next-line no-unused-vars -- read inside the RawC block below, not by JS.
|
|
4243
4312
|
function __sbSqlRowsRaw(path, sql, paramsJson) {
|
|
4244
4313
|
let res = "";
|
|
@@ -4262,21 +4331,28 @@ function __sbSqlRowsRaw(path, sql, paramsJson) {
|
|
|
4262
4331
|
sb_cells __cells = { 0, 0, 0, 0, 0 };
|
|
4263
4332
|
char* __out = sb_sql_run_x(__path, __sql, __args, &__cells);
|
|
4264
4333
|
free(__path); free(__sql); free(__args);
|
|
4265
|
-
const u32 __pair = porf_arr_new(
|
|
4334
|
+
const u32 __pair = porf_arr_new(5, 5);
|
|
4266
4335
|
res = porf_box((f64)__pair, 72);
|
|
4267
|
-
|
|
4268
|
-
|
|
4336
|
+
const int __native = !__out && __cells.used == 1;
|
|
4337
|
+
if (!__out && !__native) __out = strdup("{\"ok\":false,\"error\":\"no result\"}");
|
|
4338
|
+
porf_arr_set(__pair, 0, porf_box((f64)porf_native_fetch_alloc_bytestring(__out ? __out : "", __out ? strlen(__out) : 0), 195));
|
|
4269
4339
|
free(__out);
|
|
4270
|
-
if (
|
|
4340
|
+
if (__native) {
|
|
4271
4341
|
const size_t __ncol = (size_t)__cells.ncol;
|
|
4272
|
-
const size_t
|
|
4342
|
+
const size_t __hdr = (size_t)__cells.hdr;
|
|
4343
|
+
const size_t __nrow = __ncol ? (__cells.n - __hdr) / __ncol : 0;
|
|
4273
4344
|
const u32 __rows = porf_arr_new(0, (i32)(__nrow ? __nrow : 1));
|
|
4274
4345
|
porf_arr_set(__pair, 1, porf_box((f64)__rows, 72));
|
|
4275
|
-
|
|
4346
|
+
porf_arr_set(__pair, 3, porf_box_num((f64)__cells.changes));
|
|
4347
|
+
porf_arr_set(__pair, 4, porf_box_num((f64)__cells.rowid));
|
|
4348
|
+
if (!__ncol) porf_arr_set(__pair, 2, porf_box((f64)porf_arr_new(0, 1), 72));
|
|
4349
|
+
// Row index -1 is the column names; the data rows follow.
|
|
4350
|
+
for (long long __r = (__ncol ? -1 : 0); __r < (long long)__nrow; __r++) {
|
|
4276
4351
|
const u32 __row = porf_arr_new((i32)__ncol, (i32)__ncol);
|
|
4277
|
-
porf_arr_set(
|
|
4352
|
+
if (__r < 0) porf_arr_set(__pair, 2, porf_box((f64)__row, 72));
|
|
4353
|
+
else porf_arr_set(__rows, (u32)__r, porf_box((f64)__row, 72));
|
|
4278
4354
|
for (size_t __c = 0; __c < __ncol; __c++) {
|
|
4279
|
-
const sb_cell* __cell = &__cells.c[__r * __ncol + __c];
|
|
4355
|
+
const sb_cell* __cell = &__cells.c[(__r < 0 ? 0 : __hdr + (size_t)__r * __ncol) + __c];
|
|
4280
4356
|
jsval __v = porf_box(0.0, 7);
|
|
4281
4357
|
if (__cell->t == 1) __v = porf_box_num(__cell->d);
|
|
4282
4358
|
else if (__cell->t == 2 && __cell->ascii) {
|
|
@@ -4386,7 +4462,7 @@ function __sbD1Quota(path) {
|
|
|
4386
4462
|
// Whether capture owns this database, from the private helper (no SQL), before anything
|
|
4387
4463
|
// below depends on it. A database that cannot open (or whose capture failed) is left to
|
|
4388
4464
|
// the call itself, which reports why.
|
|
4389
|
-
const status =
|
|
4465
|
+
const status = __sbD1Captured_(path);
|
|
4390
4466
|
if (status < 0) return;
|
|
4391
4467
|
__sbD1Captured[path] = status === 1;
|
|
4392
4468
|
let full = __sbQuotaFull();
|
|
@@ -4434,8 +4510,16 @@ function __sbDOSqlPath(cls, file) {
|
|
|
4434
4510
|
if (!__sbDOSqlName(c, false) || !__sbDOSqlName(f, true)) throw new Error("invalid Durable Object SQL database");
|
|
4435
4511
|
return __sbDir() + "/do-sql/" + c + "/" + f + ".sqlite";
|
|
4436
4512
|
}
|
|
4513
|
+
// The last database's path: an app has one or two, and every query asks, so three concatenations
|
|
4514
|
+
// are done once. The directory cannot change mid-run (see __sbDir).
|
|
4515
|
+
var __sbD1PathName = "";
|
|
4516
|
+
var __sbD1PathValue = "";
|
|
4437
4517
|
function __sbD1Path(name) {
|
|
4438
|
-
|
|
4518
|
+
if (name === __sbD1PathName) return __sbD1PathValue;
|
|
4519
|
+
const path = __sbDir() + "/d1/" + name + ".sqlite";
|
|
4520
|
+
__sbD1PathName = name;
|
|
4521
|
+
__sbD1PathValue = path;
|
|
4522
|
+
return path;
|
|
4439
4523
|
}
|
|
4440
4524
|
|
|
4441
4525
|
function __sbSql(path, sql, params) {
|
|
@@ -5356,10 +5440,12 @@ function __sbD1Run(path, sql, params, strict) {
|
|
|
5356
5440
|
if (strict) {
|
|
5357
5441
|
// Rows come back as arrays, built in C (see __sbSqlRowsRaw); the header is small JSON.
|
|
5358
5442
|
const pair = __sbSqlRowsRaw(path, "/*sb-d1-strict*/" + String(sql), JSON.stringify(params || []));
|
|
5359
|
-
|
|
5360
|
-
|
|
5443
|
+
if (pair[0] !== "") {
|
|
5444
|
+
const head = JSON.parse(pair[0]);
|
|
5445
|
+
if (head.ok === false) throw __sbD1Error(head);
|
|
5446
|
+
}
|
|
5361
5447
|
const rows = pair[1];
|
|
5362
|
-
return { columns:
|
|
5448
|
+
return { columns: pair[2], rows, native: true, meta: { changes: pair[3], last_row_id: pair[4], rows_read: rows.length } };
|
|
5363
5449
|
}
|
|
5364
5450
|
const r = JSON.parse(__sbSqlRaw(path, (strict ? "/*sb-d1-strict*/" : "") + String(sql), JSON.stringify(params || [])));
|
|
5365
5451
|
if (r.ok === false) throw strict ? __sbD1Error(r) : new Error("sqlite: " + r.error);
|
|
@@ -5524,6 +5610,17 @@ function __sbCall(reqJson) {
|
|
|
5524
5610
|
}
|
|
5525
5611
|
}
|
|
5526
5612
|
|
|
5613
|
+
// A strict D1 query without the generic dispatch: no request object, no copy of its fields, no walk
|
|
5614
|
+
// down the op list and no result object. The same steps __sbEmbeddedDispatch runs for "d1.query".
|
|
5615
|
+
__sbD1QueryDirect = function (db, sql, params) {
|
|
5616
|
+
if (__sbCompatDate() >= "2026-10-22") __sbLtxConfigure();
|
|
5617
|
+
__sbEnsureSchema();
|
|
5618
|
+
const path = __sbD1Path(String(db));
|
|
5619
|
+
__sbD1Open(path, true);
|
|
5620
|
+
__sbD1Quota(path);
|
|
5621
|
+
return __sbD1Run(path, sql, params, true);
|
|
5622
|
+
};
|
|
5623
|
+
|
|
5527
5624
|
// The same dispatch without the JSON text either side: D1 calls take this (see __sbRpcDirect).
|
|
5528
5625
|
__sbCallDirect = function (req) {
|
|
5529
5626
|
if (__sbCompatDate() >= "2026-10-22") __sbLtxConfigure();
|
package/test/native-d1.ts
CHANGED
|
@@ -56,12 +56,18 @@ int main(void) {
|
|
|
56
56
|
if (!strncmp(line, ":cells ", 7)) {
|
|
57
57
|
sb_cells cs = { 0, 0, 0, 0, 0 };
|
|
58
58
|
char* head = sb_sql_run_x(line + 7, sql, params, &cs);
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
// A result has no JSON header: its column names are the first cells and its counts are in cs.
|
|
60
|
+
if (head) printf("{\\"head\\":%s,\\"rows\\":[", head);
|
|
61
|
+
else {
|
|
62
|
+
printf("{\\"head\\":{\\"ok\\":true,\\"cols\\":[");
|
|
63
|
+
for (int c = 0; c < cs.hdr; c++) printf(c ? ",\\"%.*s\\"" : "\\"%.*s\\"", cs.c[c].n, cs.a + cs.c[c].off);
|
|
64
|
+
printf("],\\"changes\\":%lld,\\"rowid\\":%lld},\\"rows\\":[", cs.changes, cs.rowid);
|
|
65
|
+
}
|
|
66
|
+
size_t nrow = cs.ncol ? (cs.n - (size_t)cs.hdr) / (size_t)cs.ncol : 0;
|
|
61
67
|
for (size_t r = 0; r < nrow; r++) {
|
|
62
68
|
printf(r ? ",[" : "[");
|
|
63
69
|
for (int c = 0; c < cs.ncol; c++) {
|
|
64
|
-
sb_cell* x = &cs.c[r * (size_t)cs.ncol + (size_t)c];
|
|
70
|
+
sb_cell* x = &cs.c[(size_t)cs.hdr + r * (size_t)cs.ncol + (size_t)c];
|
|
65
71
|
if (c) printf(",");
|
|
66
72
|
if (x->t == 0) printf("null");
|
|
67
73
|
else if (x->t == 1) printf("%.17g", x->d);
|
|
@@ -162,6 +168,7 @@ int main(void) {
|
|
|
162
168
|
assert.deepEqual(cells.rows, json.rows, `cells equal the JSON rows for ${t}`);
|
|
163
169
|
assert.deepEqual(cells.head.cols, json.cols);
|
|
164
170
|
assert.equal(cells.head.changes, json.changes);
|
|
171
|
+
assert.equal(cells.head.rowid, json.rowid);
|
|
165
172
|
}
|
|
166
173
|
const empty = cellRun("SELECT a FROM t WHERE 0");
|
|
167
174
|
assert.deepEqual(empty.cells.rows, []);
|
|
@@ -252,15 +259,16 @@ int main(void) {
|
|
|
252
259
|
};
|
|
253
260
|
// oxlint-disable-next-line no-function-constructor -- lifting a block out of the text-only transport
|
|
254
261
|
const scriptRaw = (path: string, text: string) => sqlRaw(":script " + path, text, "[]");
|
|
255
|
-
// __sbSqlRowsRaw as the transport's C block gives it: [header JSON
|
|
262
|
+
// __sbSqlRowsRaw as the transport's C block gives it: an error is [header JSON], a result is
|
|
263
|
+
// ["", rows, columns, changes, rowid], BLOBs as byte arrays.
|
|
256
264
|
const rowsOf =
|
|
257
265
|
(raw: (path: string, text: string, params: string) => string) => (path: string, text: string, params: string) => {
|
|
258
266
|
const r = JSON.parse(raw(":cells " + path, text, params));
|
|
259
|
-
if (r.head.ok === false) return [JSON.stringify(r.head)
|
|
267
|
+
if (r.head.ok === false) return [JSON.stringify(r.head)];
|
|
260
268
|
const rows = r.rows.map((row: unknown[]) =>
|
|
261
269
|
row.map((c) => (c instanceof Object && "$blob" in c ? [...Buffer.from(String(c.$blob), "hex")] : c)),
|
|
262
270
|
);
|
|
263
|
-
return [
|
|
271
|
+
return ["", rows, r.head.cols, r.head.changes, r.head.rowid];
|
|
264
272
|
};
|
|
265
273
|
const ops = new Function(
|
|
266
274
|
"__sbSqlRaw",
|
|
@@ -279,6 +287,7 @@ int main(void) {
|
|
|
279
287
|
"__sbDir",
|
|
280
288
|
"__sbDirBytesRaw",
|
|
281
289
|
"__sbD1DbRaw",
|
|
290
|
+
"__sbD1Captured_",
|
|
282
291
|
"__sbSql",
|
|
283
292
|
"__sbSqlRaw",
|
|
284
293
|
`${transport.slice(quotaStart, quotaEnd)}\nreturn { __sbQuotaCheck, __sbD1Quota, __sbQuotaFull, expire() { __sbQuotaAt = -1; } };`,
|
|
@@ -287,6 +296,7 @@ int main(void) {
|
|
|
287
296
|
() => dir,
|
|
288
297
|
(d: string) => String(JSON.parse(sqlRaw(":bytes " + d, "", "")).n),
|
|
289
298
|
(path: string, what: string) => String(JSON.parse(sqlRaw(":db " + path, what, "")).n),
|
|
299
|
+
(path: string) => JSON.parse(sqlRaw(":db " + path, "c", "")).n,
|
|
290
300
|
sql,
|
|
291
301
|
sqlRaw,
|
|
292
302
|
);
|
|
@@ -334,6 +344,7 @@ int main(void) {
|
|
|
334
344
|
"__sbDir",
|
|
335
345
|
"__sbDirBytesRaw",
|
|
336
346
|
"__sbD1DbRaw",
|
|
347
|
+
"__sbD1Captured_",
|
|
337
348
|
"__sbSql",
|
|
338
349
|
"__sbSqlRaw",
|
|
339
350
|
`${transport.slice(quotaStart, quotaEnd)}\nreturn { __sbQuotaCheck, __sbD1Quota, __sbQuotaFull };`,
|
|
@@ -342,6 +353,7 @@ int main(void) {
|
|
|
342
353
|
() => dir,
|
|
343
354
|
(d: string) => String(JSON.parse(raw(":bytes " + d, "", "")).n),
|
|
344
355
|
(path: string, what: string) => String(JSON.parse(raw(":db " + path, what, "")).n),
|
|
356
|
+
(path: string) => JSON.parse(raw(":db " + path, "c", "")).n,
|
|
345
357
|
run,
|
|
346
358
|
raw,
|
|
347
359
|
);
|
|
@@ -629,6 +641,24 @@ int main(void) {
|
|
|
629
641
|
assert.match(failedOver.error, /^LTX capture failed/);
|
|
630
642
|
third.close();
|
|
631
643
|
await third.exited;
|
|
644
|
+
// Locking mode. This driver is the transport on its own, as sproutd and the broker embed it (no
|
|
645
|
+
// standalone shim): unset it must stay shared, so those hosts behave as before. SB_SQLITE_EXCLUSIVE=1 is
|
|
646
|
+
// exclusive for a plain database; anything else, 0 included, is shared. A database under LTX replication
|
|
647
|
+
// is never exclusive.
|
|
648
|
+
const lockingMode = (env: Record<string, string>, path: string) => {
|
|
649
|
+
const e = Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== "SB_SQLITE_EXCLUSIVE"));
|
|
650
|
+
const p = Bun.spawnSync([binary], {
|
|
651
|
+
stdin: Buffer.from(`${path}\tPRAGMA locking_mode\t[]\n`),
|
|
652
|
+
env: { ...e, ...env },
|
|
653
|
+
});
|
|
654
|
+
assert.equal(p.exitCode, 0, p.stderr.toString());
|
|
655
|
+
return JSON.parse(p.stdout.toString().trim()).rows[0][0];
|
|
656
|
+
};
|
|
657
|
+
const lockDb = join(work, "data/d1/LOCK.sqlite");
|
|
658
|
+
assert.equal(lockingMode({}, lockDb), "normal", "the bare transport keeps shared locking unless asked");
|
|
659
|
+
assert.equal(lockingMode({ SB_SQLITE_EXCLUSIVE: "0" }, lockDb), "normal");
|
|
660
|
+
assert.equal(lockingMode({ SB_SQLITE_EXCLUSIVE: "1" }, lockDb), "exclusive");
|
|
661
|
+
assert.equal(lockingMode({ SB_SQLITE_EXCLUSIVE: "1" }, join(work, "data/d1/LOCK2.sqlite")), "exclusive");
|
|
632
662
|
console.log(
|
|
633
663
|
"native d1: sb_sql_run and the D1 ops pass (blob binds, atomic batch, strict counts, foreign keys, error text)",
|
|
634
664
|
);
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { ensurePorffor } from "../../toolchain/src/acquire";
|
|
6
|
+
import { preludePath, TRANSPORT_MARKER, transportPath, wrapNativeFetchHandler } from "../src/wrap";
|
|
7
|
+
|
|
8
|
+
// Headers validates a name and trims a value in C before it falls back to the regular expression and
|
|
9
|
+
// the JS loops. Every kind of name and value must come out as the Fetch spec says: valid names
|
|
10
|
+
// lowercased, invalid ones a TypeError, values trimmed, forbidden characters refused, and a name
|
|
11
|
+
// that is not a plain bytestring (a wide character) still judged by the expression.
|
|
12
|
+
const handler = `export default {fetch(request) {
|
|
13
|
+
const out = {};
|
|
14
|
+
const names = ["a", "Content-Type", "X-Sb-Test", "x_y", "a.b", "a~b", "a|b", "a^b", "a+b", "a*b", "a!b", "a#b", "a$b", "a%b", "a&b", "a'b", "a-b", "9", "A9z", "",
|
|
15
|
+
"a b", "a:b", "a/b", "a(b", "a@b", "a[b", "a\\u0000b", "a\\nb", "\\u00e9", "caf\\u00e9", "a\\u0100b", "a,b", "a;b", "a=b", "a?b", "a\\"b", "a\\\\b", "a<b", "a>b", "a{b", "a}b"];
|
|
16
|
+
const h = new Headers();
|
|
17
|
+
for (const name of names) {
|
|
18
|
+
try { h.append(name, "v"); out["n:" + name] = h.has(name.toLowerCase()) ? "ok" : "stored?"; }
|
|
19
|
+
catch (e) { out["n:" + name] = e instanceof TypeError ? "TypeError" : String(e); }
|
|
20
|
+
}
|
|
21
|
+
const values = ["plain", "", " lead", "trail ", "\\t both \\t", "\\r\\nx", "a\\u0000b", "a\\nb", "a\\rb", "in ner", "caf\\u00e9", "\\u4e2d", " \\u4e2d ", "x\\ty"];
|
|
22
|
+
for (const value of values) {
|
|
23
|
+
const g = new Headers();
|
|
24
|
+
try { g.set("x", value); out["v:" + JSON.stringify(value)] = g.get("x"); }
|
|
25
|
+
catch (e) { out["v:" + JSON.stringify(value)] = e instanceof TypeError ? "TypeError" : String(e); }
|
|
26
|
+
}
|
|
27
|
+
const dup = new Headers();
|
|
28
|
+
dup.append("Accept", "a"); dup.append("accept", "b");
|
|
29
|
+
out.dup = dup.get("ACCEPT");
|
|
30
|
+
out.fromObject = new Headers({ "Content-Type": "text/plain", "X-A": " 1 " }).get("x-a");
|
|
31
|
+
out.getCase = new Headers({ "x-a": "1" }).get("X-A");
|
|
32
|
+
out.trigger = request.headers.get("X-Sb-Trigger");
|
|
33
|
+
out.custom = request.headers.get("X-Custom");
|
|
34
|
+
return new Response(JSON.stringify(out), { headers: { "Content-Type": "application/json", "X-Case": " kept " } });
|
|
35
|
+
}};`;
|
|
36
|
+
|
|
37
|
+
function freePort(): number {
|
|
38
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
39
|
+
const port = listener.port;
|
|
40
|
+
listener.stop(true);
|
|
41
|
+
return port;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// What the Fetch spec (and workerd) say, from the names and values above.
|
|
45
|
+
function expectedName(name: string): string {
|
|
46
|
+
const valid = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name);
|
|
47
|
+
return valid ? "ok" : "TypeError";
|
|
48
|
+
}
|
|
49
|
+
function expectedValue(value: string): string {
|
|
50
|
+
const trimmed = value.replace(/^[\t\n\r ]+|[\t\n\r ]+$/g, "");
|
|
51
|
+
return /[\0\n\r]/.test(trimmed) ? "TypeError" : trimmed;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const workdir = await mkdtemp(join(tmpdir(), "sb-native-headers-"));
|
|
55
|
+
let server: ReturnType<typeof Bun.spawn> | undefined;
|
|
56
|
+
try {
|
|
57
|
+
const porffor = await ensurePorffor();
|
|
58
|
+
const node = Bun.which("node");
|
|
59
|
+
if (!node) throw new Error("native headers test needs node on PATH");
|
|
60
|
+
|
|
61
|
+
const [core, broker] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath("broker"), "utf8")]);
|
|
62
|
+
assert(core.includes(TRANSPORT_MARKER), "runtime prelude is missing its transport marker");
|
|
63
|
+
const prelude = core.replace(TRANSPORT_MARKER, broker);
|
|
64
|
+
const generated = join(workdir, "headers.generated.js");
|
|
65
|
+
const binary = join(workdir, "headers-native");
|
|
66
|
+
await writeFile(generated, wrapNativeFetchHandler(handler, prelude));
|
|
67
|
+
await rm(binary, { force: true });
|
|
68
|
+
|
|
69
|
+
const compile = Bun.spawn(
|
|
70
|
+
[node, resolve(porffor, "runtime/index.js"), "native", generated, "-o", binary, "-s", "-O0"],
|
|
71
|
+
{
|
|
72
|
+
cwd: workdir,
|
|
73
|
+
env: process.env,
|
|
74
|
+
stdout: "pipe",
|
|
75
|
+
stderr: "pipe",
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
79
|
+
compile.exited,
|
|
80
|
+
new Response(compile.stdout).text(),
|
|
81
|
+
new Response(compile.stderr).text(),
|
|
82
|
+
]);
|
|
83
|
+
assert.equal(exitCode, 0, `Porffor native compile failed:\n${stderr}\n${stdout}`);
|
|
84
|
+
|
|
85
|
+
const port = freePort();
|
|
86
|
+
server = Bun.spawn([binary], {
|
|
87
|
+
cwd: workdir,
|
|
88
|
+
env: { ...process.env, PORT: String(port) },
|
|
89
|
+
stdout: "ignore",
|
|
90
|
+
stderr: "inherit",
|
|
91
|
+
});
|
|
92
|
+
let response: Response | undefined;
|
|
93
|
+
for (let attempt = 0; attempt < 50 && !response; attempt++) {
|
|
94
|
+
try {
|
|
95
|
+
response = await fetch(`http://127.0.0.1:${port}/`, {
|
|
96
|
+
headers: { "X-Custom": "custom" },
|
|
97
|
+
signal: AbortSignal.timeout(2000),
|
|
98
|
+
});
|
|
99
|
+
} catch {
|
|
100
|
+
if (server.exitCode !== null) break;
|
|
101
|
+
await Bun.sleep(100);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
assert(response, "compiled handler did not start");
|
|
105
|
+
// SAFETY: the handler above answers a flat JSON object of strings (and null), read key by key below.
|
|
106
|
+
const out = (await response.json()) as Record<string, string>;
|
|
107
|
+
const names = [
|
|
108
|
+
"a",
|
|
109
|
+
"Content-Type",
|
|
110
|
+
"X-Sb-Test",
|
|
111
|
+
"x_y",
|
|
112
|
+
"a.b",
|
|
113
|
+
"a~b",
|
|
114
|
+
"a|b",
|
|
115
|
+
"a^b",
|
|
116
|
+
"a+b",
|
|
117
|
+
"a*b",
|
|
118
|
+
"a!b",
|
|
119
|
+
"a#b",
|
|
120
|
+
"a$b",
|
|
121
|
+
"a%b",
|
|
122
|
+
"a&b",
|
|
123
|
+
"a'b",
|
|
124
|
+
"a-b",
|
|
125
|
+
"9",
|
|
126
|
+
"A9z",
|
|
127
|
+
"",
|
|
128
|
+
"a b",
|
|
129
|
+
"a:b",
|
|
130
|
+
"a/b",
|
|
131
|
+
"a(b",
|
|
132
|
+
"a@b",
|
|
133
|
+
"a[b",
|
|
134
|
+
"a\u0000b",
|
|
135
|
+
"a\nb",
|
|
136
|
+
"é",
|
|
137
|
+
"café",
|
|
138
|
+
"aĀb",
|
|
139
|
+
"a,b",
|
|
140
|
+
"a;b",
|
|
141
|
+
"a=b",
|
|
142
|
+
"a?b",
|
|
143
|
+
'a"b',
|
|
144
|
+
"a\\b",
|
|
145
|
+
"a<b",
|
|
146
|
+
"a>b",
|
|
147
|
+
"a{b",
|
|
148
|
+
"a}b",
|
|
149
|
+
];
|
|
150
|
+
for (const name of names) assert.equal(out["n:" + name], expectedName(name), `header name ${JSON.stringify(name)}`);
|
|
151
|
+
const values = [
|
|
152
|
+
"plain",
|
|
153
|
+
"",
|
|
154
|
+
" lead",
|
|
155
|
+
"trail ",
|
|
156
|
+
"\t both \t",
|
|
157
|
+
"\r\nx",
|
|
158
|
+
"a\u0000b",
|
|
159
|
+
"a\nb",
|
|
160
|
+
"a\rb",
|
|
161
|
+
"in ner",
|
|
162
|
+
"café",
|
|
163
|
+
"中",
|
|
164
|
+
" 中 ",
|
|
165
|
+
"x\ty",
|
|
166
|
+
];
|
|
167
|
+
for (const value of values)
|
|
168
|
+
assert.equal(out["v:" + JSON.stringify(value)], expectedValue(value), `header value ${JSON.stringify(value)}`);
|
|
169
|
+
assert.equal(out.dup, "a, b");
|
|
170
|
+
assert.equal(out.fromObject, "1");
|
|
171
|
+
assert.equal(out.getCase, "1");
|
|
172
|
+
assert.equal(out.trigger, null);
|
|
173
|
+
assert.equal(out.custom, "custom");
|
|
174
|
+
assert.equal(response.headers.get("x-case"), "kept");
|
|
175
|
+
console.log("Native Headers: names and values validated in C match the Fetch spec for every class of input");
|
|
176
|
+
} finally {
|
|
177
|
+
if (server) {
|
|
178
|
+
server.kill(9);
|
|
179
|
+
await server.exited;
|
|
180
|
+
}
|
|
181
|
+
await rm(workdir, { recursive: true, force: true });
|
|
182
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
// A standalone binary (the embedded transport, built by the CLI) opens its SQLite databases in
|
|
8
|
+
// EXCLUSIVE locking mode by default; SB_SQLITE_EXCLUSIVE=0 opts out and =1 is the default. The
|
|
9
|
+
// transport on its own, as sproutd and the broker embed it, stays shared unless the variable is
|
|
10
|
+
// set: native-d1.ts covers that. Needs SB_CLI, a sproutboat-cli checkout linked to this one.
|
|
11
|
+
const handler = `export default {
|
|
12
|
+
async fetch() {
|
|
13
|
+
await env.DB.prepare("CREATE TABLE IF NOT EXISTS t (v INTEGER)").run();
|
|
14
|
+
const row = await env.DB.prepare("PRAGMA locking_mode").first();
|
|
15
|
+
return Response.json({ mode: row.locking_mode });
|
|
16
|
+
},
|
|
17
|
+
};`;
|
|
18
|
+
|
|
19
|
+
const cli = process.env.SB_CLI;
|
|
20
|
+
if (!cli) {
|
|
21
|
+
console.log("skipped: set SB_CLI to a sproutboat-cli checkout linked to this checkout (it builds the sprout)");
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function freePort(): number {
|
|
26
|
+
const listener = Bun.listen({ hostname: "127.0.0.1", port: 0, socket: { data() {} } });
|
|
27
|
+
const port = listener.port;
|
|
28
|
+
listener.stop(true);
|
|
29
|
+
return port;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const workdir = mkdtempSync(join(tmpdir(), "sb-native-sqlite-lock-"));
|
|
33
|
+
const servers: ReturnType<typeof Bun.spawn>[] = [];
|
|
34
|
+
try {
|
|
35
|
+
const project = join(workdir, "lock");
|
|
36
|
+
mkdirSync(join(project, "src"), { recursive: true });
|
|
37
|
+
writeFileSync(join(project, "src/index.js"), handler);
|
|
38
|
+
writeFileSync(
|
|
39
|
+
join(project, "sproutboat.jsonc"),
|
|
40
|
+
JSON.stringify({ name: "lock", main: "src/index.js", compatibility_date: "2026-10-28", d1_databases: ["DB"] }),
|
|
41
|
+
);
|
|
42
|
+
const built = Bun.spawnSync(["bun", join(cli, "src/main.ts"), "build", "--standalone", "--target", "host"], {
|
|
43
|
+
cwd: project,
|
|
44
|
+
stdout: "inherit",
|
|
45
|
+
stderr: "inherit",
|
|
46
|
+
});
|
|
47
|
+
assert.equal(built.exitCode, 0, "the CLI could not build the sprout");
|
|
48
|
+
|
|
49
|
+
const run = async (label: string, setting: string | undefined, expected: string) => {
|
|
50
|
+
const data = join(workdir, `data-${label}`);
|
|
51
|
+
const base = Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== "SB_SQLITE_EXCLUSIVE"));
|
|
52
|
+
const env = setting === undefined ? base : { ...base, SB_SQLITE_EXCLUSIVE: setting };
|
|
53
|
+
const port = freePort();
|
|
54
|
+
const server = Bun.spawn([join(project, "dist/lock")], {
|
|
55
|
+
cwd: project,
|
|
56
|
+
env: { ...env, SB_DATA_DIR: data, PORT: String(port) },
|
|
57
|
+
stdout: "ignore",
|
|
58
|
+
stderr: "inherit",
|
|
59
|
+
});
|
|
60
|
+
servers.push(server);
|
|
61
|
+
let response: Response | undefined;
|
|
62
|
+
for (let attempt = 0; attempt < 100 && !response; attempt++) {
|
|
63
|
+
try {
|
|
64
|
+
response = await fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(2000) });
|
|
65
|
+
} catch {
|
|
66
|
+
if (server.exitCode !== null) break;
|
|
67
|
+
await Bun.sleep(100);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
assert(response, `${label}: the compiled handler did not start`);
|
|
71
|
+
assert.deepEqual(await response.json(), { mode: expected }, label);
|
|
72
|
+
// Another process can open the database while the binary runs only in shared mode.
|
|
73
|
+
let other = "";
|
|
74
|
+
try {
|
|
75
|
+
const db = new Database(join(data, "d1/DB.sqlite"), { readonly: true });
|
|
76
|
+
other = String(db.query("SELECT count(*) AS n FROM t").get()?.n);
|
|
77
|
+
db.close();
|
|
78
|
+
} catch (error) {
|
|
79
|
+
other = String(error);
|
|
80
|
+
}
|
|
81
|
+
if (expected === "exclusive")
|
|
82
|
+
assert.match(other, /locked|busy/i, `${label}: a second opener is locked out (${other})`);
|
|
83
|
+
else assert.equal(other, "0", `${label}: a second opener can read`);
|
|
84
|
+
server.kill(9);
|
|
85
|
+
await server.exited;
|
|
86
|
+
};
|
|
87
|
+
await run("default", undefined, "exclusive");
|
|
88
|
+
await run("one", "1", "exclusive");
|
|
89
|
+
await run("zero", "0", "normal");
|
|
90
|
+
console.log("standalone SQLite: EXCLUSIVE by default, SB_SQLITE_EXCLUSIVE=0 opts out");
|
|
91
|
+
} finally {
|
|
92
|
+
for (const server of servers) server.kill(9);
|
|
93
|
+
rmSync(workdir, { recursive: true, force: true });
|
|
94
|
+
}
|