@sproutboat/runtime 0.25.0 → 0.27.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/test/native-d1.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
+ import { Database } from "bun:sqlite";
3
+ import { restore } from "./ltx-restore";
2
4
  import { closeSync, openSync, readFileSync, readdirSync, readSync, writeSync } from "node:fs";
3
5
  import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
6
  import { tmpdir } from "node:os";
@@ -47,7 +49,7 @@ int main(void) {
47
49
  if (!strncmp(line, ":break ", 7)) { line[strcspn(line, "\\t")] = 0; char p[1024]; snprintf(p, sizeof(p), "%s.ltx/position.tmp", line + 7); mkdir(p, 0700); puts("{}"); fflush(stdout); continue; }
48
50
  char* sql = strchr(line, '\\t'); if (!sql) return 2; *sql++ = 0;
49
51
  char* params = strchr(sql, '\\t'); if (!params) return 2; *params++ = 0;
50
- if (!strncmp(line, ":db ", 4)) { int r = sql[0] == 'p' ? sb_db_page_size(line + 4) : sb_db_quota_freeze(line + 4, sql[0] == 'f'); printf("{\\"n\\":%d}\\n", r); fflush(stdout); continue; }
52
+ if (!strncmp(line, ":db ", 4)) { int r = sql[0] == 'p' ? sb_db_page_size(line + 4) : sb_db_quota_freeze(line + 4, sql[0] == 'c' ? -1 : sql[0] == 'f'); printf("{\\"n\\":%d}\\n", r); fflush(stdout); continue; }
51
53
  if (!strncmp(line, ":bytes ", 7)) { line[strcspn(line, "\\t")] = 0; printf("{\\"n\\":%lld}\\n", sb_dir_bytes(line + 7, 0)); fflush(stdout); continue; }
52
54
  // ":script PATH" runs SQL as a script (sb_sql_script, what exec() uses).
53
55
  char* out = !strncmp(line, ":script ", 8) ? sb_sql_script(line + 8, sql) : sb_sql_run(line, sql, params); puts(out); fflush(stdout); free(out);
@@ -149,7 +151,9 @@ int main(void) {
149
151
  const toDriver = openSync(fifoIn, "w"),
150
152
  fromDriver = openSync(fifoOut, "r");
151
153
  let pending = "";
154
+ let driverClosed = false;
152
155
  const sqlRaw = (path: string, sql: string, params: string) => {
156
+ if (driverClosed) throw new Error("original D1 driver is closed");
153
157
  writeSync(toDriver, `${path}\t${sql}\t${params}\n`);
154
158
  const chunk = Buffer.alloc(65536);
155
159
  while (!pending.includes("\n")) {
@@ -185,6 +189,7 @@ int main(void) {
185
189
  "__sbDirBytesRaw",
186
190
  "__sbD1DbRaw",
187
191
  "__sbSql",
192
+ "__sbSqlRaw",
188
193
  `${transport.slice(quotaStart, quotaEnd)}\nreturn { __sbQuotaCheck, __sbD1Quota, __sbQuotaFull, expire() { __sbQuotaAt = -1; } };`,
189
194
  )(
190
195
  (name: string) => (name === "SB_STORAGE_MAX_BYTES" ? String(limit) : ""),
@@ -192,8 +197,70 @@ int main(void) {
192
197
  (d: string) => String(JSON.parse(sqlRaw(":bytes " + d, "", "")).n),
193
198
  (path: string, what: string) => String(JSON.parse(sqlRaw(":db " + path, what, "")).n),
194
199
  sql,
200
+ sqlRaw,
195
201
  );
196
202
  const dirBytes = (d: string) => Number(JSON.parse(sqlRaw(":bytes " + d, "", "")).n);
203
+ // Another driver process (a restarted worker), with its own FIFOs and lifted blocks.
204
+ const connect = (tag: string) => {
205
+ const inPath = join(work, "in-" + tag),
206
+ outPath = join(work, "out-" + tag);
207
+ assert.equal(Bun.spawnSync(["mkfifo", inPath, outPath]).exitCode, 0);
208
+ const child = Bun.spawn(["sh", "-c", `exec "${binary}" < "${inPath}" > "${outPath}"`], {
209
+ env: { ...process.env, SB_LTX_ENABLE: "1", SB_LTX_DATABASE: "LTX" },
210
+ });
211
+ const to = openSync(inPath, "w"),
212
+ from = openSync(outPath, "r");
213
+ let buffered = "";
214
+ const raw = (path: string, text: string, params: string) => {
215
+ writeSync(to, `${path}\t${text}\t${params}\n`);
216
+ const chunk = Buffer.alloc(65536);
217
+ while (!buffered.includes("\n")) {
218
+ const n = readSync(from, chunk, 0, chunk.length, null);
219
+ if (n <= 0) throw new Error("driver exited");
220
+ buffered += chunk.toString("utf8", 0, n);
221
+ }
222
+ const line = buffered.slice(0, buffered.indexOf("\n"));
223
+ buffered = buffered.slice(line.length + 1);
224
+ return line;
225
+ };
226
+ const run = (path: string, text: string, params: unknown[]) => {
227
+ const r = JSON.parse(raw(path, text, JSON.stringify(params)));
228
+ if (r.ok === false) throw new Error("sqlite: " + r.error);
229
+ return r;
230
+ };
231
+ // oxlint-disable-next-line no-function-constructor -- lifting a block out of the text-only transport
232
+ const lifted = new Function(
233
+ "__sbSqlRaw",
234
+ "__sbSql",
235
+ "__sbSqlScriptRaw",
236
+ `${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch, __sbD1Open, __sbD1ExecLines };`,
237
+ )(raw, run, (path: string, text: string) => raw(":script " + path, text, "[]"));
238
+ const lift = (dir: string, limit: number) =>
239
+ // oxlint-disable-next-line no-function-constructor -- lifting a block out of the text-only transport
240
+ new Function(
241
+ "__sbEnv",
242
+ "__sbDir",
243
+ "__sbDirBytesRaw",
244
+ "__sbD1DbRaw",
245
+ "__sbSql",
246
+ "__sbSqlRaw",
247
+ `${transport.slice(quotaStart, quotaEnd)}\nreturn { __sbQuotaCheck, __sbD1Quota, __sbQuotaFull };`,
248
+ )(
249
+ (name: string) => (name === "SB_STORAGE_MAX_BYTES" ? String(limit) : ""),
250
+ () => dir,
251
+ (d: string) => String(JSON.parse(raw(":bytes " + d, "", "")).n),
252
+ (path: string, what: string) => String(JSON.parse(raw(":db " + path, what, "")).n),
253
+ run,
254
+ raw,
255
+ );
256
+ return {
257
+ sqlRaw: raw,
258
+ ops: lifted,
259
+ liftQuota: lift,
260
+ exited: child.exited,
261
+ close: () => (closeSync(to), closeSync(from)),
262
+ };
263
+ };
197
264
  // exec() from 2026-10-24: line by line, all or nothing, the failing line named.
198
265
  const ex = join(work, "data/d1/EXEC.sqlite");
199
266
  ops.__sbD1ExecLines(ex, ["CREATE TABLE x (id INTEGER)", "INSERT INTO x VALUES (1); INSERT INTO x VALUES (2);"]);
@@ -282,8 +349,14 @@ int main(void) {
282
349
  // With capture on, an authorization failure says so (it read "not an error" once capture
283
350
  // had run), and the quota works: its page size comes from capture, not from the
284
351
  // page_size pragma the capture authorizer refuses.
285
- assert.throws(() => ops.__sbD1Run(ltx, "PRAGMA page_size", [], true), (e: Error) => e.message === "D1_ERROR: not authorized: SQLITE_AUTH");
286
- assert.throws(() => ops.__sbD1ExecLines(ltx, ["PRAGMA journal_mode = DELETE"]), (e: Error) => e.message === "D1_EXEC_ERROR: Error in line 1: PRAGMA journal_mode = DELETE: not authorized");
352
+ assert.throws(
353
+ () => ops.__sbD1Run(ltx, "PRAGMA page_size", [], true),
354
+ (e: Error) => e.message === "D1_ERROR: not authorized: SQLITE_AUTH",
355
+ );
356
+ assert.throws(
357
+ () => ops.__sbD1ExecLines(ltx, ["PRAGMA journal_mode = DELETE"]),
358
+ (e: Error) => e.message === "D1_EXEC_ERROR: Error in line 1: PRAGMA journal_mode = DELETE: not authorized",
359
+ );
287
360
  const data = join(work, "data");
288
361
  const roomy = liftQuota(data, dirBytes(data) + (1 << 20));
289
362
  roomy.__sbD1Quota(ltx);
@@ -295,12 +368,39 @@ int main(void) {
295
368
  const tight = liftQuota(data, 1);
296
369
  tight.__sbD1Quota(ltx);
297
370
  const spool = ltxFiles();
298
- const QUOTA = "D1_ERROR: storage quota exceeded: writes are refused while this app's data, including its replication log, is over its quota: SQLITE_FULL";
299
- for (const write of ["INSERT INTO e VALUES (11, 'x')", "UPDATE e SET v = 'y' WHERE id = 10", "DELETE FROM e WHERE id = 10"])
300
- assert.throws(() => ops.__sbD1Run(ltx, write, [], true), (e: Error) => e.message === QUOTA, write);
301
- assert.throws(() => ops.__sbD1ExecLines(ltx, ["UPDATE e SET v = 'z'"]), (e: Error) => e.message.startsWith("D1_EXEC_ERROR: Error in line 1: UPDATE e SET v = 'z': storage quota exceeded"));
302
- assert.throws(() => ops.__sbD1Batch(ltx, [{ sql: "SELECT 1", params: [] }, { sql: "DELETE FROM e", params: [] }], true), (e: Error) => e.message === QUOTA);
303
- assert.equal(ops.__sbD1Batch(ltx, [{ sql: "SELECT count(*) FROM e", params: [] }], true).length, 1, "a read-only batch runs");
371
+ const QUOTA =
372
+ "D1_ERROR: storage quota exceeded: writes are refused while this app's data, including its replication log, is over its quota: SQLITE_FULL";
373
+ for (const write of [
374
+ "INSERT INTO e VALUES (11, 'x')",
375
+ "UPDATE e SET v = 'y' WHERE id = 10",
376
+ "DELETE FROM e WHERE id = 10",
377
+ ])
378
+ assert.throws(
379
+ () => ops.__sbD1Run(ltx, write, [], true),
380
+ (e: Error) => e.message === QUOTA,
381
+ write,
382
+ );
383
+ assert.throws(
384
+ () => ops.__sbD1ExecLines(ltx, ["UPDATE e SET v = 'z'"]),
385
+ (e: Error) => e.message.startsWith("D1_EXEC_ERROR: Error in line 1: UPDATE e SET v = 'z': storage quota exceeded"),
386
+ );
387
+ assert.throws(
388
+ () =>
389
+ ops.__sbD1Batch(
390
+ ltx,
391
+ [
392
+ { sql: "SELECT 1", params: [] },
393
+ { sql: "DELETE FROM e", params: [] },
394
+ ],
395
+ true,
396
+ ),
397
+ (e: Error) => e.message === QUOTA,
398
+ );
399
+ assert.equal(
400
+ ops.__sbD1Batch(ltx, [{ sql: "SELECT count(*) FROM e", params: [] }], true).length,
401
+ 1,
402
+ "a read-only batch runs",
403
+ );
304
404
  assert.equal(ops.__sbD1Run(ltx, "SELECT v FROM e WHERE id = 10", [], true).rows[0][0], "under the quota");
305
405
  assert.equal(ltxFiles(), spool, "no LTX file while over the quota");
306
406
  const again = liftQuota(data, dirBytes(data) + (1 << 20));
@@ -314,10 +414,14 @@ int main(void) {
314
414
  "__sbSql",
315
415
  "__sbSqlScriptRaw",
316
416
  `${transport.slice(opsStart, opsEnd)}\nreturn { __sbD1Run, __sbD1Batch };`,
317
- )((path: string, text: string, params: string) => {
318
- if (text === "RELEASE sb_d1_batch") sqlRaw(":break " + path, "", "");
319
- return sqlRaw(path, text, params);
320
- }, sql, scriptRaw);
417
+ )(
418
+ (path: string, text: string, params: string) => {
419
+ if (text === "RELEASE sb_d1_batch") sqlRaw(":break " + path, "", "");
420
+ return sqlRaw(path, text, params);
421
+ },
422
+ sql,
423
+ scriptRaw,
424
+ );
321
425
  const LTX_FAILED =
322
426
  "D1_ERROR: LTX capture failed: the transaction may be committed locally but is not durable in the replication log; writes are refused until restart: SQLITE_IOERR";
323
427
  assert.throws(
@@ -336,7 +440,9 @@ int main(void) {
336
440
  );
337
441
  assert.throws(
338
442
  () => ops.__sbD1ExecLines(ltx, ["INSERT INTO e VALUES (5, 'e')"]),
339
- (e: Error) => e.message === "D1_ERROR: LTX capture failed: the transaction may be committed locally but is not durable in the replication log; writes are refused until restart: SQLITE_IOERR",
443
+ (e: Error) =>
444
+ e.message ===
445
+ "D1_ERROR: LTX capture failed: the transaction may be committed locally but is not durable in the replication log; writes are refused until restart: SQLITE_IOERR",
340
446
  "exec() after a capture failure",
341
447
  );
342
448
  const qdir = join(work, "quota");
@@ -349,25 +455,86 @@ int main(void) {
349
455
  // SQLITE_FULL; then the directory is over, a KV write is refused, and reads and deletes
350
456
  // keep working. VACUUM gives the space back and writes resume.
351
457
  let rows = 0;
352
- assert.throws(() => {
353
- for (; rows < 64; rows++) ops.__sbD1Run(qdb, "INSERT INTO q VALUES (zeroblob(65536))", [], true);
354
- }, (e: Error) => e.message === "D1_ERROR: database or disk is full: SQLITE_FULL");
458
+ assert.throws(
459
+ () => {
460
+ for (; rows < 64; rows++) ops.__sbD1Run(qdb, "INSERT INTO q VALUES (zeroblob(65536))", [], true);
461
+ },
462
+ (e: Error) => e.message === "D1_ERROR: database or disk is full: SQLITE_FULL",
463
+ );
355
464
  assert(rows >= 2 && rows < 5, "filled to about 256 KiB: " + rows);
356
465
  quota.expire();
357
466
  assert.equal(quota.__sbQuotaFull(), true);
358
- assert.throws(() => quota.__sbQuotaCheck("KV put()"), /^Error: KV put\(\) failed: storage quota exceeded \(this app stores \d+ bytes; the limit is 262144\)$/);
467
+ assert.throws(
468
+ () => quota.__sbQuotaCheck("KV put()"),
469
+ /^Error: KV put\(\) failed: storage quota exceeded \(this app stores \d+ bytes; the limit is 262144\)$/,
470
+ );
359
471
  quota.__sbD1Quota(qdb);
360
472
  assert.equal(ops.__sbD1Run(qdb, "SELECT count(*) FROM q", [], true).rows[0][0], rows, "reads still work");
361
473
  ops.__sbD1Run(qdb, "DELETE FROM q", [], true);
362
474
  ops.__sbD1Run(qdb, "VACUUM", [], true);
363
- ops.__sbD1Run(qdb, "PRAGMA wal_checkpoint(TRUNCATE)", [], true);
475
+ // No checkpoint by the app: over the quota the runtime checkpoints and truncates the WAL
476
+ // (once a second) before measuring again, so DELETE and VACUUM alone make room.
477
+ await Bun.sleep(1000);
364
478
  quota.expire();
365
479
  quota.__sbD1Quota(qdb);
366
480
  quota.__sbQuotaCheck("KV put()");
367
481
  ops.__sbD1Run(qdb, "INSERT INTO q VALUES (zeroblob(65536))", [], true);
482
+ driverClosed = true;
368
483
  closeSync(toDriver);
369
484
  closeSync(fromDriver);
370
485
  await driver.exited;
486
+
487
+ // A restart that finds the app over its quota, capture on: the first D1 call of the new
488
+ // worker (process) already freezes. Reads work, writes are refused, the spool and its
489
+ // position stay exactly as they were, and the chain restores the database byte for byte.
490
+ // A capture failure still reads as one, over the quota too.
491
+ await rm(ltx + ".ltx/position.tmp", { recursive: true, force: true });
492
+ const second = connect("restart");
493
+ const quota2 = second.liftQuota(data, 1);
494
+ quota2.__sbD1Quota(ltx);
495
+ const spoolBefore = readdirSync(generation()).sort();
496
+ const positionBefore = readFileSync(ltx + ".ltx/position", "utf8");
497
+ const count = second.ops.__sbD1Run(ltx, "SELECT count(*) FROM e", [], true).rows[0][0];
498
+ for (const write of ["INSERT INTO e VALUES (20, 'x')", "UPDATE e SET v = v", "DELETE FROM e"])
499
+ assert.throws(
500
+ () => second.ops.__sbD1Run(ltx, write, [], true),
501
+ (e: Error) => e.message === QUOTA,
502
+ "restart, over the quota: " + write,
503
+ );
504
+ assert.deepEqual(readdirSync(generation()).sort(), spoolBefore, "no LTX file after a clean resume over the quota");
505
+ assert.equal(readFileSync(ltx + ".ltx/position", "utf8"), positionBefore, "the position is unchanged");
506
+ second.close();
507
+ await second.exited;
508
+ {
509
+ const live = new Database(ltx);
510
+ live.exec("PRAGMA wal_checkpoint(TRUNCATE)");
511
+ assert.equal(live.query("SELECT count(*) AS n FROM e").get()?.n, count);
512
+ live.close();
513
+ const restored = join(work, "restart-restored.sqlite");
514
+ await restore(generation(), restored);
515
+ assert.deepEqual(
516
+ await readFile(restored),
517
+ await readFile(ltx),
518
+ "the retained chain restores the database byte for byte",
519
+ );
520
+ }
521
+ const third = connect("failure");
522
+ const thirdBytes = Number(JSON.parse(third.sqlRaw(":bytes " + data, "", "")).n);
523
+ third.liftQuota(data, thirdBytes + (1 << 20)).__sbD1Quota(ltx);
524
+ const breaking3 = (path: string, text: string, params: string) => {
525
+ if (text.startsWith("INSERT INTO e VALUES (30")) third.sqlRaw(":break " + path, "", "");
526
+ return third.sqlRaw(path, text, params);
527
+ };
528
+ const brokenWrite = JSON.parse(breaking3(ltx, "INSERT INTO e VALUES (30, 'x')", "[]"));
529
+ assert.equal(brokenWrite.code, 10, "a capture failure reports SQLITE_IOERR");
530
+ const failed3 = JSON.parse(third.sqlRaw(ltx, "INSERT INTO e VALUES (31, 'y')", "[]"));
531
+ assert.equal(failed3.code, 10, "after a capture failure, writes report it");
532
+ third.liftQuota(data, 1).__sbD1Quota(ltx);
533
+ const failedOver = JSON.parse(third.sqlRaw(ltx, "INSERT INTO e VALUES (32, 'z')", "[]"));
534
+ assert.equal(failedOver.code, 10, "over the quota too, the capture failure is what a write reports");
535
+ assert.match(failedOver.error, /^LTX capture failed/);
536
+ third.close();
537
+ await third.exited;
371
538
  console.log(
372
539
  "native d1: sb_sql_run and the D1 ops pass (blob binds, atomic batch, strict counts, foreign keys, error text)",
373
540
  );
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtemp, readFile, readdir, rm, writeFile, stat } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile, stat } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { Database } from "bun:sqlite";
@@ -24,12 +24,18 @@ const source = `
24
24
  #include <sys/stat.h>
25
25
  #include <time.h>
26
26
  #include <signal.h>
27
+ #include <errno.h>
28
+ // Reproduce denial of global temporary-file access on every test platform.
29
+ // The sproutd sandbox qualification separately exercises real Linux Landlock.
30
+ static FILE *forbidden_tmpfile(void) { errno=EACCES; return NULL; }
31
+ #define tmpfile forbidden_tmpfile
27
32
  #define SB_MAX_DB 16
28
33
  ${transport.slice(start, end)}
29
34
  int main(int argc,char **argv) {
30
35
  if(argc!=2)return 2;sb_ltx_compat=1;if(sb_ltx_register())return 3;
31
36
  char lockpath[1024];snprintf(lockpath,sizeof(lockpath),"%s.ltx.lock",argv[1]);
32
37
  int lock=open(lockpath,O_RDWR|O_CREAT,0600);if(lock<0||flock(lock,LOCK_EX|LOCK_NB))return 4;
38
+ if(getenv("SB_LTX_TEST_HOLD_LOCK")){puts("locked");fflush(stdout);getchar();close(lock);return 0;}
33
39
  sqlite3 *db=0;if(sqlite3_open_v2(argv[1],&db,6,"sb-ltx-bounded"))return 5;
34
40
  if(getenv("SB_LTX_TEST_AUTO_VACUUM"))sqlite3_exec(db,"PRAGMA auto_vacuum=INCREMENTAL;",0,0,0);
35
41
  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;
@@ -153,6 +159,13 @@ async function restore(epoch: string, output: string) {
153
159
  await writeFile(output, db);
154
160
  return { files: files.length, txid };
155
161
  }
162
+ async function noIndexScratch(path: string) {
163
+ const entries = await readdir(path + ".ltx", { withFileTypes: true });
164
+ assert(
165
+ !entries.some((entry) => entry.isFile() && /^index-[a-zA-Z0-9]{6}$/.test(entry.name)),
166
+ "index scratch file leaked",
167
+ );
168
+ }
156
169
  async function run(path: string, sql: string, extra: Record<string, string> = {}) {
157
170
  const child = Bun.spawn([binary, path], {
158
171
  env: { ...process.env, ...extra },
@@ -169,6 +182,7 @@ async function run(path: string, sql: string, extra: Record<string, string> = {}
169
182
  ]);
170
183
  assert.equal(code, 0, error);
171
184
  assert(out.startsWith("ready\n"), out);
185
+ await noIndexScratch(path);
172
186
  return out.split("\n").slice(1).filter(Boolean);
173
187
  }
174
188
  async function stoppedCommit(path: string, phase: string, count = 1) {
@@ -212,6 +226,10 @@ async function stoppedCommit(path: string, phase: string, count = 1) {
212
226
  }
213
227
  child.kill("SIGKILL");
214
228
  await child.exited;
229
+ if (phase === "index-created") {
230
+ const entries = await readdir(path + ".ltx", { withFileTypes: true });
231
+ assert.equal(entries.filter((entry) => entry.isFile() && /^index-[a-zA-Z0-9]{6}$/.test(entry.name)).length, 1);
232
+ } else await noIndexScratch(path);
215
233
  assert(stopped, "did not reach crash boundary " + phase);
216
234
  return epoch;
217
235
  }
@@ -241,6 +259,45 @@ try {
241
259
  const cleanPosition = await readFile(root + "/position", "utf8");
242
260
  assert(cleanPosition.split("\n")[2].startsWith("C "));
243
261
  const finishedBefore = await readdir(join(root, epoch));
262
+ // SIGKILL can land between mkstemp and unlink. Only the next lock owner may
263
+ // discard that reserved scratch name; unrelated files and links stay intact.
264
+ const strayIndex = join(root, "index-abc123");
265
+ await writeFile(strayIndex, "stale index");
266
+ const unrelatedIndex = join(root, "index-not-scratch");
267
+ await writeFile(unrelatedIndex, "keep");
268
+ const indexDirectory = join(root, "index-DIR123");
269
+ await mkdir(indexDirectory);
270
+ await writeFile(join(indexDirectory, "keep"), "keep directory");
271
+ const outside = join(work, "outside-index-target");
272
+ await writeFile(outside, "keep target");
273
+ const indexLink = join(root, "index-LNK123");
274
+ await symlink(outside, indexLink);
275
+ const owner = Bun.spawn([binary, path], {
276
+ stdin: "pipe",
277
+ stdout: "pipe",
278
+ stderr: "pipe",
279
+ env: { ...process.env, SB_LTX_TEST_HOLD_LOCK: "1" },
280
+ });
281
+ const lockReader = owner.stdout.getReader();
282
+ try {
283
+ const ready = await lockReader.read();
284
+ assert.equal(new TextDecoder().decode(ready.value), "locked\n");
285
+ const blocked = Bun.spawnSync([binary, path], { stdin: "ignore" });
286
+ assert.equal(blocked.exitCode, 4, "non-owner cannot initialize capture");
287
+ assert.equal(await readFile(strayIndex, "utf8"), "stale index", "non-owner must not sweep indexes");
288
+ } finally {
289
+ owner.stdin.end();
290
+ await owner.exited;
291
+ lockReader.releaseLock();
292
+ }
293
+ await run(path, "SELECT 1;\n");
294
+ assert(!(await readdir(root)).includes("index-abc123"), "resume must reclaim stale index");
295
+ assert.equal(await readFile(unrelatedIndex, "utf8"), "keep");
296
+ assert.equal(await readFile(join(indexDirectory, "keep"), "utf8"), "keep directory");
297
+ assert.equal(await readFile(indexLink, "utf8"), "keep target");
298
+ assert.equal(await readFile(outside, "utf8"), "keep target");
299
+ assert.equal(await readFile(root + "/position", "utf8"), cleanPosition);
300
+ assert.deepEqual(await readdir(join(root, epoch)), finishedBefore);
244
301
  for (let n = 0; n < 5; n++) {
245
302
  await run(path, "SELECT 1;\n");
246
303
  assert.equal(await readFile(root + "/position", "utf8"), cleanPosition, "idle restart must not rewrite position");
@@ -361,6 +418,7 @@ try {
361
418
  failedDb.close();
362
419
  const phases = [
363
420
  "wal-committed",
421
+ "index-created",
364
422
  "ltx-written",
365
423
  "ltx-fsynced",
366
424
  "ltx-published",
@@ -408,7 +466,7 @@ try {
408
466
  assert.equal(pdb.query("PRAGMA integrity_check").get()?.integrity_check, "ok");
409
467
  pdb.close();
410
468
  console.log(
411
- "PASS restart: 10 SIGKILL durability boundaries, matching-prefix resume, orphan/missing/reset fallback, pending committed WAL tail captured before serving",
469
+ "PASS restart: 11 SIGKILL durability boundaries, matching-prefix resume, orphan/missing/reset fallback, pending committed WAL tail captured before serving",
412
470
  );
413
471
  console.log(
414
472
  "PASS native LTX: 102-file chain, independent CRC/index checks, byte-exact restore, integrity, hard WAL ceiling and oversized transaction rollback",