@tinycloud/replica 0.2.4-beta.3 → 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.cjs CHANGED
@@ -22,16 +22,19 @@ var sqlite_exports = {};
22
22
  __export(sqlite_exports, {
23
23
  DURABILITY: () => DURABILITY,
24
24
  SqliteReplicaStore: () => SqliteReplicaStore,
25
+ inspectSqliteReplicaLayout: () => inspectSqliteReplicaLayout,
25
26
  loadSqlite: () => loadSqlite,
26
- nodeSqliteSupported: () => nodeSqliteSupported
27
+ nodeSqliteSupported: () => nodeSqliteSupported,
28
+ removeSqliteReplicaLayout: () => removeSqliteReplicaLayout
27
29
  });
28
30
  module.exports = __toCommonJS(sqlite_exports);
29
31
 
30
32
  // src/sqlite/store.ts
33
+ var import_node_async_hooks = require("async_hooks");
31
34
  var import_node_crypto = require("crypto");
32
- var import_node_fs = require("fs");
33
- var import_promises = require("fs/promises");
34
- var import_node_path = require("path");
35
+ var import_node_fs2 = require("fs");
36
+ var import_promises2 = require("fs/promises");
37
+ var import_node_path2 = require("path");
35
38
 
36
39
  // src/errors.ts
37
40
  var ReplicaErrorCode = {
@@ -89,7 +92,13 @@ var ReplicaErrorCode = {
89
92
  /** A stored blob no longer hashes to its name. */
90
93
  INTEGRITY_ERROR: "INTEGRITY_ERROR",
91
94
  /** The replica handle was closed; in-flight calls settle with this code. */
92
- CLOSED: "REPLICA_CLOSED"
95
+ CLOSED: "REPLICA_CLOSED",
96
+ /** The lifecycle gate refused the operation (shared contract `REPLICA_GATED`); `detail.gate` says why. */
97
+ GATED: "REPLICA_GATED",
98
+ /** Quota admission refused a page (`maxBytes` or the free-space floor); nothing committed. */
99
+ STORAGE_BUDGET: "STORAGE_BUDGET",
100
+ /** The consent deadline passed: the grant is not served until the user consents again. */
101
+ CONSENT_EXPIRED: "CONSENT_EXPIRED"
93
102
  };
94
103
  var REPLICA_ERROR = /* @__PURE__ */ Symbol.for("tinycloud.replica.error");
95
104
  var ReplicaError = class extends Error {
@@ -226,32 +235,13 @@ async function loadSqlite() {
226
235
  return (path, options) => wrap(options?.readonly ? new DatabaseSync(path, { readOnly: true }) : new DatabaseSync(path));
227
236
  }
228
237
 
229
- // src/sqlite/store.ts
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
- }
254
- var DURABILITY = "sqlite-wal-synchronous-full+fsynced-blobs";
238
+ // src/sqlite/layout.ts
239
+ var import_node_fs = require("fs");
240
+ var import_promises = require("fs/promises");
241
+ var import_node_path = require("path");
242
+
243
+ // src/sqlite/schema.ts
244
+ var SCHEMA_VERSION = 2;
255
245
  var SCHEMA = `
256
246
  CREATE TABLE IF NOT EXISTS replica (
257
247
  id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -311,7 +301,403 @@ CREATE TABLE IF NOT EXISTS entry (
311
301
  )
312
302
  ) STRICT, WITHOUT ROWID;
313
303
  CREATE INDEX IF NOT EXISTS entry_hash ON entry (hash) WHERE hash IS NOT NULL;
304
+ CREATE TABLE IF NOT EXISTS grant_install (
305
+ cid TEXT PRIMARY KEY,
306
+ installed_at INTEGER NOT NULL,
307
+ effective_expires_at INTEGER NOT NULL,
308
+ consent_deadline INTEGER NOT NULL,
309
+ consent_anchor_at INTEGER NOT NULL,
310
+ consent_anchor_source TEXT NOT NULL
311
+ ) STRICT, WITHOUT ROWID;
314
312
  `;
313
+
314
+ // src/sqlite/layout.ts
315
+ var POLICY_COLUMNS = [
316
+ "grant_installed_at REAL",
317
+ "grant_effective_expires_at REAL",
318
+ "grant_consent_deadline REAL",
319
+ "grant_consent_anchor_at REAL",
320
+ "grant_consent_anchor_source TEXT",
321
+ "pending_grant_installed_at REAL",
322
+ "pending_grant_effective_expires_at REAL",
323
+ "pending_grant_consent_deadline REAL",
324
+ "pending_grant_consent_anchor_at REAL",
325
+ "pending_grant_consent_anchor_source TEXT"
326
+ ];
327
+ var normalizeSchema = (sql) => sql.replace(" IF NOT EXISTS", "").replace(/\s+/g, " ").trim();
328
+ var SOURCE_SCHEMA = SCHEMA.trim().split(";").map((sql) => sql.trim()).filter(Boolean).map((sql) => {
329
+ const match = /^CREATE (TABLE|INDEX) IF NOT EXISTS ([a-z_]+)/.exec(sql);
330
+ if (match === null) throw new Error("Invalid source replica schema.");
331
+ return { type: match[1].toLowerCase(), name: match[2], sql: normalizeSchema(sql) };
332
+ });
333
+ var V1_SCHEMA = SOURCE_SCHEMA.filter((entry) => entry.name !== "grant_install");
334
+ var POLICY_REPLICA = V1_SCHEMA.find((entry) => entry.name === "replica").sql.replace(/\) STRICT$/, `, ${POLICY_COLUMNS.join(", ")}) STRICT`);
335
+ var POLICY_SCHEMA = V1_SCHEMA.map((entry) => entry.name === "replica" ? { ...entry, sql: POLICY_REPLICA } : entry);
336
+ async function sourceMirrorSchema(path) {
337
+ let db;
338
+ try {
339
+ db = (await loadSqlite())(path, { readonly: true });
340
+ db.exec("BEGIN");
341
+ const version = db.get("PRAGMA user_version")?.user_version;
342
+ if (version !== 1 && version !== SCHEMA_VERSION) return false;
343
+ const rows = db.all(
344
+ "SELECT type, name, sql FROM sqlite_schema WHERE sql IS NOT NULL ORDER BY name"
345
+ );
346
+ const expected = version === 1 ? V1_SCHEMA : SOURCE_SCHEMA;
347
+ const matches = (schema) => rows.length === schema.length && rows.every((row) => {
348
+ const source = schema.find((entry) => entry.name === row.name);
349
+ return source?.type === row.type && source.sql === normalizeSchema(row.sql);
350
+ });
351
+ if (matches(expected)) return true;
352
+ if (version !== 2) return false;
353
+ return matches(POLICY_SCHEMA);
354
+ } catch {
355
+ return false;
356
+ } finally {
357
+ try {
358
+ db?.close();
359
+ } catch {
360
+ }
361
+ }
362
+ }
363
+ var SQLITE_FILE = /^(?:replica\.db|replica\.db-(?:wal|shm|journal))$/;
364
+ var SHARD = /^[0-9a-f]{2}$/;
365
+ var HASH = /^[0-9a-f]{64}$/;
366
+ var TEMP = /^[0-9a-f]{64}\.[0-9a-f]{12}$/;
367
+ function sourceKind(parent, name, allowAdapterFence) {
368
+ if (parent === "") {
369
+ if (SQLITE_FILE.test(name) || allowAdapterFence && (name === "fence.json" || /^fence[.]json[.][0-9a-f]{12}[.]tmp$/.test(name))) return "file";
370
+ return name === "blobs" ? "directory" : "unknown";
371
+ }
372
+ if (parent === "blobs") return name === ".tmp" || SHARD.test(name) ? "directory" : "unknown";
373
+ if (parent === "blobs/.tmp") return TEMP.test(name) ? "file" : "unknown";
374
+ if (parent.startsWith("blobs/") && SHARD.test(parent.slice(6))) {
375
+ return HASH.test(name) && name.startsWith(parent.slice(6)) ? "file" : "unknown";
376
+ }
377
+ return "unknown";
378
+ }
379
+ function sameFile(a, b) {
380
+ return a.dev === b.dev && a.ino === b.ino;
381
+ }
382
+ function identityOf(path, kind, details) {
383
+ return { path, kind, dev: details.dev.toString(), ino: details.ino.toString() };
384
+ }
385
+ function fileKind(details) {
386
+ if (details.isFile()) return "file";
387
+ if (details.isDirectory()) return "directory";
388
+ if (details.isSymbolicLink()) return "symlink";
389
+ throw new Error("Unsupported replica entry type.");
390
+ }
391
+ async function pathComponents(path) {
392
+ const absolute = (0, import_node_path.resolve)(path);
393
+ const parts = absolute.slice((0, import_node_path.parse)(absolute).root.length).split(import_node_path.sep).filter(Boolean);
394
+ let current = (0, import_node_path.parse)(absolute).root;
395
+ const components = [];
396
+ for (const part of ["", ...parts]) {
397
+ if (part) current = (0, import_node_path.join)(current, part);
398
+ const details = await (0, import_promises.lstat)(current, { bigint: true });
399
+ if (details.isSymbolicLink()) throw new Error(`Symlink in replica path: ${current}`);
400
+ if (!details.isDirectory()) throw new Error(`Replica path component is not a directory: ${current}`);
401
+ components.push({ path: current, identity: details });
402
+ }
403
+ return components;
404
+ }
405
+ async function pinnedDirectory(path, parent, relative = "") {
406
+ const components = process.platform === "linux" ? void 0 : parent?.components ?? await pathComponents(path);
407
+ const before = await (0, import_promises.lstat)(path, { bigint: true });
408
+ if (before.isSymbolicLink()) throw new Error(`Symlink in replica path: ${path}`);
409
+ if (!before.isDirectory()) throw new Error("Replica directory is not a real directory.");
410
+ const handle = await (0, import_promises.open)(path, import_node_fs.constants.O_RDONLY | import_node_fs.constants.O_DIRECTORY | import_node_fs.constants.O_NOFOLLOW);
411
+ try {
412
+ if (!sameFile(before, await handle.stat({ bigint: true }))) {
413
+ throw new Error("Replica directory changed during inspection.");
414
+ }
415
+ return {
416
+ handle,
417
+ identity: before,
418
+ parent,
419
+ components,
420
+ children: [],
421
+ relative,
422
+ name: path,
423
+ path: process.platform === "linux" ? `/proc/self/fd/${handle.fd}` : path
424
+ };
425
+ } catch (error) {
426
+ await handle.close();
427
+ throw error;
428
+ }
429
+ }
430
+ async function checkDirectory(directory) {
431
+ const details = await (0, import_promises.lstat)(directory.name, { bigint: true });
432
+ if (!details.isDirectory() || !sameFile(directory.identity, details)) {
433
+ throw new Error(`Replica directory changed: ${directory.relative || directory.name}`);
434
+ }
435
+ if (directory.parent) await checkDirectory(directory.parent);
436
+ else if (directory.components) {
437
+ for (const component of directory.components) {
438
+ const current = await (0, import_promises.lstat)(component.path, { bigint: true });
439
+ if (current.isSymbolicLink() || !current.isDirectory() || !sameFile(component.identity, current)) {
440
+ throw new Error(`Replica path component changed: ${component.path}`);
441
+ }
442
+ }
443
+ }
444
+ }
445
+ async function inspect(dir, allowAdapterFence) {
446
+ const root = await pinnedDirectory(dir);
447
+ const directories = [root];
448
+ const unknownPaths = [];
449
+ const knownEntries = [];
450
+ const unknownEntries = [];
451
+ let hasDatabase = false;
452
+ try {
453
+ async function walk(parent) {
454
+ await checkDirectory(parent);
455
+ for (const name of (await (0, import_promises.readdir)(parent.path)).sort()) {
456
+ await checkDirectory(parent);
457
+ const relative = parent.relative ? `${parent.relative}/${name}` : name;
458
+ const path = (0, import_node_path.join)(parent.path, name);
459
+ const details = await (0, import_promises.lstat)(path, { bigint: true });
460
+ if (process.platform !== "linux" && details.isSymbolicLink()) {
461
+ throw new Error(`Symlink in replica path: ${relative}`);
462
+ }
463
+ const kind = fileKind(details);
464
+ const child = { path, relative, kind, identity: details, parent };
465
+ parent.children.push(child);
466
+ if (parent.relative === "" && name === "replica.db" && kind === "file") hasDatabase = true;
467
+ if (sourceKind(parent.relative, name, allowAdapterFence) !== kind) {
468
+ unknownPaths.push(relative);
469
+ unknownEntries.push(identityOf(relative, kind, details));
470
+ } else {
471
+ knownEntries.push(identityOf(relative, kind, details));
472
+ }
473
+ if (kind === "directory") {
474
+ const directory = await pinnedDirectory(path, parent, relative);
475
+ if (!sameFile(details, directory.identity)) {
476
+ await directory.handle.close();
477
+ throw new Error(`Replica directory changed during inspection: ${relative}`);
478
+ }
479
+ child.directory = directory;
480
+ directories.push(directory);
481
+ await walk(directory);
482
+ }
483
+ }
484
+ }
485
+ await walk(root);
486
+ if (!hasDatabase) {
487
+ unknownPaths.push("replica.db");
488
+ } else if (!await sourceMirrorSchema((0, import_node_path.join)(root.path, "replica.db"))) {
489
+ const index = knownEntries.findIndex((entry) => entry.path === "replica.db");
490
+ if (index >= 0) unknownEntries.push(knownEntries.splice(index, 1)[0]);
491
+ unknownPaths.push("replica.db");
492
+ }
493
+ await checkDirectory(root);
494
+ const names = new Set(await (0, import_promises.readdir)(root.path));
495
+ for (let index = root.children.length - 1; index >= 0; index--) {
496
+ const child = root.children[index];
497
+ if (!names.has(child.relative)) {
498
+ if (child.directory) throw new Error(`Replica directory vanished: ${child.relative}`);
499
+ root.children.splice(index, 1);
500
+ for (const list of [knownEntries, unknownEntries]) {
501
+ const entryIndex = list.findIndex((entry) => entry.path === child.relative);
502
+ if (entryIndex >= 0) list.splice(entryIndex, 1);
503
+ }
504
+ const pathIndex = unknownPaths.indexOf(child.relative);
505
+ if (pathIndex >= 0) unknownPaths.splice(pathIndex, 1);
506
+ continue;
507
+ }
508
+ const current = await (0, import_promises.lstat)(child.path, { bigint: true });
509
+ if (!sameFile(child.identity, current) || fileKind(current) !== child.kind) {
510
+ throw new Error(`Replica child changed during schema inspection: ${child.relative}`);
511
+ }
512
+ }
513
+ for (const name of names) {
514
+ if (root.children.some((child) => child.relative === name)) continue;
515
+ const path = (0, import_node_path.join)(root.path, name);
516
+ const details = await (0, import_promises.lstat)(path, { bigint: true });
517
+ if (!/^replica\.db-(?:wal|shm|journal)$/.test(name) || !details.isFile()) {
518
+ throw new Error(`Unexpected replica child during schema inspection: ${name}`);
519
+ }
520
+ root.children.push({ path, relative: name, kind: "file", identity: details, parent: root });
521
+ knownEntries.push(identityOf(name, "file", details));
522
+ }
523
+ return { root, directories, knownEntries, unknownPaths, unknownEntries };
524
+ } catch (error) {
525
+ await closeDirectories(directories);
526
+ throw error;
527
+ }
528
+ }
529
+ async function closeDirectories(directories) {
530
+ for (const directory of directories.reverse()) await directory.handle.close();
531
+ }
532
+ async function inspectSqliteReplicaLayout(dir, options = {}) {
533
+ const inventory = await inspect(dir, options.allowAdapterFence === true);
534
+ try {
535
+ return {
536
+ knownEntries: inventory.knownEntries,
537
+ unknownPaths: inventory.unknownPaths,
538
+ unknownEntries: inventory.unknownEntries,
539
+ rootIdentity: { dev: inventory.root.identity.dev.toString(), ino: inventory.root.identity.ino.toString() }
540
+ };
541
+ } finally {
542
+ await closeDirectories(inventory.directories);
543
+ }
544
+ }
545
+ function approvedEntries(entries) {
546
+ const approved = /* @__PURE__ */ new Map();
547
+ for (const entry of entries) {
548
+ 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)) {
549
+ throw new Error("Invalid or duplicate replica approval.");
550
+ }
551
+ approved.set(entry.path, entry);
552
+ }
553
+ return approved;
554
+ }
555
+ var LAYOUT_FAULTS = /* @__PURE__ */ Symbol("tinycloud.replica.layout-faults");
556
+ async function removeSqliteReplicaLayout(dir, options = {}) {
557
+ const inventory = await inspect(dir, options.allowAdapterFence === true);
558
+ try {
559
+ if (options.expectedRoot && (options.expectedRoot.dev !== inventory.root.identity.dev.toString() || options.expectedRoot.ino !== inventory.root.identity.ino.toString())) {
560
+ throw new Error("Replica root identity changed since approval.");
561
+ }
562
+ const approved = approvedEntries(options.approvedUnknown ?? []);
563
+ for (const entry of inventory.unknownEntries) {
564
+ const match = approved.get(entry.path);
565
+ if (!match || match.kind !== entry.kind || match.dev !== entry.dev || match.ino !== entry.ino) {
566
+ throw new Error(`Unknown replica child or approval identity mismatch: ${entry.path}`);
567
+ }
568
+ }
569
+ if (options.expectedKnown) {
570
+ const expected = approvedEntries(options.expectedKnown);
571
+ for (const entry of inventory.knownEntries) {
572
+ const match = expected.get(entry.path);
573
+ if (!match || match.kind !== entry.kind || match.dev !== entry.dev || match.ino !== entry.ino) {
574
+ throw new Error(`Known replica child changed since preflight: ${entry.path}`);
575
+ }
576
+ }
577
+ }
578
+ if (options.allowMissingDatabase && inventory.knownEntries.some((entry) => entry.path === "replica.db")) {
579
+ throw new Error("Replica database appeared after approval of its absence.");
580
+ }
581
+ if (inventory.unknownPaths.length !== inventory.unknownEntries.length && inventory.root.children.length > 0 && !options.allowMissingDatabase) {
582
+ throw new Error("Replica database is missing; cannot approve removal.");
583
+ }
584
+ async function removeDirectory(directory) {
585
+ for (const child of directory.children.sort((a, b) => a.relative === "replica.db" ? 1 : b.relative === "replica.db" ? -1 : 0)) {
586
+ if (child.directory) {
587
+ await removeDirectory(child.directory);
588
+ }
589
+ await options[LAYOUT_FAULTS]?.beforeUnlink?.(child.relative);
590
+ await checkDirectory(directory);
591
+ const details = await (0, import_promises.lstat)(child.path, { bigint: true });
592
+ if (process.platform !== "linux" && details.isSymbolicLink()) {
593
+ throw new Error(`Symlink in replica path: ${child.relative}`);
594
+ }
595
+ if (fileKind(details) !== child.kind || !sameFile(child.identity, details)) {
596
+ throw new Error(`Replica child identity changed: ${child.relative}`);
597
+ }
598
+ if (child.directory) await (0, import_promises.rmdir)(child.path);
599
+ else await (0, import_promises.unlink)(child.path);
600
+ }
601
+ }
602
+ await removeDirectory(inventory.root);
603
+ await checkDirectory(inventory.root);
604
+ await (0, import_promises.rmdir)(inventory.root.name);
605
+ } finally {
606
+ await closeDirectories(inventory.directories);
607
+ }
608
+ }
609
+
610
+ // src/sqlite/store.ts
611
+ var sqliteOpenQueue = Promise.resolve();
612
+ async function acquireSqliteOpen() {
613
+ const previous = sqliteOpenQueue;
614
+ let release;
615
+ sqliteOpenQueue = new Promise((resolve3) => {
616
+ release = resolve3;
617
+ });
618
+ await previous;
619
+ return release;
620
+ }
621
+ async function captureNamedPath(path) {
622
+ const names = [];
623
+ for (let current = (0, import_node_path2.resolve)(path); ; current = (0, import_node_path2.dirname)(current)) {
624
+ names.push(current);
625
+ if ((0, import_node_path2.dirname)(current) === current) break;
626
+ }
627
+ const identities = [];
628
+ for (const name of names.reverse()) {
629
+ const stats = await (0, import_promises2.lstat)(name, { bigint: true });
630
+ identities.push({ path: name, dev: stats.dev, ino: stats.ino });
631
+ }
632
+ return identities;
633
+ }
634
+ async function openVerifiedDatabase(path, opener, options = {}) {
635
+ const leaf = await (0, import_promises2.open)(path, import_node_fs2.constants.O_RDONLY | import_node_fs2.constants.O_NOFOLLOW);
636
+ let db;
637
+ try {
638
+ const identity = await leaf.stat();
639
+ const named = await (0, import_promises2.lstat)(path);
640
+ 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)) {
641
+ throw new Error("The replica database changed before SQLite opened it.");
642
+ }
643
+ const previousFds = process.platform === "linux" && options.discoverDescriptors !== false ? new Map((0, import_node_fs2.readdirSync)("/proc/self/fd").map((fd) => {
644
+ try {
645
+ return [fd, (0, import_node_fs2.readlinkSync)(`/proc/self/fd/${fd}`)];
646
+ } catch {
647
+ return [fd, ""];
648
+ }
649
+ })) : null;
650
+ db = options.readonly ? opener(path, { readonly: true }) : opener(path);
651
+ const afterOpen = (0, import_node_fs2.lstatSync)(path);
652
+ if (!afterOpen.isFile() || afterOpen.dev !== identity.dev || afterOpen.ino !== identity.ino) {
653
+ throw new Error("The replica database changed while SQLite opened it.");
654
+ }
655
+ if (previousFds) db.get("SELECT count(*) FROM sqlite_master");
656
+ if (previousFds) {
657
+ const databases = (0, import_node_fs2.readdirSync)("/proc/self/fd").filter((fd) => {
658
+ try {
659
+ const target = (0, import_node_fs2.readlinkSync)(`/proc/self/fd/${fd}`);
660
+ return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
661
+ } catch {
662
+ return false;
663
+ }
664
+ });
665
+ const sharedPager = options.readonly && databases.length === 0 && [...previousFds].some(([fd, target]) => {
666
+ if (Number(fd) === leaf.fd || !target.endsWith("/replica.db")) return false;
667
+ try {
668
+ const actual = (0, import_node_fs2.fstatSync)(Number(fd));
669
+ return actual.dev === identity.dev && actual.ino === identity.ino;
670
+ } catch {
671
+ return false;
672
+ }
673
+ });
674
+ if (!databases.length && !sharedPager || databases.some((fd) => {
675
+ try {
676
+ const actual = (0, import_node_fs2.fstatSync)(Number(fd));
677
+ return actual.dev !== identity.dev || actual.ino !== identity.ino;
678
+ } catch {
679
+ return true;
680
+ }
681
+ })) {
682
+ throw new Error("SQLite opened a different replica database.");
683
+ }
684
+ }
685
+ const after = (0, import_node_fs2.lstatSync)(path);
686
+ if (!after.isFile() || after.dev !== identity.dev || after.ino !== identity.ino) {
687
+ throw new Error("The replica database changed while SQLite opened it.");
688
+ }
689
+ const checked = db;
690
+ db = void 0;
691
+ return { db: checked, identity };
692
+ } finally {
693
+ try {
694
+ db?.close();
695
+ } finally {
696
+ await leaf.close();
697
+ }
698
+ }
699
+ }
700
+ var DURABILITY = "sqlite-wal-synchronous-full+fsynced-blobs";
315
701
  function grantOf(cid, bytes, audience, issuer, nbf, exp) {
316
702
  if (cid === null || bytes === null || audience === null || issuer === null) return null;
317
703
  return { cid, bytes: new Uint8Array(bytes), audience, issuer, notBefore: nbf, expiresAt: exp };
@@ -340,9 +726,79 @@ var isHash = (name) => /^[0-9a-f]{64}$/.test(name);
340
726
  function errnoOf(error) {
341
727
  return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
342
728
  }
729
+ var isShard = (name) => /^[0-9a-f]{2}$/.test(name);
730
+ var isTempName = (name) => /^[0-9a-f]{64}\.[0-9a-f]{12}$/.test(name);
731
+ async function writerBlobFiles(root) {
732
+ const out = [];
733
+ let shards;
734
+ try {
735
+ shards = await (0, import_promises2.readdir)(root, { withFileTypes: true });
736
+ } catch (error) {
737
+ if (errnoOf(error) === "ENOENT" || errnoOf(error) === "ENOTDIR") return out;
738
+ throw error;
739
+ }
740
+ for (const shard of shards) {
741
+ const temp = shard.name === ".tmp";
742
+ if (!shard.isDirectory() || !(temp || isShard(shard.name))) continue;
743
+ const shardPath = (0, import_node_path2.join)(root, shard.name);
744
+ let files;
745
+ try {
746
+ files = await (0, import_promises2.readdir)(shardPath, { withFileTypes: true });
747
+ } catch (error) {
748
+ if (errnoOf(error) === "ENOENT" || errnoOf(error) === "ENOTDIR") continue;
749
+ throw error;
750
+ }
751
+ for (const file of files) {
752
+ if (!file.isFile()) continue;
753
+ const ok = temp ? isTempName(file.name) : isHash(file.name) && file.name.startsWith(shard.name);
754
+ if (!ok) continue;
755
+ const path = (0, import_node_path2.join)(shardPath, file.name);
756
+ const info = await (0, import_promises2.lstat)(path).catch(() => void 0);
757
+ if (!info?.isFile()) continue;
758
+ out.push({ path, hash: temp ? null : file.name, size: info.size });
759
+ }
760
+ }
761
+ return out;
762
+ }
763
+ async function unlinkWriterOrphans(root, keep) {
764
+ let removed = 0;
765
+ const touched = /* @__PURE__ */ new Set();
766
+ for (const file of await writerBlobFiles(root)) {
767
+ if (file.hash !== null && keep.has(file.hash)) continue;
768
+ await (0, import_promises2.rm)(file.path, { force: true });
769
+ touched.add((0, import_node_path2.dirname)(file.path));
770
+ removed += 1;
771
+ }
772
+ for (const dir of touched) if (!dir.endsWith(".tmp")) await (0, import_promises2.rmdir)(dir).catch(() => void 0);
773
+ return removed;
774
+ }
775
+ async function unlinkBlobsIn(root, keep) {
776
+ let removed = 0;
777
+ for (const shard of await entriesOf(root)) {
778
+ const shardPath = (0, import_node_path2.join)(root, shard);
779
+ if (shard === ".tmp") {
780
+ for (const name of await entriesOf(shardPath)) {
781
+ await (0, import_promises2.rm)((0, import_node_path2.join)(shardPath, name), { force: true });
782
+ removed += 1;
783
+ }
784
+ continue;
785
+ }
786
+ let kept = 0;
787
+ for (const name of await entriesOf(shardPath)) {
788
+ if (isHash(name) && keep.has(name)) {
789
+ kept += 1;
790
+ continue;
791
+ }
792
+ await (0, import_promises2.rm)((0, import_node_path2.join)(shardPath, name), { force: true });
793
+ removed += 1;
794
+ }
795
+ if (kept === 0) await (0, import_promises2.rmdir)(shardPath).catch(() => void 0);
796
+ }
797
+ return removed;
798
+ }
343
799
  async function entriesOf(path) {
344
800
  try {
345
- return await (0, import_promises.readdir)(path);
801
+ return await (0, import_promises2.readdir)(path);
346
802
  } catch (error) {
347
803
  if (errnoOf(error) === "ENOENT") return [];
348
804
  throw error;
@@ -351,7 +807,7 @@ async function entriesOf(path) {
351
807
  var FAULTS = /* @__PURE__ */ Symbol("tinycloud.replica.faults");
352
808
  var pinnedDirectories = new FinalizationRegistry((fd) => {
353
809
  try {
354
- (0, import_node_fs.closeSync)(fd);
810
+ (0, import_node_fs2.closeSync)(fd);
355
811
  } catch {
356
812
  }
357
813
  });
@@ -362,7 +818,7 @@ function releaseWritable(shared) {
362
818
  try {
363
819
  shared.db.close();
364
820
  } finally {
365
- if (shared.directoryFd !== void 0) (0, import_node_fs.closeSync)(shared.directoryFd);
821
+ if (shared.directoryFd !== void 0) (0, import_node_fs2.closeSync)(shared.directoryFd);
366
822
  }
367
823
  }
368
824
  var sharedStores = new FinalizationRegistry((shared) => {
@@ -374,12 +830,13 @@ var sharedStores = new FinalizationRegistry((shared) => {
374
830
  async function acquireWritableMutation(shared) {
375
831
  const previous = shared.mutationTail;
376
832
  let release;
377
- shared.mutationTail = new Promise((resolve2) => {
378
- release = resolve2;
833
+ shared.mutationTail = new Promise((resolve3) => {
834
+ release = resolve3;
379
835
  });
380
836
  await previous;
381
837
  return release;
382
838
  }
839
+ var activeReadSection = new import_node_async_hooks.AsyncLocalStorage();
383
840
  var SqliteReplicaStore = class _SqliteReplicaStore {
384
841
  dir;
385
842
  #database;
@@ -393,21 +850,25 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
393
850
  #activeMutations = 0;
394
851
  #drained;
395
852
  #shared;
853
+ #readonlySnapshot;
396
854
  #pinnedDir;
397
855
  #directoryFd;
398
- #holder = `${process.pid}-${(0, import_node_crypto.randomBytes)(6).toString("hex")}`;
856
+ #holder;
399
857
  #now;
400
858
  #faults;
401
859
  #guard;
402
860
  /** Identity of the database file this store opened: a deleted-and-recreated replica is a different file. */
403
861
  #ino;
862
+ #admit;
863
+ #partitionId;
404
864
  #dev;
405
865
  #namedAncestors;
406
- constructor(dir, db, identity, options, directoryFd, namedAncestors = [], shared) {
866
+ constructor(dir, db, identity, options, directoryFd, namedAncestors = [], shared, readonlySnapshot = false) {
407
867
  this.dir = dir;
408
868
  this.#pinnedDir = shared?.pinnedDir ?? (directoryFd !== void 0 && process.platform === "linux" ? `/proc/self/fd/${directoryFd}` : dir);
409
869
  this.#directoryFd = directoryFd;
410
870
  this.#shared = shared;
871
+ this.#readonlySnapshot = readonlySnapshot;
411
872
  if (shared) {
412
873
  shared.references++;
413
874
  sharedStores.register(this, shared, this);
@@ -419,6 +880,8 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
419
880
  this.#dev = identity.dev;
420
881
  this.#now = options.now ?? Date.now;
421
882
  this.#faults = options[FAULTS] ?? {};
883
+ this.#admit = options.admit;
884
+ this.#partitionId = options.partitionId ?? "";
422
885
  this.#guard = options.guard ?? ((section) => section());
423
886
  this.#namedAncestors = namedAncestors;
424
887
  }
@@ -432,10 +895,10 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
432
895
  if (options.signal?.aborted) throw options.signal.reason ?? new ReplicaError(ReplicaErrorCode.CLOSED, "Replica status inspection was cancelled.");
433
896
  };
434
897
  checkAbort();
435
- const dbPath = (0, import_node_path.join)(dir, "replica.db");
898
+ const dbPath = (0, import_node_path2.join)(dir, "replica.db");
436
899
  let identity;
437
900
  try {
438
- identity = await (0, import_promises.lstat)(dbPath);
901
+ identity = await (0, import_promises2.lstat)(dbPath);
439
902
  if (!identity.isFile()) return null;
440
903
  } catch (error) {
441
904
  if (errnoOf(error) === "ENOENT") return null;
@@ -445,21 +908,30 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
445
908
  const opener = await loadSqlite();
446
909
  checkAbort();
447
910
  let db;
911
+ let releaseOpen;
448
912
  try {
449
- db = opener(dbPath, { readonly: true });
913
+ releaseOpen = await acquireSqliteOpen();
914
+ const checked = await openVerifiedDatabase(dbPath, opener, { readonly: true, expected: identity });
915
+ db = checked.db;
916
+ releaseOpen();
917
+ releaseOpen = void 0;
450
918
  db.exec("PRAGMA busy_timeout = 0");
451
919
  checkAbort();
452
- const store = new _SqliteReplicaStore(dir, db, identity, { create: false, ...options.now ? { now: options.now } : {} });
920
+ const store = new _SqliteReplicaStore(dir, db, checked.identity, { create: false, ...options.now ? { now: options.now } : {} });
453
921
  for (let attempt = 0; attempt < 2; attempt++) {
454
922
  const before = db.get("PRAGMA data_version").data_version;
455
923
  const state = await store.open();
456
924
  checkAbort();
457
925
  if (state === null) return null;
458
926
  const status = await store.status();
459
- await new Promise((resolve2) => setImmediate(resolve2));
927
+ const latest = state.pendingGrant ?? state.grant;
928
+ const version = db.get("PRAGMA user_version")?.user_version ?? 0;
929
+ const grantInstall = latest === null || version < 2 ? null : await store.getGrantInstall(latest.cid);
930
+ const activeGrantInstall = state.grant === null || version < 2 ? null : state.grant === latest ? grantInstall : await store.getGrantInstall(state.grant.cid);
931
+ await new Promise((resolve3) => setImmediate(resolve3));
460
932
  checkAbort();
461
933
  const after = db.get("PRAGMA data_version").data_version;
462
- if (before === after) return { state, status };
934
+ if (before === after) return { state, status, grantInstall, activeGrantInstall };
463
935
  }
464
936
  throw new ReplicaError(ReplicaErrorCode.BUSY, "The replica changed during status inspection.");
465
937
  } catch (error) {
@@ -467,18 +939,75 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
467
939
  throw new ReplicaError(ReplicaErrorCode.BUSY, "The replica is busy during status inspection.", void 0, { cause: error });
468
940
  }
469
941
  throw storageError(error, "Inspecting the replica");
942
+ } finally {
943
+ releaseOpen?.();
944
+ if (db) {
945
+ const releaseClose = await acquireSqliteOpen();
946
+ try {
947
+ db.close();
948
+ } finally {
949
+ releaseClose();
950
+ }
951
+ }
952
+ }
953
+ }
954
+ /**
955
+ * Blob content bytes on disk in the replica at `dir` (0 when absent): the writer's blob files under `blobs/`,
956
+ * including writer temp files and blobs no entry references any more, so garbage a failed sync
957
+ * left behind still counts. Counts exactly what `collectOrphans` may delete: foreign entries
958
+ * (stray files, symlinks, unknown names) are neither counted nor touched. Only blob content is counted: not the database, its WAL, rows or the
959
+ * pending store. Filesystem-only: it opens no
960
+ * database, so it can sum a profile's replicas under the caller's own mutation hold.
961
+ */
962
+ static async contentBytes(dir) {
963
+ try {
964
+ let bytes = 0;
965
+ for (const file of await writerBlobFiles((0, import_node_path2.join)(dir, "blobs"))) bytes += file.size;
966
+ return bytes;
967
+ } catch (error) {
968
+ throw storageError(error, "Measuring replica content");
969
+ }
970
+ }
971
+ /**
972
+ * Remove blobs and temp files in the replica at `dir` that no committed row references: what a
973
+ * crash between writing blobs and committing (or between committing and collecting) leaves.
974
+ * Returns the number of files removed; 0 when the replica is absent or unreadable.
975
+ *
976
+ * The caller MUST hold the profile mutation guard, which every writer of this replica holds
977
+ * across its blob write and commit; that is what makes an unreferenced blob safe to delete
978
+ * (it cannot belong to an in-flight write). Never call it without that hold. The database is
979
+ * read only, so nothing here can take a lease or change committed state.
980
+ */
981
+ static async collectOrphans(dir) {
982
+ const dbPath = (0, import_node_path2.join)(dir, "replica.db");
983
+ const identity = await (0, import_promises2.lstat)(dbPath).catch(() => void 0);
984
+ if (!identity?.isFile()) return 0;
985
+ const opener = await loadSqlite();
986
+ let keep;
987
+ let db;
988
+ try {
989
+ db = opener(dbPath, { readonly: true });
990
+ db.exec("PRAGMA busy_timeout = 0");
991
+ keep = new Set(db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash));
992
+ } catch {
993
+ return 0;
470
994
  } finally {
471
995
  db?.close();
472
996
  }
997
+ try {
998
+ return await unlinkWriterOrphans((0, import_node_path2.join)(dir, "blobs"), keep);
999
+ } catch (error) {
1000
+ throw storageError(error, "Collecting orphaned content");
1001
+ }
473
1002
  }
474
1003
  /**
475
1004
  * Open the replica store in `dir`. With `create: false` a missing replica is
476
1005
  * REPLICA_NOT_FOUND and nothing is written to disk.
477
1006
  */
478
1007
  static async open(dir, options) {
479
- const namedDbPath = (0, import_node_path.join)(dir, "replica.db");
1008
+ const namedDbPath = (0, import_node_path2.join)(dir, "replica.db");
480
1009
  if (!options.create) {
481
- const exists = await (0, import_promises.lstat)(namedDbPath).then(
1010
+ const exists = await (0, import_promises2.lstat)(namedDbPath).then(
482
1011
  (stats) => stats.isFile(),
483
1012
  () => false
484
1013
  );
@@ -488,11 +1017,11 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
488
1017
  let store;
489
1018
  let directoryFd;
490
1019
  try {
491
- if (options.create) await (0, import_promises.mkdir)(dir, { recursive: true, mode: 448 });
1020
+ if (options.create) await (0, import_promises2.mkdir)(dir, { recursive: true, mode: 448 });
492
1021
  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);
1022
+ directoryFd = (0, import_node_fs2.openSync)(dir, import_node_fs2.constants.O_RDONLY | import_node_fs2.constants.O_DIRECTORY | import_node_fs2.constants.O_NOFOLLOW);
494
1023
  }
495
- const opened = directoryFd === void 0 ? (0, import_node_fs.lstatSync)(dir, { bigint: true }) : (0, import_node_fs.fstatSync)(directoryFd, { bigint: true });
1024
+ const opened = directoryFd === void 0 ? (0, import_node_fs2.lstatSync)(dir, { bigint: true }) : (0, import_node_fs2.fstatSync)(directoryFd, { bigint: true });
496
1025
  if (!opened.isDirectory() || opened.isSymbolicLink() || options.expectedDirectory && (opened.dev !== BigInt(options.expectedDirectory.dev) || opened.ino !== BigInt(options.expectedDirectory.ino))) {
497
1026
  throw new Error("The replica directory changed before SQLite opened it.");
498
1027
  }
@@ -501,11 +1030,11 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
501
1030
  throw new Error("The replica directory changed while checking its ancestry.");
502
1031
  }
503
1032
  const pinnedDir = directoryFd === void 0 ? dir : `/proc/self/fd/${directoryFd}`;
504
- const dbPath = (0, import_node_path.join)(pinnedDir, "replica.db");
1033
+ const dbPath = (0, import_node_path2.join)(pinnedDir, "replica.db");
505
1034
  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) => {
1035
+ await (0, import_promises2.mkdir)((0, import_node_path2.join)(pinnedDir, "blobs", ".tmp"), { recursive: true, mode: 448 });
1036
+ await Promise.all([(0, import_promises2.chmod)(pinnedDir, 448), (0, import_promises2.chmod)((0, import_node_path2.join)(pinnedDir, "blobs"), 448), (0, import_promises2.chmod)((0, import_node_path2.join)(pinnedDir, "blobs", ".tmp"), 448)]);
1037
+ const handle = await (0, import_promises2.open)(dbPath, import_node_fs2.constants.O_CREAT | import_node_fs2.constants.O_EXCL | import_node_fs2.constants.O_RDWR | import_node_fs2.constants.O_NOFOLLOW, 384).catch((error) => {
509
1038
  if (errnoOf(error) === "EEXIST") return null;
510
1039
  throw error;
511
1040
  });
@@ -517,11 +1046,11 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
517
1046
  }
518
1047
  }
519
1048
  }
520
- const leaf = await (0, import_promises.open)(dbPath, import_node_fs.constants.O_RDONLY | import_node_fs.constants.O_NOFOLLOW);
1049
+ const leaf = await (0, import_promises2.open)(dbPath, import_node_fs2.constants.O_RDONLY | import_node_fs2.constants.O_NOFOLLOW);
521
1050
  let db;
522
1051
  try {
523
1052
  const identity = await leaf.stat();
524
- const named = await (0, import_promises.lstat)(dbPath);
1053
+ const named = await (0, import_promises2.lstat)(dbPath);
525
1054
  if (!identity.isFile() || !named.isFile() || named.dev !== identity.dev || named.ino !== identity.ino) {
526
1055
  throw new Error("The replica database changed before SQLite opened it.");
527
1056
  }
@@ -531,40 +1060,40 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
531
1060
  throw new Error("The replica database changed while another controller holds it open.");
532
1061
  }
533
1062
  if (active && !options.sqlite) {
534
- const namedDirectory = (0, import_node_fs.lstatSync)(dir, { bigint: true });
1063
+ const namedDirectory = (0, import_node_fs2.lstatSync)(dir, { bigint: true });
535
1064
  if (!namedDirectory.isDirectory() || namedDirectory.dev !== opened.dev || namedDirectory.ino !== opened.ino) {
536
1065
  throw new Error("The replica directory changed before its checked SQLite handle could be shared.");
537
1066
  }
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"));
1067
+ const pinned = active.directoryFd === void 0 ? (0, import_node_fs2.lstatSync)(active.pinnedDir, { bigint: true }) : (0, import_node_fs2.fstatSync)(active.directoryFd, { bigint: true });
1068
+ const activeLeaf = (0, import_node_fs2.lstatSync)((0, import_node_path2.join)(active.pinnedDir, "replica.db"));
540
1069
  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
1070
  throw new Error("The live replica changed before its checked SQLite handle could be shared.");
542
1071
  }
543
1072
  if (directoryFd !== void 0) {
544
- (0, import_node_fs.closeSync)(directoryFd);
1073
+ (0, import_node_fs2.closeSync)(directoryFd);
545
1074
  directoryFd = void 0;
546
1075
  }
547
1076
  store = new _SqliteReplicaStore(dir, active.db, identity, options, active.directoryFd, namedAncestors, active);
548
1077
  }
549
1078
  if (!store) {
550
1079
  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) => {
1080
+ const previousFds = process.platform === "linux" && !options.sqlite ? new Map((0, import_node_fs2.readdirSync)("/proc/self/fd").map((fd) => {
552
1081
  try {
553
- return [fd, (0, import_node_fs.readlinkSync)(`/proc/self/fd/${fd}`)];
1082
+ return [fd, (0, import_node_fs2.readlinkSync)(`/proc/self/fd/${fd}`)];
554
1083
  } catch {
555
1084
  return [fd, ""];
556
1085
  }
557
1086
  })) : null;
558
1087
  db = opener(dbPath);
559
- const afterOpen = (0, import_node_fs.lstatSync)(dbPath);
1088
+ const afterOpen = (0, import_node_fs2.lstatSync)(dbPath);
560
1089
  if (!afterOpen.isFile() || afterOpen.dev !== identity.dev || afterOpen.ino !== identity.ino) {
561
1090
  throw new Error("The replica database changed while SQLite opened it.");
562
1091
  }
563
1092
  if (previousFds) db.get("SELECT count(*) FROM sqlite_master");
564
1093
  if (previousFds) {
565
- const databases = (0, import_node_fs.readdirSync)("/proc/self/fd").filter((fd) => {
1094
+ const databases = (0, import_node_fs2.readdirSync)("/proc/self/fd").filter((fd) => {
566
1095
  try {
567
- const target = (0, import_node_fs.readlinkSync)(`/proc/self/fd/${fd}`);
1096
+ const target = (0, import_node_fs2.readlinkSync)(`/proc/self/fd/${fd}`);
568
1097
  return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
569
1098
  } catch {
570
1099
  return false;
@@ -572,7 +1101,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
572
1101
  });
573
1102
  if (databases.length === 0 || databases.some((fd) => {
574
1103
  try {
575
- const actual = (0, import_node_fs.fstatSync)(Number(fd));
1104
+ const actual = (0, import_node_fs2.fstatSync)(Number(fd));
576
1105
  return actual.dev !== identity.dev || actual.ino !== identity.ino;
577
1106
  } catch {
578
1107
  return true;
@@ -581,7 +1110,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
581
1110
  throw new Error("SQLite opened a different replica database.");
582
1111
  }
583
1112
  }
584
- const after = (0, import_node_fs.lstatSync)(dbPath);
1113
+ const after = (0, import_node_fs2.lstatSync)(dbPath);
585
1114
  if (!after.isFile() || after.dev !== identity.dev || after.ino !== identity.ino) {
586
1115
  throw new Error("The replica database changed while SQLite opened it.");
587
1116
  }
@@ -603,7 +1132,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
603
1132
  db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
604
1133
  db.exec("COMMIT");
605
1134
  }
606
- const shared = options.sqlite ? void 0 : {
1135
+ const shared = {
607
1136
  key,
608
1137
  db,
609
1138
  directoryFd,
@@ -615,7 +1144,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
615
1144
  mutationTail: Promise.resolve()
616
1145
  };
617
1146
  store = new _SqliteReplicaStore(dir, db, identity, options, directoryFd, namedAncestors, shared);
618
- if (shared) writableStores.set(key, shared);
1147
+ if (!options.sqlite) writableStores.set(key, shared);
619
1148
  db = void 0;
620
1149
  }
621
1150
  } finally {
@@ -627,7 +1156,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
627
1156
  }
628
1157
  } catch (error) {
629
1158
  if (store) await store.close().catch(() => void 0);
630
- else if (directoryFd !== void 0) (0, import_node_fs.closeSync)(directoryFd);
1159
+ else if (directoryFd !== void 0) (0, import_node_fs2.closeSync)(directoryFd);
631
1160
  throw storageError(error, `Opening the replica at ${dir}`);
632
1161
  } finally {
633
1162
  releaseOpen();
@@ -643,12 +1172,109 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
643
1172
  throw error;
644
1173
  }
645
1174
  }
1175
+ /**
1176
+ * A gated read borrows this controller's already-verified writable handle.
1177
+ * Borrow before the first await: the parent may close in the callback, but
1178
+ * its pin and native handle remain live until the snapshot releases them.
1179
+ * Serialize BEGIN through COMMIT/ROLLBACK with writes on the shared handle.
1180
+ */
1181
+ async readTransaction(generation, checkGate, section) {
1182
+ this.#assertAccepting();
1183
+ const shared = this.#shared;
1184
+ if (!shared) throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica handle is closed.");
1185
+ const snapshot = new _SqliteReplicaStore(
1186
+ this.dir,
1187
+ shared.db,
1188
+ { dev: this.#dev, ino: this.#ino },
1189
+ { create: false, now: this.#now },
1190
+ shared.directoryFd,
1191
+ this.#namedAncestors,
1192
+ shared,
1193
+ true
1194
+ );
1195
+ let release;
1196
+ let active = false;
1197
+ let callbackFailed = false;
1198
+ try {
1199
+ release = await acquireWritableMutation(shared);
1200
+ await snapshot.#assertReadIdentity();
1201
+ snapshot.#db.exec("BEGIN");
1202
+ active = true;
1203
+ const tombstone = snapshot.#db.get("SELECT generation FROM replica WHERE id = 1");
1204
+ try {
1205
+ await checkGate();
1206
+ } catch (error) {
1207
+ callbackFailed = true;
1208
+ throw error;
1209
+ }
1210
+ if (tombstone === void 0 || tombstone.generation !== generation) {
1211
+ throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica generation changed; reopen it to continue.");
1212
+ }
1213
+ let result;
1214
+ const context = { controller: this, active: true };
1215
+ try {
1216
+ try {
1217
+ result = await activeReadSection.run(context, () => section(snapshot));
1218
+ } catch (error) {
1219
+ callbackFailed = true;
1220
+ throw error;
1221
+ }
1222
+ } finally {
1223
+ context.active = false;
1224
+ }
1225
+ try {
1226
+ await checkGate();
1227
+ } catch (error) {
1228
+ callbackFailed = true;
1229
+ throw error;
1230
+ }
1231
+ await snapshot.#assertReadIdentity();
1232
+ snapshot.#db.exec("COMMIT");
1233
+ active = false;
1234
+ return result;
1235
+ } catch (error) {
1236
+ if (callbackFailed) throw error;
1237
+ if (isReplicaError(error) || typeof error === "object" && error !== null && "code" in error && error.code === "REPLICA_GATED") throw error;
1238
+ 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))) {
1239
+ throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica was replaced; reopen it to continue.", void 0, { cause: error });
1240
+ }
1241
+ throw storageError(error, "Reading the replica snapshot");
1242
+ } finally {
1243
+ if (active) {
1244
+ try {
1245
+ snapshot.#db.exec("ROLLBACK");
1246
+ } catch {
1247
+ }
1248
+ }
1249
+ try {
1250
+ await snapshot.close();
1251
+ } finally {
1252
+ release?.();
1253
+ }
1254
+ }
1255
+ }
1256
+ async #assertReadIdentity() {
1257
+ const shared = this.#shared;
1258
+ const pinned = shared.directoryFd === void 0 ? (0, import_node_fs2.lstatSync)(shared.pinnedDir) : (0, import_node_fs2.fstatSync)(shared.directoryFd);
1259
+ const namedDir = (0, import_node_fs2.lstatSync)(this.dir);
1260
+ const namedDb = (0, import_node_fs2.lstatSync)((0, import_node_path2.join)(this.dir, "replica.db"));
1261
+ const pinnedDb = (0, import_node_fs2.lstatSync)((0, import_node_path2.join)(shared.pinnedDir, "replica.db"));
1262
+ 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) {
1263
+ throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica was replaced during its read; reopen it to continue.");
1264
+ }
1265
+ if (this.#namedAncestors.length) {
1266
+ const ancestors = await captureNamedPath(this.dir);
1267
+ if (ancestors.length !== this.#namedAncestors.length || ancestors.some((entry, index) => entry.dev !== this.#namedAncestors[index].dev || entry.ino !== this.#namedAncestors[index].ino)) {
1268
+ throw new ReplicaError(ReplicaErrorCode.RESET_REQUIRED, "The replica directory was replaced; reopen it to continue.");
1269
+ }
1270
+ }
1271
+ }
646
1272
  #row() {
647
1273
  return this.#db.get("SELECT * FROM replica WHERE id = 1");
648
1274
  }
649
1275
  /** Refuse to touch the filesystem for a replica that was deleted (or replaced) under us. */
650
1276
  async #assertPresent() {
651
- const identity = await (0, import_promises.lstat)((0, import_node_path.join)(this.#pinnedDir, "replica.db")).catch(() => null);
1277
+ const identity = await (0, import_promises2.lstat)((0, import_node_path2.join)(this.#pinnedDir, "replica.db")).catch(() => null);
652
1278
  if (!identity?.isFile() || identity.dev !== this.#dev || identity.ino !== this.#ino) {
653
1279
  throw new ReplicaError(
654
1280
  ReplicaErrorCode.NOT_FOUND,
@@ -665,6 +1291,16 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
665
1291
  /** A short mutation section: guarded (profile lock) and checked against the database this store opened. */
666
1292
  #mutate(section) {
667
1293
  this.#assertAccepting();
1294
+ if (this.#readonlySnapshot) {
1295
+ throw new ReplicaError(ReplicaErrorCode.STORAGE_ERROR, "A read-only replica snapshot cannot mutate the shared database.");
1296
+ }
1297
+ const read = activeReadSection.getStore();
1298
+ if (read?.active && read.controller === this) {
1299
+ throw new ReplicaError(
1300
+ ReplicaErrorCode.STORAGE_ERROR,
1301
+ "mutation attempted on the same controller while its readTransaction section is active; this would deadlock"
1302
+ );
1303
+ }
668
1304
  this.#activeMutations++;
669
1305
  return (async () => {
670
1306
  try {
@@ -808,6 +1444,110 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
808
1444
  );
809
1445
  });
810
1446
  }
1447
+ /** Per-CID first-install anchor and policy bound share the pending-grant transaction. */
1448
+ #grantInstall(cid) {
1449
+ const row = this.#db.get(
1450
+ "SELECT installed_at, effective_expires_at, consent_deadline, consent_anchor_at, consent_anchor_source FROM grant_install WHERE cid = ?",
1451
+ cid
1452
+ );
1453
+ if (row === void 0) return null;
1454
+ 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) {
1455
+ throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The stored grant expiry or consent anchor is invalid.");
1456
+ }
1457
+ return {
1458
+ installedAt: row.installed_at,
1459
+ effectiveExpiresAt: row.effective_expires_at,
1460
+ consentDeadline: row.consent_deadline,
1461
+ consentAnchor: { at: row.consent_anchor_at, source: row.consent_anchor_source }
1462
+ };
1463
+ }
1464
+ async getGrantInstall(cid) {
1465
+ return this.#grantInstall(cid);
1466
+ }
1467
+ /** Every installed CID, including superseded ones, for an origin-wide purge floor. */
1468
+ async grantInstallTimes() {
1469
+ const rows = this.#db.all(
1470
+ "SELECT cid, installed_at FROM grant_install"
1471
+ );
1472
+ return rows.map((row) => {
1473
+ if (typeof row.cid !== "string" || row.cid.length === 0 || !Number.isSafeInteger(row.installed_at) || row.installed_at < 0) {
1474
+ throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The stored grant install time is invalid.");
1475
+ }
1476
+ return { cid: row.cid, installedAt: row.installed_at };
1477
+ });
1478
+ }
1479
+ /** Phase 2 installed grants cannot commit a page after their shorter local TTL. */
1480
+ #assertInstalledGrantTime() {
1481
+ const row = this.#row();
1482
+ const cid = row?.pending_grant_cid ?? row?.grant_cid;
1483
+ if (cid === void 0 || cid === null) return;
1484
+ const installed = this.#grantInstall(cid);
1485
+ const now = this.#now();
1486
+ if (installed !== null && (now >= installed.effectiveExpiresAt || now >= installed.consentDeadline)) {
1487
+ throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The installed grant expired before this page committed.");
1488
+ }
1489
+ }
1490
+ #storeGrantPolicy(g, policy, existing) {
1491
+ const installedAt = existing?.installedAt ?? policy.at;
1492
+ const keepExisting = existing !== null && existing.consentDeadline < policy.consentDeadline;
1493
+ const consentDeadline = keepExisting ? existing.consentDeadline : policy.consentDeadline;
1494
+ const consentAnchor = keepExisting ? existing.consentAnchor : policy.consentAnchor;
1495
+ const effectiveExpiresAt = Math.min(
1496
+ g.expiresAt === null ? Infinity : g.expiresAt * 1e3,
1497
+ installedAt + policy.ttlMs,
1498
+ consentDeadline
1499
+ );
1500
+ 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) {
1501
+ throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The replica grant has no finite effective expiry.");
1502
+ }
1503
+ this.#db.run(
1504
+ `INSERT INTO grant_install (cid, installed_at, effective_expires_at, consent_deadline, consent_anchor_at, consent_anchor_source)
1505
+ VALUES (?, ?, ?, ?, ?, ?)
1506
+ ON CONFLICT (cid) DO UPDATE SET effective_expires_at = excluded.effective_expires_at,
1507
+ consent_deadline = excluded.consent_deadline, consent_anchor_at = excluded.consent_anchor_at,
1508
+ consent_anchor_source = excluded.consent_anchor_source`,
1509
+ g.cid,
1510
+ installedAt,
1511
+ effectiveExpiresAt,
1512
+ consentDeadline,
1513
+ consentAnchor.at,
1514
+ consentAnchor.source
1515
+ );
1516
+ return { installedAt, effectiveExpiresAt, consentDeadline, consentAnchor };
1517
+ }
1518
+ async installGatedGrant(g, policy) {
1519
+ return this.#guardedWrite("Installing the gated grant", () => {
1520
+ const row = this.#row();
1521
+ if (row === void 0) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica has not been created.");
1522
+ const previous = this.#grantInstall(g.cid);
1523
+ if (previous === null && (row.grant_cid === g.cid || row.pending_grant_cid === g.cid)) {
1524
+ throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The installed grant has no durable first-install time.");
1525
+ }
1526
+ const next = this.#storeGrantPolicy(g, policy, previous);
1527
+ if (next.effectiveExpiresAt <= this.#now()) {
1528
+ throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The replica grant expired under the current policy.");
1529
+ }
1530
+ if (row.grant_cid !== g.cid && row.pending_grant_cid !== g.cid) {
1531
+ this.#db.run(
1532
+ `UPDATE replica SET pending_grant_cid = ?, pending_grant_bytes = ?, pending_grant_audience = ?,
1533
+ pending_grant_issuer = ?, pending_grant_nbf = ?, pending_grant_exp = ?, pending_grant_error = NULL WHERE id = 1`,
1534
+ g.cid,
1535
+ g.bytes,
1536
+ g.audience,
1537
+ g.issuer,
1538
+ g.notBefore,
1539
+ g.expiresAt
1540
+ );
1541
+ }
1542
+ return next;
1543
+ });
1544
+ }
1545
+ async recomputeGrantInstall(g, policy) {
1546
+ return this.#guardedWrite("Recomputing the gated grant", () => {
1547
+ const previous = this.#grantInstall(g.cid);
1548
+ return previous === null ? null : this.#storeGrantPolicy(g, policy, previous);
1549
+ });
1550
+ }
811
1551
  async installGrant(g) {
812
1552
  await this.#guardedWrite("Installing the grant", () => {
813
1553
  const row = this.#row();
@@ -866,13 +1606,14 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
866
1606
  const now = this.#now();
867
1607
  if (row.lease_holder !== null && row.lease_expires_at !== null && row.lease_expires_at > now) return null;
868
1608
  const token = row.lease_token + 1;
1609
+ const holder = this.#holder ??= `${process.pid}-${(0, import_node_crypto.randomBytes)(6).toString("hex")}`;
869
1610
  this.#db.run(
870
1611
  "UPDATE replica SET lease_token = ?, lease_holder = ?, lease_expires_at = ? WHERE id = 1",
871
1612
  token,
872
- this.#holder,
1613
+ holder,
873
1614
  now + ttlMs
874
1615
  );
875
- return { token, holder: this.#holder };
1616
+ return { token, holder };
876
1617
  });
877
1618
  }
878
1619
  async renewLease(t, ttlMs) {
@@ -904,7 +1645,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
904
1645
  return held;
905
1646
  }
906
1647
  #blobPath(hash) {
907
- return (0, import_node_path.join)(this.#pinnedDir, "blobs", hash.slice(0, 2), hash);
1648
+ return (0, import_node_path2.join)(this.#pinnedDir, "blobs", hash.slice(0, 2), hash);
908
1649
  }
909
1650
  /**
910
1651
  * Make every blob durable before any row references it: write to a fresh
@@ -916,17 +1657,17 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
916
1657
  const dirs = /* @__PURE__ */ new Set();
917
1658
  for (const [hash, bytes] of blobs) {
918
1659
  const final = this.#blobPath(hash);
919
- const exists = await (0, import_promises.stat)(final).then(
1660
+ const exists = await (0, import_promises2.stat)(final).then(
920
1661
  () => true,
921
1662
  () => false
922
1663
  );
923
1664
  if (exists) continue;
924
- const shard = (0, import_node_path.join)(this.#pinnedDir, "blobs", hash.slice(0, 2));
925
- await (0, import_promises.mkdir)(shard, { mode: 448 }).catch((error) => {
1665
+ const shard = (0, import_node_path2.join)(this.#pinnedDir, "blobs", hash.slice(0, 2));
1666
+ await (0, import_promises2.mkdir)(shard, { mode: 448 }).catch((error) => {
926
1667
  if (errnoOf(error) !== "EEXIST") throw error;
927
1668
  });
928
- const temp = (0, import_node_path.join)(this.#pinnedDir, "blobs", ".tmp", `${hash}.${(0, import_node_crypto.randomBytes)(6).toString("hex")}`);
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);
1669
+ const temp = (0, import_node_path2.join)(this.#pinnedDir, "blobs", ".tmp", `${hash}.${(0, import_node_crypto.randomBytes)(6).toString("hex")}`);
1670
+ const handle = await (0, import_promises2.open)(temp, import_node_fs2.constants.O_CREAT | import_node_fs2.constants.O_EXCL | import_node_fs2.constants.O_WRONLY, 384);
930
1671
  try {
931
1672
  await handle.writeFile(bytes);
932
1673
  await handle.sync();
@@ -934,17 +1675,17 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
934
1675
  await handle.close();
935
1676
  }
936
1677
  try {
937
- await (0, import_promises.link)(temp, final);
1678
+ await (0, import_promises2.link)(temp, final);
938
1679
  } catch (error) {
939
1680
  if (errnoOf(error) !== "EEXIST") throw error;
940
1681
  } finally {
941
- await (0, import_promises.unlink)(temp);
1682
+ await (0, import_promises2.unlink)(temp);
942
1683
  }
943
1684
  dirs.add(shard);
944
- dirs.add((0, import_node_path.join)(this.#pinnedDir, "blobs"));
1685
+ dirs.add((0, import_node_path2.join)(this.#pinnedDir, "blobs"));
945
1686
  }
946
1687
  for (const dir of dirs) {
947
- const handle = await (0, import_promises.open)(dir, import_node_fs.constants.O_RDONLY);
1688
+ const handle = await (0, import_promises2.open)(dir, import_node_fs2.constants.O_RDONLY);
948
1689
  try {
949
1690
  await handle.sync();
950
1691
  } finally {
@@ -955,6 +1696,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
955
1696
  async applyPage(t, p) {
956
1697
  await this.#mutate(async () => {
957
1698
  this.#checkLease(t);
1699
+ this.#assertInstalledGrantTime();
958
1700
  const reused = /* @__PURE__ */ new Map();
959
1701
  for (const change of p.changes) {
960
1702
  if (change.deleted || !change.content || p.blobs.has(change.hash) || reused.has(change.hash)) continue;
@@ -967,6 +1709,41 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
967
1709
  }
968
1710
  reused.set(change.hash, size);
969
1711
  }
1712
+ if (this.#admit) {
1713
+ await this.#collectLocked(t);
1714
+ const changed = new Set(p.changes.map((change) => change.key));
1715
+ const kept = /* @__PURE__ */ new Set();
1716
+ for (const change of p.changes) if (!change.deleted && change.content) kept.add(change.hash);
1717
+ let blobBytes = 0;
1718
+ for (const [hash, bytes] of p.blobs) {
1719
+ const stored = this.#db.get(
1720
+ "SELECT 1 AS hit FROM entry WHERE hash = ? AND content = 1 AND deleted = 0 LIMIT 1",
1721
+ hash
1722
+ );
1723
+ if (stored === void 0) blobBytes += bytes.byteLength;
1724
+ }
1725
+ const released = /* @__PURE__ */ new Map();
1726
+ for (const key of changed) {
1727
+ const old = this.#db.get(
1728
+ "SELECT hash, size FROM entry WHERE key = ? AND deleted = 0 AND content = 1 AND hash IS NOT NULL AND size IS NOT NULL",
1729
+ key
1730
+ );
1731
+ if (old === void 0 || kept.has(old.hash) || released.has(old.hash)) continue;
1732
+ const holders = this.#db.all(
1733
+ "SELECT key FROM entry WHERE hash = ? AND content = 1 AND deleted = 0",
1734
+ old.hash
1735
+ );
1736
+ if (holders.every((holder) => changed.has(holder.key))) released.set(old.hash, old.size);
1737
+ }
1738
+ let freed = 0;
1739
+ for (const size of released.values()) freed += size;
1740
+ await this.#admit({
1741
+ partitionId: this.#partitionId,
1742
+ blobBytes: blobBytes - freed,
1743
+ entries: p.changes.length,
1744
+ freeBytes: null
1745
+ });
1746
+ }
970
1747
  try {
971
1748
  await this.#writeBlobs(p.blobs);
972
1749
  } catch (error) {
@@ -976,6 +1753,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
976
1753
  try {
977
1754
  await this.#write("Committing a sync page", () => {
978
1755
  this.#checkLease(t);
1756
+ this.#assertInstalledGrantTime();
979
1757
  const now = this.#now();
980
1758
  if (p.window.notBefore !== null && now < Date.parse(p.window.notBefore)) {
981
1759
  throw new ReplicaError(ReplicaErrorCode.GRANT_NOT_YET_VALID, `The grant is not valid before ${p.window.notBefore}; nothing was committed.`);
@@ -1041,73 +1819,55 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1041
1819
  });
1042
1820
  } catch (error) {
1043
1821
  if (isReplicaError(error, ReplicaErrorCode.GRANT_REVOKED)) await this.#purgeContent().catch(() => void 0);
1822
+ else if (this.#admit) await this.#collectLocked(t).catch(() => void 0);
1044
1823
  throw error;
1045
1824
  }
1046
1825
  this.#faults.afterCommit?.();
1826
+ if (this.#admit) await this.#collectLocked(t);
1047
1827
  });
1048
1828
  }
1049
1829
  /** Unlink every blob not in `keep`, and every temp file. Only a missing directory counts as empty. */
1050
- async #unlinkBlobs(keep) {
1051
- let removed = 0;
1052
- const root = (0, import_node_path.join)(this.#pinnedDir, "blobs");
1053
- for (const shard of await entriesOf(root)) {
1054
- const shardPath = (0, import_node_path.join)(root, shard);
1055
- if (shard === ".tmp") {
1056
- for (const name of await entriesOf(shardPath)) {
1057
- await (0, import_promises.rm)((0, import_node_path.join)(shardPath, name), { force: true });
1058
- removed += 1;
1059
- }
1060
- continue;
1061
- }
1062
- let kept = 0;
1063
- for (const name of await entriesOf(shardPath)) {
1064
- if (isHash(name) && keep.has(name)) {
1065
- kept += 1;
1066
- continue;
1067
- }
1068
- await (0, import_promises.rm)((0, import_node_path.join)(shardPath, name), { force: true });
1069
- removed += 1;
1070
- }
1071
- if (kept === 0) await (0, import_promises.rmdir)(shardPath).catch(() => void 0);
1072
- }
1073
- return removed;
1830
+ #unlinkBlobs(keep) {
1831
+ return unlinkBlobsIn((0, import_node_path2.join)(this.#pinnedDir, "blobs"), keep);
1074
1832
  }
1075
1833
  /** Whether any content file (a blob or a temp file) is on disk. */
1076
1834
  async #holdsContentFiles() {
1077
- const root = (0, import_node_path.join)(this.#pinnedDir, "blobs");
1835
+ const root = (0, import_node_path2.join)(this.#pinnedDir, "blobs");
1078
1836
  for (const shard of await entriesOf(root)) {
1079
- if ((await entriesOf((0, import_node_path.join)(root, shard))).length > 0) return true;
1837
+ if ((await entriesOf((0, import_node_path2.join)(root, shard))).length > 0) return true;
1080
1838
  }
1081
1839
  return false;
1082
1840
  }
1083
1841
  async collectGarbage(t) {
1084
- return this.#mutate(async () => {
1085
- const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
1086
- try {
1087
- this.#db.exec("BEGIN IMMEDIATE");
1088
- } catch (error) {
1089
- release?.();
1090
- throw storageError(error, "Collecting unused content");
1091
- }
1842
+ return this.#mutate(() => this.#collectLocked(t));
1843
+ }
1844
+ /** `collectGarbage` for callers already inside the mutation hold (it cannot re-enter `#mutate`). */
1845
+ async #collectLocked(t) {
1846
+ const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
1847
+ try {
1848
+ this.#db.exec("BEGIN IMMEDIATE");
1849
+ } catch (error) {
1850
+ release?.();
1851
+ throw storageError(error, "Collecting unused content");
1852
+ }
1853
+ try {
1854
+ this.#checkLease(t);
1855
+ await this.#faults.afterGarbageCollectionBegin?.();
1856
+ const keep = new Set(
1857
+ this.#db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash)
1858
+ );
1859
+ const removed = await this.#unlinkBlobs(keep);
1860
+ this.#db.exec("COMMIT");
1861
+ return removed;
1862
+ } catch (error) {
1092
1863
  try {
1093
- this.#checkLease(t);
1094
- await this.#faults.afterGarbageCollectionBegin?.();
1095
- const keep = new Set(
1096
- this.#db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash)
1097
- );
1098
- const removed = await this.#unlinkBlobs(keep);
1099
- this.#db.exec("COMMIT");
1100
- return removed;
1101
- } catch (error) {
1102
- try {
1103
- this.#db.exec("ROLLBACK");
1104
- } catch {
1105
- }
1106
- throw storageError(error, "Collecting unused content");
1107
- } finally {
1108
- release?.();
1864
+ this.#db.exec("ROLLBACK");
1865
+ } catch {
1109
1866
  }
1110
- });
1867
+ throw storageError(error, "Collecting unused content");
1868
+ } finally {
1869
+ release?.();
1870
+ }
1111
1871
  }
1112
1872
  async reset(t, reason, options = {}) {
1113
1873
  await this.#mutate(async () => {
@@ -1130,13 +1890,10 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1130
1890
  }
1131
1891
  }
1132
1892
  /**
1133
- * Remove this replica's directory for good (`tc replica reset --purge`).
1134
- * Fenced like a commit: `t` must still be a live lease on the database this
1135
- * store opened, checked under the write lock, so a paused process can
1136
- * never remove a replica another process took over or recreated. Allowed
1137
- * on a revoked replica. The store is unusable afterwards; close it.
1893
+ * Remove the source-created mirror layout under a live lease. Unknown files
1894
+ * are removed only when their exact preflight identities were approved.
1138
1895
  */
1139
- async destroy(t) {
1896
+ async destroy(t, options = {}) {
1140
1897
  await this.#mutate(async () => {
1141
1898
  const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
1142
1899
  try {
@@ -1147,92 +1904,36 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1147
1904
  }
1148
1905
  try {
1149
1906
  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 });
1907
+ const expected = this.#directoryFd === void 0 ? this.#namedAncestors.at(-1) : (0, import_node_fs2.fstatSync)(this.#directoryFd, { bigint: true });
1151
1908
  if (!expected) {
1152
1909
  throw new ReplicaError(ReplicaErrorCode.RUNTIME_UNSUPPORTED, "Replica removal requires an opened or identity-checked directory.");
1153
1910
  }
1154
1911
  const assertDirectory = async () => {
1155
- const current = await (0, import_promises.lstat)(this.dir, { bigint: true });
1912
+ const current = await (0, import_promises2.lstat)(this.dir, { bigint: true });
1156
1913
  if (!current.isDirectory() || current.dev !== expected.dev || current.ino !== expected.ino) {
1157
1914
  throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica directory changed during removal.");
1158
1915
  }
1159
1916
  return current;
1160
1917
  };
1161
1918
  const pinned = await assertDirectory();
1162
- await this.#assertPresent();
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
- }
1919
+ const assertNamedPath = async () => {
1920
+ for (const ancestor of this.#namedAncestors) {
1921
+ const current = await (0, import_promises2.lstat)(ancestor.path, { bigint: true });
1922
+ if (current.dev !== ancestor.dev || current.ino !== ancestor.ino) {
1923
+ throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, "The replica ancestry changed during removal.");
1177
1924
  }
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
1925
  }
1233
1926
  await assertDirectory();
1234
- await checkAncestors();
1235
- await (0, import_promises.rmdir)(this.dir);
1927
+ };
1928
+ await this.#assertPresent();
1929
+ try {
1930
+ await removeSqliteReplicaLayout(this.dir, {
1931
+ ...options,
1932
+ expectedRoot: options.expectedRoot ?? { dev: pinned.dev.toString(), ino: pinned.ino.toString() }
1933
+ });
1934
+ } catch (error) {
1935
+ await assertNamedPath();
1936
+ throw error;
1236
1937
  }
1237
1938
  } catch (error) {
1238
1939
  throw storageError(error, "Removing the replica");
@@ -1345,7 +2046,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1345
2046
  async readContent(hash) {
1346
2047
  if (!isHash(hash)) return void 0;
1347
2048
  try {
1348
- return new Uint8Array(await (0, import_promises.readFile)(this.#blobPath(hash)));
2049
+ return new Uint8Array(await (0, import_promises2.readFile)(this.#blobPath(hash)));
1349
2050
  } catch (error) {
1350
2051
  if (errnoOf(error) === "ENOENT") return void 0;
1351
2052
  throw storageError(error, "Reading replica content");
@@ -1406,8 +2107,8 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1406
2107
  this.#closing = true;
1407
2108
  this.#closePromise = (async () => {
1408
2109
  if (this.#activeMutations !== 0) {
1409
- await new Promise((resolve2) => {
1410
- this.#drained = resolve2;
2110
+ await new Promise((resolve3) => {
2111
+ this.#drained = resolve3;
1411
2112
  });
1412
2113
  }
1413
2114
  this.#closed = true;
@@ -1421,7 +2122,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1421
2122
  try {
1422
2123
  this.#database.close();
1423
2124
  } finally {
1424
- if (fd !== void 0) (0, import_node_fs.closeSync)(fd);
2125
+ if (fd !== void 0) (0, import_node_fs2.closeSync)(fd);
1425
2126
  }
1426
2127
  }
1427
2128
  })();
@@ -1432,7 +2133,9 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1432
2133
  0 && (module.exports = {
1433
2134
  DURABILITY,
1434
2135
  SqliteReplicaStore,
2136
+ inspectSqliteReplicaLayout,
1435
2137
  loadSqlite,
1436
- nodeSqliteSupported
2138
+ nodeSqliteSupported,
2139
+ removeSqliteReplicaLayout
1437
2140
  });
1438
2141
  //# sourceMappingURL=sqlite.cjs.map