@tinycloud/replica 0.2.4-beta.1 → 0.2.4-beta.3

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/dist/sqlite.cjs CHANGED
@@ -228,6 +228,29 @@ async function loadSqlite() {
228
228
 
229
229
  // src/sqlite/store.ts
230
230
  var SCHEMA_VERSION = 1;
231
+ var sqliteOpenQueue = Promise.resolve();
232
+ async function acquireSqliteOpen() {
233
+ const previous = sqliteOpenQueue;
234
+ let release;
235
+ sqliteOpenQueue = new Promise((resolve2) => {
236
+ release = resolve2;
237
+ });
238
+ await previous;
239
+ return release;
240
+ }
241
+ async function captureNamedPath(path) {
242
+ const names = [];
243
+ for (let current = (0, import_node_path.resolve)(path); ; current = (0, import_node_path.dirname)(current)) {
244
+ names.push(current);
245
+ if ((0, import_node_path.dirname)(current) === current) break;
246
+ }
247
+ const identities = [];
248
+ for (const name of names.reverse()) {
249
+ const stats = await (0, import_promises.lstat)(name, { bigint: true });
250
+ identities.push({ path: name, dev: stats.dev, ino: stats.ino });
251
+ }
252
+ return identities;
253
+ }
231
254
  var DURABILITY = "sqlite-wal-synchronous-full+fsynced-blobs";
232
255
  var SCHEMA = `
233
256
  CREATE TABLE IF NOT EXISTS replica (
@@ -326,22 +349,78 @@ async function entriesOf(path) {
326
349
  }
327
350
  }
328
351
  var FAULTS = /* @__PURE__ */ Symbol("tinycloud.replica.faults");
352
+ var pinnedDirectories = new FinalizationRegistry((fd) => {
353
+ try {
354
+ (0, import_node_fs.closeSync)(fd);
355
+ } catch {
356
+ }
357
+ });
358
+ var writableStores = /* @__PURE__ */ new Map();
359
+ function releaseWritable(shared) {
360
+ if (shared.references === 0 || --shared.references !== 0) return;
361
+ if (writableStores.get(shared.key) === shared) writableStores.delete(shared.key);
362
+ try {
363
+ shared.db.close();
364
+ } finally {
365
+ if (shared.directoryFd !== void 0) (0, import_node_fs.closeSync)(shared.directoryFd);
366
+ }
367
+ }
368
+ var sharedStores = new FinalizationRegistry((shared) => {
369
+ try {
370
+ releaseWritable(shared);
371
+ } catch {
372
+ }
373
+ });
374
+ async function acquireWritableMutation(shared) {
375
+ const previous = shared.mutationTail;
376
+ let release;
377
+ shared.mutationTail = new Promise((resolve2) => {
378
+ release = resolve2;
379
+ });
380
+ await previous;
381
+ return release;
382
+ }
329
383
  var SqliteReplicaStore = class _SqliteReplicaStore {
330
384
  dir;
331
- #db;
385
+ #database;
386
+ get #db() {
387
+ if (this.#closed) throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica store is closed.");
388
+ return this.#database;
389
+ }
390
+ #closed = false;
391
+ #closing = false;
392
+ #closePromise;
393
+ #activeMutations = 0;
394
+ #drained;
395
+ #shared;
396
+ #pinnedDir;
397
+ #directoryFd;
332
398
  #holder = `${process.pid}-${(0, import_node_crypto.randomBytes)(6).toString("hex")}`;
333
399
  #now;
334
400
  #faults;
335
401
  #guard;
336
402
  /** Identity of the database file this store opened: a deleted-and-recreated replica is a different file. */
337
403
  #ino;
338
- constructor(dir, db, ino, options) {
404
+ #dev;
405
+ #namedAncestors;
406
+ constructor(dir, db, identity, options, directoryFd, namedAncestors = [], shared) {
339
407
  this.dir = dir;
340
- this.#db = db;
341
- this.#ino = ino;
408
+ this.#pinnedDir = shared?.pinnedDir ?? (directoryFd !== void 0 && process.platform === "linux" ? `/proc/self/fd/${directoryFd}` : dir);
409
+ this.#directoryFd = directoryFd;
410
+ this.#shared = shared;
411
+ if (shared) {
412
+ shared.references++;
413
+ sharedStores.register(this, shared, this);
414
+ } else if (directoryFd !== void 0) {
415
+ pinnedDirectories.register(this, directoryFd, this);
416
+ }
417
+ this.#database = db;
418
+ this.#ino = identity.ino;
419
+ this.#dev = identity.dev;
342
420
  this.#now = options.now ?? Date.now;
343
421
  this.#faults = options[FAULTS] ?? {};
344
422
  this.#guard = options.guard ?? ((section) => section());
423
+ this.#namedAncestors = namedAncestors;
345
424
  }
346
425
  /**
347
426
  * Read-only status snapshot; does not create a replica, migrate, recover
@@ -354,9 +433,10 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
354
433
  };
355
434
  checkAbort();
356
435
  const dbPath = (0, import_node_path.join)(dir, "replica.db");
357
- let ino;
436
+ let identity;
358
437
  try {
359
- ino = (await (0, import_promises.stat)(dbPath)).ino;
438
+ identity = await (0, import_promises.lstat)(dbPath);
439
+ if (!identity.isFile()) return null;
360
440
  } catch (error) {
361
441
  if (errnoOf(error) === "ENOENT") return null;
362
442
  throw storageError(error, "Inspecting the replica");
@@ -369,14 +449,14 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
369
449
  db = opener(dbPath, { readonly: true });
370
450
  db.exec("PRAGMA busy_timeout = 0");
371
451
  checkAbort();
372
- const store = new _SqliteReplicaStore(dir, db, ino, { create: false, ...options.now ? { now: options.now } : {} });
452
+ const store = new _SqliteReplicaStore(dir, db, identity, { create: false, ...options.now ? { now: options.now } : {} });
373
453
  for (let attempt = 0; attempt < 2; attempt++) {
374
454
  const before = db.get("PRAGMA data_version").data_version;
375
455
  const state = await store.open();
376
456
  checkAbort();
377
457
  if (state === null) return null;
378
458
  const status = await store.status();
379
- await new Promise((resolve) => setImmediate(resolve));
459
+ await new Promise((resolve2) => setImmediate(resolve2));
380
460
  checkAbort();
381
461
  const after = db.get("PRAGMA data_version").data_version;
382
462
  if (before === after) return { state, status };
@@ -396,95 +476,233 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
396
476
  * REPLICA_NOT_FOUND and nothing is written to disk.
397
477
  */
398
478
  static async open(dir, options) {
399
- const dbPath = (0, import_node_path.join)(dir, "replica.db");
479
+ const namedDbPath = (0, import_node_path.join)(dir, "replica.db");
400
480
  if (!options.create) {
401
- const exists = await (0, import_promises.stat)(dbPath).then(
402
- () => true,
481
+ const exists = await (0, import_promises.lstat)(namedDbPath).then(
482
+ (stats) => stats.isFile(),
403
483
  () => false
404
484
  );
405
- if (!exists) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, `No replica at ${dir}.`);
485
+ if (!exists) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, `No regular replica database at ${dir}.`);
406
486
  }
407
- const opener = options.sqlite ?? await loadSqlite();
487
+ const releaseOpen = await acquireSqliteOpen();
408
488
  let store;
489
+ let directoryFd;
409
490
  try {
410
- if (options.create) {
411
- await (0, import_promises.mkdir)((0, import_node_path.join)(dir, "blobs", ".tmp"), { recursive: true, mode: 448 });
412
- await Promise.all([(0, import_promises.chmod)(dir, 448), (0, import_promises.chmod)((0, import_node_path.join)(dir, "blobs"), 448), (0, import_promises.chmod)((0, import_node_path.join)(dir, "blobs", ".tmp"), 448)]);
413
- const handle = await (0, import_promises.open)(dbPath, import_node_fs.constants.O_CREAT | import_node_fs.constants.O_RDWR, 384);
414
- await handle.close();
415
- await (0, import_promises.chmod)(dbPath, 384);
491
+ if (options.create) await (0, import_promises.mkdir)(dir, { recursive: true, mode: 448 });
492
+ if (process.platform === "linux") {
493
+ directoryFd = (0, import_node_fs.openSync)(dir, import_node_fs.constants.O_RDONLY | import_node_fs.constants.O_DIRECTORY | import_node_fs.constants.O_NOFOLLOW);
416
494
  }
417
- const ino = (await (0, import_promises.stat)(dbPath)).ino;
418
- const db = opener(dbPath);
419
- db.exec("PRAGMA busy_timeout = 5000");
420
- db.exec("PRAGMA journal_mode = WAL");
421
- db.exec("PRAGMA synchronous = FULL");
422
- db.exec("PRAGMA secure_delete = ON");
423
- db.exec("PRAGMA foreign_keys = ON");
424
- const version = db.get("PRAGMA user_version")?.user_version ?? 0;
425
- if (version > SCHEMA_VERSION) {
426
- db.close();
427
- throw new ReplicaError(
428
- ReplicaErrorCode.STORAGE_ERROR,
429
- `The replica at ${dir} was written by a newer tc (schema ${version}); upgrade tc to read it.`
430
- );
495
+ const opened = directoryFd === void 0 ? (0, import_node_fs.lstatSync)(dir, { bigint: true }) : (0, import_node_fs.fstatSync)(directoryFd, { bigint: true });
496
+ if (!opened.isDirectory() || opened.isSymbolicLink() || options.expectedDirectory && (opened.dev !== BigInt(options.expectedDirectory.dev) || opened.ino !== BigInt(options.expectedDirectory.ino))) {
497
+ throw new Error("The replica directory changed before SQLite opened it.");
431
498
  }
432
- if (version < SCHEMA_VERSION) {
433
- db.exec("BEGIN IMMEDIATE");
434
- db.exec(SCHEMA);
435
- db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
436
- db.exec("COMMIT");
499
+ const namedAncestors = process.platform === "linux" ? [] : await captureNamedPath(dir);
500
+ if (namedAncestors.length && (namedAncestors[namedAncestors.length - 1].dev !== opened.dev || namedAncestors[namedAncestors.length - 1].ino !== opened.ino)) {
501
+ throw new Error("The replica directory changed while checking its ancestry.");
502
+ }
503
+ const pinnedDir = directoryFd === void 0 ? dir : `/proc/self/fd/${directoryFd}`;
504
+ const dbPath = (0, import_node_path.join)(pinnedDir, "replica.db");
505
+ if (options.create) {
506
+ await (0, import_promises.mkdir)((0, import_node_path.join)(pinnedDir, "blobs", ".tmp"), { recursive: true, mode: 448 });
507
+ await Promise.all([(0, import_promises.chmod)(pinnedDir, 448), (0, import_promises.chmod)((0, import_node_path.join)(pinnedDir, "blobs"), 448), (0, import_promises.chmod)((0, import_node_path.join)(pinnedDir, "blobs", ".tmp"), 448)]);
508
+ const handle = await (0, import_promises.open)(dbPath, import_node_fs.constants.O_CREAT | import_node_fs.constants.O_EXCL | import_node_fs.constants.O_RDWR | import_node_fs.constants.O_NOFOLLOW, 384).catch((error) => {
509
+ if (errnoOf(error) === "EEXIST") return null;
510
+ throw error;
511
+ });
512
+ if (handle) {
513
+ try {
514
+ await handle.chmod(384);
515
+ } finally {
516
+ await handle.close();
517
+ }
518
+ }
519
+ }
520
+ const leaf = await (0, import_promises.open)(dbPath, import_node_fs.constants.O_RDONLY | import_node_fs.constants.O_NOFOLLOW);
521
+ let db;
522
+ try {
523
+ const identity = await leaf.stat();
524
+ const named = await (0, import_promises.lstat)(dbPath);
525
+ if (!identity.isFile() || !named.isFile() || named.dev !== identity.dev || named.ino !== identity.ino) {
526
+ throw new Error("The replica database changed before SQLite opened it.");
527
+ }
528
+ const key = `${opened.dev}:${opened.ino}`;
529
+ const active = writableStores.get(key);
530
+ if (active && (active.dev !== identity.dev || active.ino !== identity.ino)) {
531
+ throw new Error("The replica database changed while another controller holds it open.");
532
+ }
533
+ if (active && !options.sqlite) {
534
+ const namedDirectory = (0, import_node_fs.lstatSync)(dir, { bigint: true });
535
+ if (!namedDirectory.isDirectory() || namedDirectory.dev !== opened.dev || namedDirectory.ino !== opened.ino) {
536
+ throw new Error("The replica directory changed before its checked SQLite handle could be shared.");
537
+ }
538
+ const pinned = active.directoryFd === void 0 ? (0, import_node_fs.lstatSync)(active.pinnedDir, { bigint: true }) : (0, import_node_fs.fstatSync)(active.directoryFd, { bigint: true });
539
+ const activeLeaf = (0, import_node_fs.lstatSync)((0, import_node_path.join)(active.pinnedDir, "replica.db"));
540
+ if (!pinned.isDirectory() || pinned.dev !== opened.dev || pinned.ino !== opened.ino || !activeLeaf.isFile() || activeLeaf.dev !== identity.dev || activeLeaf.ino !== identity.ino || active.directoryFd === void 0 && active.namedPath !== dir) {
541
+ throw new Error("The live replica changed before its checked SQLite handle could be shared.");
542
+ }
543
+ if (directoryFd !== void 0) {
544
+ (0, import_node_fs.closeSync)(directoryFd);
545
+ directoryFd = void 0;
546
+ }
547
+ store = new _SqliteReplicaStore(dir, active.db, identity, options, active.directoryFd, namedAncestors, active);
548
+ }
549
+ if (!store) {
550
+ const opener = options.sqlite ?? await loadSqlite();
551
+ const previousFds = process.platform === "linux" && !options.sqlite ? new Map((0, import_node_fs.readdirSync)("/proc/self/fd").map((fd) => {
552
+ try {
553
+ return [fd, (0, import_node_fs.readlinkSync)(`/proc/self/fd/${fd}`)];
554
+ } catch {
555
+ return [fd, ""];
556
+ }
557
+ })) : null;
558
+ db = opener(dbPath);
559
+ const afterOpen = (0, import_node_fs.lstatSync)(dbPath);
560
+ if (!afterOpen.isFile() || afterOpen.dev !== identity.dev || afterOpen.ino !== identity.ino) {
561
+ throw new Error("The replica database changed while SQLite opened it.");
562
+ }
563
+ if (previousFds) db.get("SELECT count(*) FROM sqlite_master");
564
+ if (previousFds) {
565
+ const databases = (0, import_node_fs.readdirSync)("/proc/self/fd").filter((fd) => {
566
+ try {
567
+ const target = (0, import_node_fs.readlinkSync)(`/proc/self/fd/${fd}`);
568
+ return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
569
+ } catch {
570
+ return false;
571
+ }
572
+ });
573
+ if (databases.length === 0 || databases.some((fd) => {
574
+ try {
575
+ const actual = (0, import_node_fs.fstatSync)(Number(fd));
576
+ return actual.dev !== identity.dev || actual.ino !== identity.ino;
577
+ } catch {
578
+ return true;
579
+ }
580
+ })) {
581
+ throw new Error("SQLite opened a different replica database.");
582
+ }
583
+ }
584
+ const after = (0, import_node_fs.lstatSync)(dbPath);
585
+ if (!after.isFile() || after.dev !== identity.dev || after.ino !== identity.ino) {
586
+ throw new Error("The replica database changed while SQLite opened it.");
587
+ }
588
+ db.exec("PRAGMA busy_timeout = 5000");
589
+ db.exec("PRAGMA journal_mode = WAL");
590
+ db.exec("PRAGMA synchronous = FULL");
591
+ db.exec("PRAGMA secure_delete = ON");
592
+ db.exec("PRAGMA foreign_keys = ON");
593
+ const version = db.get("PRAGMA user_version")?.user_version ?? 0;
594
+ if (version > SCHEMA_VERSION) {
595
+ throw new ReplicaError(
596
+ ReplicaErrorCode.STORAGE_ERROR,
597
+ `The replica at ${dir} was written by a newer tc (schema ${version}); upgrade tc to read it.`
598
+ );
599
+ }
600
+ if (version < SCHEMA_VERSION) {
601
+ db.exec("BEGIN IMMEDIATE");
602
+ db.exec(SCHEMA);
603
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
604
+ db.exec("COMMIT");
605
+ }
606
+ const shared = options.sqlite ? void 0 : {
607
+ key,
608
+ db,
609
+ directoryFd,
610
+ pinnedDir,
611
+ namedPath: dir,
612
+ dev: identity.dev,
613
+ ino: identity.ino,
614
+ references: 0,
615
+ mutationTail: Promise.resolve()
616
+ };
617
+ store = new _SqliteReplicaStore(dir, db, identity, options, directoryFd, namedAncestors, shared);
618
+ if (shared) writableStores.set(key, shared);
619
+ db = void 0;
620
+ }
621
+ } finally {
622
+ try {
623
+ db?.close();
624
+ } finally {
625
+ await leaf.close();
626
+ }
437
627
  }
438
- store = new _SqliteReplicaStore(dir, db, ino, options);
439
628
  } catch (error) {
629
+ if (store) await store.close().catch(() => void 0);
630
+ else if (directoryFd !== void 0) (0, import_node_fs.closeSync)(directoryFd);
440
631
  throw storageError(error, `Opening the replica at ${dir}`);
632
+ } finally {
633
+ releaseOpen();
441
634
  }
442
- const row = store.#row();
443
- if (row?.revoked_detail != null && (row.purge_pending === 1 || await store.#holdsContentFiles().catch(() => true))) {
444
- await store.#finishRevocationPurge().catch(() => void 0);
635
+ try {
636
+ const row = store.#row();
637
+ if (row?.revoked_detail != null && (row.purge_pending === 1 || await store.#holdsContentFiles().catch(() => true))) {
638
+ await store.#finishRevocationPurge().catch(() => void 0);
639
+ }
640
+ return store;
641
+ } catch (error) {
642
+ await store.close().catch(() => void 0);
643
+ throw error;
445
644
  }
446
- return store;
447
645
  }
448
646
  #row() {
449
647
  return this.#db.get("SELECT * FROM replica WHERE id = 1");
450
648
  }
451
649
  /** Refuse to touch the filesystem for a replica that was deleted (or replaced) under us. */
452
650
  async #assertPresent() {
453
- const ino = await (0, import_promises.stat)((0, import_node_path.join)(this.dir, "replica.db")).then(
454
- (stats) => stats.ino,
455
- () => void 0
456
- );
457
- if (ino !== this.#ino) {
651
+ const identity = await (0, import_promises.lstat)((0, import_node_path.join)(this.#pinnedDir, "replica.db")).catch(() => null);
652
+ if (!identity?.isFile() || identity.dev !== this.#dev || identity.ino !== this.#ino) {
458
653
  throw new ReplicaError(
459
654
  ReplicaErrorCode.NOT_FOUND,
460
655
  `The replica at ${this.dir} was deleted while this command ran (its profile was deleted or the replica purged); nothing more was written.`
461
656
  );
462
657
  }
463
658
  }
659
+ /** New calls stop at close; accepted mutation sections may still finish their transactions. */
660
+ #assertAccepting() {
661
+ if (this.#closing || this.#closed) {
662
+ throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica store is closed.");
663
+ }
664
+ }
464
665
  /** A short mutation section: guarded (profile lock) and checked against the database this store opened. */
465
666
  #mutate(section) {
466
- return this.#guard(async () => {
467
- await this.#assertPresent();
468
- return section();
469
- });
667
+ this.#assertAccepting();
668
+ this.#activeMutations++;
669
+ return (async () => {
670
+ try {
671
+ return await this.#guard(async () => {
672
+ await this.#assertPresent();
673
+ return section();
674
+ });
675
+ } finally {
676
+ if (--this.#activeMutations === 0) {
677
+ const drained = this.#drained;
678
+ this.#drained = void 0;
679
+ drained?.();
680
+ }
681
+ }
682
+ })();
470
683
  }
471
684
  /** Run `body` in a write transaction (BEGIN IMMEDIATE). Callers outside a mutation section use `#guardedWrite`. */
472
- #write(action, body) {
473
- try {
474
- this.#db.exec("BEGIN IMMEDIATE");
475
- } catch (error) {
476
- throw storageError(error, action);
477
- }
685
+ async #write(action, body) {
686
+ const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
478
687
  try {
479
- const result = body();
480
- this.#db.exec("COMMIT");
481
- return result;
482
- } catch (error) {
483
688
  try {
484
- this.#db.exec("ROLLBACK");
485
- } catch {
689
+ this.#db.exec("BEGIN IMMEDIATE");
690
+ } catch (error) {
691
+ throw storageError(error, action);
692
+ }
693
+ try {
694
+ const result = body();
695
+ this.#db.exec("COMMIT");
696
+ return result;
697
+ } catch (error) {
698
+ try {
699
+ this.#db.exec("ROLLBACK");
700
+ } catch {
701
+ }
702
+ throw storageError(error, action);
486
703
  }
487
- throw storageError(error, action);
704
+ } finally {
705
+ release?.();
488
706
  }
489
707
  }
490
708
  /** One write transaction as its own mutation section. */
@@ -511,6 +729,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
511
729
  }
512
730
  }
513
731
  async open() {
732
+ this.#assertAccepting();
514
733
  let row;
515
734
  try {
516
735
  row = this.#row();
@@ -672,6 +891,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
672
891
  });
673
892
  }
674
893
  async hasContent(hashes) {
894
+ this.#assertAccepting();
675
895
  const held = /* @__PURE__ */ new Set();
676
896
  for (let start = 0; start < hashes.length; start += 500) {
677
897
  const chunk = hashes.slice(start, start + 500);
@@ -684,7 +904,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
684
904
  return held;
685
905
  }
686
906
  #blobPath(hash) {
687
- return (0, import_node_path.join)(this.dir, "blobs", hash.slice(0, 2), hash);
907
+ return (0, import_node_path.join)(this.#pinnedDir, "blobs", hash.slice(0, 2), hash);
688
908
  }
689
909
  /**
690
910
  * Make every blob durable before any row references it: write to a fresh
@@ -701,11 +921,11 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
701
921
  () => false
702
922
  );
703
923
  if (exists) continue;
704
- const shard = (0, import_node_path.join)(this.dir, "blobs", hash.slice(0, 2));
924
+ const shard = (0, import_node_path.join)(this.#pinnedDir, "blobs", hash.slice(0, 2));
705
925
  await (0, import_promises.mkdir)(shard, { mode: 448 }).catch((error) => {
706
926
  if (errnoOf(error) !== "EEXIST") throw error;
707
927
  });
708
- const temp = (0, import_node_path.join)(this.dir, "blobs", ".tmp", `${hash}.${(0, import_node_crypto.randomBytes)(6).toString("hex")}`);
928
+ const temp = (0, import_node_path.join)(this.#pinnedDir, "blobs", ".tmp", `${hash}.${(0, import_node_crypto.randomBytes)(6).toString("hex")}`);
709
929
  const handle = await (0, import_promises.open)(temp, import_node_fs.constants.O_CREAT | import_node_fs.constants.O_EXCL | import_node_fs.constants.O_WRONLY, 384);
710
930
  try {
711
931
  await handle.writeFile(bytes);
@@ -721,7 +941,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
721
941
  await (0, import_promises.unlink)(temp);
722
942
  }
723
943
  dirs.add(shard);
724
- dirs.add((0, import_node_path.join)(this.dir, "blobs"));
944
+ dirs.add((0, import_node_path.join)(this.#pinnedDir, "blobs"));
725
945
  }
726
946
  for (const dir of dirs) {
727
947
  const handle = await (0, import_promises.open)(dir, import_node_fs.constants.O_RDONLY);
@@ -754,7 +974,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
754
974
  }
755
975
  await this.#faults.afterBlobs?.();
756
976
  try {
757
- this.#write("Committing a sync page", () => {
977
+ await this.#write("Committing a sync page", () => {
758
978
  this.#checkLease(t);
759
979
  const now = this.#now();
760
980
  if (p.window.notBefore !== null && now < Date.parse(p.window.notBefore)) {
@@ -829,7 +1049,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
829
1049
  /** Unlink every blob not in `keep`, and every temp file. Only a missing directory counts as empty. */
830
1050
  async #unlinkBlobs(keep) {
831
1051
  let removed = 0;
832
- const root = (0, import_node_path.join)(this.dir, "blobs");
1052
+ const root = (0, import_node_path.join)(this.#pinnedDir, "blobs");
833
1053
  for (const shard of await entriesOf(root)) {
834
1054
  const shardPath = (0, import_node_path.join)(root, shard);
835
1055
  if (shard === ".tmp") {
@@ -854,7 +1074,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
854
1074
  }
855
1075
  /** Whether any content file (a blob or a temp file) is on disk. */
856
1076
  async #holdsContentFiles() {
857
- const root = (0, import_node_path.join)(this.dir, "blobs");
1077
+ const root = (0, import_node_path.join)(this.#pinnedDir, "blobs");
858
1078
  for (const shard of await entriesOf(root)) {
859
1079
  if ((await entriesOf((0, import_node_path.join)(root, shard))).length > 0) return true;
860
1080
  }
@@ -862,13 +1082,16 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
862
1082
  }
863
1083
  async collectGarbage(t) {
864
1084
  return this.#mutate(async () => {
1085
+ const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
865
1086
  try {
866
1087
  this.#db.exec("BEGIN IMMEDIATE");
867
1088
  } catch (error) {
1089
+ release?.();
868
1090
  throw storageError(error, "Collecting unused content");
869
1091
  }
870
1092
  try {
871
1093
  this.#checkLease(t);
1094
+ await this.#faults.afterGarbageCollectionBegin?.();
872
1095
  const keep = new Set(
873
1096
  this.#db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash)
874
1097
  );
@@ -881,12 +1104,14 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
881
1104
  } catch {
882
1105
  }
883
1106
  throw storageError(error, "Collecting unused content");
1107
+ } finally {
1108
+ release?.();
884
1109
  }
885
1110
  });
886
1111
  }
887
1112
  async reset(t, reason, options = {}) {
888
1113
  await this.#mutate(async () => {
889
- this.#write("Resetting the replica", () => {
1114
+ await this.#write("Resetting the replica", () => {
890
1115
  this.#checkLease(t);
891
1116
  this.#db.run("DELETE FROM entry");
892
1117
  this.#db.run(
@@ -913,15 +1138,102 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
913
1138
  */
914
1139
  async destroy(t) {
915
1140
  await this.#mutate(async () => {
1141
+ const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
916
1142
  try {
917
1143
  this.#db.exec("BEGIN IMMEDIATE");
918
1144
  } catch (error) {
1145
+ release?.();
919
1146
  throw storageError(error, "Removing the replica");
920
1147
  }
921
1148
  try {
922
1149
  this.#checkLease(t, { allowRevoked: true });
1150
+ const expected = this.#directoryFd === void 0 ? this.#namedAncestors.at(-1) : (0, import_node_fs.fstatSync)(this.#directoryFd, { bigint: true });
1151
+ if (!expected) {
1152
+ throw new ReplicaError(ReplicaErrorCode.RUNTIME_UNSUPPORTED, "Replica removal requires an opened or identity-checked directory.");
1153
+ }
1154
+ const assertDirectory = async () => {
1155
+ const current = await (0, import_promises.lstat)(this.dir, { bigint: true });
1156
+ if (!current.isDirectory() || current.dev !== expected.dev || current.ino !== expected.ino) {
1157
+ throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica directory changed during removal.");
1158
+ }
1159
+ return current;
1160
+ };
1161
+ const pinned = await assertDirectory();
923
1162
  await this.#assertPresent();
924
- await (0, import_promises.rm)(this.dir, { recursive: true, force: true });
1163
+ if (process.platform === "linux") {
1164
+ for (const entry of await (0, import_promises.readdir)(this.#pinnedDir)) {
1165
+ await (0, import_promises.rm)((0, import_node_path.join)(this.#pinnedDir, entry), { recursive: true, force: true });
1166
+ }
1167
+ await assertDirectory();
1168
+ await (0, import_promises.rmdir)(this.dir);
1169
+ } else {
1170
+ const ancestors = this.#namedAncestors;
1171
+ const checkAncestors = async () => {
1172
+ for (const expected2 of ancestors) {
1173
+ const current = await (0, import_promises.lstat)(expected2.path, { bigint: true });
1174
+ if (current.dev !== expected2.dev || current.ino !== expected2.ino) {
1175
+ throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica ancestry changed during removal.");
1176
+ }
1177
+ }
1178
+ };
1179
+ const directories = /* @__PURE__ */ new Map([[this.dir, pinned]]);
1180
+ const files = /* @__PURE__ */ new Map();
1181
+ const check = async (path, expected2, directory) => {
1182
+ if (path !== this.dir) {
1183
+ const parent = (0, import_node_path.dirname)(path);
1184
+ await check(parent, directories.get(parent), true);
1185
+ }
1186
+ await checkAncestors();
1187
+ const current = await (0, import_promises.lstat)(path, { bigint: true });
1188
+ if ((directory ? !current.isDirectory() : !current.isFile()) || current.dev !== expected2.dev || current.ino !== expected2.ino) {
1189
+ throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica layout changed during removal.");
1190
+ }
1191
+ };
1192
+ const record = async (path, directory) => {
1193
+ const parent = (0, import_node_path.dirname)(path);
1194
+ await check(parent, directories.get(parent), true);
1195
+ const current = await (0, import_promises.lstat)(path, { bigint: true });
1196
+ if (!(directory ? current.isDirectory() : current.isFile())) {
1197
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica layout during removal.");
1198
+ }
1199
+ (directory ? directories : files).set(path, current);
1200
+ };
1201
+ const blobs = (0, import_node_path.join)(this.dir, "blobs");
1202
+ for (const name of await (0, import_promises.readdir)(this.dir)) {
1203
+ if (name === "blobs") await record(blobs, true);
1204
+ else if (["replica.db", "replica.db-wal", "replica.db-shm"].includes(name)) await record((0, import_node_path.join)(this.dir, name), false);
1205
+ else throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica layout during removal.");
1206
+ }
1207
+ if (directories.has(blobs)) {
1208
+ await check(blobs, directories.get(blobs), true);
1209
+ for (const shard of await (0, import_promises.readdir)(blobs)) {
1210
+ if (shard !== ".tmp" && !/^[0-9a-f]{2}$/.test(shard)) {
1211
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica blob shard during removal.");
1212
+ }
1213
+ const shardPath = (0, import_node_path.join)(blobs, shard);
1214
+ await record(shardPath, true);
1215
+ await check(shardPath, directories.get(shardPath), true);
1216
+ for (const name of await (0, import_promises.readdir)(shardPath)) {
1217
+ if (shard === ".tmp" ? !/^[0-9a-f]{64}\.[0-9a-f]{12}$/.test(name) : !isHash(name) || !name.startsWith(shard)) {
1218
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica blob file during removal.");
1219
+ }
1220
+ await record((0, import_node_path.join)(shardPath, name), false);
1221
+ }
1222
+ }
1223
+ }
1224
+ for (const [path, identity] of files) {
1225
+ await check(path, identity, false);
1226
+ await (0, import_promises.unlink)(path);
1227
+ }
1228
+ for (const [path, identity] of [...directories].reverse()) {
1229
+ if (path === this.dir) continue;
1230
+ await check(path, identity, true);
1231
+ await (0, import_promises.rmdir)(path);
1232
+ }
1233
+ await assertDirectory();
1234
+ await checkAncestors();
1235
+ await (0, import_promises.rmdir)(this.dir);
1236
+ }
925
1237
  } catch (error) {
926
1238
  throw storageError(error, "Removing the replica");
927
1239
  } finally {
@@ -929,6 +1241,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
929
1241
  this.#db.exec("ROLLBACK");
930
1242
  } catch {
931
1243
  }
1244
+ release?.();
932
1245
  }
933
1246
  });
934
1247
  }
@@ -972,7 +1285,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
972
1285
  * when both are done.
973
1286
  */
974
1287
  async #purgeContent() {
975
- this.#write("Starting the revocation purge", () => {
1288
+ await this.#write("Starting the revocation purge", () => {
976
1289
  this.#db.run("UPDATE replica SET purge_pending = 1 WHERE id = 1 AND revoked_detail IS NOT NULL");
977
1290
  });
978
1291
  try {
@@ -983,7 +1296,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
983
1296
  if (!this.#checkpoint()) {
984
1297
  throw new ReplicaError(ReplicaErrorCode.BUSY, "A reader holds the replica's write-ahead log; the purge finishes on the next open.");
985
1298
  }
986
- this.#write("Finishing the revocation purge", () => {
1299
+ await this.#write("Finishing the revocation purge", () => {
987
1300
  this.#db.run("UPDATE replica SET purge_pending = 0 WHERE id = 1 AND revoked_detail IS NOT NULL");
988
1301
  });
989
1302
  }
@@ -993,6 +1306,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
993
1306
  });
994
1307
  }
995
1308
  async get(key) {
1309
+ this.#assertAccepting();
996
1310
  try {
997
1311
  const row = this.#db.get("SELECT key, deleted, etag, hash, metadata, content FROM entry WHERE key = ?", key);
998
1312
  return row === void 0 ? void 0 : entryOf(row);
@@ -1001,6 +1315,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1001
1315
  }
1002
1316
  }
1003
1317
  async list(o) {
1318
+ this.#assertAccepting();
1004
1319
  const where = ["deleted = 0"];
1005
1320
  const params = [];
1006
1321
  if (o.prefix !== void 0 && o.prefix !== "") {
@@ -1086,8 +1401,31 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1086
1401
  lastReset: state.lastReset
1087
1402
  };
1088
1403
  }
1089
- async close() {
1090
- this.#db.close();
1404
+ close() {
1405
+ if (this.#closePromise) return this.#closePromise;
1406
+ this.#closing = true;
1407
+ this.#closePromise = (async () => {
1408
+ if (this.#activeMutations !== 0) {
1409
+ await new Promise((resolve2) => {
1410
+ this.#drained = resolve2;
1411
+ });
1412
+ }
1413
+ this.#closed = true;
1414
+ const fd = this.#directoryFd;
1415
+ this.#directoryFd = void 0;
1416
+ if (this.#shared) {
1417
+ sharedStores.unregister(this);
1418
+ releaseWritable(this.#shared);
1419
+ } else {
1420
+ if (fd !== void 0) pinnedDirectories.unregister(this);
1421
+ try {
1422
+ this.#database.close();
1423
+ } finally {
1424
+ if (fd !== void 0) (0, import_node_fs.closeSync)(fd);
1425
+ }
1426
+ }
1427
+ })();
1428
+ return this.#closePromise;
1091
1429
  }
1092
1430
  };
1093
1431
  // Annotate the CommonJS export names for ESM import in node: