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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/sqlite.cjs CHANGED
@@ -355,9 +355,44 @@ var pinnedDirectories = new FinalizationRegistry((fd) => {
355
355
  } catch {
356
356
  }
357
357
  });
358
+ var writableStores = /* @__PURE__ */ new Map();
359
+ function releaseWritable(shared) {
360
+ if (shared.references === 0 || --shared.references !== 0) return;
361
+ if (writableStores.get(shared.key) === shared) writableStores.delete(shared.key);
362
+ try {
363
+ shared.db.close();
364
+ } finally {
365
+ if (shared.directoryFd !== void 0) (0, import_node_fs.closeSync)(shared.directoryFd);
366
+ }
367
+ }
368
+ var sharedStores = new FinalizationRegistry((shared) => {
369
+ try {
370
+ releaseWritable(shared);
371
+ } catch {
372
+ }
373
+ });
374
+ async function acquireWritableMutation(shared) {
375
+ const previous = shared.mutationTail;
376
+ let release;
377
+ shared.mutationTail = new Promise((resolve2) => {
378
+ release = resolve2;
379
+ });
380
+ await previous;
381
+ return release;
382
+ }
358
383
  var SqliteReplicaStore = class _SqliteReplicaStore {
359
384
  dir;
360
- #db;
385
+ #database;
386
+ get #db() {
387
+ if (this.#closed) throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica store is closed.");
388
+ return this.#database;
389
+ }
390
+ #closed = false;
391
+ #closing = false;
392
+ #closePromise;
393
+ #activeMutations = 0;
394
+ #drained;
395
+ #shared;
361
396
  #pinnedDir;
362
397
  #directoryFd;
363
398
  #holder = `${process.pid}-${(0, import_node_crypto.randomBytes)(6).toString("hex")}`;
@@ -368,12 +403,18 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
368
403
  #ino;
369
404
  #dev;
370
405
  #namedAncestors;
371
- constructor(dir, db, identity, options, directoryFd, namedAncestors = []) {
406
+ constructor(dir, db, identity, options, directoryFd, namedAncestors = [], shared) {
372
407
  this.dir = dir;
373
- this.#pinnedDir = directoryFd !== void 0 && process.platform === "linux" ? `/proc/self/fd/${directoryFd}` : dir;
408
+ this.#pinnedDir = shared?.pinnedDir ?? (directoryFd !== void 0 && process.platform === "linux" ? `/proc/self/fd/${directoryFd}` : dir);
374
409
  this.#directoryFd = directoryFd;
375
- if (directoryFd !== void 0) pinnedDirectories.register(this, directoryFd, this);
376
- this.#db = db;
410
+ this.#shared = shared;
411
+ if (shared) {
412
+ shared.references++;
413
+ sharedStores.register(this, shared, this);
414
+ } else if (directoryFd !== void 0) {
415
+ pinnedDirectories.register(this, directoryFd, this);
416
+ }
417
+ this.#database = db;
377
418
  this.#ino = identity.ino;
378
419
  this.#dev = identity.dev;
379
420
  this.#now = options.now ?? Date.now;
@@ -443,7 +484,6 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
443
484
  );
444
485
  if (!exists) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, `No regular replica database at ${dir}.`);
445
486
  }
446
- const opener = options.sqlite ?? await loadSqlite();
447
487
  const releaseOpen = await acquireSqliteOpen();
448
488
  let store;
449
489
  let directoryFd;
@@ -485,63 +525,99 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
485
525
  if (!identity.isFile() || !named.isFile() || named.dev !== identity.dev || named.ino !== identity.ino) {
486
526
  throw new Error("The replica database changed before SQLite opened it.");
487
527
  }
488
- const previousFds = process.platform === "linux" && !options.sqlite ? new Map((0, import_node_fs.readdirSync)("/proc/self/fd").map((fd) => {
489
- try {
490
- return [fd, (0, import_node_fs.readlinkSync)(`/proc/self/fd/${fd}`)];
491
- } catch {
492
- return [fd, ""];
528
+ const key = `${opened.dev}:${opened.ino}`;
529
+ const active = writableStores.get(key);
530
+ if (active && (active.dev !== identity.dev || active.ino !== identity.ino)) {
531
+ throw new Error("The replica database changed while another controller holds it open.");
532
+ }
533
+ if (active && !options.sqlite) {
534
+ const namedDirectory = (0, import_node_fs.lstatSync)(dir, { bigint: true });
535
+ if (!namedDirectory.isDirectory() || namedDirectory.dev !== opened.dev || namedDirectory.ino !== opened.ino) {
536
+ throw new Error("The replica directory changed before its checked SQLite handle could be shared.");
537
+ }
538
+ const pinned = active.directoryFd === void 0 ? (0, import_node_fs.lstatSync)(active.pinnedDir, { bigint: true }) : (0, import_node_fs.fstatSync)(active.directoryFd, { bigint: true });
539
+ const activeLeaf = (0, import_node_fs.lstatSync)((0, import_node_path.join)(active.pinnedDir, "replica.db"));
540
+ if (!pinned.isDirectory() || pinned.dev !== opened.dev || pinned.ino !== opened.ino || !activeLeaf.isFile() || activeLeaf.dev !== identity.dev || activeLeaf.ino !== identity.ino || active.directoryFd === void 0 && active.namedPath !== dir) {
541
+ throw new Error("The live replica changed before its checked SQLite handle could be shared.");
493
542
  }
494
- })) : null;
495
- db = opener(dbPath);
496
- const afterOpen = (0, import_node_fs.lstatSync)(dbPath);
497
- if (!afterOpen.isFile() || afterOpen.dev !== identity.dev || afterOpen.ino !== identity.ino) {
498
- throw new Error("The replica database changed while SQLite opened it.");
543
+ if (directoryFd !== void 0) {
544
+ (0, import_node_fs.closeSync)(directoryFd);
545
+ directoryFd = void 0;
546
+ }
547
+ store = new _SqliteReplicaStore(dir, active.db, identity, options, active.directoryFd, namedAncestors, active);
499
548
  }
500
- if (previousFds) db.get("SELECT count(*) FROM sqlite_master");
501
- if (previousFds) {
502
- const databases = (0, import_node_fs.readdirSync)("/proc/self/fd").filter((fd) => {
549
+ if (!store) {
550
+ const opener = options.sqlite ?? await loadSqlite();
551
+ const previousFds = process.platform === "linux" && !options.sqlite ? new Map((0, import_node_fs.readdirSync)("/proc/self/fd").map((fd) => {
503
552
  try {
504
- const target = (0, import_node_fs.readlinkSync)(`/proc/self/fd/${fd}`);
505
- return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
553
+ return [fd, (0, import_node_fs.readlinkSync)(`/proc/self/fd/${fd}`)];
506
554
  } catch {
507
- return false;
555
+ return [fd, ""];
508
556
  }
509
- });
510
- if (databases.length === 0 || databases.some((fd) => {
511
- try {
512
- const actual = (0, import_node_fs.fstatSync)(Number(fd));
513
- return actual.dev !== identity.dev || actual.ino !== identity.ino;
514
- } catch {
515
- return true;
557
+ })) : null;
558
+ db = opener(dbPath);
559
+ const afterOpen = (0, import_node_fs.lstatSync)(dbPath);
560
+ if (!afterOpen.isFile() || afterOpen.dev !== identity.dev || afterOpen.ino !== identity.ino) {
561
+ throw new Error("The replica database changed while SQLite opened it.");
562
+ }
563
+ if (previousFds) db.get("SELECT count(*) FROM sqlite_master");
564
+ if (previousFds) {
565
+ const databases = (0, import_node_fs.readdirSync)("/proc/self/fd").filter((fd) => {
566
+ try {
567
+ const target = (0, import_node_fs.readlinkSync)(`/proc/self/fd/${fd}`);
568
+ return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
569
+ } catch {
570
+ return false;
571
+ }
572
+ });
573
+ if (databases.length === 0 || databases.some((fd) => {
574
+ try {
575
+ const actual = (0, import_node_fs.fstatSync)(Number(fd));
576
+ return actual.dev !== identity.dev || actual.ino !== identity.ino;
577
+ } catch {
578
+ return true;
579
+ }
580
+ })) {
581
+ throw new Error("SQLite opened a different replica database.");
516
582
  }
517
- })) {
518
- throw new Error("SQLite opened a different replica database.");
519
583
  }
584
+ const after = (0, import_node_fs.lstatSync)(dbPath);
585
+ if (!after.isFile() || after.dev !== identity.dev || after.ino !== identity.ino) {
586
+ throw new Error("The replica database changed while SQLite opened it.");
587
+ }
588
+ db.exec("PRAGMA busy_timeout = 5000");
589
+ db.exec("PRAGMA journal_mode = WAL");
590
+ db.exec("PRAGMA synchronous = FULL");
591
+ db.exec("PRAGMA secure_delete = ON");
592
+ db.exec("PRAGMA foreign_keys = ON");
593
+ const version = db.get("PRAGMA user_version")?.user_version ?? 0;
594
+ if (version > SCHEMA_VERSION) {
595
+ throw new ReplicaError(
596
+ ReplicaErrorCode.STORAGE_ERROR,
597
+ `The replica at ${dir} was written by a newer tc (schema ${version}); upgrade tc to read it.`
598
+ );
599
+ }
600
+ if (version < SCHEMA_VERSION) {
601
+ db.exec("BEGIN IMMEDIATE");
602
+ db.exec(SCHEMA);
603
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
604
+ db.exec("COMMIT");
605
+ }
606
+ const shared = options.sqlite ? void 0 : {
607
+ key,
608
+ db,
609
+ directoryFd,
610
+ pinnedDir,
611
+ namedPath: dir,
612
+ dev: identity.dev,
613
+ ino: identity.ino,
614
+ references: 0,
615
+ mutationTail: Promise.resolve()
616
+ };
617
+ store = new _SqliteReplicaStore(dir, db, identity, options, directoryFd, namedAncestors, shared);
618
+ if (shared) writableStores.set(key, shared);
619
+ db = void 0;
520
620
  }
521
- const after = (0, import_node_fs.lstatSync)(dbPath);
522
- if (!after.isFile() || after.dev !== identity.dev || after.ino !== identity.ino) {
523
- throw new Error("The replica database changed while SQLite opened it.");
524
- }
525
- db.exec("PRAGMA busy_timeout = 5000");
526
- db.exec("PRAGMA journal_mode = WAL");
527
- db.exec("PRAGMA synchronous = FULL");
528
- db.exec("PRAGMA secure_delete = ON");
529
- db.exec("PRAGMA foreign_keys = ON");
530
- const version = db.get("PRAGMA user_version")?.user_version ?? 0;
531
- if (version > SCHEMA_VERSION) {
532
- throw new ReplicaError(
533
- ReplicaErrorCode.STORAGE_ERROR,
534
- `The replica at ${dir} was written by a newer tc (schema ${version}); upgrade tc to read it.`
535
- );
536
- }
537
- if (version < SCHEMA_VERSION) {
538
- db.exec("BEGIN IMMEDIATE");
539
- db.exec(SCHEMA);
540
- db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
541
- db.exec("COMMIT");
542
- }
543
- store = new _SqliteReplicaStore(dir, db, identity, options, directoryFd, namedAncestors);
544
- db = void 0;
545
621
  } finally {
546
622
  try {
547
623
  db?.close();
@@ -580,30 +656,53 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
580
656
  );
581
657
  }
582
658
  }
659
+ /** New calls stop at close; accepted mutation sections may still finish their transactions. */
660
+ #assertAccepting() {
661
+ if (this.#closing || this.#closed) {
662
+ throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica store is closed.");
663
+ }
664
+ }
583
665
  /** A short mutation section: guarded (profile lock) and checked against the database this store opened. */
584
666
  #mutate(section) {
585
- return this.#guard(async () => {
586
- await this.#assertPresent();
587
- return section();
588
- });
667
+ this.#assertAccepting();
668
+ this.#activeMutations++;
669
+ return (async () => {
670
+ try {
671
+ return await this.#guard(async () => {
672
+ await this.#assertPresent();
673
+ return section();
674
+ });
675
+ } finally {
676
+ if (--this.#activeMutations === 0) {
677
+ const drained = this.#drained;
678
+ this.#drained = void 0;
679
+ drained?.();
680
+ }
681
+ }
682
+ })();
589
683
  }
590
684
  /** Run `body` in a write transaction (BEGIN IMMEDIATE). Callers outside a mutation section use `#guardedWrite`. */
591
- #write(action, body) {
685
+ async #write(action, body) {
686
+ const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
592
687
  try {
593
- this.#db.exec("BEGIN IMMEDIATE");
594
- } catch (error) {
595
- throw storageError(error, action);
596
- }
597
- try {
598
- const result = body();
599
- this.#db.exec("COMMIT");
600
- return result;
601
- } catch (error) {
602
688
  try {
603
- this.#db.exec("ROLLBACK");
604
- } catch {
689
+ this.#db.exec("BEGIN IMMEDIATE");
690
+ } catch (error) {
691
+ throw storageError(error, action);
605
692
  }
606
- throw storageError(error, action);
693
+ try {
694
+ const result = body();
695
+ this.#db.exec("COMMIT");
696
+ return result;
697
+ } catch (error) {
698
+ try {
699
+ this.#db.exec("ROLLBACK");
700
+ } catch {
701
+ }
702
+ throw storageError(error, action);
703
+ }
704
+ } finally {
705
+ release?.();
607
706
  }
608
707
  }
609
708
  /** One write transaction as its own mutation section. */
@@ -630,6 +729,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
630
729
  }
631
730
  }
632
731
  async open() {
732
+ this.#assertAccepting();
633
733
  let row;
634
734
  try {
635
735
  row = this.#row();
@@ -791,6 +891,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
791
891
  });
792
892
  }
793
893
  async hasContent(hashes) {
894
+ this.#assertAccepting();
794
895
  const held = /* @__PURE__ */ new Set();
795
896
  for (let start = 0; start < hashes.length; start += 500) {
796
897
  const chunk = hashes.slice(start, start + 500);
@@ -873,7 +974,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
873
974
  }
874
975
  await this.#faults.afterBlobs?.();
875
976
  try {
876
- this.#write("Committing a sync page", () => {
977
+ await this.#write("Committing a sync page", () => {
877
978
  this.#checkLease(t);
878
979
  const now = this.#now();
879
980
  if (p.window.notBefore !== null && now < Date.parse(p.window.notBefore)) {
@@ -981,13 +1082,16 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
981
1082
  }
982
1083
  async collectGarbage(t) {
983
1084
  return this.#mutate(async () => {
1085
+ const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
984
1086
  try {
985
1087
  this.#db.exec("BEGIN IMMEDIATE");
986
1088
  } catch (error) {
1089
+ release?.();
987
1090
  throw storageError(error, "Collecting unused content");
988
1091
  }
989
1092
  try {
990
1093
  this.#checkLease(t);
1094
+ await this.#faults.afterGarbageCollectionBegin?.();
991
1095
  const keep = new Set(
992
1096
  this.#db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash)
993
1097
  );
@@ -1000,12 +1104,14 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1000
1104
  } catch {
1001
1105
  }
1002
1106
  throw storageError(error, "Collecting unused content");
1107
+ } finally {
1108
+ release?.();
1003
1109
  }
1004
1110
  });
1005
1111
  }
1006
1112
  async reset(t, reason, options = {}) {
1007
1113
  await this.#mutate(async () => {
1008
- this.#write("Resetting the replica", () => {
1114
+ await this.#write("Resetting the replica", () => {
1009
1115
  this.#checkLease(t);
1010
1116
  this.#db.run("DELETE FROM entry");
1011
1117
  this.#db.run(
@@ -1032,9 +1138,11 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1032
1138
  */
1033
1139
  async destroy(t) {
1034
1140
  await this.#mutate(async () => {
1141
+ const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
1035
1142
  try {
1036
1143
  this.#db.exec("BEGIN IMMEDIATE");
1037
1144
  } catch (error) {
1145
+ release?.();
1038
1146
  throw storageError(error, "Removing the replica");
1039
1147
  }
1040
1148
  try {
@@ -1133,6 +1241,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1133
1241
  this.#db.exec("ROLLBACK");
1134
1242
  } catch {
1135
1243
  }
1244
+ release?.();
1136
1245
  }
1137
1246
  });
1138
1247
  }
@@ -1176,7 +1285,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1176
1285
  * when both are done.
1177
1286
  */
1178
1287
  async #purgeContent() {
1179
- this.#write("Starting the revocation purge", () => {
1288
+ await this.#write("Starting the revocation purge", () => {
1180
1289
  this.#db.run("UPDATE replica SET purge_pending = 1 WHERE id = 1 AND revoked_detail IS NOT NULL");
1181
1290
  });
1182
1291
  try {
@@ -1187,7 +1296,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1187
1296
  if (!this.#checkpoint()) {
1188
1297
  throw new ReplicaError(ReplicaErrorCode.BUSY, "A reader holds the replica's write-ahead log; the purge finishes on the next open.");
1189
1298
  }
1190
- this.#write("Finishing the revocation purge", () => {
1299
+ await this.#write("Finishing the revocation purge", () => {
1191
1300
  this.#db.run("UPDATE replica SET purge_pending = 0 WHERE id = 1 AND revoked_detail IS NOT NULL");
1192
1301
  });
1193
1302
  }
@@ -1197,6 +1306,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1197
1306
  });
1198
1307
  }
1199
1308
  async get(key) {
1309
+ this.#assertAccepting();
1200
1310
  try {
1201
1311
  const row = this.#db.get("SELECT key, deleted, etag, hash, metadata, content FROM entry WHERE key = ?", key);
1202
1312
  return row === void 0 ? void 0 : entryOf(row);
@@ -1205,6 +1315,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1205
1315
  }
1206
1316
  }
1207
1317
  async list(o) {
1318
+ this.#assertAccepting();
1208
1319
  const where = ["deleted = 0"];
1209
1320
  const params = [];
1210
1321
  if (o.prefix !== void 0 && o.prefix !== "") {
@@ -1290,15 +1401,31 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1290
1401
  lastReset: state.lastReset
1291
1402
  };
1292
1403
  }
1293
- async close() {
1294
- const fd = this.#directoryFd;
1295
- this.#directoryFd = void 0;
1296
- if (fd !== void 0) pinnedDirectories.unregister(this);
1297
- try {
1298
- this.#db.close();
1299
- } finally {
1300
- if (fd !== void 0) (0, import_node_fs.closeSync)(fd);
1301
- }
1404
+ close() {
1405
+ if (this.#closePromise) return this.#closePromise;
1406
+ this.#closing = true;
1407
+ this.#closePromise = (async () => {
1408
+ if (this.#activeMutations !== 0) {
1409
+ await new Promise((resolve2) => {
1410
+ this.#drained = resolve2;
1411
+ });
1412
+ }
1413
+ this.#closed = true;
1414
+ const fd = this.#directoryFd;
1415
+ this.#directoryFd = void 0;
1416
+ if (this.#shared) {
1417
+ sharedStores.unregister(this);
1418
+ releaseWritable(this.#shared);
1419
+ } else {
1420
+ if (fd !== void 0) pinnedDirectories.unregister(this);
1421
+ try {
1422
+ this.#database.close();
1423
+ } finally {
1424
+ if (fd !== void 0) (0, import_node_fs.closeSync)(fd);
1425
+ }
1426
+ }
1427
+ })();
1428
+ return this.#closePromise;
1302
1429
  }
1303
1430
  };
1304
1431
  // Annotate the CommonJS export names for ESM import in node: