@tinycloud/replica 0.3.0-beta.6 → 0.3.0-beta.8
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/kv-storage-testing.js.map +1 -1
- package/dist/kv-storage.js +1 -1
- package/dist/kv-storage.worker.f6f98ad5dc547a29274c0b539fb78e25f8a2051ccfaafc9490cb307051a0e0ef.js +19004 -0
- package/dist/kv-storage.worker.js +1 -1
- package/dist/kv-storage.worker.js.map +1 -1
- package/dist/replica.worker.js +1 -1
- package/dist/replica.worker.js.map +1 -1
- package/dist/sqlite.cjs +198 -55
- package/dist/sqlite.cjs.map +1 -1
- package/dist/sqlite.d.cts +25 -1
- package/dist/sqlite.d.ts +25 -1
- package/dist/sqlite.js +198 -55
- package/dist/sqlite.js.map +1 -1
- package/package.json +1 -1
- package/dist/kv-storage.worker.f9311d47e8d4d08d5413987b5f8aa5e3cd180708f3e4debed3b0a3d938156fcd.js +0 -19004
package/dist/sqlite.cjs
CHANGED
|
@@ -726,6 +726,76 @@ var isHash = (name) => /^[0-9a-f]{64}$/.test(name);
|
|
|
726
726
|
function errnoOf(error) {
|
|
727
727
|
return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
|
|
728
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
|
+
}
|
|
729
799
|
async function entriesOf(path) {
|
|
730
800
|
try {
|
|
731
801
|
return await (0, import_promises2.readdir)(path);
|
|
@@ -789,6 +859,8 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
789
859
|
#guard;
|
|
790
860
|
/** Identity of the database file this store opened: a deleted-and-recreated replica is a different file. */
|
|
791
861
|
#ino;
|
|
862
|
+
#admit;
|
|
863
|
+
#partitionId;
|
|
792
864
|
#dev;
|
|
793
865
|
#namedAncestors;
|
|
794
866
|
constructor(dir, db, identity, options, directoryFd, namedAncestors = [], shared, readonlySnapshot = false) {
|
|
@@ -808,6 +880,8 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
808
880
|
this.#dev = identity.dev;
|
|
809
881
|
this.#now = options.now ?? Date.now;
|
|
810
882
|
this.#faults = options[FAULTS] ?? {};
|
|
883
|
+
this.#admit = options.admit;
|
|
884
|
+
this.#partitionId = options.partitionId ?? "";
|
|
811
885
|
this.#guard = options.guard ?? ((section) => section());
|
|
812
886
|
this.#namedAncestors = namedAncestors;
|
|
813
887
|
}
|
|
@@ -877,6 +951,55 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
877
951
|
}
|
|
878
952
|
}
|
|
879
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;
|
|
994
|
+
} finally {
|
|
995
|
+
db?.close();
|
|
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
|
+
}
|
|
1002
|
+
}
|
|
880
1003
|
/**
|
|
881
1004
|
* Open the replica store in `dir`. With `create: false` a missing replica is
|
|
882
1005
|
* REPLICA_NOT_FOUND and nothing is written to disk.
|
|
@@ -1366,12 +1489,15 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1366
1489
|
}
|
|
1367
1490
|
#storeGrantPolicy(g, policy, existing) {
|
|
1368
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;
|
|
1369
1495
|
const effectiveExpiresAt = Math.min(
|
|
1370
1496
|
g.expiresAt === null ? Infinity : g.expiresAt * 1e3,
|
|
1371
1497
|
installedAt + policy.ttlMs,
|
|
1372
|
-
|
|
1498
|
+
consentDeadline
|
|
1373
1499
|
);
|
|
1374
|
-
if (!Number.isSafeInteger(installedAt) || installedAt < 0 || !Number.isSafeInteger(effectiveExpiresAt) || effectiveExpiresAt < 0 || !Number.isSafeInteger(
|
|
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) {
|
|
1375
1501
|
throw new ReplicaError(ReplicaErrorCode.GRANT_EXPIRED, "The replica grant has no finite effective expiry.");
|
|
1376
1502
|
}
|
|
1377
1503
|
this.#db.run(
|
|
@@ -1383,11 +1509,11 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1383
1509
|
g.cid,
|
|
1384
1510
|
installedAt,
|
|
1385
1511
|
effectiveExpiresAt,
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1512
|
+
consentDeadline,
|
|
1513
|
+
consentAnchor.at,
|
|
1514
|
+
consentAnchor.source
|
|
1389
1515
|
);
|
|
1390
|
-
return { installedAt, effectiveExpiresAt, consentDeadline
|
|
1516
|
+
return { installedAt, effectiveExpiresAt, consentDeadline, consentAnchor };
|
|
1391
1517
|
}
|
|
1392
1518
|
async installGatedGrant(g, policy) {
|
|
1393
1519
|
return this.#guardedWrite("Installing the gated grant", () => {
|
|
@@ -1583,6 +1709,41 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1583
1709
|
}
|
|
1584
1710
|
reused.set(change.hash, size);
|
|
1585
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
|
+
}
|
|
1586
1747
|
try {
|
|
1587
1748
|
await this.#writeBlobs(p.blobs);
|
|
1588
1749
|
} catch (error) {
|
|
@@ -1658,36 +1819,16 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1658
1819
|
});
|
|
1659
1820
|
} catch (error) {
|
|
1660
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);
|
|
1661
1823
|
throw error;
|
|
1662
1824
|
}
|
|
1663
1825
|
this.#faults.afterCommit?.();
|
|
1826
|
+
if (this.#admit) await this.#collectLocked(t);
|
|
1664
1827
|
});
|
|
1665
1828
|
}
|
|
1666
1829
|
/** Unlink every blob not in `keep`, and every temp file. Only a missing directory counts as empty. */
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
const root = (0, import_node_path2.join)(this.#pinnedDir, "blobs");
|
|
1670
|
-
for (const shard of await entriesOf(root)) {
|
|
1671
|
-
const shardPath = (0, import_node_path2.join)(root, shard);
|
|
1672
|
-
if (shard === ".tmp") {
|
|
1673
|
-
for (const name of await entriesOf(shardPath)) {
|
|
1674
|
-
await (0, import_promises2.rm)((0, import_node_path2.join)(shardPath, name), { force: true });
|
|
1675
|
-
removed += 1;
|
|
1676
|
-
}
|
|
1677
|
-
continue;
|
|
1678
|
-
}
|
|
1679
|
-
let kept = 0;
|
|
1680
|
-
for (const name of await entriesOf(shardPath)) {
|
|
1681
|
-
if (isHash(name) && keep.has(name)) {
|
|
1682
|
-
kept += 1;
|
|
1683
|
-
continue;
|
|
1684
|
-
}
|
|
1685
|
-
await (0, import_promises2.rm)((0, import_node_path2.join)(shardPath, name), { force: true });
|
|
1686
|
-
removed += 1;
|
|
1687
|
-
}
|
|
1688
|
-
if (kept === 0) await (0, import_promises2.rmdir)(shardPath).catch(() => void 0);
|
|
1689
|
-
}
|
|
1690
|
-
return removed;
|
|
1830
|
+
#unlinkBlobs(keep) {
|
|
1831
|
+
return unlinkBlobsIn((0, import_node_path2.join)(this.#pinnedDir, "blobs"), keep);
|
|
1691
1832
|
}
|
|
1692
1833
|
/** Whether any content file (a blob or a temp file) is on disk. */
|
|
1693
1834
|
async #holdsContentFiles() {
|
|
@@ -1698,33 +1839,35 @@ var SqliteReplicaStore = class _SqliteReplicaStore {
|
|
|
1698
1839
|
return false;
|
|
1699
1840
|
}
|
|
1700
1841
|
async collectGarbage(t) {
|
|
1701
|
-
return this.#mutate(
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
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) {
|
|
1709
1863
|
try {
|
|
1710
|
-
this.#
|
|
1711
|
-
|
|
1712
|
-
const keep = new Set(
|
|
1713
|
-
this.#db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash)
|
|
1714
|
-
);
|
|
1715
|
-
const removed = await this.#unlinkBlobs(keep);
|
|
1716
|
-
this.#db.exec("COMMIT");
|
|
1717
|
-
return removed;
|
|
1718
|
-
} catch (error) {
|
|
1719
|
-
try {
|
|
1720
|
-
this.#db.exec("ROLLBACK");
|
|
1721
|
-
} catch {
|
|
1722
|
-
}
|
|
1723
|
-
throw storageError(error, "Collecting unused content");
|
|
1724
|
-
} finally {
|
|
1725
|
-
release?.();
|
|
1864
|
+
this.#db.exec("ROLLBACK");
|
|
1865
|
+
} catch {
|
|
1726
1866
|
}
|
|
1727
|
-
|
|
1867
|
+
throw storageError(error, "Collecting unused content");
|
|
1868
|
+
} finally {
|
|
1869
|
+
release?.();
|
|
1870
|
+
}
|
|
1728
1871
|
}
|
|
1729
1872
|
async reset(t, reason, options = {}) {
|
|
1730
1873
|
await this.#mutate(async () => {
|