@tinycloud/replica 0.2.4-beta.2 → 0.3.0-beta.10

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.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // src/sqlite/store.ts
2
+ import { AsyncLocalStorage } from "async_hooks";
2
3
  import { randomBytes } from "crypto";
3
- import { closeSync, constants as fsConstants, fstatSync, lstatSync, openSync, readdirSync, readlinkSync } from "fs";
4
- import { chmod, link, lstat, mkdir, open, readFile, readdir, rm, rmdir, stat, unlink } from "fs/promises";
5
- import { dirname, join, resolve } from "path";
4
+ import { closeSync, constants as fsConstants2, fstatSync, lstatSync, openSync, readdirSync, readlinkSync } from "fs";
5
+ import { chmod, link, lstat as lstat2, mkdir, open as open2, readFile, readdir as readdir2, rm, rmdir as rmdir2, stat, unlink as unlink2 } from "fs/promises";
6
+ import { dirname, join as join2, resolve as resolve2 } from "path";
6
7
 
7
8
  // src/errors.ts
8
9
  var ReplicaErrorCode = {
@@ -60,7 +61,13 @@ var ReplicaErrorCode = {
60
61
  /** A stored blob no longer hashes to its name. */
61
62
  INTEGRITY_ERROR: "INTEGRITY_ERROR",
62
63
  /** The replica handle was closed; in-flight calls settle with this code. */
63
- CLOSED: "REPLICA_CLOSED"
64
+ CLOSED: "REPLICA_CLOSED",
65
+ /** The lifecycle gate refused the operation (shared contract `REPLICA_GATED`); `detail.gate` says why. */
66
+ GATED: "REPLICA_GATED",
67
+ /** Quota admission refused a page (`maxBytes` or the free-space floor); nothing committed. */
68
+ STORAGE_BUDGET: "STORAGE_BUDGET",
69
+ /** The consent deadline passed: the grant is not served until the user consents again. */
70
+ CONSENT_EXPIRED: "CONSENT_EXPIRED"
64
71
  };
65
72
  var REPLICA_ERROR = /* @__PURE__ */ Symbol.for("tinycloud.replica.error");
66
73
  var ReplicaError = class extends Error {
@@ -197,32 +204,13 @@ async function loadSqlite() {
197
204
  return (path, options) => wrap(options?.readonly ? new DatabaseSync(path, { readOnly: true }) : new DatabaseSync(path));
198
205
  }
199
206
 
200
- // src/sqlite/store.ts
201
- var SCHEMA_VERSION = 1;
202
- var sqliteOpenQueue = Promise.resolve();
203
- async function acquireSqliteOpen() {
204
- const previous = sqliteOpenQueue;
205
- let release;
206
- sqliteOpenQueue = new Promise((resolve2) => {
207
- release = resolve2;
208
- });
209
- await previous;
210
- return release;
211
- }
212
- async function captureNamedPath(path) {
213
- const names = [];
214
- for (let current = resolve(path); ; current = dirname(current)) {
215
- names.push(current);
216
- if (dirname(current) === current) break;
217
- }
218
- const identities = [];
219
- for (const name of names.reverse()) {
220
- const stats = await lstat(name, { bigint: true });
221
- identities.push({ path: name, dev: stats.dev, ino: stats.ino });
222
- }
223
- return identities;
224
- }
225
- var DURABILITY = "sqlite-wal-synchronous-full+fsynced-blobs";
207
+ // src/sqlite/layout.ts
208
+ import { constants as fsConstants } from "fs";
209
+ import { lstat, open, readdir, rmdir, unlink } from "fs/promises";
210
+ import { join, parse, resolve, sep } from "path";
211
+
212
+ // src/sqlite/schema.ts
213
+ var SCHEMA_VERSION = 2;
226
214
  var SCHEMA = `
227
215
  CREATE TABLE IF NOT EXISTS replica (
228
216
  id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -282,7 +270,403 @@ CREATE TABLE IF NOT EXISTS entry (
282
270
  )
283
271
  ) STRICT, WITHOUT ROWID;
284
272
  CREATE INDEX IF NOT EXISTS entry_hash ON entry (hash) WHERE hash IS NOT NULL;
273
+ CREATE TABLE IF NOT EXISTS grant_install (
274
+ cid TEXT PRIMARY KEY,
275
+ installed_at INTEGER NOT NULL,
276
+ effective_expires_at INTEGER NOT NULL,
277
+ consent_deadline INTEGER NOT NULL,
278
+ consent_anchor_at INTEGER NOT NULL,
279
+ consent_anchor_source TEXT NOT NULL
280
+ ) STRICT, WITHOUT ROWID;
285
281
  `;
282
+
283
+ // src/sqlite/layout.ts
284
+ var POLICY_COLUMNS = [
285
+ "grant_installed_at REAL",
286
+ "grant_effective_expires_at REAL",
287
+ "grant_consent_deadline REAL",
288
+ "grant_consent_anchor_at REAL",
289
+ "grant_consent_anchor_source TEXT",
290
+ "pending_grant_installed_at REAL",
291
+ "pending_grant_effective_expires_at REAL",
292
+ "pending_grant_consent_deadline REAL",
293
+ "pending_grant_consent_anchor_at REAL",
294
+ "pending_grant_consent_anchor_source TEXT"
295
+ ];
296
+ var normalizeSchema = (sql) => sql.replace(" IF NOT EXISTS", "").replace(/\s+/g, " ").trim();
297
+ var SOURCE_SCHEMA = SCHEMA.trim().split(";").map((sql) => sql.trim()).filter(Boolean).map((sql) => {
298
+ const match = /^CREATE (TABLE|INDEX) IF NOT EXISTS ([a-z_]+)/.exec(sql);
299
+ if (match === null) throw new Error("Invalid source replica schema.");
300
+ return { type: match[1].toLowerCase(), name: match[2], sql: normalizeSchema(sql) };
301
+ });
302
+ var V1_SCHEMA = SOURCE_SCHEMA.filter((entry) => entry.name !== "grant_install");
303
+ var POLICY_REPLICA = V1_SCHEMA.find((entry) => entry.name === "replica").sql.replace(/\) STRICT$/, `, ${POLICY_COLUMNS.join(", ")}) STRICT`);
304
+ var POLICY_SCHEMA = V1_SCHEMA.map((entry) => entry.name === "replica" ? { ...entry, sql: POLICY_REPLICA } : entry);
305
+ async function sourceMirrorSchema(path) {
306
+ let db;
307
+ try {
308
+ db = (await loadSqlite())(path, { readonly: true });
309
+ db.exec("BEGIN");
310
+ const version = db.get("PRAGMA user_version")?.user_version;
311
+ if (version !== 1 && version !== SCHEMA_VERSION) return false;
312
+ const rows = db.all(
313
+ "SELECT type, name, sql FROM sqlite_schema WHERE sql IS NOT NULL ORDER BY name"
314
+ );
315
+ const expected = version === 1 ? V1_SCHEMA : SOURCE_SCHEMA;
316
+ const matches = (schema) => rows.length === schema.length && rows.every((row) => {
317
+ const source = schema.find((entry) => entry.name === row.name);
318
+ return source?.type === row.type && source.sql === normalizeSchema(row.sql);
319
+ });
320
+ if (matches(expected)) return true;
321
+ if (version !== 2) return false;
322
+ return matches(POLICY_SCHEMA);
323
+ } catch {
324
+ return false;
325
+ } finally {
326
+ try {
327
+ db?.close();
328
+ } catch {
329
+ }
330
+ }
331
+ }
332
+ var SQLITE_FILE = /^(?:replica\.db|replica\.db-(?:wal|shm|journal))$/;
333
+ var SHARD = /^[0-9a-f]{2}$/;
334
+ var HASH = /^[0-9a-f]{64}$/;
335
+ var TEMP = /^[0-9a-f]{64}\.[0-9a-f]{12}$/;
336
+ function sourceKind(parent, name, allowAdapterFence) {
337
+ if (parent === "") {
338
+ if (SQLITE_FILE.test(name) || allowAdapterFence && (name === "fence.json" || /^fence[.]json[.][0-9a-f]{12}[.]tmp$/.test(name))) return "file";
339
+ return name === "blobs" ? "directory" : "unknown";
340
+ }
341
+ if (parent === "blobs") return name === ".tmp" || SHARD.test(name) ? "directory" : "unknown";
342
+ if (parent === "blobs/.tmp") return TEMP.test(name) ? "file" : "unknown";
343
+ if (parent.startsWith("blobs/") && SHARD.test(parent.slice(6))) {
344
+ return HASH.test(name) && name.startsWith(parent.slice(6)) ? "file" : "unknown";
345
+ }
346
+ return "unknown";
347
+ }
348
+ function sameFile(a, b) {
349
+ return a.dev === b.dev && a.ino === b.ino;
350
+ }
351
+ function identityOf(path, kind, details) {
352
+ return { path, kind, dev: details.dev.toString(), ino: details.ino.toString() };
353
+ }
354
+ function fileKind(details) {
355
+ if (details.isFile()) return "file";
356
+ if (details.isDirectory()) return "directory";
357
+ if (details.isSymbolicLink()) return "symlink";
358
+ throw new Error("Unsupported replica entry type.");
359
+ }
360
+ async function pathComponents(path) {
361
+ const absolute = resolve(path);
362
+ const parts = absolute.slice(parse(absolute).root.length).split(sep).filter(Boolean);
363
+ let current = parse(absolute).root;
364
+ const components = [];
365
+ for (const part of ["", ...parts]) {
366
+ if (part) current = join(current, part);
367
+ const details = await lstat(current, { bigint: true });
368
+ if (details.isSymbolicLink()) throw new Error(`Symlink in replica path: ${current}`);
369
+ if (!details.isDirectory()) throw new Error(`Replica path component is not a directory: ${current}`);
370
+ components.push({ path: current, identity: details });
371
+ }
372
+ return components;
373
+ }
374
+ async function pinnedDirectory(path, parent, relative = "") {
375
+ const components = process.platform === "linux" ? void 0 : parent?.components ?? await pathComponents(path);
376
+ const before = await lstat(path, { bigint: true });
377
+ if (before.isSymbolicLink()) throw new Error(`Symlink in replica path: ${path}`);
378
+ if (!before.isDirectory()) throw new Error("Replica directory is not a real directory.");
379
+ const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
380
+ try {
381
+ if (!sameFile(before, await handle.stat({ bigint: true }))) {
382
+ throw new Error("Replica directory changed during inspection.");
383
+ }
384
+ return {
385
+ handle,
386
+ identity: before,
387
+ parent,
388
+ components,
389
+ children: [],
390
+ relative,
391
+ name: path,
392
+ path: process.platform === "linux" ? `/proc/self/fd/${handle.fd}` : path
393
+ };
394
+ } catch (error) {
395
+ await handle.close();
396
+ throw error;
397
+ }
398
+ }
399
+ async function checkDirectory(directory) {
400
+ const details = await lstat(directory.name, { bigint: true });
401
+ if (!details.isDirectory() || !sameFile(directory.identity, details)) {
402
+ throw new Error(`Replica directory changed: ${directory.relative || directory.name}`);
403
+ }
404
+ if (directory.parent) await checkDirectory(directory.parent);
405
+ else if (directory.components) {
406
+ for (const component of directory.components) {
407
+ const current = await lstat(component.path, { bigint: true });
408
+ if (current.isSymbolicLink() || !current.isDirectory() || !sameFile(component.identity, current)) {
409
+ throw new Error(`Replica path component changed: ${component.path}`);
410
+ }
411
+ }
412
+ }
413
+ }
414
+ async function inspect(dir, allowAdapterFence) {
415
+ const root = await pinnedDirectory(dir);
416
+ const directories = [root];
417
+ const unknownPaths = [];
418
+ const knownEntries = [];
419
+ const unknownEntries = [];
420
+ let hasDatabase = false;
421
+ try {
422
+ async function walk(parent) {
423
+ await checkDirectory(parent);
424
+ for (const name of (await readdir(parent.path)).sort()) {
425
+ await checkDirectory(parent);
426
+ const relative = parent.relative ? `${parent.relative}/${name}` : name;
427
+ const path = join(parent.path, name);
428
+ const details = await lstat(path, { bigint: true });
429
+ if (process.platform !== "linux" && details.isSymbolicLink()) {
430
+ throw new Error(`Symlink in replica path: ${relative}`);
431
+ }
432
+ const kind = fileKind(details);
433
+ const child = { path, relative, kind, identity: details, parent };
434
+ parent.children.push(child);
435
+ if (parent.relative === "" && name === "replica.db" && kind === "file") hasDatabase = true;
436
+ if (sourceKind(parent.relative, name, allowAdapterFence) !== kind) {
437
+ unknownPaths.push(relative);
438
+ unknownEntries.push(identityOf(relative, kind, details));
439
+ } else {
440
+ knownEntries.push(identityOf(relative, kind, details));
441
+ }
442
+ if (kind === "directory") {
443
+ const directory = await pinnedDirectory(path, parent, relative);
444
+ if (!sameFile(details, directory.identity)) {
445
+ await directory.handle.close();
446
+ throw new Error(`Replica directory changed during inspection: ${relative}`);
447
+ }
448
+ child.directory = directory;
449
+ directories.push(directory);
450
+ await walk(directory);
451
+ }
452
+ }
453
+ }
454
+ await walk(root);
455
+ if (!hasDatabase) {
456
+ unknownPaths.push("replica.db");
457
+ } else if (!await sourceMirrorSchema(join(root.path, "replica.db"))) {
458
+ const index = knownEntries.findIndex((entry) => entry.path === "replica.db");
459
+ if (index >= 0) unknownEntries.push(knownEntries.splice(index, 1)[0]);
460
+ unknownPaths.push("replica.db");
461
+ }
462
+ await checkDirectory(root);
463
+ const names = new Set(await readdir(root.path));
464
+ for (let index = root.children.length - 1; index >= 0; index--) {
465
+ const child = root.children[index];
466
+ if (!names.has(child.relative)) {
467
+ if (child.directory) throw new Error(`Replica directory vanished: ${child.relative}`);
468
+ root.children.splice(index, 1);
469
+ for (const list of [knownEntries, unknownEntries]) {
470
+ const entryIndex = list.findIndex((entry) => entry.path === child.relative);
471
+ if (entryIndex >= 0) list.splice(entryIndex, 1);
472
+ }
473
+ const pathIndex = unknownPaths.indexOf(child.relative);
474
+ if (pathIndex >= 0) unknownPaths.splice(pathIndex, 1);
475
+ continue;
476
+ }
477
+ const current = await lstat(child.path, { bigint: true });
478
+ if (!sameFile(child.identity, current) || fileKind(current) !== child.kind) {
479
+ throw new Error(`Replica child changed during schema inspection: ${child.relative}`);
480
+ }
481
+ }
482
+ for (const name of names) {
483
+ if (root.children.some((child) => child.relative === name)) continue;
484
+ const path = join(root.path, name);
485
+ const details = await lstat(path, { bigint: true });
486
+ if (!/^replica\.db-(?:wal|shm|journal)$/.test(name) || !details.isFile()) {
487
+ throw new Error(`Unexpected replica child during schema inspection: ${name}`);
488
+ }
489
+ root.children.push({ path, relative: name, kind: "file", identity: details, parent: root });
490
+ knownEntries.push(identityOf(name, "file", details));
491
+ }
492
+ return { root, directories, knownEntries, unknownPaths, unknownEntries };
493
+ } catch (error) {
494
+ await closeDirectories(directories);
495
+ throw error;
496
+ }
497
+ }
498
+ async function closeDirectories(directories) {
499
+ for (const directory of directories.reverse()) await directory.handle.close();
500
+ }
501
+ async function inspectSqliteReplicaLayout(dir, options = {}) {
502
+ const inventory = await inspect(dir, options.allowAdapterFence === true);
503
+ try {
504
+ return {
505
+ knownEntries: inventory.knownEntries,
506
+ unknownPaths: inventory.unknownPaths,
507
+ unknownEntries: inventory.unknownEntries,
508
+ rootIdentity: { dev: inventory.root.identity.dev.toString(), ino: inventory.root.identity.ino.toString() }
509
+ };
510
+ } finally {
511
+ await closeDirectories(inventory.directories);
512
+ }
513
+ }
514
+ function approvedEntries(entries) {
515
+ const approved = /* @__PURE__ */ new Map();
516
+ for (const entry of entries) {
517
+ if (typeof entry !== "object" || entry === null || Object.keys(entry).sort().join(",") !== "dev,ino,kind,path" || typeof entry.path !== "string" || !entry.path || entry.path.split("/").some((part) => !part || part === "." || part === ".." || part.includes("\\")) || !["file", "directory", "symlink"].includes(entry.kind) || typeof entry.dev !== "string" || !/^(0|[1-9][0-9]*)$/.test(entry.dev) || typeof entry.ino !== "string" || !/^(0|[1-9][0-9]*)$/.test(entry.ino) || approved.has(entry.path)) {
518
+ throw new Error("Invalid or duplicate replica approval.");
519
+ }
520
+ approved.set(entry.path, entry);
521
+ }
522
+ return approved;
523
+ }
524
+ var LAYOUT_FAULTS = /* @__PURE__ */ Symbol("tinycloud.replica.layout-faults");
525
+ async function removeSqliteReplicaLayout(dir, options = {}) {
526
+ const inventory = await inspect(dir, options.allowAdapterFence === true);
527
+ try {
528
+ if (options.expectedRoot && (options.expectedRoot.dev !== inventory.root.identity.dev.toString() || options.expectedRoot.ino !== inventory.root.identity.ino.toString())) {
529
+ throw new Error("Replica root identity changed since approval.");
530
+ }
531
+ const approved = approvedEntries(options.approvedUnknown ?? []);
532
+ for (const entry of inventory.unknownEntries) {
533
+ const match = approved.get(entry.path);
534
+ if (!match || match.kind !== entry.kind || match.dev !== entry.dev || match.ino !== entry.ino) {
535
+ throw new Error(`Unknown replica child or approval identity mismatch: ${entry.path}`);
536
+ }
537
+ }
538
+ if (options.expectedKnown) {
539
+ const expected = approvedEntries(options.expectedKnown);
540
+ for (const entry of inventory.knownEntries) {
541
+ const match = expected.get(entry.path);
542
+ if (!match || match.kind !== entry.kind || match.dev !== entry.dev || match.ino !== entry.ino) {
543
+ throw new Error(`Known replica child changed since preflight: ${entry.path}`);
544
+ }
545
+ }
546
+ }
547
+ if (options.allowMissingDatabase && inventory.knownEntries.some((entry) => entry.path === "replica.db")) {
548
+ throw new Error("Replica database appeared after approval of its absence.");
549
+ }
550
+ if (inventory.unknownPaths.length !== inventory.unknownEntries.length && inventory.root.children.length > 0 && !options.allowMissingDatabase) {
551
+ throw new Error("Replica database is missing; cannot approve removal.");
552
+ }
553
+ async function removeDirectory(directory) {
554
+ for (const child of directory.children.sort((a, b) => a.relative === "replica.db" ? 1 : b.relative === "replica.db" ? -1 : 0)) {
555
+ if (child.directory) {
556
+ await removeDirectory(child.directory);
557
+ }
558
+ await options[LAYOUT_FAULTS]?.beforeUnlink?.(child.relative);
559
+ await checkDirectory(directory);
560
+ const details = await lstat(child.path, { bigint: true });
561
+ if (process.platform !== "linux" && details.isSymbolicLink()) {
562
+ throw new Error(`Symlink in replica path: ${child.relative}`);
563
+ }
564
+ if (fileKind(details) !== child.kind || !sameFile(child.identity, details)) {
565
+ throw new Error(`Replica child identity changed: ${child.relative}`);
566
+ }
567
+ if (child.directory) await rmdir(child.path);
568
+ else await unlink(child.path);
569
+ }
570
+ }
571
+ await removeDirectory(inventory.root);
572
+ await checkDirectory(inventory.root);
573
+ await rmdir(inventory.root.name);
574
+ } finally {
575
+ await closeDirectories(inventory.directories);
576
+ }
577
+ }
578
+
579
+ // src/sqlite/store.ts
580
+ var sqliteOpenQueue = Promise.resolve();
581
+ async function acquireSqliteOpen() {
582
+ const previous = sqliteOpenQueue;
583
+ let release;
584
+ sqliteOpenQueue = new Promise((resolve3) => {
585
+ release = resolve3;
586
+ });
587
+ await previous;
588
+ return release;
589
+ }
590
+ async function captureNamedPath(path) {
591
+ const names = [];
592
+ for (let current = resolve2(path); ; current = dirname(current)) {
593
+ names.push(current);
594
+ if (dirname(current) === current) break;
595
+ }
596
+ const identities = [];
597
+ for (const name of names.reverse()) {
598
+ const stats = await lstat2(name, { bigint: true });
599
+ identities.push({ path: name, dev: stats.dev, ino: stats.ino });
600
+ }
601
+ return identities;
602
+ }
603
+ async function openVerifiedDatabase(path, opener, options = {}) {
604
+ const leaf = await open2(path, fsConstants2.O_RDONLY | fsConstants2.O_NOFOLLOW);
605
+ let db;
606
+ try {
607
+ const identity = await leaf.stat();
608
+ const named = await lstat2(path);
609
+ if (!identity.isFile() || !named.isFile() || named.dev !== identity.dev || named.ino !== identity.ino || options.expected !== void 0 && (options.expected.dev !== identity.dev || options.expected.ino !== identity.ino)) {
610
+ throw new Error("The replica database changed before SQLite opened it.");
611
+ }
612
+ const previousFds = process.platform === "linux" && options.discoverDescriptors !== false ? new Map(readdirSync("/proc/self/fd").map((fd) => {
613
+ try {
614
+ return [fd, readlinkSync(`/proc/self/fd/${fd}`)];
615
+ } catch {
616
+ return [fd, ""];
617
+ }
618
+ })) : null;
619
+ db = options.readonly ? opener(path, { readonly: true }) : opener(path);
620
+ const afterOpen = lstatSync(path);
621
+ if (!afterOpen.isFile() || afterOpen.dev !== identity.dev || afterOpen.ino !== identity.ino) {
622
+ throw new Error("The replica database changed while SQLite opened it.");
623
+ }
624
+ if (previousFds) db.get("SELECT count(*) FROM sqlite_master");
625
+ if (previousFds) {
626
+ const databases = readdirSync("/proc/self/fd").filter((fd) => {
627
+ try {
628
+ const target = readlinkSync(`/proc/self/fd/${fd}`);
629
+ return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
630
+ } catch {
631
+ return false;
632
+ }
633
+ });
634
+ const sharedPager = options.readonly && databases.length === 0 && [...previousFds].some(([fd, target]) => {
635
+ if (Number(fd) === leaf.fd || !target.endsWith("/replica.db")) return false;
636
+ try {
637
+ const actual = fstatSync(Number(fd));
638
+ return actual.dev === identity.dev && actual.ino === identity.ino;
639
+ } catch {
640
+ return false;
641
+ }
642
+ });
643
+ if (!databases.length && !sharedPager || databases.some((fd) => {
644
+ try {
645
+ const actual = fstatSync(Number(fd));
646
+ return actual.dev !== identity.dev || actual.ino !== identity.ino;
647
+ } catch {
648
+ return true;
649
+ }
650
+ })) {
651
+ throw new Error("SQLite opened a different replica database.");
652
+ }
653
+ }
654
+ const after = lstatSync(path);
655
+ if (!after.isFile() || after.dev !== identity.dev || after.ino !== identity.ino) {
656
+ throw new Error("The replica database changed while SQLite opened it.");
657
+ }
658
+ const checked = db;
659
+ db = void 0;
660
+ return { db: checked, identity };
661
+ } finally {
662
+ try {
663
+ db?.close();
664
+ } finally {
665
+ await leaf.close();
666
+ }
667
+ }
668
+ }
669
+ var DURABILITY = "sqlite-wal-synchronous-full+fsynced-blobs";
286
670
  function grantOf(cid, bytes, audience, issuer, nbf, exp) {
287
671
  if (cid === null || bytes === null || audience === null || issuer === null) return null;
288
672
  return { cid, bytes: new Uint8Array(bytes), audience, issuer, notBefore: nbf, expiresAt: exp };
@@ -311,9 +695,79 @@ var isHash = (name) => /^[0-9a-f]{64}$/.test(name);
311
695
  function errnoOf(error) {
312
696
  return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
313
697
  }
698
+ var isShard = (name) => /^[0-9a-f]{2}$/.test(name);
699
+ var isTempName = (name) => /^[0-9a-f]{64}\.[0-9a-f]{12}$/.test(name);
700
+ async function writerBlobFiles(root) {
701
+ const out = [];
702
+ let shards;
703
+ try {
704
+ shards = await readdir2(root, { withFileTypes: true });
705
+ } catch (error) {
706
+ if (errnoOf(error) === "ENOENT" || errnoOf(error) === "ENOTDIR") return out;
707
+ throw error;
708
+ }
709
+ for (const shard of shards) {
710
+ const temp = shard.name === ".tmp";
711
+ if (!shard.isDirectory() || !(temp || isShard(shard.name))) continue;
712
+ const shardPath = join2(root, shard.name);
713
+ let files;
714
+ try {
715
+ files = await readdir2(shardPath, { withFileTypes: true });
716
+ } catch (error) {
717
+ if (errnoOf(error) === "ENOENT" || errnoOf(error) === "ENOTDIR") continue;
718
+ throw error;
719
+ }
720
+ for (const file of files) {
721
+ if (!file.isFile()) continue;
722
+ const ok = temp ? isTempName(file.name) : isHash(file.name) && file.name.startsWith(shard.name);
723
+ if (!ok) continue;
724
+ const path = join2(shardPath, file.name);
725
+ const info = await lstat2(path).catch(() => void 0);
726
+ if (!info?.isFile()) continue;
727
+ out.push({ path, hash: temp ? null : file.name, size: info.size });
728
+ }
729
+ }
730
+ return out;
731
+ }
732
+ async function unlinkWriterOrphans(root, keep) {
733
+ let removed = 0;
734
+ const touched = /* @__PURE__ */ new Set();
735
+ for (const file of await writerBlobFiles(root)) {
736
+ if (file.hash !== null && keep.has(file.hash)) continue;
737
+ await rm(file.path, { force: true });
738
+ touched.add(dirname(file.path));
739
+ removed += 1;
740
+ }
741
+ for (const dir of touched) if (!dir.endsWith(".tmp")) await rmdir2(dir).catch(() => void 0);
742
+ return removed;
743
+ }
744
+ async function unlinkBlobsIn(root, keep) {
745
+ let removed = 0;
746
+ for (const shard of await entriesOf(root)) {
747
+ const shardPath = join2(root, shard);
748
+ if (shard === ".tmp") {
749
+ for (const name of await entriesOf(shardPath)) {
750
+ await rm(join2(shardPath, name), { force: true });
751
+ removed += 1;
752
+ }
753
+ continue;
754
+ }
755
+ let kept = 0;
756
+ for (const name of await entriesOf(shardPath)) {
757
+ if (isHash(name) && keep.has(name)) {
758
+ kept += 1;
759
+ continue;
760
+ }
761
+ await rm(join2(shardPath, name), { force: true });
762
+ removed += 1;
763
+ }
764
+ if (kept === 0) await rmdir2(shardPath).catch(() => void 0);
765
+ }
766
+ return removed;
767
+ }
314
768
  async function entriesOf(path) {
315
769
  try {
316
- return await readdir(path);
770
+ return await readdir2(path);
317
771
  } catch (error) {
318
772
  if (errnoOf(error) === "ENOENT") return [];
319
773
  throw error;
@@ -326,29 +780,77 @@ var pinnedDirectories = new FinalizationRegistry((fd) => {
326
780
  } catch {
327
781
  }
328
782
  });
783
+ var writableStores = /* @__PURE__ */ new Map();
784
+ function releaseWritable(shared) {
785
+ if (shared.references === 0 || --shared.references !== 0) return;
786
+ if (writableStores.get(shared.key) === shared) writableStores.delete(shared.key);
787
+ try {
788
+ shared.db.close();
789
+ } finally {
790
+ if (shared.directoryFd !== void 0) closeSync(shared.directoryFd);
791
+ }
792
+ }
793
+ var sharedStores = new FinalizationRegistry((shared) => {
794
+ try {
795
+ releaseWritable(shared);
796
+ } catch {
797
+ }
798
+ });
799
+ async function acquireWritableMutation(shared) {
800
+ const previous = shared.mutationTail;
801
+ let release;
802
+ shared.mutationTail = new Promise((resolve3) => {
803
+ release = resolve3;
804
+ });
805
+ await previous;
806
+ return release;
807
+ }
808
+ var activeReadSection = new AsyncLocalStorage();
329
809
  var SqliteReplicaStore = class _SqliteReplicaStore {
330
810
  dir;
331
- #db;
811
+ #database;
812
+ get #db() {
813
+ if (this.#closed) throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica store is closed.");
814
+ return this.#database;
815
+ }
816
+ #closed = false;
817
+ #closing = false;
818
+ #closePromise;
819
+ #activeMutations = 0;
820
+ #drained;
821
+ #shared;
822
+ #readonlySnapshot;
332
823
  #pinnedDir;
333
824
  #directoryFd;
334
- #holder = `${process.pid}-${randomBytes(6).toString("hex")}`;
825
+ #holder;
335
826
  #now;
336
827
  #faults;
337
828
  #guard;
338
829
  /** Identity of the database file this store opened: a deleted-and-recreated replica is a different file. */
339
830
  #ino;
831
+ #admit;
832
+ #partitionId;
340
833
  #dev;
341
834
  #namedAncestors;
342
- constructor(dir, db, identity, options, directoryFd, namedAncestors = []) {
835
+ constructor(dir, db, identity, options, directoryFd, namedAncestors = [], shared, readonlySnapshot = false) {
343
836
  this.dir = dir;
344
- this.#pinnedDir = directoryFd !== void 0 && process.platform === "linux" ? `/proc/self/fd/${directoryFd}` : dir;
837
+ this.#pinnedDir = shared?.pinnedDir ?? (directoryFd !== void 0 && process.platform === "linux" ? `/proc/self/fd/${directoryFd}` : dir);
345
838
  this.#directoryFd = directoryFd;
346
- if (directoryFd !== void 0) pinnedDirectories.register(this, directoryFd, this);
347
- this.#db = db;
839
+ this.#shared = shared;
840
+ this.#readonlySnapshot = readonlySnapshot;
841
+ if (shared) {
842
+ shared.references++;
843
+ sharedStores.register(this, shared, this);
844
+ } else if (directoryFd !== void 0) {
845
+ pinnedDirectories.register(this, directoryFd, this);
846
+ }
847
+ this.#database = db;
348
848
  this.#ino = identity.ino;
349
849
  this.#dev = identity.dev;
350
850
  this.#now = options.now ?? Date.now;
351
851
  this.#faults = options[FAULTS] ?? {};
852
+ this.#admit = options.admit;
853
+ this.#partitionId = options.partitionId ?? "";
352
854
  this.#guard = options.guard ?? ((section) => section());
353
855
  this.#namedAncestors = namedAncestors;
354
856
  }
@@ -362,10 +864,10 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
362
864
  if (options.signal?.aborted) throw options.signal.reason ?? new ReplicaError(ReplicaErrorCode.CLOSED, "Replica status inspection was cancelled.");
363
865
  };
364
866
  checkAbort();
365
- const dbPath = join(dir, "replica.db");
867
+ const dbPath = join2(dir, "replica.db");
366
868
  let identity;
367
869
  try {
368
- identity = await lstat(dbPath);
870
+ identity = await lstat2(dbPath);
369
871
  if (!identity.isFile()) return null;
370
872
  } catch (error) {
371
873
  if (errnoOf(error) === "ENOENT") return null;
@@ -375,21 +877,30 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
375
877
  const opener = await loadSqlite();
376
878
  checkAbort();
377
879
  let db;
880
+ let releaseOpen;
378
881
  try {
379
- db = opener(dbPath, { readonly: true });
882
+ releaseOpen = await acquireSqliteOpen();
883
+ const checked = await openVerifiedDatabase(dbPath, opener, { readonly: true, expected: identity });
884
+ db = checked.db;
885
+ releaseOpen();
886
+ releaseOpen = void 0;
380
887
  db.exec("PRAGMA busy_timeout = 0");
381
888
  checkAbort();
382
- const store = new _SqliteReplicaStore(dir, db, identity, { create: false, ...options.now ? { now: options.now } : {} });
889
+ const store = new _SqliteReplicaStore(dir, db, checked.identity, { create: false, ...options.now ? { now: options.now } : {} });
383
890
  for (let attempt = 0; attempt < 2; attempt++) {
384
891
  const before = db.get("PRAGMA data_version").data_version;
385
892
  const state = await store.open();
386
893
  checkAbort();
387
894
  if (state === null) return null;
388
895
  const status = await store.status();
389
- await new Promise((resolve2) => setImmediate(resolve2));
896
+ const latest = state.pendingGrant ?? state.grant;
897
+ const version = db.get("PRAGMA user_version")?.user_version ?? 0;
898
+ const grantInstall = latest === null || version < 2 ? null : await store.getGrantInstall(latest.cid);
899
+ const activeGrantInstall = state.grant === null || version < 2 ? null : state.grant === latest ? grantInstall : await store.getGrantInstall(state.grant.cid);
900
+ await new Promise((resolve3) => setImmediate(resolve3));
390
901
  checkAbort();
391
902
  const after = db.get("PRAGMA data_version").data_version;
392
- if (before === after) return { state, status };
903
+ if (before === after) return { state, status, grantInstall, activeGrantInstall };
393
904
  }
394
905
  throw new ReplicaError(ReplicaErrorCode.BUSY, "The replica changed during status inspection.");
395
906
  } catch (error) {
@@ -397,31 +908,87 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
397
908
  throw new ReplicaError(ReplicaErrorCode.BUSY, "The replica is busy during status inspection.", void 0, { cause: error });
398
909
  }
399
910
  throw storageError(error, "Inspecting the replica");
911
+ } finally {
912
+ releaseOpen?.();
913
+ if (db) {
914
+ const releaseClose = await acquireSqliteOpen();
915
+ try {
916
+ db.close();
917
+ } finally {
918
+ releaseClose();
919
+ }
920
+ }
921
+ }
922
+ }
923
+ /**
924
+ * Blob content bytes on disk in the replica at `dir` (0 when absent): the writer's blob files under `blobs/`,
925
+ * including writer temp files and blobs no entry references any more, so garbage a failed sync
926
+ * left behind still counts. Counts exactly what `collectOrphans` may delete: foreign entries
927
+ * (stray files, symlinks, unknown names) are neither counted nor touched. Only blob content is counted: not the database, its WAL, rows or the
928
+ * pending store. Filesystem-only: it opens no
929
+ * database, so it can sum a profile's replicas under the caller's own mutation hold.
930
+ */
931
+ static async contentBytes(dir) {
932
+ try {
933
+ let bytes = 0;
934
+ for (const file of await writerBlobFiles(join2(dir, "blobs"))) bytes += file.size;
935
+ return bytes;
936
+ } catch (error) {
937
+ throw storageError(error, "Measuring replica content");
938
+ }
939
+ }
940
+ /**
941
+ * Remove blobs and temp files in the replica at `dir` that no committed row references: what a
942
+ * crash between writing blobs and committing (or between committing and collecting) leaves.
943
+ * Returns the number of files removed; 0 when the replica is absent or unreadable.
944
+ *
945
+ * The caller MUST hold the profile mutation guard, which every writer of this replica holds
946
+ * across its blob write and commit; that is what makes an unreferenced blob safe to delete
947
+ * (it cannot belong to an in-flight write). Never call it without that hold. The database is
948
+ * read only, so nothing here can take a lease or change committed state.
949
+ */
950
+ static async collectOrphans(dir) {
951
+ const dbPath = join2(dir, "replica.db");
952
+ const identity = await lstat2(dbPath).catch(() => void 0);
953
+ if (!identity?.isFile()) return 0;
954
+ const opener = await loadSqlite();
955
+ let keep;
956
+ let db;
957
+ try {
958
+ db = opener(dbPath, { readonly: true });
959
+ db.exec("PRAGMA busy_timeout = 0");
960
+ keep = new Set(db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash));
961
+ } catch {
962
+ return 0;
400
963
  } finally {
401
964
  db?.close();
402
965
  }
966
+ try {
967
+ return await unlinkWriterOrphans(join2(dir, "blobs"), keep);
968
+ } catch (error) {
969
+ throw storageError(error, "Collecting orphaned content");
970
+ }
403
971
  }
404
972
  /**
405
973
  * Open the replica store in `dir`. With `create: false` a missing replica is
406
974
  * REPLICA_NOT_FOUND and nothing is written to disk.
407
975
  */
408
976
  static async open(dir, options) {
409
- const namedDbPath = join(dir, "replica.db");
977
+ const namedDbPath = join2(dir, "replica.db");
410
978
  if (!options.create) {
411
- const exists = await lstat(namedDbPath).then(
979
+ const exists = await lstat2(namedDbPath).then(
412
980
  (stats) => stats.isFile(),
413
981
  () => false
414
982
  );
415
983
  if (!exists) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, `No regular replica database at ${dir}.`);
416
984
  }
417
- const opener = options.sqlite ?? await loadSqlite();
418
985
  const releaseOpen = await acquireSqliteOpen();
419
986
  let store;
420
987
  let directoryFd;
421
988
  try {
422
989
  if (options.create) await mkdir(dir, { recursive: true, mode: 448 });
423
990
  if (process.platform === "linux") {
424
- directoryFd = openSync(dir, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
991
+ directoryFd = openSync(dir, fsConstants2.O_RDONLY | fsConstants2.O_DIRECTORY | fsConstants2.O_NOFOLLOW);
425
992
  }
426
993
  const opened = directoryFd === void 0 ? lstatSync(dir, { bigint: true }) : fstatSync(directoryFd, { bigint: true });
427
994
  if (!opened.isDirectory() || opened.isSymbolicLink() || options.expectedDirectory && (opened.dev !== BigInt(options.expectedDirectory.dev) || opened.ino !== BigInt(options.expectedDirectory.ino))) {
@@ -432,11 +999,11 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
432
999
  throw new Error("The replica directory changed while checking its ancestry.");
433
1000
  }
434
1001
  const pinnedDir = directoryFd === void 0 ? dir : `/proc/self/fd/${directoryFd}`;
435
- const dbPath = join(pinnedDir, "replica.db");
1002
+ const dbPath = join2(pinnedDir, "replica.db");
436
1003
  if (options.create) {
437
- await mkdir(join(pinnedDir, "blobs", ".tmp"), { recursive: true, mode: 448 });
438
- await Promise.all([chmod(pinnedDir, 448), chmod(join(pinnedDir, "blobs"), 448), chmod(join(pinnedDir, "blobs", ".tmp"), 448)]);
439
- const handle = await open(dbPath, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_RDWR | fsConstants.O_NOFOLLOW, 384).catch((error) => {
1004
+ await mkdir(join2(pinnedDir, "blobs", ".tmp"), { recursive: true, mode: 448 });
1005
+ await Promise.all([chmod(pinnedDir, 448), chmod(join2(pinnedDir, "blobs"), 448), chmod(join2(pinnedDir, "blobs", ".tmp"), 448)]);
1006
+ const handle = await open2(dbPath, fsConstants2.O_CREAT | fsConstants2.O_EXCL | fsConstants2.O_RDWR | fsConstants2.O_NOFOLLOW, 384).catch((error) => {
440
1007
  if (errnoOf(error) === "EEXIST") return null;
441
1008
  throw error;
442
1009
  });
@@ -448,71 +1015,107 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
448
1015
  }
449
1016
  }
450
1017
  }
451
- const leaf = await open(dbPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
1018
+ const leaf = await open2(dbPath, fsConstants2.O_RDONLY | fsConstants2.O_NOFOLLOW);
452
1019
  let db;
453
1020
  try {
454
1021
  const identity = await leaf.stat();
455
- const named = await lstat(dbPath);
1022
+ const named = await lstat2(dbPath);
456
1023
  if (!identity.isFile() || !named.isFile() || named.dev !== identity.dev || named.ino !== identity.ino) {
457
1024
  throw new Error("The replica database changed before SQLite opened it.");
458
1025
  }
459
- const previousFds = process.platform === "linux" && !options.sqlite ? new Map(readdirSync("/proc/self/fd").map((fd) => {
460
- try {
461
- return [fd, readlinkSync(`/proc/self/fd/${fd}`)];
462
- } catch {
463
- return [fd, ""];
1026
+ const key = `${opened.dev}:${opened.ino}`;
1027
+ const active = writableStores.get(key);
1028
+ if (active && (active.dev !== identity.dev || active.ino !== identity.ino)) {
1029
+ throw new Error("The replica database changed while another controller holds it open.");
1030
+ }
1031
+ if (active && !options.sqlite) {
1032
+ const namedDirectory = lstatSync(dir, { bigint: true });
1033
+ if (!namedDirectory.isDirectory() || namedDirectory.dev !== opened.dev || namedDirectory.ino !== opened.ino) {
1034
+ throw new Error("The replica directory changed before its checked SQLite handle could be shared.");
1035
+ }
1036
+ const pinned = active.directoryFd === void 0 ? lstatSync(active.pinnedDir, { bigint: true }) : fstatSync(active.directoryFd, { bigint: true });
1037
+ const activeLeaf = lstatSync(join2(active.pinnedDir, "replica.db"));
1038
+ 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) {
1039
+ throw new Error("The live replica changed before its checked SQLite handle could be shared.");
464
1040
  }
465
- })) : null;
466
- db = opener(dbPath);
467
- const afterOpen = lstatSync(dbPath);
468
- if (!afterOpen.isFile() || afterOpen.dev !== identity.dev || afterOpen.ino !== identity.ino) {
469
- throw new Error("The replica database changed while SQLite opened it.");
1041
+ if (directoryFd !== void 0) {
1042
+ closeSync(directoryFd);
1043
+ directoryFd = void 0;
1044
+ }
1045
+ store = new _SqliteReplicaStore(dir, active.db, identity, options, active.directoryFd, namedAncestors, active);
470
1046
  }
471
- if (previousFds) db.get("SELECT count(*) FROM sqlite_master");
472
- if (previousFds) {
473
- const databases = readdirSync("/proc/self/fd").filter((fd) => {
1047
+ if (!store) {
1048
+ const opener = options.sqlite ?? await loadSqlite();
1049
+ const previousFds = process.platform === "linux" && !options.sqlite ? new Map(readdirSync("/proc/self/fd").map((fd) => {
474
1050
  try {
475
- const target = readlinkSync(`/proc/self/fd/${fd}`);
476
- return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
1051
+ return [fd, readlinkSync(`/proc/self/fd/${fd}`)];
477
1052
  } catch {
478
- return false;
1053
+ return [fd, ""];
479
1054
  }
480
- });
481
- if (databases.length === 0 || databases.some((fd) => {
482
- try {
483
- const actual = fstatSync(Number(fd));
484
- return actual.dev !== identity.dev || actual.ino !== identity.ino;
485
- } catch {
486
- return true;
1055
+ })) : null;
1056
+ db = opener(dbPath);
1057
+ const afterOpen = lstatSync(dbPath);
1058
+ if (!afterOpen.isFile() || afterOpen.dev !== identity.dev || afterOpen.ino !== identity.ino) {
1059
+ throw new Error("The replica database changed while SQLite opened it.");
1060
+ }
1061
+ if (previousFds) db.get("SELECT count(*) FROM sqlite_master");
1062
+ if (previousFds) {
1063
+ const databases = readdirSync("/proc/self/fd").filter((fd) => {
1064
+ try {
1065
+ const target = readlinkSync(`/proc/self/fd/${fd}`);
1066
+ return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
1067
+ } catch {
1068
+ return false;
1069
+ }
1070
+ });
1071
+ if (databases.length === 0 || databases.some((fd) => {
1072
+ try {
1073
+ const actual = fstatSync(Number(fd));
1074
+ return actual.dev !== identity.dev || actual.ino !== identity.ino;
1075
+ } catch {
1076
+ return true;
1077
+ }
1078
+ })) {
1079
+ throw new Error("SQLite opened a different replica database.");
487
1080
  }
488
- })) {
489
- throw new Error("SQLite opened a different replica database.");
490
1081
  }
1082
+ const after = lstatSync(dbPath);
1083
+ if (!after.isFile() || after.dev !== identity.dev || after.ino !== identity.ino) {
1084
+ throw new Error("The replica database changed while SQLite opened it.");
1085
+ }
1086
+ db.exec("PRAGMA busy_timeout = 5000");
1087
+ db.exec("PRAGMA journal_mode = WAL");
1088
+ db.exec("PRAGMA synchronous = FULL");
1089
+ db.exec("PRAGMA secure_delete = ON");
1090
+ db.exec("PRAGMA foreign_keys = ON");
1091
+ const version = db.get("PRAGMA user_version")?.user_version ?? 0;
1092
+ if (version > SCHEMA_VERSION) {
1093
+ throw new ReplicaError(
1094
+ ReplicaErrorCode.STORAGE_ERROR,
1095
+ `The replica at ${dir} was written by a newer tc (schema ${version}); upgrade tc to read it.`
1096
+ );
1097
+ }
1098
+ if (version < SCHEMA_VERSION) {
1099
+ db.exec("BEGIN IMMEDIATE");
1100
+ db.exec(SCHEMA);
1101
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
1102
+ db.exec("COMMIT");
1103
+ }
1104
+ const shared = {
1105
+ key,
1106
+ db,
1107
+ directoryFd,
1108
+ pinnedDir,
1109
+ namedPath: dir,
1110
+ dev: identity.dev,
1111
+ ino: identity.ino,
1112
+ references: 0,
1113
+ mutationTail: Promise.resolve()
1114
+ };
1115
+ store = new _SqliteReplicaStore(dir, db, identity, options, directoryFd, namedAncestors, shared);
1116
+ if (!options.sqlite) writableStores.set(key, shared);
1117
+ db = void 0;
491
1118
  }
492
- const after = lstatSync(dbPath);
493
- if (!after.isFile() || after.dev !== identity.dev || after.ino !== identity.ino) {
494
- throw new Error("The replica database changed while SQLite opened it.");
495
- }
496
- db.exec("PRAGMA busy_timeout = 5000");
497
- db.exec("PRAGMA journal_mode = WAL");
498
- db.exec("PRAGMA synchronous = FULL");
499
- db.exec("PRAGMA secure_delete = ON");
500
- db.exec("PRAGMA foreign_keys = ON");
501
- const version = db.get("PRAGMA user_version")?.user_version ?? 0;
502
- if (version > SCHEMA_VERSION) {
503
- throw new ReplicaError(
504
- ReplicaErrorCode.STORAGE_ERROR,
505
- `The replica at ${dir} was written by a newer tc (schema ${version}); upgrade tc to read it.`
506
- );
507
- }
508
- if (version < SCHEMA_VERSION) {
509
- db.exec("BEGIN IMMEDIATE");
510
- db.exec(SCHEMA);
511
- db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
512
- db.exec("COMMIT");
513
- }
514
- store = new _SqliteReplicaStore(dir, db, identity, options, directoryFd, namedAncestors);
515
- db = void 0;
516
1119
  } finally {
517
1120
  try {
518
1121
  db?.close();
@@ -538,12 +1141,109 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
538
1141
  throw error;
539
1142
  }
540
1143
  }
1144
+ /**
1145
+ * A gated read borrows this controller's already-verified writable handle.
1146
+ * Borrow before the first await: the parent may close in the callback, but
1147
+ * its pin and native handle remain live until the snapshot releases them.
1148
+ * Serialize BEGIN through COMMIT/ROLLBACK with writes on the shared handle.
1149
+ */
1150
+ async readTransaction(generation, checkGate, section) {
1151
+ this.#assertAccepting();
1152
+ const shared = this.#shared;
1153
+ if (!shared) throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica handle is closed.");
1154
+ const snapshot = new _SqliteReplicaStore(
1155
+ this.dir,
1156
+ shared.db,
1157
+ { dev: this.#dev, ino: this.#ino },
1158
+ { create: false, now: this.#now },
1159
+ shared.directoryFd,
1160
+ this.#namedAncestors,
1161
+ shared,
1162
+ true
1163
+ );
1164
+ let release;
1165
+ let active = false;
1166
+ let callbackFailed = false;
1167
+ try {
1168
+ release = await acquireWritableMutation(shared);
1169
+ await snapshot.#assertReadIdentity();
1170
+ snapshot.#db.exec("BEGIN");
1171
+ active = true;
1172
+ const tombstone = snapshot.#db.get("SELECT generation FROM replica WHERE id = 1");
1173
+ try {
1174
+ await checkGate();
1175
+ } catch (error) {
1176
+ callbackFailed = true;
1177
+ throw error;
1178
+ }
1179
+ if (tombstone === void 0 || tombstone.generation !== generation) {
1180
+ throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica generation changed; reopen it to continue.");
1181
+ }
1182
+ let result;
1183
+ const context = { controller: this, active: true };
1184
+ try {
1185
+ try {
1186
+ result = await activeReadSection.run(context, () => section(snapshot));
1187
+ } catch (error) {
1188
+ callbackFailed = true;
1189
+ throw error;
1190
+ }
1191
+ } finally {
1192
+ context.active = false;
1193
+ }
1194
+ try {
1195
+ await checkGate();
1196
+ } catch (error) {
1197
+ callbackFailed = true;
1198
+ throw error;
1199
+ }
1200
+ await snapshot.#assertReadIdentity();
1201
+ snapshot.#db.exec("COMMIT");
1202
+ active = false;
1203
+ return result;
1204
+ } catch (error) {
1205
+ if (callbackFailed) throw error;
1206
+ if (isReplicaError(error) || typeof error === "object" && error !== null && "code" in error && error.code === "REPLICA_GATED") throw error;
1207
+ if (["ENOENT", "ELOOP", "ENOTDIR"].includes(String(errnoOf(error))) || /replica database changed|SQLite opened a different replica database/i.test(error instanceof Error ? error.message : String(error))) {
1208
+ throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica was replaced; reopen it to continue.", void 0, { cause: error });
1209
+ }
1210
+ throw storageError(error, "Reading the replica snapshot");
1211
+ } finally {
1212
+ if (active) {
1213
+ try {
1214
+ snapshot.#db.exec("ROLLBACK");
1215
+ } catch {
1216
+ }
1217
+ }
1218
+ try {
1219
+ await snapshot.close();
1220
+ } finally {
1221
+ release?.();
1222
+ }
1223
+ }
1224
+ }
1225
+ async #assertReadIdentity() {
1226
+ const shared = this.#shared;
1227
+ const pinned = shared.directoryFd === void 0 ? lstatSync(shared.pinnedDir) : fstatSync(shared.directoryFd);
1228
+ const namedDir = lstatSync(this.dir);
1229
+ const namedDb = lstatSync(join2(this.dir, "replica.db"));
1230
+ const pinnedDb = lstatSync(join2(shared.pinnedDir, "replica.db"));
1231
+ if (!pinned.isDirectory() || !namedDir.isDirectory() || pinned.dev !== namedDir.dev || pinned.ino !== namedDir.ino || !namedDb.isFile() || !pinnedDb.isFile() || namedDb.dev !== this.#dev || namedDb.ino !== this.#ino || pinnedDb.dev !== this.#dev || pinnedDb.ino !== this.#ino) {
1232
+ throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica was replaced during its read; reopen it to continue.");
1233
+ }
1234
+ if (this.#namedAncestors.length) {
1235
+ const ancestors = await captureNamedPath(this.dir);
1236
+ if (ancestors.length !== this.#namedAncestors.length || ancestors.some((entry, index) => entry.dev !== this.#namedAncestors[index].dev || entry.ino !== this.#namedAncestors[index].ino)) {
1237
+ throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica directory was replaced; reopen it to continue.");
1238
+ }
1239
+ }
1240
+ }
541
1241
  #row() {
542
1242
  return this.#db.get("SELECT * FROM replica WHERE id = 1");
543
1243
  }
544
1244
  /** Refuse to touch the filesystem for a replica that was deleted (or replaced) under us. */
545
1245
  async #assertPresent() {
546
- const identity = await lstat(join(this.#pinnedDir, "replica.db")).catch(() => null);
1246
+ const identity = await lstat2(join2(this.#pinnedDir, "replica.db")).catch(() => null);
547
1247
  if (!identity?.isFile() || identity.dev !== this.#dev || identity.ino !== this.#ino) {
548
1248
  throw new ReplicaError(
549
1249
  ReplicaErrorCode.NOT_FOUND,
@@ -551,30 +1251,63 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
551
1251
  );
552
1252
  }
553
1253
  }
1254
+ /** New calls stop at close; accepted mutation sections may still finish their transactions. */
1255
+ #assertAccepting() {
1256
+ if (this.#closing || this.#closed) {
1257
+ throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica store is closed.");
1258
+ }
1259
+ }
554
1260
  /** A short mutation section: guarded (profile lock) and checked against the database this store opened. */
555
1261
  #mutate(section) {
556
- return this.#guard(async () => {
557
- await this.#assertPresent();
558
- return section();
559
- });
1262
+ this.#assertAccepting();
1263
+ if (this.#readonlySnapshot) {
1264
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "A read-only replica snapshot cannot mutate the shared database.");
1265
+ }
1266
+ const read = activeReadSection.getStore();
1267
+ if (read?.active && read.controller === this) {
1268
+ throw new ReplicaError(
1269
+ ReplicaErrorCode.STORAGE_ERROR,
1270
+ "mutation attempted on the same controller while its readTransaction section is active; this would deadlock"
1271
+ );
1272
+ }
1273
+ this.#activeMutations++;
1274
+ return (async () => {
1275
+ try {
1276
+ return await this.#guard(async () => {
1277
+ await this.#assertPresent();
1278
+ return section();
1279
+ });
1280
+ } finally {
1281
+ if (--this.#activeMutations === 0) {
1282
+ const drained = this.#drained;
1283
+ this.#drained = void 0;
1284
+ drained?.();
1285
+ }
1286
+ }
1287
+ })();
560
1288
  }
561
1289
  /** Run `body` in a write transaction (BEGIN IMMEDIATE). Callers outside a mutation section use `#guardedWrite`. */
562
- #write(action, body) {
563
- try {
564
- this.#db.exec("BEGIN IMMEDIATE");
565
- } catch (error) {
566
- throw storageError(error, action);
567
- }
1290
+ async #write(action, body) {
1291
+ const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
568
1292
  try {
569
- const result = body();
570
- this.#db.exec("COMMIT");
571
- return result;
572
- } catch (error) {
573
1293
  try {
574
- this.#db.exec("ROLLBACK");
575
- } catch {
1294
+ this.#db.exec("BEGIN IMMEDIATE");
1295
+ } catch (error) {
1296
+ throw storageError(error, action);
1297
+ }
1298
+ try {
1299
+ const result = body();
1300
+ this.#db.exec("COMMIT");
1301
+ return result;
1302
+ } catch (error) {
1303
+ try {
1304
+ this.#db.exec("ROLLBACK");
1305
+ } catch {
1306
+ }
1307
+ throw storageError(error, action);
576
1308
  }
577
- throw storageError(error, action);
1309
+ } finally {
1310
+ release?.();
578
1311
  }
579
1312
  }
580
1313
  /** One write transaction as its own mutation section. */
@@ -601,6 +1334,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
601
1334
  }
602
1335
  }
603
1336
  async open() {
1337
+ this.#assertAccepting();
604
1338
  let row;
605
1339
  try {
606
1340
  row = this.#row();
@@ -679,6 +1413,110 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
679
1413
  );
680
1414
  });
681
1415
  }
1416
+ /** Per-CID first-install anchor and policy bound share the pending-grant transaction. */
1417
+ #grantInstall(cid) {
1418
+ const row = this.#db.get(
1419
+ "SELECT installed_at, effective_expires_at, consent_deadline, consent_anchor_at, consent_anchor_source FROM grant_install WHERE cid = ?",
1420
+ cid
1421
+ );
1422
+ if (row === void 0) return null;
1423
+ if (!(row.consent_anchor_source === "siwe" || row.consent_anchor_source === "delegation_iat" || row.consent_anchor_source === "delegation_nbf" || row.consent_anchor_source === "first_install") || !Number.isSafeInteger(row.installed_at) || row.installed_at < 0 || !Number.isSafeInteger(row.effective_expires_at) || row.effective_expires_at < 0 || !Number.isSafeInteger(row.consent_deadline) || row.consent_deadline < 0 || !Number.isSafeInteger(row.consent_anchor_at) || row.consent_anchor_at < 0) {
1424
+ throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The stored grant expiry or consent anchor is invalid.");
1425
+ }
1426
+ return {
1427
+ installedAt: row.installed_at,
1428
+ effectiveExpiresAt: row.effective_expires_at,
1429
+ consentDeadline: row.consent_deadline,
1430
+ consentAnchor: { at: row.consent_anchor_at, source: row.consent_anchor_source }
1431
+ };
1432
+ }
1433
+ async getGrantInstall(cid) {
1434
+ return this.#grantInstall(cid);
1435
+ }
1436
+ /** Every installed CID, including superseded ones, for an origin-wide purge floor. */
1437
+ async grantInstallTimes() {
1438
+ const rows = this.#db.all(
1439
+ "SELECT cid, installed_at FROM grant_install"
1440
+ );
1441
+ return rows.map((row) => {
1442
+ if (typeof row.cid !== "string" || row.cid.length === 0 || !Number.isSafeInteger(row.installed_at) || row.installed_at < 0) {
1443
+ throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The stored grant install time is invalid.");
1444
+ }
1445
+ return { cid: row.cid, installedAt: row.installed_at };
1446
+ });
1447
+ }
1448
+ /** Phase 2 installed grants cannot commit a page after their shorter local TTL. */
1449
+ #assertInstalledGrantTime() {
1450
+ const row = this.#row();
1451
+ const cid = row?.pending_grant_cid ?? row?.grant_cid;
1452
+ if (cid === void 0 || cid === null) return;
1453
+ const installed = this.#grantInstall(cid);
1454
+ const now = this.#now();
1455
+ if (installed !== null && (now >= installed.effectiveExpiresAt || now >= installed.consentDeadline)) {
1456
+ throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The installed grant expired before this page committed.");
1457
+ }
1458
+ }
1459
+ #storeGrantPolicy(g, policy, existing) {
1460
+ const installedAt = existing?.installedAt ?? policy.at;
1461
+ const keepExisting = existing !== null && existing.consentDeadline < policy.consentDeadline;
1462
+ const consentDeadline = keepExisting ? existing.consentDeadline : policy.consentDeadline;
1463
+ const consentAnchor = keepExisting ? existing.consentAnchor : policy.consentAnchor;
1464
+ const effectiveExpiresAt = Math.min(
1465
+ g.expiresAt === null ? Infinity : g.expiresAt * 1e3,
1466
+ installedAt + policy.ttlMs,
1467
+ consentDeadline
1468
+ );
1469
+ if (!Number.isSafeInteger(installedAt) || installedAt < 0 || !Number.isSafeInteger(effectiveExpiresAt) || effectiveExpiresAt < 0 || !Number.isSafeInteger(consentDeadline) || consentDeadline < 0 || !Number.isSafeInteger(consentAnchor.at) || consentAnchor.at < 0 || !Number.isSafeInteger(policy.ttlMs) || policy.ttlMs <= 0) {
1470
+ throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The replica grant has no finite effective expiry.");
1471
+ }
1472
+ this.#db.run(
1473
+ `INSERT INTO grant_install (cid, installed_at, effective_expires_at, consent_deadline, consent_anchor_at, consent_anchor_source)
1474
+ VALUES (?, ?, ?, ?, ?, ?)
1475
+ ON CONFLICT (cid) DO UPDATE SET effective_expires_at = excluded.effective_expires_at,
1476
+ consent_deadline = excluded.consent_deadline, consent_anchor_at = excluded.consent_anchor_at,
1477
+ consent_anchor_source = excluded.consent_anchor_source`,
1478
+ g.cid,
1479
+ installedAt,
1480
+ effectiveExpiresAt,
1481
+ consentDeadline,
1482
+ consentAnchor.at,
1483
+ consentAnchor.source
1484
+ );
1485
+ return { installedAt, effectiveExpiresAt, consentDeadline, consentAnchor };
1486
+ }
1487
+ async installGatedGrant(g, policy) {
1488
+ return this.#guardedWrite("Installing the gated grant", () => {
1489
+ const row = this.#row();
1490
+ if (row === void 0) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica has not been created.");
1491
+ const previous = this.#grantInstall(g.cid);
1492
+ if (previous === null && (row.grant_cid === g.cid || row.pending_grant_cid === g.cid)) {
1493
+ throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The installed grant has no durable first-install time.");
1494
+ }
1495
+ const next = this.#storeGrantPolicy(g, policy, previous);
1496
+ if (next.effectiveExpiresAt <= this.#now()) {
1497
+ throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The replica grant expired under the current policy.");
1498
+ }
1499
+ if (row.grant_cid !== g.cid && row.pending_grant_cid !== g.cid) {
1500
+ this.#db.run(
1501
+ `UPDATE replica SET pending_grant_cid = ?, pending_grant_bytes = ?, pending_grant_audience = ?,
1502
+ pending_grant_issuer = ?, pending_grant_nbf = ?, pending_grant_exp = ?, pending_grant_error = NULL WHERE id = 1`,
1503
+ g.cid,
1504
+ g.bytes,
1505
+ g.audience,
1506
+ g.issuer,
1507
+ g.notBefore,
1508
+ g.expiresAt
1509
+ );
1510
+ }
1511
+ return next;
1512
+ });
1513
+ }
1514
+ async recomputeGrantInstall(g, policy) {
1515
+ return this.#guardedWrite("Recomputing the gated grant", () => {
1516
+ const previous = this.#grantInstall(g.cid);
1517
+ return previous === null ? null : this.#storeGrantPolicy(g, policy, previous);
1518
+ });
1519
+ }
682
1520
  async installGrant(g) {
683
1521
  await this.#guardedWrite("Installing the grant", () => {
684
1522
  const row = this.#row();
@@ -737,13 +1575,14 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
737
1575
  const now = this.#now();
738
1576
  if (row.lease_holder !== null && row.lease_expires_at !== null && row.lease_expires_at > now) return null;
739
1577
  const token = row.lease_token + 1;
1578
+ const holder = this.#holder ??= `${process.pid}-${randomBytes(6).toString("hex")}`;
740
1579
  this.#db.run(
741
1580
  "UPDATE replica SET lease_token = ?, lease_holder = ?, lease_expires_at = ? WHERE id = 1",
742
1581
  token,
743
- this.#holder,
1582
+ holder,
744
1583
  now + ttlMs
745
1584
  );
746
- return { token, holder: this.#holder };
1585
+ return { token, holder };
747
1586
  });
748
1587
  }
749
1588
  async renewLease(t, ttlMs) {
@@ -762,6 +1601,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
762
1601
  });
763
1602
  }
764
1603
  async hasContent(hashes) {
1604
+ this.#assertAccepting();
765
1605
  const held = /* @__PURE__ */ new Set();
766
1606
  for (let start = 0; start < hashes.length; start += 500) {
767
1607
  const chunk = hashes.slice(start, start + 500);
@@ -774,7 +1614,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
774
1614
  return held;
775
1615
  }
776
1616
  #blobPath(hash) {
777
- return join(this.#pinnedDir, "blobs", hash.slice(0, 2), hash);
1617
+ return join2(this.#pinnedDir, "blobs", hash.slice(0, 2), hash);
778
1618
  }
779
1619
  /**
780
1620
  * Make every blob durable before any row references it: write to a fresh
@@ -791,12 +1631,12 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
791
1631
  () => false
792
1632
  );
793
1633
  if (exists) continue;
794
- const shard = join(this.#pinnedDir, "blobs", hash.slice(0, 2));
1634
+ const shard = join2(this.#pinnedDir, "blobs", hash.slice(0, 2));
795
1635
  await mkdir(shard, { mode: 448 }).catch((error) => {
796
1636
  if (errnoOf(error) !== "EEXIST") throw error;
797
1637
  });
798
- const temp = join(this.#pinnedDir, "blobs", ".tmp", `${hash}.${randomBytes(6).toString("hex")}`);
799
- const handle = await open(temp, fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY, 384);
1638
+ const temp = join2(this.#pinnedDir, "blobs", ".tmp", `${hash}.${randomBytes(6).toString("hex")}`);
1639
+ const handle = await open2(temp, fsConstants2.O_CREAT | fsConstants2.O_EXCL | fsConstants2.O_WRONLY, 384);
800
1640
  try {
801
1641
  await handle.writeFile(bytes);
802
1642
  await handle.sync();
@@ -808,13 +1648,13 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
808
1648
  } catch (error) {
809
1649
  if (errnoOf(error) !== "EEXIST") throw error;
810
1650
  } finally {
811
- await unlink(temp);
1651
+ await unlink2(temp);
812
1652
  }
813
1653
  dirs.add(shard);
814
- dirs.add(join(this.#pinnedDir, "blobs"));
1654
+ dirs.add(join2(this.#pinnedDir, "blobs"));
815
1655
  }
816
1656
  for (const dir of dirs) {
817
- const handle = await open(dir, fsConstants.O_RDONLY);
1657
+ const handle = await open2(dir, fsConstants2.O_RDONLY);
818
1658
  try {
819
1659
  await handle.sync();
820
1660
  } finally {
@@ -825,6 +1665,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
825
1665
  async applyPage(t, p) {
826
1666
  await this.#mutate(async () => {
827
1667
  this.#checkLease(t);
1668
+ this.#assertInstalledGrantTime();
828
1669
  const reused = /* @__PURE__ */ new Map();
829
1670
  for (const change of p.changes) {
830
1671
  if (change.deleted || !change.content || p.blobs.has(change.hash) || reused.has(change.hash)) continue;
@@ -837,6 +1678,41 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
837
1678
  }
838
1679
  reused.set(change.hash, size);
839
1680
  }
1681
+ if (this.#admit) {
1682
+ await this.#collectLocked(t);
1683
+ const changed = new Set(p.changes.map((change) => change.key));
1684
+ const kept = /* @__PURE__ */ new Set();
1685
+ for (const change of p.changes) if (!change.deleted && change.content) kept.add(change.hash);
1686
+ let blobBytes = 0;
1687
+ for (const [hash, bytes] of p.blobs) {
1688
+ const stored = this.#db.get(
1689
+ "SELECT 1 AS hit FROM entry WHERE hash = ? AND content = 1 AND deleted = 0 LIMIT 1",
1690
+ hash
1691
+ );
1692
+ if (stored === void 0) blobBytes += bytes.byteLength;
1693
+ }
1694
+ const released = /* @__PURE__ */ new Map();
1695
+ for (const key of changed) {
1696
+ const old = this.#db.get(
1697
+ "SELECT hash, size FROM entry WHERE key = ? AND deleted = 0 AND content = 1 AND hash IS NOT NULL AND size IS NOT NULL",
1698
+ key
1699
+ );
1700
+ if (old === void 0 || kept.has(old.hash) || released.has(old.hash)) continue;
1701
+ const holders = this.#db.all(
1702
+ "SELECT key FROM entry WHERE hash = ? AND content = 1 AND deleted = 0",
1703
+ old.hash
1704
+ );
1705
+ if (holders.every((holder) => changed.has(holder.key))) released.set(old.hash, old.size);
1706
+ }
1707
+ let freed = 0;
1708
+ for (const size of released.values()) freed += size;
1709
+ await this.#admit({
1710
+ partitionId: this.#partitionId,
1711
+ blobBytes: blobBytes - freed,
1712
+ entries: p.changes.length,
1713
+ freeBytes: null
1714
+ });
1715
+ }
840
1716
  try {
841
1717
  await this.#writeBlobs(p.blobs);
842
1718
  } catch (error) {
@@ -844,8 +1720,9 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
844
1720
  }
845
1721
  await this.#faults.afterBlobs?.();
846
1722
  try {
847
- this.#write("Committing a sync page", () => {
1723
+ await this.#write("Committing a sync page", () => {
848
1724
  this.#checkLease(t);
1725
+ this.#assertInstalledGrantTime();
849
1726
  const now = this.#now();
850
1727
  if (p.window.notBefore !== null && now < Date.parse(p.window.notBefore)) {
851
1728
  throw new ReplicaError(ReplicaErrorCode.GRANT_NOT_YET_VALID, `The grant is not valid before ${p.window.notBefore}; nothing was committed.`);
@@ -911,72 +1788,59 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
911
1788
  });
912
1789
  } catch (error) {
913
1790
  if (isReplicaError(error, ReplicaErrorCode.GRANT_REVOKED)) await this.#purgeContent().catch(() => void 0);
1791
+ else if (this.#admit) await this.#collectLocked(t).catch(() => void 0);
914
1792
  throw error;
915
1793
  }
916
1794
  this.#faults.afterCommit?.();
1795
+ if (this.#admit) await this.#collectLocked(t);
917
1796
  });
918
1797
  }
919
1798
  /** Unlink every blob not in `keep`, and every temp file. Only a missing directory counts as empty. */
920
- async #unlinkBlobs(keep) {
921
- let removed = 0;
922
- const root = join(this.#pinnedDir, "blobs");
923
- for (const shard of await entriesOf(root)) {
924
- const shardPath = join(root, shard);
925
- if (shard === ".tmp") {
926
- for (const name of await entriesOf(shardPath)) {
927
- await rm(join(shardPath, name), { force: true });
928
- removed += 1;
929
- }
930
- continue;
931
- }
932
- let kept = 0;
933
- for (const name of await entriesOf(shardPath)) {
934
- if (isHash(name) && keep.has(name)) {
935
- kept += 1;
936
- continue;
937
- }
938
- await rm(join(shardPath, name), { force: true });
939
- removed += 1;
940
- }
941
- if (kept === 0) await rmdir(shardPath).catch(() => void 0);
942
- }
943
- return removed;
1799
+ #unlinkBlobs(keep) {
1800
+ return unlinkBlobsIn(join2(this.#pinnedDir, "blobs"), keep);
944
1801
  }
945
1802
  /** Whether any content file (a blob or a temp file) is on disk. */
946
1803
  async #holdsContentFiles() {
947
- const root = join(this.#pinnedDir, "blobs");
1804
+ const root = join2(this.#pinnedDir, "blobs");
948
1805
  for (const shard of await entriesOf(root)) {
949
- if ((await entriesOf(join(root, shard))).length > 0) return true;
1806
+ if ((await entriesOf(join2(root, shard))).length > 0) return true;
950
1807
  }
951
1808
  return false;
952
1809
  }
953
1810
  async collectGarbage(t) {
954
- return this.#mutate(async () => {
955
- try {
956
- this.#db.exec("BEGIN IMMEDIATE");
957
- } catch (error) {
958
- throw storageError(error, "Collecting unused content");
959
- }
1811
+ return this.#mutate(() => this.#collectLocked(t));
1812
+ }
1813
+ /** `collectGarbage` for callers already inside the mutation hold (it cannot re-enter `#mutate`). */
1814
+ async #collectLocked(t) {
1815
+ const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
1816
+ try {
1817
+ this.#db.exec("BEGIN IMMEDIATE");
1818
+ } catch (error) {
1819
+ release?.();
1820
+ throw storageError(error, "Collecting unused content");
1821
+ }
1822
+ try {
1823
+ this.#checkLease(t);
1824
+ await this.#faults.afterGarbageCollectionBegin?.();
1825
+ const keep = new Set(
1826
+ this.#db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash)
1827
+ );
1828
+ const removed = await this.#unlinkBlobs(keep);
1829
+ this.#db.exec("COMMIT");
1830
+ return removed;
1831
+ } catch (error) {
960
1832
  try {
961
- this.#checkLease(t);
962
- const keep = new Set(
963
- this.#db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash)
964
- );
965
- const removed = await this.#unlinkBlobs(keep);
966
- this.#db.exec("COMMIT");
967
- return removed;
968
- } catch (error) {
969
- try {
970
- this.#db.exec("ROLLBACK");
971
- } catch {
972
- }
973
- throw storageError(error, "Collecting unused content");
1833
+ this.#db.exec("ROLLBACK");
1834
+ } catch {
974
1835
  }
975
- });
1836
+ throw storageError(error, "Collecting unused content");
1837
+ } finally {
1838
+ release?.();
1839
+ }
976
1840
  }
977
1841
  async reset(t, reason, options = {}) {
978
1842
  await this.#mutate(async () => {
979
- this.#write("Resetting the replica", () => {
1843
+ await this.#write("Resetting the replica", () => {
980
1844
  this.#checkLease(t);
981
1845
  this.#db.run("DELETE FROM entry");
982
1846
  this.#db.run(
@@ -995,17 +1859,16 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
995
1859
  }
996
1860
  }
997
1861
  /**
998
- * Remove this replica's directory for good (`tc replica reset --purge`).
999
- * Fenced like a commit: `t` must still be a live lease on the database this
1000
- * store opened, checked under the write lock, so a paused process can
1001
- * never remove a replica another process took over or recreated. Allowed
1002
- * on a revoked replica. The store is unusable afterwards; close it.
1862
+ * Remove the source-created mirror layout under a live lease. Unknown files
1863
+ * are removed only when their exact preflight identities were approved.
1003
1864
  */
1004
- async destroy(t) {
1865
+ async destroy(t, options = {}) {
1005
1866
  await this.#mutate(async () => {
1867
+ const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
1006
1868
  try {
1007
1869
  this.#db.exec("BEGIN IMMEDIATE");
1008
1870
  } catch (error) {
1871
+ release?.();
1009
1872
  throw storageError(error, "Removing the replica");
1010
1873
  }
1011
1874
  try {
@@ -1015,87 +1878,31 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1015
1878
  throw new ReplicaError(ReplicaErrorCode.RUNTIME_UNSUPPORTED, "Replica removal requires an opened or identity-checked directory.");
1016
1879
  }
1017
1880
  const assertDirectory = async () => {
1018
- const current = await lstat(this.dir, { bigint: true });
1881
+ const current = await lstat2(this.dir, { bigint: true });
1019
1882
  if (!current.isDirectory() || current.dev !== expected.dev || current.ino !== expected.ino) {
1020
1883
  throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica directory changed during removal.");
1021
1884
  }
1022
1885
  return current;
1023
1886
  };
1024
1887
  const pinned = await assertDirectory();
1025
- await this.#assertPresent();
1026
- if (process.platform === "linux") {
1027
- for (const entry of await readdir(this.#pinnedDir)) {
1028
- await rm(join(this.#pinnedDir, entry), { recursive: true, force: true });
1029
- }
1030
- await assertDirectory();
1031
- await rmdir(this.dir);
1032
- } else {
1033
- const ancestors = this.#namedAncestors;
1034
- const checkAncestors = async () => {
1035
- for (const expected2 of ancestors) {
1036
- const current = await lstat(expected2.path, { bigint: true });
1037
- if (current.dev !== expected2.dev || current.ino !== expected2.ino) {
1038
- throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica ancestry changed during removal.");
1039
- }
1888
+ const assertNamedPath = async () => {
1889
+ for (const ancestor of this.#namedAncestors) {
1890
+ const current = await lstat2(ancestor.path, { bigint: true });
1891
+ if (current.dev !== ancestor.dev || current.ino !== ancestor.ino) {
1892
+ throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica ancestry changed during removal.");
1040
1893
  }
1041
- };
1042
- const directories = /* @__PURE__ */ new Map([[this.dir, pinned]]);
1043
- const files = /* @__PURE__ */ new Map();
1044
- const check = async (path, expected2, directory) => {
1045
- if (path !== this.dir) {
1046
- const parent = dirname(path);
1047
- await check(parent, directories.get(parent), true);
1048
- }
1049
- await checkAncestors();
1050
- const current = await lstat(path, { bigint: true });
1051
- if ((directory ? !current.isDirectory() : !current.isFile()) || current.dev !== expected2.dev || current.ino !== expected2.ino) {
1052
- throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica layout changed during removal.");
1053
- }
1054
- };
1055
- const record = async (path, directory) => {
1056
- const parent = dirname(path);
1057
- await check(parent, directories.get(parent), true);
1058
- const current = await lstat(path, { bigint: true });
1059
- if (!(directory ? current.isDirectory() : current.isFile())) {
1060
- throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica layout during removal.");
1061
- }
1062
- (directory ? directories : files).set(path, current);
1063
- };
1064
- const blobs = join(this.dir, "blobs");
1065
- for (const name of await readdir(this.dir)) {
1066
- if (name === "blobs") await record(blobs, true);
1067
- else if (["replica.db", "replica.db-wal", "replica.db-shm"].includes(name)) await record(join(this.dir, name), false);
1068
- else throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica layout during removal.");
1069
- }
1070
- if (directories.has(blobs)) {
1071
- await check(blobs, directories.get(blobs), true);
1072
- for (const shard of await readdir(blobs)) {
1073
- if (shard !== ".tmp" && !/^[0-9a-f]{2}$/.test(shard)) {
1074
- throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica blob shard during removal.");
1075
- }
1076
- const shardPath = join(blobs, shard);
1077
- await record(shardPath, true);
1078
- await check(shardPath, directories.get(shardPath), true);
1079
- for (const name of await readdir(shardPath)) {
1080
- if (shard === ".tmp" ? !/^[0-9a-f]{64}\.[0-9a-f]{12}$/.test(name) : !isHash(name) || !name.startsWith(shard)) {
1081
- throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "Unexpected replica blob file during removal.");
1082
- }
1083
- await record(join(shardPath, name), false);
1084
- }
1085
- }
1086
- }
1087
- for (const [path, identity] of files) {
1088
- await check(path, identity, false);
1089
- await unlink(path);
1090
- }
1091
- for (const [path, identity] of [...directories].reverse()) {
1092
- if (path === this.dir) continue;
1093
- await check(path, identity, true);
1094
- await rmdir(path);
1095
1894
  }
1096
1895
  await assertDirectory();
1097
- await checkAncestors();
1098
- await rmdir(this.dir);
1896
+ };
1897
+ await this.#assertPresent();
1898
+ try {
1899
+ await removeSqliteReplicaLayout(this.dir, {
1900
+ ...options,
1901
+ expectedRoot: options.expectedRoot ?? { dev: pinned.dev.toString(), ino: pinned.ino.toString() }
1902
+ });
1903
+ } catch (error) {
1904
+ await assertNamedPath();
1905
+ throw error;
1099
1906
  }
1100
1907
  } catch (error) {
1101
1908
  throw storageError(error, "Removing the replica");
@@ -1104,6 +1911,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1104
1911
  this.#db.exec("ROLLBACK");
1105
1912
  } catch {
1106
1913
  }
1914
+ release?.();
1107
1915
  }
1108
1916
  });
1109
1917
  }
@@ -1147,7 +1955,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1147
1955
  * when both are done.
1148
1956
  */
1149
1957
  async #purgeContent() {
1150
- this.#write("Starting the revocation purge", () => {
1958
+ await this.#write("Starting the revocation purge", () => {
1151
1959
  this.#db.run("UPDATE replica SET purge_pending = 1 WHERE id = 1 AND revoked_detail IS NOT NULL");
1152
1960
  });
1153
1961
  try {
@@ -1158,7 +1966,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1158
1966
  if (!this.#checkpoint()) {
1159
1967
  throw new ReplicaError(ReplicaErrorCode.BUSY, "A reader holds the replica's write-ahead log; the purge finishes on the next open.");
1160
1968
  }
1161
- this.#write("Finishing the revocation purge", () => {
1969
+ await this.#write("Finishing the revocation purge", () => {
1162
1970
  this.#db.run("UPDATE replica SET purge_pending = 0 WHERE id = 1 AND revoked_detail IS NOT NULL");
1163
1971
  });
1164
1972
  }
@@ -1168,6 +1976,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1168
1976
  });
1169
1977
  }
1170
1978
  async get(key) {
1979
+ this.#assertAccepting();
1171
1980
  try {
1172
1981
  const row = this.#db.get("SELECT key, deleted, etag, hash, metadata, content FROM entry WHERE key = ?", key);
1173
1982
  return row === void 0 ? void 0 : entryOf(row);
@@ -1176,6 +1985,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1176
1985
  }
1177
1986
  }
1178
1987
  async list(o) {
1988
+ this.#assertAccepting();
1179
1989
  const where = ["deleted = 0"];
1180
1990
  const params = [];
1181
1991
  if (o.prefix !== void 0 && o.prefix !== "") {
@@ -1261,21 +2071,39 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1261
2071
  lastReset: state.lastReset
1262
2072
  };
1263
2073
  }
1264
- async close() {
1265
- const fd = this.#directoryFd;
1266
- this.#directoryFd = void 0;
1267
- if (fd !== void 0) pinnedDirectories.unregister(this);
1268
- try {
1269
- this.#db.close();
1270
- } finally {
1271
- if (fd !== void 0) closeSync(fd);
1272
- }
2074
+ close() {
2075
+ if (this.#closePromise) return this.#closePromise;
2076
+ this.#closing = true;
2077
+ this.#closePromise = (async () => {
2078
+ if (this.#activeMutations !== 0) {
2079
+ await new Promise((resolve3) => {
2080
+ this.#drained = resolve3;
2081
+ });
2082
+ }
2083
+ this.#closed = true;
2084
+ const fd = this.#directoryFd;
2085
+ this.#directoryFd = void 0;
2086
+ if (this.#shared) {
2087
+ sharedStores.unregister(this);
2088
+ releaseWritable(this.#shared);
2089
+ } else {
2090
+ if (fd !== void 0) pinnedDirectories.unregister(this);
2091
+ try {
2092
+ this.#database.close();
2093
+ } finally {
2094
+ if (fd !== void 0) closeSync(fd);
2095
+ }
2096
+ }
2097
+ })();
2098
+ return this.#closePromise;
1273
2099
  }
1274
2100
  };
1275
2101
  export {
1276
2102
  DURABILITY,
1277
2103
  SqliteReplicaStore,
2104
+ inspectSqliteReplicaLayout,
1278
2105
  loadSqlite,
1279
- nodeSqliteSupported
2106
+ nodeSqliteSupported,
2107
+ removeSqliteReplicaLayout
1280
2108
  };
1281
2109
  //# sourceMappingURL=sqlite.js.map