@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.js CHANGED
@@ -326,9 +326,44 @@ var pinnedDirectories = new FinalizationRegistry((fd) => {
326
326
  } catch {
327
327
  }
328
328
  });
329
+ var writableStores = /* @__PURE__ */ new Map();
330
+ function releaseWritable(shared) {
331
+ if (shared.references === 0 || --shared.references !== 0) return;
332
+ if (writableStores.get(shared.key) === shared) writableStores.delete(shared.key);
333
+ try {
334
+ shared.db.close();
335
+ } finally {
336
+ if (shared.directoryFd !== void 0) closeSync(shared.directoryFd);
337
+ }
338
+ }
339
+ var sharedStores = new FinalizationRegistry((shared) => {
340
+ try {
341
+ releaseWritable(shared);
342
+ } catch {
343
+ }
344
+ });
345
+ async function acquireWritableMutation(shared) {
346
+ const previous = shared.mutationTail;
347
+ let release;
348
+ shared.mutationTail = new Promise((resolve2) => {
349
+ release = resolve2;
350
+ });
351
+ await previous;
352
+ return release;
353
+ }
329
354
  var SqliteReplicaStore = class _SqliteReplicaStore {
330
355
  dir;
331
- #db;
356
+ #database;
357
+ get #db() {
358
+ if (this.#closed) throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica store is closed.");
359
+ return this.#database;
360
+ }
361
+ #closed = false;
362
+ #closing = false;
363
+ #closePromise;
364
+ #activeMutations = 0;
365
+ #drained;
366
+ #shared;
332
367
  #pinnedDir;
333
368
  #directoryFd;
334
369
  #holder = `${process.pid}-${randomBytes(6).toString("hex")}`;
@@ -339,12 +374,18 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
339
374
  #ino;
340
375
  #dev;
341
376
  #namedAncestors;
342
- constructor(dir, db, identity, options, directoryFd, namedAncestors = []) {
377
+ constructor(dir, db, identity, options, directoryFd, namedAncestors = [], shared) {
343
378
  this.dir = dir;
344
- this.#pinnedDir = directoryFd !== void 0 && process.platform === "linux" ? `/proc/self/fd/${directoryFd}` : dir;
379
+ this.#pinnedDir = shared?.pinnedDir ?? (directoryFd !== void 0 && process.platform === "linux" ? `/proc/self/fd/${directoryFd}` : dir);
345
380
  this.#directoryFd = directoryFd;
346
- if (directoryFd !== void 0) pinnedDirectories.register(this, directoryFd, this);
347
- this.#db = db;
381
+ this.#shared = shared;
382
+ if (shared) {
383
+ shared.references++;
384
+ sharedStores.register(this, shared, this);
385
+ } else if (directoryFd !== void 0) {
386
+ pinnedDirectories.register(this, directoryFd, this);
387
+ }
388
+ this.#database = db;
348
389
  this.#ino = identity.ino;
349
390
  this.#dev = identity.dev;
350
391
  this.#now = options.now ?? Date.now;
@@ -414,7 +455,6 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
414
455
  );
415
456
  if (!exists) throw new ReplicaError(ReplicaErrorCode.NOT_FOUND, `No regular replica database at ${dir}.`);
416
457
  }
417
- const opener = options.sqlite ?? await loadSqlite();
418
458
  const releaseOpen = await acquireSqliteOpen();
419
459
  let store;
420
460
  let directoryFd;
@@ -456,63 +496,99 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
456
496
  if (!identity.isFile() || !named.isFile() || named.dev !== identity.dev || named.ino !== identity.ino) {
457
497
  throw new Error("The replica database changed before SQLite opened it.");
458
498
  }
459
- const previousFds = process.platform === "linux" && !options.sqlite ? new Map(readdirSync("/proc/self/fd").map((fd) => {
460
- try {
461
- return [fd, readlinkSync(`/proc/self/fd/${fd}`)];
462
- } catch {
463
- return [fd, ""];
499
+ const key = `${opened.dev}:${opened.ino}`;
500
+ const active = writableStores.get(key);
501
+ if (active && (active.dev !== identity.dev || active.ino !== identity.ino)) {
502
+ throw new Error("The replica database changed while another controller holds it open.");
503
+ }
504
+ if (active && !options.sqlite) {
505
+ const namedDirectory = lstatSync(dir, { bigint: true });
506
+ if (!namedDirectory.isDirectory() || namedDirectory.dev !== opened.dev || namedDirectory.ino !== opened.ino) {
507
+ throw new Error("The replica directory changed before its checked SQLite handle could be shared.");
508
+ }
509
+ const pinned = active.directoryFd === void 0 ? lstatSync(active.pinnedDir, { bigint: true }) : fstatSync(active.directoryFd, { bigint: true });
510
+ const activeLeaf = lstatSync(join(active.pinnedDir, "replica.db"));
511
+ 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) {
512
+ throw new Error("The live replica changed before its checked SQLite handle could be shared.");
464
513
  }
465
- })) : null;
466
- db = opener(dbPath);
467
- const afterOpen = lstatSync(dbPath);
468
- if (!afterOpen.isFile() || afterOpen.dev !== identity.dev || afterOpen.ino !== identity.ino) {
469
- throw new Error("The replica database changed while SQLite opened it.");
514
+ if (directoryFd !== void 0) {
515
+ closeSync(directoryFd);
516
+ directoryFd = void 0;
517
+ }
518
+ store = new _SqliteReplicaStore(dir, active.db, identity, options, active.directoryFd, namedAncestors, active);
470
519
  }
471
- if (previousFds) db.get("SELECT count(*) FROM sqlite_master");
472
- if (previousFds) {
473
- const databases = readdirSync("/proc/self/fd").filter((fd) => {
520
+ if (!store) {
521
+ const opener = options.sqlite ?? await loadSqlite();
522
+ const previousFds = process.platform === "linux" && !options.sqlite ? new Map(readdirSync("/proc/self/fd").map((fd) => {
474
523
  try {
475
- const target = readlinkSync(`/proc/self/fd/${fd}`);
476
- return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
524
+ return [fd, readlinkSync(`/proc/self/fd/${fd}`)];
477
525
  } catch {
478
- return false;
526
+ return [fd, ""];
479
527
  }
480
- });
481
- if (databases.length === 0 || databases.some((fd) => {
482
- try {
483
- const actual = fstatSync(Number(fd));
484
- return actual.dev !== identity.dev || actual.ino !== identity.ino;
485
- } catch {
486
- return true;
528
+ })) : null;
529
+ db = opener(dbPath);
530
+ const afterOpen = lstatSync(dbPath);
531
+ if (!afterOpen.isFile() || afterOpen.dev !== identity.dev || afterOpen.ino !== identity.ino) {
532
+ throw new Error("The replica database changed while SQLite opened it.");
533
+ }
534
+ if (previousFds) db.get("SELECT count(*) FROM sqlite_master");
535
+ if (previousFds) {
536
+ const databases = readdirSync("/proc/self/fd").filter((fd) => {
537
+ try {
538
+ const target = readlinkSync(`/proc/self/fd/${fd}`);
539
+ return target.endsWith("/replica.db") && previousFds.get(fd) !== target;
540
+ } catch {
541
+ return false;
542
+ }
543
+ });
544
+ if (databases.length === 0 || databases.some((fd) => {
545
+ try {
546
+ const actual = fstatSync(Number(fd));
547
+ return actual.dev !== identity.dev || actual.ino !== identity.ino;
548
+ } catch {
549
+ return true;
550
+ }
551
+ })) {
552
+ throw new Error("SQLite opened a different replica database.");
487
553
  }
488
- })) {
489
- throw new Error("SQLite opened a different replica database.");
490
554
  }
555
+ const after = lstatSync(dbPath);
556
+ if (!after.isFile() || after.dev !== identity.dev || after.ino !== identity.ino) {
557
+ throw new Error("The replica database changed while SQLite opened it.");
558
+ }
559
+ db.exec("PRAGMA busy_timeout = 5000");
560
+ db.exec("PRAGMA journal_mode = WAL");
561
+ db.exec("PRAGMA synchronous = FULL");
562
+ db.exec("PRAGMA secure_delete = ON");
563
+ db.exec("PRAGMA foreign_keys = ON");
564
+ const version = db.get("PRAGMA user_version")?.user_version ?? 0;
565
+ if (version > SCHEMA_VERSION) {
566
+ throw new ReplicaError(
567
+ ReplicaErrorCode.STORAGE_ERROR,
568
+ `The replica at ${dir} was written by a newer tc (schema ${version}); upgrade tc to read it.`
569
+ );
570
+ }
571
+ if (version < SCHEMA_VERSION) {
572
+ db.exec("BEGIN IMMEDIATE");
573
+ db.exec(SCHEMA);
574
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
575
+ db.exec("COMMIT");
576
+ }
577
+ const shared = options.sqlite ? void 0 : {
578
+ key,
579
+ db,
580
+ directoryFd,
581
+ pinnedDir,
582
+ namedPath: dir,
583
+ dev: identity.dev,
584
+ ino: identity.ino,
585
+ references: 0,
586
+ mutationTail: Promise.resolve()
587
+ };
588
+ store = new _SqliteReplicaStore(dir, db, identity, options, directoryFd, namedAncestors, shared);
589
+ if (shared) writableStores.set(key, shared);
590
+ db = void 0;
491
591
  }
492
- const after = lstatSync(dbPath);
493
- if (!after.isFile() || after.dev !== identity.dev || after.ino !== identity.ino) {
494
- throw new Error("The replica database changed while SQLite opened it.");
495
- }
496
- db.exec("PRAGMA busy_timeout = 5000");
497
- db.exec("PRAGMA journal_mode = WAL");
498
- db.exec("PRAGMA synchronous = FULL");
499
- db.exec("PRAGMA secure_delete = ON");
500
- db.exec("PRAGMA foreign_keys = ON");
501
- const version = db.get("PRAGMA user_version")?.user_version ?? 0;
502
- if (version > SCHEMA_VERSION) {
503
- throw new ReplicaError(
504
- ReplicaErrorCode.STORAGE_ERROR,
505
- `The replica at ${dir} was written by a newer tc (schema ${version}); upgrade tc to read it.`
506
- );
507
- }
508
- if (version < SCHEMA_VERSION) {
509
- db.exec("BEGIN IMMEDIATE");
510
- db.exec(SCHEMA);
511
- db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
512
- db.exec("COMMIT");
513
- }
514
- store = new _SqliteReplicaStore(dir, db, identity, options, directoryFd, namedAncestors);
515
- db = void 0;
516
592
  } finally {
517
593
  try {
518
594
  db?.close();
@@ -551,30 +627,53 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
551
627
  );
552
628
  }
553
629
  }
630
+ /** New calls stop at close; accepted mutation sections may still finish their transactions. */
631
+ #assertAccepting() {
632
+ if (this.#closing || this.#closed) {
633
+ throw new ReplicaError(ReplicaErrorCode.CLOSED, "The replica store is closed.");
634
+ }
635
+ }
554
636
  /** A short mutation section: guarded (profile lock) and checked against the database this store opened. */
555
637
  #mutate(section) {
556
- return this.#guard(async () => {
557
- await this.#assertPresent();
558
- return section();
559
- });
638
+ this.#assertAccepting();
639
+ this.#activeMutations++;
640
+ return (async () => {
641
+ try {
642
+ return await this.#guard(async () => {
643
+ await this.#assertPresent();
644
+ return section();
645
+ });
646
+ } finally {
647
+ if (--this.#activeMutations === 0) {
648
+ const drained = this.#drained;
649
+ this.#drained = void 0;
650
+ drained?.();
651
+ }
652
+ }
653
+ })();
560
654
  }
561
655
  /** Run `body` in a write transaction (BEGIN IMMEDIATE). Callers outside a mutation section use `#guardedWrite`. */
562
- #write(action, body) {
656
+ async #write(action, body) {
657
+ const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
563
658
  try {
564
- this.#db.exec("BEGIN IMMEDIATE");
565
- } catch (error) {
566
- throw storageError(error, action);
567
- }
568
- try {
569
- const result = body();
570
- this.#db.exec("COMMIT");
571
- return result;
572
- } catch (error) {
573
659
  try {
574
- this.#db.exec("ROLLBACK");
575
- } catch {
660
+ this.#db.exec("BEGIN IMMEDIATE");
661
+ } catch (error) {
662
+ throw storageError(error, action);
576
663
  }
577
- throw storageError(error, action);
664
+ try {
665
+ const result = body();
666
+ this.#db.exec("COMMIT");
667
+ return result;
668
+ } catch (error) {
669
+ try {
670
+ this.#db.exec("ROLLBACK");
671
+ } catch {
672
+ }
673
+ throw storageError(error, action);
674
+ }
675
+ } finally {
676
+ release?.();
578
677
  }
579
678
  }
580
679
  /** One write transaction as its own mutation section. */
@@ -601,6 +700,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
601
700
  }
602
701
  }
603
702
  async open() {
703
+ this.#assertAccepting();
604
704
  let row;
605
705
  try {
606
706
  row = this.#row();
@@ -762,6 +862,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
762
862
  });
763
863
  }
764
864
  async hasContent(hashes) {
865
+ this.#assertAccepting();
765
866
  const held = /* @__PURE__ */ new Set();
766
867
  for (let start = 0; start < hashes.length; start += 500) {
767
868
  const chunk = hashes.slice(start, start + 500);
@@ -844,7 +945,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
844
945
  }
845
946
  await this.#faults.afterBlobs?.();
846
947
  try {
847
- this.#write("Committing a sync page", () => {
948
+ await this.#write("Committing a sync page", () => {
848
949
  this.#checkLease(t);
849
950
  const now = this.#now();
850
951
  if (p.window.notBefore !== null && now < Date.parse(p.window.notBefore)) {
@@ -952,13 +1053,16 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
952
1053
  }
953
1054
  async collectGarbage(t) {
954
1055
  return this.#mutate(async () => {
1056
+ const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
955
1057
  try {
956
1058
  this.#db.exec("BEGIN IMMEDIATE");
957
1059
  } catch (error) {
1060
+ release?.();
958
1061
  throw storageError(error, "Collecting unused content");
959
1062
  }
960
1063
  try {
961
1064
  this.#checkLease(t);
1065
+ await this.#faults.afterGarbageCollectionBegin?.();
962
1066
  const keep = new Set(
963
1067
  this.#db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash)
964
1068
  );
@@ -971,12 +1075,14 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
971
1075
  } catch {
972
1076
  }
973
1077
  throw storageError(error, "Collecting unused content");
1078
+ } finally {
1079
+ release?.();
974
1080
  }
975
1081
  });
976
1082
  }
977
1083
  async reset(t, reason, options = {}) {
978
1084
  await this.#mutate(async () => {
979
- this.#write("Resetting the replica", () => {
1085
+ await this.#write("Resetting the replica", () => {
980
1086
  this.#checkLease(t);
981
1087
  this.#db.run("DELETE FROM entry");
982
1088
  this.#db.run(
@@ -1003,9 +1109,11 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1003
1109
  */
1004
1110
  async destroy(t) {
1005
1111
  await this.#mutate(async () => {
1112
+ const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
1006
1113
  try {
1007
1114
  this.#db.exec("BEGIN IMMEDIATE");
1008
1115
  } catch (error) {
1116
+ release?.();
1009
1117
  throw storageError(error, "Removing the replica");
1010
1118
  }
1011
1119
  try {
@@ -1104,6 +1212,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1104
1212
  this.#db.exec("ROLLBACK");
1105
1213
  } catch {
1106
1214
  }
1215
+ release?.();
1107
1216
  }
1108
1217
  });
1109
1218
  }
@@ -1147,7 +1256,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1147
1256
  * when both are done.
1148
1257
  */
1149
1258
  async #purgeContent() {
1150
- this.#write("Starting the revocation purge", () => {
1259
+ await this.#write("Starting the revocation purge", () => {
1151
1260
  this.#db.run("UPDATE replica SET purge_pending = 1 WHERE id = 1 AND revoked_detail IS NOT NULL");
1152
1261
  });
1153
1262
  try {
@@ -1158,7 +1267,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1158
1267
  if (!this.#checkpoint()) {
1159
1268
  throw new ReplicaError(ReplicaErrorCode.BUSY, "A reader holds the replica's write-ahead log; the purge finishes on the next open.");
1160
1269
  }
1161
- this.#write("Finishing the revocation purge", () => {
1270
+ await this.#write("Finishing the revocation purge", () => {
1162
1271
  this.#db.run("UPDATE replica SET purge_pending = 0 WHERE id = 1 AND revoked_detail IS NOT NULL");
1163
1272
  });
1164
1273
  }
@@ -1168,6 +1277,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1168
1277
  });
1169
1278
  }
1170
1279
  async get(key) {
1280
+ this.#assertAccepting();
1171
1281
  try {
1172
1282
  const row = this.#db.get("SELECT key, deleted, etag, hash, metadata, content FROM entry WHERE key = ?", key);
1173
1283
  return row === void 0 ? void 0 : entryOf(row);
@@ -1176,6 +1286,7 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1176
1286
  }
1177
1287
  }
1178
1288
  async list(o) {
1289
+ this.#assertAccepting();
1179
1290
  const where = ["deleted = 0"];
1180
1291
  const params = [];
1181
1292
  if (o.prefix !== void 0 && o.prefix !== "") {
@@ -1261,15 +1372,31 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
1261
1372
  lastReset: state.lastReset
1262
1373
  };
1263
1374
  }
1264
- async close() {
1265
- const fd = this.#directoryFd;
1266
- this.#directoryFd = void 0;
1267
- if (fd !== void 0) pinnedDirectories.unregister(this);
1268
- try {
1269
- this.#db.close();
1270
- } finally {
1271
- if (fd !== void 0) closeSync(fd);
1272
- }
1375
+ close() {
1376
+ if (this.#closePromise) return this.#closePromise;
1377
+ this.#closing = true;
1378
+ this.#closePromise = (async () => {
1379
+ if (this.#activeMutations !== 0) {
1380
+ await new Promise((resolve2) => {
1381
+ this.#drained = resolve2;
1382
+ });
1383
+ }
1384
+ this.#closed = true;
1385
+ const fd = this.#directoryFd;
1386
+ this.#directoryFd = void 0;
1387
+ if (this.#shared) {
1388
+ sharedStores.unregister(this);
1389
+ releaseWritable(this.#shared);
1390
+ } else {
1391
+ if (fd !== void 0) pinnedDirectories.unregister(this);
1392
+ try {
1393
+ this.#database.close();
1394
+ } finally {
1395
+ if (fd !== void 0) closeSync(fd);
1396
+ }
1397
+ }
1398
+ })();
1399
+ return this.#closePromise;
1273
1400
  }
1274
1401
  };
1275
1402
  export {