@sproutboat/runtime 0.21.1 → 0.22.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.
@@ -0,0 +1,359 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, readFile, readdir, rm, writeFile, stat } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { Database } from "bun:sqlite";
6
+
7
+ // Compile the exact inline C used by the embedded transport against SQLite.
8
+ // This tests WAL bytes, crash boundaries and fsync paths without a JS mock.
9
+ const transport = await readFile(new URL("../src/transport-embedded.js", import.meta.url), "utf8");
10
+ const start = transport.indexOf("// BEGIN LTX NATIVE");
11
+ const end = transport.indexOf("// END LTX NATIVE", start);
12
+ assert(start > 0 && end > start);
13
+ const work = await mkdtemp(join(tmpdir(), "sb-native-ltx-"));
14
+ const binary = join(work, "driver");
15
+ const source = `
16
+ #include <sqlite3.h>
17
+ #include <stdio.h>
18
+ #include <stdlib.h>
19
+ #include <stdint.h>
20
+ #include <string.h>
21
+ #include <strings.h>
22
+ #include <unistd.h>
23
+ #include <fcntl.h>
24
+ #include <sys/stat.h>
25
+ #include <time.h>
26
+ #include <signal.h>
27
+ #define SB_MAX_DB 16
28
+ ${transport.slice(start, end)}
29
+ int main(int argc,char **argv) {
30
+ if(argc!=2)return 2;sb_ltx_compat=1;if(sb_ltx_register())return 3;
31
+ char lockpath[1024];snprintf(lockpath,sizeof(lockpath),"%s.ltx.lock",argv[1]);
32
+ int lock=open(lockpath,O_RDWR|O_CREAT,0600);if(lock<0||flock(lock,LOCK_EX|LOCK_NB))return 4;
33
+ sqlite3 *db=0;if(sqlite3_open_v2(argv[1],&db,6,"sb-ltx-bounded"))return 5;
34
+ if(getenv("SB_LTX_TEST_AUTO_VACUUM"))sqlite3_exec(db,"PRAGMA auto_vacuum=INCREMENTAL;",0,0,0);
35
+ if(sqlite3_exec(db,"PRAGMA journal_mode=WAL;PRAGMA cache_size=-2000;",0,0,0)||sb_ltx_start(0,db,argv[1],lock))return 6;
36
+ puts("ready");fflush(stdout);sb_ltx_test_armed=1;
37
+ char *line=0;size_t cap=0;
38
+ while(getline(&line,&cap,stdin)>0) {
39
+ if(strncmp(line,":break-position",15)==0){char p[1024];snprintf(p,sizeof(p),"%s.ltx/position.tmp",argv[1]);mkdir(p,0700);puts("0 injected");fflush(stdout);continue;}
40
+ char *error=0;int rc=sb_ltx_states[0].failed?10:sqlite3_exec(db,line,0,0,&error);
41
+ if(sb_ltx_after(0))rc=10;
42
+ printf("%d %s\\n",rc,error?error:sqlite3_errmsg(db));fflush(stdout);sqlite3_free(error);
43
+ }
44
+ free(line);sqlite3_close(db);close(lock);return 0;
45
+ }
46
+ `;
47
+ await writeFile(join(work, "driver.c"), source);
48
+ await rm(binary, { force: true });
49
+ const compile = Bun.spawnSync(["cc", "-O2", "-DSB_LTX_TESTING", join(work, "driver.c"), "-lsqlite3", "-o", binary]);
50
+ assert.equal(compile.exitCode, 0, compile.stderr.toString());
51
+
52
+ // Independent CRC baseline in Bun. This intentionally uses BigInt, unlike C.
53
+ const table: bigint[] = [];
54
+ for (let i = 0; i < 256; i++) {
55
+ let c = BigInt(i);
56
+ for (let j = 0; j < 8; j++) c = (c >> 1n) ^ (c & 1n ? 0xd800000000000000n : 0n);
57
+ table.push(c);
58
+ }
59
+ function crc(data: Uint8Array, init = 0xffffffffffffffffn) {
60
+ let c = init;
61
+ for (const b of data) c = table[Number((c ^ BigInt(b)) & 255n)] ^ (c >> 8n);
62
+ return c;
63
+ }
64
+ const flag = 0x8000000000000000n,
65
+ mask = 0xffffffffffffffffn;
66
+ function pageCrc(pgno: number, page: Uint8Array) {
67
+ const p = new Uint8Array(4);
68
+ new DataView(p.buffer).setUint32(0, pgno);
69
+ return flag | (crc(page, crc(p)) ^ mask);
70
+ }
71
+
72
+ async function restore(epoch: string, output: string) {
73
+ const files = (await readdir(epoch)).filter((n) => n.endsWith(".ltx")).sort();
74
+ let db = new Uint8Array(0),
75
+ checksum = 0n,
76
+ txid = 0n;
77
+ for (const file of files) {
78
+ const b = new Uint8Array(await readFile(join(epoch, file)));
79
+ const v = new DataView(b.buffer, b.byteOffset, b.byteLength);
80
+ assert.equal(new TextDecoder().decode(b.subarray(0, 4)), "LTX1");
81
+ const pageSize = v.getUint32(8),
82
+ commit = v.getUint32(12);
83
+ assert.equal(v.getBigUint64(16), txid + 1n);
84
+ assert.equal(v.getBigUint64(40), checksum);
85
+ const next = new Uint8Array(commit * pageSize);
86
+ next.set(db.subarray(0, next.length));
87
+ db = next;
88
+ let offset = 100,
89
+ hash = crc(b.subarray(0, 100));
90
+ const entries = [];
91
+ for (;;) {
92
+ const begin = offset,
93
+ pgno = v.getUint32(offset),
94
+ flags = v.getUint16(offset + 4);
95
+ hash = crc(b.subarray(offset, offset + 6), hash);
96
+ offset += 6;
97
+ if (!pgno) {
98
+ assert.equal(flags, 0);
99
+ break;
100
+ }
101
+ assert.equal(flags, 1);
102
+ assert(pgno <= commit);
103
+ const size = v.getUint32(offset);
104
+ hash = crc(b.subarray(offset, offset + 4), hash);
105
+ offset += 4;
106
+ const stop = offset + size;
107
+ assert(stop <= b.length);
108
+ assert.equal(b[offset++], 0xf0);
109
+ let literals = 15,
110
+ byte;
111
+ do {
112
+ byte = b[offset++];
113
+ literals += byte;
114
+ } while (byte === 255);
115
+ assert.equal(literals, pageSize);
116
+ assert.equal(offset + pageSize, stop);
117
+ const page = b.subarray(offset, stop);
118
+ db.set(page, (pgno - 1) * pageSize);
119
+ hash = crc(page, hash);
120
+ offset = stop;
121
+ entries.push({ pgno, offset: begin, size: stop - begin });
122
+ }
123
+ const indexStart = offset;
124
+ function varint() {
125
+ let n = 0n,
126
+ s = 0n;
127
+ for (let i = 0; i < 10; i++) {
128
+ assert(offset < b.length);
129
+ const c = b[offset++];
130
+ n |= BigInt(c & 127) << s;
131
+ if (c < 128) return Number(n);
132
+ s += 7n;
133
+ }
134
+ throw new Error("varint overflow");
135
+ }
136
+ for (const entry of entries) {
137
+ assert.equal(varint(), entry.pgno);
138
+ assert.equal(varint(), entry.offset);
139
+ assert.equal(varint(), entry.size);
140
+ }
141
+ assert.equal(varint(), 0);
142
+ assert.equal(v.getBigUint64(offset), BigInt(offset - indexStart));
143
+ offset += 8;
144
+ hash = crc(b.subarray(indexStart, offset + 8), hash);
145
+ checksum = flag;
146
+ for (let p = 1; p <= commit; p++)
147
+ checksum = flag | (checksum ^ pageCrc(p, db.subarray((p - 1) * pageSize, p * pageSize)));
148
+ assert.equal(v.getBigUint64(offset), checksum, file);
149
+ assert.equal(v.getBigUint64(offset + 8), flag | (hash ^ mask), file);
150
+ assert.equal(offset + 16, b.length);
151
+ txid = v.getBigUint64(24);
152
+ }
153
+ await writeFile(output, db);
154
+ return { files: files.length, txid };
155
+ }
156
+ async function run(path: string, sql: string, extra: Record<string, string> = {}) {
157
+ const child = Bun.spawn([binary, path], {
158
+ env: { ...process.env, ...extra },
159
+ stdin: "pipe",
160
+ stdout: "pipe",
161
+ stderr: "pipe",
162
+ });
163
+ child.stdin.write(sql);
164
+ child.stdin.end();
165
+ const [code, out, error] = await Promise.all([
166
+ child.exited,
167
+ new Response(child.stdout).text(),
168
+ new Response(child.stderr).text(),
169
+ ]);
170
+ assert.equal(code, 0, error);
171
+ assert(out.startsWith("ready\n"), out);
172
+ return out.split("\n").slice(1).filter(Boolean);
173
+ }
174
+ async function stoppedCommit(path: string, phase: string, count = 1) {
175
+ const child = Bun.spawn([binary, path], {
176
+ env: { ...process.env, SB_LTX_TEST_STOP: phase, SB_LTX_TEST_COMMIT: String(count) },
177
+ stdin: "pipe",
178
+ stdout: "pipe",
179
+ stderr: "pipe",
180
+ });
181
+ const reader = child.stdout.getReader();
182
+ let text = "";
183
+ async function line() {
184
+ while (!text.includes("\n")) {
185
+ const r = await reader.read();
186
+ if (r.done) throw new Error("driver exited before ready: " + (await new Response(child.stderr).text()));
187
+ text += new TextDecoder().decode(r.value);
188
+ }
189
+ const n = text.indexOf("\n");
190
+ const value = text.slice(0, n);
191
+ text = text.slice(n + 1);
192
+ return value;
193
+ }
194
+ assert.equal(await line(), "ready");
195
+ const position = await readFile(path + ".ltx/position", "utf8");
196
+ const epoch = position.split("\n")[1];
197
+ const sql =
198
+ count === 2
199
+ ? "INSERT INTO events VALUES(1,'one'); INSERT INTO events VALUES(2,'two');\n"
200
+ : "INSERT INTO events VALUES(1,'one');\n";
201
+ child.stdin.write(sql);
202
+ child.stdin.flush();
203
+ let stopped = false;
204
+ for (let i = 0; i < 200; i++) {
205
+ const state = Bun.spawnSync(["ps", "-o", "state=", "-p", String(child.pid)]).stdout.toString();
206
+ if (state.includes("T")) {
207
+ stopped = true;
208
+ break;
209
+ }
210
+ if (child.exitCode !== null) break;
211
+ await Bun.sleep(10);
212
+ }
213
+ child.kill("SIGKILL");
214
+ await child.exited;
215
+ assert(stopped, "did not reach crash boundary " + phase);
216
+ return epoch;
217
+ }
218
+
219
+ try {
220
+ const path = join(work, "test.sqlite");
221
+ const sql =
222
+ "CREATE TABLE t(id INTEGER PRIMARY KEY,value TEXT);\n" +
223
+ Array.from({ length: 100 }, (_, i) => `INSERT INTO t VALUES(${i + 1},'row-${i + 1}');`).join("\n") +
224
+ "\n";
225
+ const replies = await run(path, sql);
226
+ assert.equal(replies.length, 101);
227
+ assert(
228
+ replies.every((n) => n.startsWith("0 ")),
229
+ replies.join("\n"),
230
+ );
231
+ const root = path + ".ltx";
232
+ const epoch = (await readdir(root)).find((n) => n.startsWith("epoch-"));
233
+ assert(epoch);
234
+ const restored = join(work, "restored.sqlite");
235
+ const result = await restore(join(root, epoch), restored);
236
+ assert.equal(result.files, 102);
237
+ assert.deepEqual(await readFile(restored), await readFile(path));
238
+ const db = new Database(restored);
239
+ assert.equal(db.query("PRAGMA integrity_check").get()?.integrity_check, "ok");
240
+ db.close();
241
+ const cleanPosition = await readFile(root + "/position", "utf8");
242
+ assert(cleanPosition.split("\n")[2].startsWith("C "));
243
+ const finishedBefore = await readdir(join(root, epoch));
244
+ for (let n = 0; n < 5; n++) {
245
+ await run(path, "SELECT 1;\n");
246
+ assert.equal(await readFile(root + "/position", "utf8"), cleanPosition, "idle restart must not rewrite position");
247
+ assert.deepEqual(await readdir(join(root, epoch)), finishedBefore, "idle restart must not rewrite a base");
248
+ assert.equal((await readdir(root)).filter((n) => n.startsWith("epoch-")).length, 1);
249
+ }
250
+ // Size alone is not enough: an offline committed change must invalidate a clean marker.
251
+ const offline = new Database(path);
252
+ offline.exec("UPDATE t SET value='changed-offline' WHERE id=1");
253
+ offline.close();
254
+ await run(path, "SELECT 1;\n");
255
+ const offlineEpoch = (await readFile(root + "/position", "utf8")).split("\n")[1];
256
+ assert.notEqual(offlineEpoch, epoch);
257
+ const offlineRestore = join(work, "offline.sqlite");
258
+ await restore(join(root, offlineEpoch), offlineRestore);
259
+ assert.deepEqual(await readFile(offlineRestore), await readFile(path));
260
+ const bounded = join(work, "bounded.sqlite");
261
+ const boundedReplies = await run(
262
+ bounded,
263
+ "CREATE TABLE t(v BLOB);\nINSERT INTO t VALUES(zeroblob(1000000));\nINSERT INTO t VALUES('small');\n",
264
+ { SB_LTX_MAX_WAL_BYTES: "131072" },
265
+ );
266
+ assert(boundedReplies[1].startsWith("13 "), boundedReplies.join("\n"));
267
+ assert(boundedReplies[2].startsWith("0 "), boundedReplies.join("\n"));
268
+ const live = new Database(bounded);
269
+ assert.equal(live.query("SELECT count(*) n FROM t").get()?.n, 1);
270
+ assert.equal(live.query("PRAGMA integrity_check").get()?.integrity_check, "ok");
271
+ live.close();
272
+ const walStat = await stat(bounded + "-wal").catch(() => null);
273
+ assert(!walStat || walStat.size <= 131072);
274
+ const shrinking = join(work, "shrinking.sqlite");
275
+ const shrinkReplies = await run(
276
+ shrinking,
277
+ "CREATE TABLE t(v BLOB);\nBEGIN; INSERT INTO t VALUES(zeroblob(500000)); COMMIT;\nBEGIN; INSERT INTO t VALUES('rolled-back'); ROLLBACK;\nDELETE FROM t;\nPRAGMA incremental_vacuum(10000);\n",
278
+ { SB_LTX_TEST_AUTO_VACUUM: "1" },
279
+ );
280
+ assert(
281
+ shrinkReplies.every((n) => n.startsWith("0 ")),
282
+ shrinkReplies.join("\n"),
283
+ );
284
+ const shrinkEpoch = (await readFile(shrinking + ".ltx/position", "utf8")).split("\n")[1];
285
+ const shrinkRestore = join(work, "shrinking-restored.sqlite");
286
+ await restore(shrinking + ".ltx/" + shrinkEpoch, shrinkRestore);
287
+ assert.deepEqual(await readFile(shrinking), await readFile(shrinkRestore));
288
+ assert((await stat(shrinking)).size < 32768, "incremental vacuum should shrink the file");
289
+ const failure = join(work, "failure.sqlite");
290
+ const failed = await run(
291
+ failure,
292
+ "CREATE TABLE events(id INTEGER PRIMARY KEY,value TEXT);\n:break-position\nINSERT INTO events VALUES(1,'committed-before-error');\nINSERT INTO events VALUES(2,'must-be-rejected');\n",
293
+ );
294
+ assert(failed[2].startsWith("10 ") && failed[3].startsWith("10 "), failed.join("\n"));
295
+ await rm(failure + ".ltx/position.tmp", { recursive: true });
296
+ await run(failure, "SELECT 1;\n");
297
+ const failureEpoch = (await readFile(failure + ".ltx/position", "utf8")).split("\n")[1];
298
+ const failureRestore = join(work, "failure-restored.sqlite");
299
+ await restore(failure + ".ltx/" + failureEpoch, failureRestore);
300
+ const failedDb = new Database(failureRestore);
301
+ assert.equal(failedDb.query("SELECT count(*) n FROM events").get()?.n, 1);
302
+ failedDb.close();
303
+ const phases = [
304
+ "wal-committed",
305
+ "ltx-written",
306
+ "ltx-fsynced",
307
+ "ltx-published",
308
+ "position-fsynced",
309
+ "position-published",
310
+ "before-checkpoint",
311
+ "after-checkpoint",
312
+ "checkpoint-record-fsynced",
313
+ "checkpoint-record-published",
314
+ ];
315
+ for (const phase of phases) {
316
+ const crashPath = join(work, "crash-" + phase + ".sqlite");
317
+ await run(crashPath, "CREATE TABLE events(id INTEGER PRIMARY KEY,value TEXT);\n");
318
+ const epoch = await stoppedCommit(crashPath, phase);
319
+ const before = (await readdir(crashPath + ".ltx/" + epoch)).filter((n) => n.endsWith(".ltx")).length;
320
+ const oldRestore = join(work, "old-" + phase + ".sqlite");
321
+ await restore(crashPath + ".ltx/" + epoch, oldRestore);
322
+ const beforeDb = new Database(oldRestore);
323
+ assert.equal(beforeDb.query("PRAGMA integrity_check").get()?.integrity_check, "ok");
324
+ const durableRows = beforeDb.query("SELECT count(*) n FROM events").get()?.n;
325
+ beforeDb.close();
326
+ assert.equal(durableRows, before > 2 ? 1 : 0);
327
+ await run(crashPath, "SELECT 1;\n");
328
+ const position = (await readFile(crashPath + ".ltx/position", "utf8")).split("\n");
329
+ if (phase === "position-published" || phase === "before-checkpoint" || phase === "checkpoint-record-published")
330
+ assert.equal(position[1], epoch, "matching WAL must resume");
331
+ else assert.notEqual(position[1], epoch, "mismatched position must make a fresh base");
332
+ const recovered = join(work, "recovered-" + phase + ".sqlite");
333
+ await restore(crashPath + ".ltx/" + position[1], recovered);
334
+ const rdb = new Database(recovered);
335
+ assert.equal(rdb.query("SELECT count(*) n FROM events").get()?.n, 1);
336
+ assert.equal(rdb.query("PRAGMA integrity_check").get()?.integrity_check, "ok");
337
+ rdb.close();
338
+ }
339
+ const pending = join(work, "pending.sqlite");
340
+ await run(pending, "CREATE TABLE events(id INTEGER PRIMARY KEY,value TEXT);\n");
341
+ const pendingEpoch = await stoppedCommit(pending, "wal-committed", 2);
342
+ await run(pending, "SELECT 1;\n");
343
+ assert.equal((await readFile(pending + ".ltx/position", "utf8")).split("\n")[1], pendingEpoch);
344
+ const pendingRestore = join(work, "pending-restored.sqlite");
345
+ const pendingResult = await restore(pending + ".ltx/" + pendingEpoch, pendingRestore);
346
+ assert.equal(pendingResult.files, 4); // Base, schema commit and both recovered inserts.
347
+ const pdb = new Database(pendingRestore);
348
+ assert.equal(pdb.query("SELECT count(*) n FROM events").get()?.n, 2);
349
+ assert.equal(pdb.query("PRAGMA integrity_check").get()?.integrity_check, "ok");
350
+ pdb.close();
351
+ console.log(
352
+ "PASS restart: 10 SIGKILL durability boundaries, matching-prefix resume, orphan/missing/reset fallback, pending committed WAL tail captured before serving",
353
+ );
354
+ console.log(
355
+ "PASS native LTX: 102-file chain, independent CRC/index checks, byte-exact restore, integrity, hard WAL ceiling and oversized transaction rollback",
356
+ );
357
+ } finally {
358
+ await rm(work, { recursive: true, force: true });
359
+ }