@tinycloud/cli 1.3.0-beta.29 → 1.3.0-beta.31

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/index.js CHANGED
@@ -4548,6 +4548,74 @@ function storageError(error, action) {
4548
4548
  function errnoOf(error) {
4549
4549
  return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
4550
4550
  }
4551
+ async function writerBlobFiles(root) {
4552
+ const out = [];
4553
+ let shards;
4554
+ try {
4555
+ shards = await readdir2(root, { withFileTypes: true });
4556
+ } catch (error) {
4557
+ if (errnoOf(error) === "ENOENT" || errnoOf(error) === "ENOTDIR") return out;
4558
+ throw error;
4559
+ }
4560
+ for (const shard of shards) {
4561
+ const temp = shard.name === ".tmp";
4562
+ if (!shard.isDirectory() || !(temp || isShard(shard.name))) continue;
4563
+ const shardPath = join22(root, shard.name);
4564
+ let files;
4565
+ try {
4566
+ files = await readdir2(shardPath, { withFileTypes: true });
4567
+ } catch (error) {
4568
+ if (errnoOf(error) === "ENOENT" || errnoOf(error) === "ENOTDIR") continue;
4569
+ throw error;
4570
+ }
4571
+ for (const file of files) {
4572
+ if (!file.isFile()) continue;
4573
+ const ok = temp ? isTempName(file.name) : isHash(file.name) && file.name.startsWith(shard.name);
4574
+ if (!ok) continue;
4575
+ const path = join22(shardPath, file.name);
4576
+ const info = await lstat2(path).catch(() => void 0);
4577
+ if (!info?.isFile()) continue;
4578
+ out.push({ path, hash: temp ? null : file.name, size: info.size });
4579
+ }
4580
+ }
4581
+ return out;
4582
+ }
4583
+ async function unlinkWriterOrphans(root, keep) {
4584
+ let removed = 0;
4585
+ const touched = /* @__PURE__ */ new Set();
4586
+ for (const file of await writerBlobFiles(root)) {
4587
+ if (file.hash !== null && keep.has(file.hash)) continue;
4588
+ await rm3(file.path, { force: true });
4589
+ touched.add(dirname3(file.path));
4590
+ removed += 1;
4591
+ }
4592
+ for (const dir of touched) if (!dir.endsWith(".tmp")) await rmdir2(dir).catch(() => void 0);
4593
+ return removed;
4594
+ }
4595
+ async function unlinkBlobsIn(root, keep) {
4596
+ let removed = 0;
4597
+ for (const shard of await entriesOf(root)) {
4598
+ const shardPath = join22(root, shard);
4599
+ if (shard === ".tmp") {
4600
+ for (const name of await entriesOf(shardPath)) {
4601
+ await rm3(join22(shardPath, name), { force: true });
4602
+ removed += 1;
4603
+ }
4604
+ continue;
4605
+ }
4606
+ let kept = 0;
4607
+ for (const name of await entriesOf(shardPath)) {
4608
+ if (isHash(name) && keep.has(name)) {
4609
+ kept += 1;
4610
+ continue;
4611
+ }
4612
+ await rm3(join22(shardPath, name), { force: true });
4613
+ removed += 1;
4614
+ }
4615
+ if (kept === 0) await rmdir2(shardPath).catch(() => void 0);
4616
+ }
4617
+ return removed;
4618
+ }
4551
4619
  async function entriesOf(path) {
4552
4620
  try {
4553
4621
  return await readdir2(path);
@@ -4574,7 +4642,7 @@ async function acquireWritableMutation(shared) {
4574
4642
  await previous;
4575
4643
  return release;
4576
4644
  }
4577
- var ReplicaErrorCode3, REPLICA_ERROR3, ReplicaError3, SCHEMA_VERSION, SCHEMA, POLICY_COLUMNS, normalizeSchema, SOURCE_SCHEMA, V1_SCHEMA, POLICY_REPLICA, POLICY_SCHEMA, SQLITE_FILE, SHARD, HASH, TEMP, LAYOUT_FAULTS, sqliteOpenQueue, DURABILITY, isHash, FAULTS, pinnedDirectories, writableStores, sharedStores, activeReadSection, SqliteReplicaStore;
4645
+ var ReplicaErrorCode3, REPLICA_ERROR3, ReplicaError3, SCHEMA_VERSION, SCHEMA, POLICY_COLUMNS, normalizeSchema, SOURCE_SCHEMA, V1_SCHEMA, POLICY_REPLICA, POLICY_SCHEMA, SQLITE_FILE, SHARD, HASH, TEMP, LAYOUT_FAULTS, sqliteOpenQueue, DURABILITY, isHash, isShard, isTempName, FAULTS, pinnedDirectories, writableStores, sharedStores, activeReadSection, SqliteReplicaStore;
4578
4646
  var init_sqlite = __esm({
4579
4647
  "../replica/dist/sqlite.js"() {
4580
4648
  "use strict";
@@ -4751,6 +4819,8 @@ CREATE TABLE IF NOT EXISTS grant_install (
4751
4819
  sqliteOpenQueue = Promise.resolve();
4752
4820
  DURABILITY = "sqlite-wal-synchronous-full+fsynced-blobs";
4753
4821
  isHash = (name) => /^[0-9a-f]{64}$/.test(name);
4822
+ isShard = (name) => /^[0-9a-f]{2}$/.test(name);
4823
+ isTempName = (name) => /^[0-9a-f]{64}\.[0-9a-f]{12}$/.test(name);
4754
4824
  FAULTS = /* @__PURE__ */ Symbol("tinycloud.replica.faults");
4755
4825
  pinnedDirectories = new FinalizationRegistry((fd) => {
4756
4826
  try {
@@ -4788,6 +4858,8 @@ CREATE TABLE IF NOT EXISTS grant_install (
4788
4858
  #guard;
4789
4859
  /** Identity of the database file this store opened: a deleted-and-recreated replica is a different file. */
4790
4860
  #ino;
4861
+ #admit;
4862
+ #partitionId;
4791
4863
  #dev;
4792
4864
  #namedAncestors;
4793
4865
  constructor(dir, db, identity, options, directoryFd, namedAncestors = [], shared, readonlySnapshot = false) {
@@ -4807,6 +4879,8 @@ CREATE TABLE IF NOT EXISTS grant_install (
4807
4879
  this.#dev = identity.dev;
4808
4880
  this.#now = options.now ?? Date.now;
4809
4881
  this.#faults = options[FAULTS] ?? {};
4882
+ this.#admit = options.admit;
4883
+ this.#partitionId = options.partitionId ?? "";
4810
4884
  this.#guard = options.guard ?? ((section) => section());
4811
4885
  this.#namedAncestors = namedAncestors;
4812
4886
  }
@@ -4876,6 +4950,55 @@ CREATE TABLE IF NOT EXISTS grant_install (
4876
4950
  }
4877
4951
  }
4878
4952
  }
4953
+ /**
4954
+ * Blob content bytes on disk in the replica at `dir` (0 when absent): the writer's blob files under `blobs/`,
4955
+ * including writer temp files and blobs no entry references any more, so garbage a failed sync
4956
+ * left behind still counts. Counts exactly what `collectOrphans` may delete: foreign entries
4957
+ * (stray files, symlinks, unknown names) are neither counted nor touched. Only blob content is counted: not the database, its WAL, rows or the
4958
+ * pending store. Filesystem-only: it opens no
4959
+ * database, so it can sum a profile's replicas under the caller's own mutation hold.
4960
+ */
4961
+ static async contentBytes(dir) {
4962
+ try {
4963
+ let bytes = 0;
4964
+ for (const file of await writerBlobFiles(join22(dir, "blobs"))) bytes += file.size;
4965
+ return bytes;
4966
+ } catch (error) {
4967
+ throw storageError(error, "Measuring replica content");
4968
+ }
4969
+ }
4970
+ /**
4971
+ * Remove blobs and temp files in the replica at `dir` that no committed row references: what a
4972
+ * crash between writing blobs and committing (or between committing and collecting) leaves.
4973
+ * Returns the number of files removed; 0 when the replica is absent or unreadable.
4974
+ *
4975
+ * The caller MUST hold the profile mutation guard, which every writer of this replica holds
4976
+ * across its blob write and commit; that is what makes an unreferenced blob safe to delete
4977
+ * (it cannot belong to an in-flight write). Never call it without that hold. The database is
4978
+ * read only, so nothing here can take a lease or change committed state.
4979
+ */
4980
+ static async collectOrphans(dir) {
4981
+ const dbPath = join22(dir, "replica.db");
4982
+ const identity = await lstat2(dbPath).catch(() => void 0);
4983
+ if (!identity?.isFile()) return 0;
4984
+ const opener = await loadSqlite();
4985
+ let keep;
4986
+ let db;
4987
+ try {
4988
+ db = opener(dbPath, { readonly: true });
4989
+ db.exec("PRAGMA busy_timeout = 0");
4990
+ keep = new Set(db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash));
4991
+ } catch {
4992
+ return 0;
4993
+ } finally {
4994
+ db?.close();
4995
+ }
4996
+ try {
4997
+ return await unlinkWriterOrphans(join22(dir, "blobs"), keep);
4998
+ } catch (error) {
4999
+ throw storageError(error, "Collecting orphaned content");
5000
+ }
5001
+ }
4879
5002
  /**
4880
5003
  * Open the replica store in `dir`. With `create: false` a missing replica is
4881
5004
  * REPLICA_NOT_FOUND and nothing is written to disk.
@@ -5585,6 +5708,41 @@ CREATE TABLE IF NOT EXISTS grant_install (
5585
5708
  }
5586
5709
  reused.set(change.hash, size);
5587
5710
  }
5711
+ if (this.#admit) {
5712
+ await this.#collectLocked(t);
5713
+ const changed = new Set(p.changes.map((change) => change.key));
5714
+ const kept = /* @__PURE__ */ new Set();
5715
+ for (const change of p.changes) if (!change.deleted && change.content) kept.add(change.hash);
5716
+ let blobBytes = 0;
5717
+ for (const [hash, bytes] of p.blobs) {
5718
+ const stored = this.#db.get(
5719
+ "SELECT 1 AS hit FROM entry WHERE hash = ? AND content = 1 AND deleted = 0 LIMIT 1",
5720
+ hash
5721
+ );
5722
+ if (stored === void 0) blobBytes += bytes.byteLength;
5723
+ }
5724
+ const released = /* @__PURE__ */ new Map();
5725
+ for (const key of changed) {
5726
+ const old = this.#db.get(
5727
+ "SELECT hash, size FROM entry WHERE key = ? AND deleted = 0 AND content = 1 AND hash IS NOT NULL AND size IS NOT NULL",
5728
+ key
5729
+ );
5730
+ if (old === void 0 || kept.has(old.hash) || released.has(old.hash)) continue;
5731
+ const holders = this.#db.all(
5732
+ "SELECT key FROM entry WHERE hash = ? AND content = 1 AND deleted = 0",
5733
+ old.hash
5734
+ );
5735
+ if (holders.every((holder) => changed.has(holder.key))) released.set(old.hash, old.size);
5736
+ }
5737
+ let freed = 0;
5738
+ for (const size of released.values()) freed += size;
5739
+ await this.#admit({
5740
+ partitionId: this.#partitionId,
5741
+ blobBytes: blobBytes - freed,
5742
+ entries: p.changes.length,
5743
+ freeBytes: null
5744
+ });
5745
+ }
5588
5746
  try {
5589
5747
  await this.#writeBlobs(p.blobs);
5590
5748
  } catch (error) {
@@ -5660,36 +5818,16 @@ CREATE TABLE IF NOT EXISTS grant_install (
5660
5818
  });
5661
5819
  } catch (error) {
5662
5820
  if (isReplicaError(error, ReplicaErrorCode3.GRANT_REVOKED)) await this.#purgeContent().catch(() => void 0);
5821
+ else if (this.#admit) await this.#collectLocked(t).catch(() => void 0);
5663
5822
  throw error;
5664
5823
  }
5665
5824
  this.#faults.afterCommit?.();
5825
+ if (this.#admit) await this.#collectLocked(t);
5666
5826
  });
5667
5827
  }
5668
5828
  /** Unlink every blob not in `keep`, and every temp file. Only a missing directory counts as empty. */
5669
- async #unlinkBlobs(keep) {
5670
- let removed = 0;
5671
- const root = join22(this.#pinnedDir, "blobs");
5672
- for (const shard of await entriesOf(root)) {
5673
- const shardPath = join22(root, shard);
5674
- if (shard === ".tmp") {
5675
- for (const name of await entriesOf(shardPath)) {
5676
- await rm3(join22(shardPath, name), { force: true });
5677
- removed += 1;
5678
- }
5679
- continue;
5680
- }
5681
- let kept = 0;
5682
- for (const name of await entriesOf(shardPath)) {
5683
- if (isHash(name) && keep.has(name)) {
5684
- kept += 1;
5685
- continue;
5686
- }
5687
- await rm3(join22(shardPath, name), { force: true });
5688
- removed += 1;
5689
- }
5690
- if (kept === 0) await rmdir2(shardPath).catch(() => void 0);
5691
- }
5692
- return removed;
5829
+ #unlinkBlobs(keep) {
5830
+ return unlinkBlobsIn(join22(this.#pinnedDir, "blobs"), keep);
5693
5831
  }
5694
5832
  /** Whether any content file (a blob or a temp file) is on disk. */
5695
5833
  async #holdsContentFiles() {
@@ -5700,33 +5838,35 @@ CREATE TABLE IF NOT EXISTS grant_install (
5700
5838
  return false;
5701
5839
  }
5702
5840
  async collectGarbage(t) {
5703
- return this.#mutate(async () => {
5704
- const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
5705
- try {
5706
- this.#db.exec("BEGIN IMMEDIATE");
5707
- } catch (error) {
5708
- release?.();
5709
- throw storageError(error, "Collecting unused content");
5710
- }
5841
+ return this.#mutate(() => this.#collectLocked(t));
5842
+ }
5843
+ /** `collectGarbage` for callers already inside the mutation hold (it cannot re-enter `#mutate`). */
5844
+ async #collectLocked(t) {
5845
+ const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
5846
+ try {
5847
+ this.#db.exec("BEGIN IMMEDIATE");
5848
+ } catch (error) {
5849
+ release?.();
5850
+ throw storageError(error, "Collecting unused content");
5851
+ }
5852
+ try {
5853
+ this.#checkLease(t);
5854
+ await this.#faults.afterGarbageCollectionBegin?.();
5855
+ const keep = new Set(
5856
+ this.#db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash)
5857
+ );
5858
+ const removed = await this.#unlinkBlobs(keep);
5859
+ this.#db.exec("COMMIT");
5860
+ return removed;
5861
+ } catch (error) {
5711
5862
  try {
5712
- this.#checkLease(t);
5713
- await this.#faults.afterGarbageCollectionBegin?.();
5714
- const keep = new Set(
5715
- this.#db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash)
5716
- );
5717
- const removed = await this.#unlinkBlobs(keep);
5718
- this.#db.exec("COMMIT");
5719
- return removed;
5720
- } catch (error) {
5721
- try {
5722
- this.#db.exec("ROLLBACK");
5723
- } catch {
5724
- }
5725
- throw storageError(error, "Collecting unused content");
5726
- } finally {
5727
- release?.();
5863
+ this.#db.exec("ROLLBACK");
5864
+ } catch {
5728
5865
  }
5729
- });
5866
+ throw storageError(error, "Collecting unused content");
5867
+ } finally {
5868
+ release?.();
5869
+ }
5730
5870
  }
5731
5871
  async reset(t, reason, options = {}) {
5732
5872
  await this.#mutate(async () => {