@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.
@@ -6856,6 +6856,74 @@ function storageError(error, action) {
6856
6856
  function errnoOf(error) {
6857
6857
  return typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
6858
6858
  }
6859
+ async function writerBlobFiles(root) {
6860
+ const out = [];
6861
+ let shards;
6862
+ try {
6863
+ shards = await readdir2(root, { withFileTypes: true });
6864
+ } catch (error) {
6865
+ if (errnoOf(error) === "ENOENT" || errnoOf(error) === "ENOTDIR") return out;
6866
+ throw error;
6867
+ }
6868
+ for (const shard of shards) {
6869
+ const temp = shard.name === ".tmp";
6870
+ if (!shard.isDirectory() || !(temp || isShard(shard.name))) continue;
6871
+ const shardPath = join22(root, shard.name);
6872
+ let files;
6873
+ try {
6874
+ files = await readdir2(shardPath, { withFileTypes: true });
6875
+ } catch (error) {
6876
+ if (errnoOf(error) === "ENOENT" || errnoOf(error) === "ENOTDIR") continue;
6877
+ throw error;
6878
+ }
6879
+ for (const file of files) {
6880
+ if (!file.isFile()) continue;
6881
+ const ok = temp ? isTempName(file.name) : isHash(file.name) && file.name.startsWith(shard.name);
6882
+ if (!ok) continue;
6883
+ const path = join22(shardPath, file.name);
6884
+ const info = await lstat2(path).catch(() => void 0);
6885
+ if (!info?.isFile()) continue;
6886
+ out.push({ path, hash: temp ? null : file.name, size: info.size });
6887
+ }
6888
+ }
6889
+ return out;
6890
+ }
6891
+ async function unlinkWriterOrphans(root, keep) {
6892
+ let removed = 0;
6893
+ const touched = /* @__PURE__ */ new Set();
6894
+ for (const file of await writerBlobFiles(root)) {
6895
+ if (file.hash !== null && keep.has(file.hash)) continue;
6896
+ await rm3(file.path, { force: true });
6897
+ touched.add(dirname3(file.path));
6898
+ removed += 1;
6899
+ }
6900
+ for (const dir of touched) if (!dir.endsWith(".tmp")) await rmdir2(dir).catch(() => void 0);
6901
+ return removed;
6902
+ }
6903
+ async function unlinkBlobsIn(root, keep) {
6904
+ let removed = 0;
6905
+ for (const shard of await entriesOf(root)) {
6906
+ const shardPath = join22(root, shard);
6907
+ if (shard === ".tmp") {
6908
+ for (const name of await entriesOf(shardPath)) {
6909
+ await rm3(join22(shardPath, name), { force: true });
6910
+ removed += 1;
6911
+ }
6912
+ continue;
6913
+ }
6914
+ let kept = 0;
6915
+ for (const name of await entriesOf(shardPath)) {
6916
+ if (isHash(name) && keep.has(name)) {
6917
+ kept += 1;
6918
+ continue;
6919
+ }
6920
+ await rm3(join22(shardPath, name), { force: true });
6921
+ removed += 1;
6922
+ }
6923
+ if (kept === 0) await rmdir2(shardPath).catch(() => void 0);
6924
+ }
6925
+ return removed;
6926
+ }
6859
6927
  async function entriesOf(path) {
6860
6928
  try {
6861
6929
  return await readdir2(path);
@@ -6882,7 +6950,7 @@ async function acquireWritableMutation(shared) {
6882
6950
  await previous;
6883
6951
  return release;
6884
6952
  }
6885
- 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;
6953
+ 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;
6886
6954
  var init_sqlite = __esm({
6887
6955
  "../replica/dist/sqlite.js"() {
6888
6956
  "use strict";
@@ -7059,6 +7127,8 @@ CREATE TABLE IF NOT EXISTS grant_install (
7059
7127
  sqliteOpenQueue = Promise.resolve();
7060
7128
  DURABILITY = "sqlite-wal-synchronous-full+fsynced-blobs";
7061
7129
  isHash = (name) => /^[0-9a-f]{64}$/.test(name);
7130
+ isShard = (name) => /^[0-9a-f]{2}$/.test(name);
7131
+ isTempName = (name) => /^[0-9a-f]{64}\.[0-9a-f]{12}$/.test(name);
7062
7132
  FAULTS = /* @__PURE__ */ Symbol("tinycloud.replica.faults");
7063
7133
  pinnedDirectories = new FinalizationRegistry((fd) => {
7064
7134
  try {
@@ -7096,6 +7166,8 @@ CREATE TABLE IF NOT EXISTS grant_install (
7096
7166
  #guard;
7097
7167
  /** Identity of the database file this store opened: a deleted-and-recreated replica is a different file. */
7098
7168
  #ino;
7169
+ #admit;
7170
+ #partitionId;
7099
7171
  #dev;
7100
7172
  #namedAncestors;
7101
7173
  constructor(dir, db, identity, options, directoryFd, namedAncestors = [], shared, readonlySnapshot = false) {
@@ -7115,6 +7187,8 @@ CREATE TABLE IF NOT EXISTS grant_install (
7115
7187
  this.#dev = identity.dev;
7116
7188
  this.#now = options.now ?? Date.now;
7117
7189
  this.#faults = options[FAULTS] ?? {};
7190
+ this.#admit = options.admit;
7191
+ this.#partitionId = options.partitionId ?? "";
7118
7192
  this.#guard = options.guard ?? ((section) => section());
7119
7193
  this.#namedAncestors = namedAncestors;
7120
7194
  }
@@ -7184,6 +7258,55 @@ CREATE TABLE IF NOT EXISTS grant_install (
7184
7258
  }
7185
7259
  }
7186
7260
  }
7261
+ /**
7262
+ * Blob content bytes on disk in the replica at `dir` (0 when absent): the writer's blob files under `blobs/`,
7263
+ * including writer temp files and blobs no entry references any more, so garbage a failed sync
7264
+ * left behind still counts. Counts exactly what `collectOrphans` may delete: foreign entries
7265
+ * (stray files, symlinks, unknown names) are neither counted nor touched. Only blob content is counted: not the database, its WAL, rows or the
7266
+ * pending store. Filesystem-only: it opens no
7267
+ * database, so it can sum a profile's replicas under the caller's own mutation hold.
7268
+ */
7269
+ static async contentBytes(dir) {
7270
+ try {
7271
+ let bytes = 0;
7272
+ for (const file of await writerBlobFiles(join22(dir, "blobs"))) bytes += file.size;
7273
+ return bytes;
7274
+ } catch (error) {
7275
+ throw storageError(error, "Measuring replica content");
7276
+ }
7277
+ }
7278
+ /**
7279
+ * Remove blobs and temp files in the replica at `dir` that no committed row references: what a
7280
+ * crash between writing blobs and committing (or between committing and collecting) leaves.
7281
+ * Returns the number of files removed; 0 when the replica is absent or unreadable.
7282
+ *
7283
+ * The caller MUST hold the profile mutation guard, which every writer of this replica holds
7284
+ * across its blob write and commit; that is what makes an unreferenced blob safe to delete
7285
+ * (it cannot belong to an in-flight write). Never call it without that hold. The database is
7286
+ * read only, so nothing here can take a lease or change committed state.
7287
+ */
7288
+ static async collectOrphans(dir) {
7289
+ const dbPath = join22(dir, "replica.db");
7290
+ const identity = await lstat2(dbPath).catch(() => void 0);
7291
+ if (!identity?.isFile()) return 0;
7292
+ const opener = await loadSqlite();
7293
+ let keep;
7294
+ let db;
7295
+ try {
7296
+ db = opener(dbPath, { readonly: true });
7297
+ db.exec("PRAGMA busy_timeout = 0");
7298
+ keep = new Set(db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash));
7299
+ } catch {
7300
+ return 0;
7301
+ } finally {
7302
+ db?.close();
7303
+ }
7304
+ try {
7305
+ return await unlinkWriterOrphans(join22(dir, "blobs"), keep);
7306
+ } catch (error) {
7307
+ throw storageError(error, "Collecting orphaned content");
7308
+ }
7309
+ }
7187
7310
  /**
7188
7311
  * Open the replica store in `dir`. With `create: false` a missing replica is
7189
7312
  * REPLICA_NOT_FOUND and nothing is written to disk.
@@ -7893,6 +8016,41 @@ CREATE TABLE IF NOT EXISTS grant_install (
7893
8016
  }
7894
8017
  reused.set(change.hash, size);
7895
8018
  }
8019
+ if (this.#admit) {
8020
+ await this.#collectLocked(t);
8021
+ const changed = new Set(p.changes.map((change) => change.key));
8022
+ const kept = /* @__PURE__ */ new Set();
8023
+ for (const change of p.changes) if (!change.deleted && change.content) kept.add(change.hash);
8024
+ let blobBytes = 0;
8025
+ for (const [hash, bytes] of p.blobs) {
8026
+ const stored = this.#db.get(
8027
+ "SELECT 1 AS hit FROM entry WHERE hash = ? AND content = 1 AND deleted = 0 LIMIT 1",
8028
+ hash
8029
+ );
8030
+ if (stored === void 0) blobBytes += bytes.byteLength;
8031
+ }
8032
+ const released = /* @__PURE__ */ new Map();
8033
+ for (const key of changed) {
8034
+ const old = this.#db.get(
8035
+ "SELECT hash, size FROM entry WHERE key = ? AND deleted = 0 AND content = 1 AND hash IS NOT NULL AND size IS NOT NULL",
8036
+ key
8037
+ );
8038
+ if (old === void 0 || kept.has(old.hash) || released.has(old.hash)) continue;
8039
+ const holders = this.#db.all(
8040
+ "SELECT key FROM entry WHERE hash = ? AND content = 1 AND deleted = 0",
8041
+ old.hash
8042
+ );
8043
+ if (holders.every((holder) => changed.has(holder.key))) released.set(old.hash, old.size);
8044
+ }
8045
+ let freed = 0;
8046
+ for (const size of released.values()) freed += size;
8047
+ await this.#admit({
8048
+ partitionId: this.#partitionId,
8049
+ blobBytes: blobBytes - freed,
8050
+ entries: p.changes.length,
8051
+ freeBytes: null
8052
+ });
8053
+ }
7896
8054
  try {
7897
8055
  await this.#writeBlobs(p.blobs);
7898
8056
  } catch (error) {
@@ -7968,36 +8126,16 @@ CREATE TABLE IF NOT EXISTS grant_install (
7968
8126
  });
7969
8127
  } catch (error) {
7970
8128
  if (isReplicaError2(error, ReplicaErrorCode3.GRANT_REVOKED)) await this.#purgeContent().catch(() => void 0);
8129
+ else if (this.#admit) await this.#collectLocked(t).catch(() => void 0);
7971
8130
  throw error;
7972
8131
  }
7973
8132
  this.#faults.afterCommit?.();
8133
+ if (this.#admit) await this.#collectLocked(t);
7974
8134
  });
7975
8135
  }
7976
8136
  /** Unlink every blob not in `keep`, and every temp file. Only a missing directory counts as empty. */
7977
- async #unlinkBlobs(keep) {
7978
- let removed = 0;
7979
- const root = join22(this.#pinnedDir, "blobs");
7980
- for (const shard of await entriesOf(root)) {
7981
- const shardPath = join22(root, shard);
7982
- if (shard === ".tmp") {
7983
- for (const name of await entriesOf(shardPath)) {
7984
- await rm3(join22(shardPath, name), { force: true });
7985
- removed += 1;
7986
- }
7987
- continue;
7988
- }
7989
- let kept = 0;
7990
- for (const name of await entriesOf(shardPath)) {
7991
- if (isHash(name) && keep.has(name)) {
7992
- kept += 1;
7993
- continue;
7994
- }
7995
- await rm3(join22(shardPath, name), { force: true });
7996
- removed += 1;
7997
- }
7998
- if (kept === 0) await rmdir2(shardPath).catch(() => void 0);
7999
- }
8000
- return removed;
8137
+ #unlinkBlobs(keep) {
8138
+ return unlinkBlobsIn(join22(this.#pinnedDir, "blobs"), keep);
8001
8139
  }
8002
8140
  /** Whether any content file (a blob or a temp file) is on disk. */
8003
8141
  async #holdsContentFiles() {
@@ -8008,33 +8146,35 @@ CREATE TABLE IF NOT EXISTS grant_install (
8008
8146
  return false;
8009
8147
  }
8010
8148
  async collectGarbage(t) {
8011
- return this.#mutate(async () => {
8012
- const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
8013
- try {
8014
- this.#db.exec("BEGIN IMMEDIATE");
8015
- } catch (error) {
8016
- release?.();
8017
- throw storageError(error, "Collecting unused content");
8018
- }
8149
+ return this.#mutate(() => this.#collectLocked(t));
8150
+ }
8151
+ /** `collectGarbage` for callers already inside the mutation hold (it cannot re-enter `#mutate`). */
8152
+ async #collectLocked(t) {
8153
+ const release = this.#shared ? await acquireWritableMutation(this.#shared) : void 0;
8154
+ try {
8155
+ this.#db.exec("BEGIN IMMEDIATE");
8156
+ } catch (error) {
8157
+ release?.();
8158
+ throw storageError(error, "Collecting unused content");
8159
+ }
8160
+ try {
8161
+ this.#checkLease(t);
8162
+ await this.#faults.afterGarbageCollectionBegin?.();
8163
+ const keep = new Set(
8164
+ this.#db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash)
8165
+ );
8166
+ const removed = await this.#unlinkBlobs(keep);
8167
+ this.#db.exec("COMMIT");
8168
+ return removed;
8169
+ } catch (error) {
8019
8170
  try {
8020
- this.#checkLease(t);
8021
- await this.#faults.afterGarbageCollectionBegin?.();
8022
- const keep = new Set(
8023
- this.#db.all("SELECT DISTINCT hash FROM entry WHERE content = 1").map((row) => row.hash)
8024
- );
8025
- const removed = await this.#unlinkBlobs(keep);
8026
- this.#db.exec("COMMIT");
8027
- return removed;
8028
- } catch (error) {
8029
- try {
8030
- this.#db.exec("ROLLBACK");
8031
- } catch {
8032
- }
8033
- throw storageError(error, "Collecting unused content");
8034
- } finally {
8035
- release?.();
8171
+ this.#db.exec("ROLLBACK");
8172
+ } catch {
8036
8173
  }
8037
- });
8174
+ throw storageError(error, "Collecting unused content");
8175
+ } finally {
8176
+ release?.();
8177
+ }
8038
8178
  }
8039
8179
  async reset(t, reason, options = {}) {
8040
8180
  await this.#mutate(async () => {