Skip to content

client.vfs

client.storage gives you files identified by a manifest. client.vfs gives you a filesystem: directories, paths you can navigate, version history, and copies that cost nothing.

The bytes are chunked, encrypted and erasure-coded exactly as client.storage does it, into the same global shard store. They are written with space.putFile into a private content Space the SDK creates for you on your first write (its id is kept in your personal space), and that Space does not index the manifest. What the VFS adds is the metadata: one encrypted record per directory in your personal space, each listing only its immediate children. The record holds each file’s manifest, so the manifest never reaches the server in the clear.

The VFS has a working directory, so paths behave the way they do in a shell.

await client.vfs.cd("/apps/scratch");
client.vfs.cwd; // "/apps/scratch"
await client.vfs.readText("src/App.tsx"); // relative to cwd
await client.vfs.readText("/other.txt"); // absolute, unaffected
await client.vfs.cd(".."); // climbs; stops at the root
client.vfs.resolve("src/App.tsx"); // "/apps/scratch/src/App.tsx" — pure, no fetch

cd checks the target is a real directory, so a typo fails there — where the error can name the path — instead of turning every later relative path into a puzzling “does not exist”.

It lives here rather than in each consumer so a CLI, a script and an app all agree on what “here” means. muhkoo vfs cd is this, persisted between commands.

await client.vfs.list("/apps/my-app"); // immediate children, dirs first
await client.vfs.stat("/apps/my-app/App.tsx");
await client.vfs.exists("/apps/my-app");
await client.vfs.walk("/apps"); // every file path beneath, depth-first
await client.vfs.walkEntries("/apps"); // files and directories, as VfsStat
await client.vfs.readText("/apps/my-app/App.tsx");
await client.vfs.readFile("/apps/my-app/logo.png"); // Uint8Array
await client.vfs.glob("/apps/**/*.{ts,tsx}");
await client.vfs.glob("**/*.ts", { from: "/apps/my-app" }); // walk only under `from`

list() with no argument lists the working directory. glob matches over a walk of opts.from (default: the working directory), so a from that narrows the walk saves fetches.

list is one record fetch. walk and glob are one fetch per directory in the subtree — cheap for a project, worth thinking about at the root of a large filesystem.

The root is always there. exists("/") is true, and stat("/") describes it as a directory — { path: "/", name: "", kind: "dir", size: 0, versions: 0 } with the root’s mtime — as soon as the filesystem can be opened. The root has no entry of its own, and earlier versions answered as if it did not exist, which is why muhkoo vfs mount at its default root of / saw an empty filesystem on every pass.

statManifest, readByManifest, writeManifest and retainManifest are the seam version control uses; you rarely need the last two.

  • writeManifest(path, manifest, size) points a path at content that already exists — nothing is uploaded — and takes a reference to it. It returns a VfsStat.
  • retainManifest(manifest) takes an extra reference to a manifest’s shards so deleting the file does not free content something else (a commit) still names.
  • readByManifest(manifest) reads bytes with no path lookup.
const { manifest, size } = await client.vfs.statManifest("/photos/beach.jpg");
const bytes = await client.vfs.readByManifest(manifest);

The current version of one file, without reading its bytes. The manifest is the file’s read capability: anyone holding it can read the bytes with readByManifest, with no access to your filesystem. Treat it like a secret link. Throws VfsConflictError for a directory and VfsNotFoundError for a missing path.

A walk is depth-first. Each directory comes just before its contents, and within a directory the order is the one list uses: directories first, then by name.

const entries = await client.vfs.walkEntries("/apps/my-app"); // VfsStat[], dirs included
await client.vfs.eachEntry("/apps/my-app", (stat, manifest) => {
// Same order. `manifest` is the file's handle, or null for a directory.
});

walk is walkEntries with the directories left out, so the two can never disagree about what is in a tree. Use walkEntries when empty directories matter; version control uses it, because a commit records them too.

eachEntry is the same walk without building the list. visit gets each entry as it is reached, and for a file its manifest — what statManifest would return, without resolving the path again. It is for a caller that compares as it goes rather than holding the whole tree, as version control’s status and commit do. Its path has no default. visit is called synchronously, and throwing from it stops the walk with that error.

walk, glob, walkEntries and eachEntry share one set of guards:

  • A cycle is skipped, not followed. A directory that is one of its own ancestors is left out, with a warning, once per walk. The directories above where the walk started count too, so a pointer back above the start is a cycle. A directory reachable from two parents — an interrupted move can leave one — is not a cycle, and appears under both.
  • A tree too large or too deep throws VfsLimitError instead of returning part of it: more than 500,000 entries, more than 32,000,000 characters of path text, or more than 64 directories below where the walk starts. A caller that syncs reads a missing path as a deleted one, so half a tree is worse than none.
  • An unreadable directory throws VfsCorruptRecordError rather than being skipped, for the same reason.

Reading the contents is not one fetch per shard. File bytes go through client.storage, where concurrent shard reads coalesce into a single batched request and parity shards are fetched only when a data shard is missing. Opening a 519-file project in the Muhkoo IDE costs 22 requests, not 2,086 — 8.8s rather than 18.4s in Chromium, and 14.9s rather than 37.5s in Brave, which filters every request and so paid most for the round trips.

That only holds if the reads overlap. A walk visits one directory at a time, but it reads ahead: one pool of up to 64 record fetches, shared by the whole walk, works on the directories it will reach next, and the walk’s own reads come out of the same pool. The walk is what discovers the files whose shards then batch together, so a narrow walk starves the batcher. Read files with Promise.all (or a bounded map) rather than in a for loop, for the same reason — a sequential loop hands the batcher one shard hash at a time.

The pool is a fixed total. Earlier versions listed up to 8 sibling directories at once per directory, which multiplies down the tree: on a tree built to fan out, every visit was in flight at once, and a few dozen tiny records could exhaust gigabytes of memory. A cold walk of a 333-directory project now takes about 0.37s at 30ms a fetch, against 0.25s before. That is the price of a walk that holds only its current path and its output.

A long walk lets the rest of the program run. Once every record is cached a walk never waits on the network, so without a break a timer, a click or a Ctrl-C would wait for it. Every 8,192 entries it yields a turn of the event loop — with setImmediate where the runtime has it, a MessageChannel message in a browser, and setTimeout(0) only when neither exists, because browsers throttle timers in background tabs. Not scheduler.yield(): its continuation runs ahead of timers and messages already queued, which defeats the point. sweep, the check copy makes before it writes, and assertCreatable yield the same way. A walk that fails stops fetching.

await client.vfs.writeFile("/apps/my-app/src/App.tsx", source); // parents created
await client.vfs.writeFile("/apps/my-app/logo.png", pngBytes); // binary is fine
await client.vfs.mkdir("/apps/my-app/assets");
await client.vfs.rename("/apps/my-app", "/archive/my-app"); // also moves
await client.vfs.copy("/apps/my-app", "/apps/my-app-2");
await client.vfs.delete("/archive", { recursive: true });

Most changes to the tree are one read-modify-write of one directory record, so they cannot leave it half-updated: either the parent names the new child or it does not. A copy writes its new records first and attaches them with one write at the end. A delete detaches with one write and only then clears out what it held. The exception is a move between two directories, which has to write both — see Moving.

writeFile takes an options object: type overrides the content type it would otherwise infer from the name, and onProgress(done, total) reports upload progress, the same callback space.putFile takes.

await client.vfs.writeFile("/videos/a.mp4", file, {
onProgress: (done, total) => console.log(`${Math.round((done / total) * 100)}%`),
});

A name is usable when it is not empty, is not . or .., contains no / or backslash, no control characters (U+0000–U+001F, U+007F–U+009F) and no bidi overrides (U+202A–U+202E, U+2066–U+2069), and is at most 255 bytes of UTF-8. A write with any other name throws VFS: "<name>" is not a usable name. Slash and backslash would become path structure, . and .. navigate rather than name, and control and bidi characters let a name print as something other than what it is. The same rule is applied again when a record is read — see Reading an untrusted filesystem.

Names that happen to be built-in object properties — constructor, toString, __proto__ — are ordinary names, in the working tree and in commits. Earlier versions looked them up on the object prototype: exists("/constructor") was true on an empty filesystem, src/constructor/ could not be created, and a file named __proto__ was reported written and stored nowhere.

No directory may be more than 64 levels below the root. writeFile, writeManifest and mkdir throw VfsLimitError (limit: "depth") for a deeper one, having written nothing. A file may still sit in a directory 64 levels deep; for writeFile and writeManifest, the error’s path is the parent directory they would have created.

The limit matches what a walk reads. A walk refuses anything more than 64 levels below where it starts, so a tree written deeper under / would make every walk of the root fail — every pass of muhkoo vfs mount at its default root included. rename and copy of a directory are held to the same limit for everything in it, and their error names the destination rather than the source, since the source is fine where it is.

await client.vfs.assertCreatable(["/apps/my-app/src", "/apps/my-app/assets/icons"]);

assertCreatable checks in advance what creating these directories with mkdir -p would refuse part way, and writes nothing. It throws VfsLimitError (limit: "depth") for a directory more than 64 levels below the root, and VfsConflictError when a missing name on the way is held by an entry this version cannot read — as well as VfsCorruptRecordError for a directory on the way it cannot read. Relative paths resolve against cwd, and duplicates are ignored.

It is for an operation that removes things before it writes. Version control’s checkout runs it before deleting anything, so a refusal leaves the working tree as it was; earlier versions could stop half way, with files gone and HEAD unchanged. A file in the way is not refused, because a checkout removes files first. What it cannot cover is a write that fails part way — on a network error, most simply — which can still leave a checkout half done.

Moving a directory is O(1) however much is inside it. Nothing in a subtree records its own path, so re-parenting edits one entry and the subtree is not touched. The exception is a move to a deeper place, which reads the subtree first to check that nothing in it would land past the depth limit. If part of the subtree cannot be read, that check does not stop the move.

A rename within one directory is a single write, so it happens entirely or not at all. A move between two directories writes both parents, attaching before it detaches: interrupted, the entry is in both rather than in neither. So both copies carry a moving: true mark on their stored entry (the exported FileEntry and DirEntry types; it is not part of VfsStat) until the move’s last write clears it. The mark is what lets a delete of either copy find the other before letting go of anything they share — see Deleting. writeFile, writeManifest and restore keep the mark on the entry they replace.

A moved file keeps its history. A history record is sealed with its directory’s key, so a move re-seals it for the new directory; earlier versions did not, and every later history, restore and writeFile of a moved file failed with a bare decryption error. A history that does not open with its directory’s key — a file moved by an earlier version, or one shared by the two halves of an interrupted move — is tried with every other directory key in the tree, which is a read of the whole tree. A file an earlier version moved is then re-sealed, so that search happens once.

rename onto a name that exists throws VfsConflictError, including a name held by an entry this version cannot read.

Copying a file moves no bytes. Content is immutable and content-addressed, so the copy is a second handle to the same shards — duplicating a 200 MB asset is one small metadata write. The copy does get a fresh identity, so the two files do not share version history.

Copying a directory checks the whole source tree before it writes any of the copy — cycles, depth, both budgets and unreadable records — so a copy that is going to fail fails first, instead of part way with unreachable records and references left behind. It then:

  • shares a walk’s budgets: 500,000 entries and 32,000,000 characters of path, past which it throws VfsLimitError (stopped copying …);
  • counts depth per branch, from the root, where the copy will land, and throws VfsLimitError naming the destination past 64 levels;
  • refuses a directory that appears in more than 4 places in the source, with VfsLimitError (limit: "shared"). In a real tree a directory is in one place, or two after an interrupted move; three records naming each other a few hundred times describe tens of thousands of directories, each a separate write;
  • refuses a true cycle with VfsConflictError (refusing to copy a directory that contains a cycle), while a subtree reachable twice without one is copied under both names;
  • leaves out entries this version cannot read, and unusable names.
await client.vfs.delete("/apps/my-app/old.txt");
await client.vfs.delete("/apps/old-app", { recursive: true });
await client.vfs.delete("/apps/my-app/dist", { recursive: true, keepContent: true });

A delete works in three steps: it reads everything it will touch, detaches the entry, and only then lets go of content. A failure before the detach — a network error, most simply — has changed nothing, so retrying is safe. Earlier versions released content first and detached last, so a retry after a failure part way released shared content a second time, and could free bytes a copy still used.

  • Not recursive. A directory that is not empty throws VfsConflictError (<path> is not empty). A directory that holds only entries this version cannot read counts as not empty.
  • Never above the target. If something inside points back at the root, or at any directory on the way down to the target, that directory is left alone with a warning. Earlier versions followed such a pointer, and an entry under /junk naming the root pulled the whole filesystem into the delete. If the target itself points at a directory above it, a recursive delete only detaches it, and a plain delete throws is not empty.
  • keepContent: true keeps the content of every file it removes — for a directory, of everything in it — and deletes only the files’ history records. A checkout needs it: the files are leaving the working tree, but commits still reference their content.
  • An unreadable directory throws VfsCorruptRecordError from a plain delete. A recursive delete detaches it without clearing it out, leaving what it held for sweep(). Inside a recursive delete, an unreadable subdirectory is skipped with a warning and the rest goes. Any other failure stops the delete before anything is released.
  • Half of an interrupted move. Deleting an entry marked moving, or a directory holding one, first reads the whole tree, once, and keeps whatever is still named elsewhere: directory records, and each file’s content and history. When exactly one marked copy is left, its mark is cleared. If part of the tree cannot be read, the entry is detached but everything it held is kept, which costs only storage.

The root cannot be deleted.

Every write keeps the previous manifest, so you get undo that survives a reload.

await client.vfs.history("/apps/my-app/App.tsx"); // newest first
await client.vfs.restore("/apps/my-app/App.tsx", 0);

Restoring pushes the current version onto history first, so it is itself undoable. Unchanged chunks dedupe in the shard store, so keeping versions costs little beyond the history record — which is capped per file (default 20).

history() and restore(path, index) read the same list. A version with no manifest, or with a manifest that has no id, is left out of both with a warning, so restore(path, 0) is always the first version history lists. Earlier, history skipped some versions that restore counted.

A file lets go of each piece of content once, and only when nothing else names it. restore brings a version back without taking a new reference, so the same content can be both the file and a version in its history. Deleting the file, or a version falling off a full history, releases it only when neither names it any more. Earlier versions released per mention, and a restore of the oldest version could release the very content being restored. A file marked as half of an interrupted move releases nothing that falls off its history, because the other half may still hold it.

The root key is derived from your master seed, which lives in memory only and is lost on reload until you sign in again. While it is missing, every VFS call throws VfsLockedError — exists("/") and stat("/") included. Earlier versions answered exists("/") with false while locked, which read as an empty filesystem rather than a locked one.

if (!client.vfs.unlocked) await client.auth.resume();

client.vfs.clearCache() drops every cached directory record and resets cwd to /. Call it after signing in as someone else — the cache has no notion of whose filesystem a record came from.

In a browser tab with Web Locks, the SDK orders writes of one directory record across all the tabs of your app on that device, and tells the others which record changed, so two tabs editing the same folder do not overwrite each other. It cannot order writes from two devices, or from a runtime with no Web Locks (Node, the CLI): there the last write of a record wins, which is what sweep exists to clean up after.

const stop = client.vfs.watch(() => {
// re-read whatever you are showing
});

Fires when your filesystem is written somewhere else — another tab, another machine, the CLI. It shares the personal space’s websocket with client.kv, so subscribing costs no extra connection.

It is a notification, not a diff: the change names a record, and a record is not a path, so the cache is dropped and you re-read what you are actually showing. In a runtime with no WebSocket it is a no-op — watching is an optimisation, never required for correctness.

const { removed } = await client.vfs.sweep();

Because each write touches a single record, two tabs editing the same directory can leave a child whose parent entry lost the race. Such a record is unreachable rather than corrupting, but it costs storage. sweep() reclaims them. Run it when the filesystem is idle — a sweep racing an in-flight write could remove a record that is about to be referenced.

A sweep asks the server only for the vfs/ keys in your personal space, so its cost does not grow with what other apps keep there.

A sweep deletes only what it can prove is unreachable, so it refuses when it cannot tell:

  • A record a path reaches that cannot be read throws VfsCorruptRecordError, before anything is deleted: with part of the tree unknown, “unreachable” is a guess. Remove it with a recursive delete, then sweep. Forcing does not get past this one.
  • An entry this version cannot read — perhaps a newer SDK’s — may be the only thing keeping some record reachable. While any exist, sweep() throws VfsConflictError (refusing to sweep: <n> entries in this filesystem cannot be read by this version, so what they reference is unknown; upgrade, or sweep with force to go ahead anyway). An unreadable record behind an unusable name counts too: no path can reach it, so no recursive delete can remove it.
  • A root that reads as empty while orphans exist looks like a failed load, and is refused (… sweep with force if it really is empty).

sweep({ force: true }) goes past the last two. It keeps a record it cannot read and carries on, and may delete records that only the unreadable entries referenced. Entries hidden only for their name are still followed, so what they point at is never swept.

Only the root key comes from your master seed. Every other directory has a random key stored in its parent’s entry, which chains capabilities downward: holding {id, key} for a subtree lets you walk that subtree and nothing above it, because no key can be derived upward.

That is the seam sharing hangs off — a subtree can later be handed to a Space without re-encrypting the rest of the filesystem.

The arrangement above has a consequence. The seed opens the root, and every other key sits in its parent, so whoever holds the seed can open, re-seal and overwrite every record in the tree — directory records, history records and version control’s objects. Any app a user signs into holds their master seed. A record may therefore have been written by another app, or crafted by hand rather than through this SDK, and the SDK’s write-side checks never saw it. None of this crosses accounts: another user cannot write your filesystem.

So the SDK treats everything it reads from the filesystem as untrusted input, the way a parser treats bytes from the network:

  • A record it cannot read throws. A directory record that does not decrypt, or is not a shape this version reads, throws VfsCorruptRecordError from every operation whose path runs through it: list, stat, exists, cd, walks, reads and writes. It is never read as empty — the next write would seal an empty record over the real one, and the record may simply be a newer SDK’s. A record that does not exist at all is still an empty directory; that is how the root and every new directory start.
  • An entry it cannot read is hidden, not dropped. An unknown kind (perhaps a newer SDK’s), a file with no manifest or no manifest id, a directory whose key does not decode to 32 bytes, an id this SDK could not have minted, or an unusable name: the entry disappears from list, stat, exists (even by its exact name), walks, glob and copy, with one warning per record read. It is written back untouched whenever its directory changes, so an older SDK no longer erases what a newer one stored.
  • A tree built to be expensive throws. Records can name one directory many times without a cycle, so a handful of tiny records can describe more paths than any machine can hold. Walks and copies have budgets and a depth ceiling, and throw VfsLimitError past them rather than return part of the tree.
  • What reaches your code is well-formed. mtime, size and versions — in listings, walks, stat("/") and history() — are always usable numbers: a time outside a Date’s range, or a size or count that is not a whole number of zero or more, reads as 0. Earlier, a planted mtime of "yesterday" crashed muhkoo vfs ls. A value nested more than 32 levels deep is dropped when its record is read, because it could never be written back and would make every write to that directory throw. Record ids and format versions in messages are escaped (control and bidi characters become \uXXXX), so a planted value cannot send control sequences to a terminal that prints the message.

Records the SDK sets aside produce console.warn lines prefixed [muhkoo/vfs] ([muhkoo/vcs] from version control), which the CLI shows. A name unfit to print is counted, never printed.

What each operation does with a name held by a hidden entry:

OperationResult
list, walks, glob, copyLeft out.
stat, exists, cdAs if absent: VfsNotFoundError, or false.
writeFile, writeManifest to that exact nameReplaces it.
rename, copy, mkdir onto it — including the parents writeFile createsVfsConflictError: <path> already exists, but this version cannot read it; a recursive delete removes it.
delete(path)VfsConflictError: <path> cannot be read by this version; a recursive delete removes it. Earlier versions threw VfsNotFoundError.
delete(path, { recursive: true })Detaches it without reading or releasing what it holds, with a warning.
sweep()Refuses unless forced — see Maintenance.

An entry id must be 1–64 characters of A–Z a–z 0–9 _ -; the SDK mints up to 22 letters and digits, and the root’s id is root. An id becomes a store key, and the key goes into a URL: an id holding a lone surrogate could not be encoded at all, and one past the server’s 2,048-byte key limit failed every walk above it and every attempt to delete it. Any other id is hidden like any entry this version cannot read: never fetched, and removed by a recursive delete of its directory.

Every refusal above has the same way out: a recursive delete of the entry — delete(path, { recursive: true }) in the SDK, muhkoo vfs rm -r <path> in the CLI. An error’s path or at says where to point it. The messages say “remove it with a recursive delete” rather than naming the call, because the CLI prints them as they are.

The one exception is the root. An unreadable root record cannot be deleted, and there is no in-product recovery for it; its error says so and changes nothing.

These checks are a mitigation. They stop records from crashing, hanging or quietly misleading the SDK; they do not stop a seed holder from writing them.

  • Any app you sign into can still read and rewrite your filesystem. The root cause is that apps receive the master seed. The designed fix — apps propose changes and only your own client writes them, with per-app keys closing the read side — is not built yet.
  • Refusals are denial of service by design. A planted tree can make every walk above it throw, or make sweep refuse, until it is removed with a recursive delete. A partial answer would read as deletions, so the SDK refuses instead. sweep({ force: true }) past entries it cannot read may delete records only they referenced.
  • Memory. The namespace caches every directory record it reads until the record changes or clearCache() is called, and a record can be up to about 2MB. A few hundred records of about 1MB each can run a walk out of a 512MB heap, and half a million empty directories, inside the entry budget, did too. The whole-tree read behind sweep, a delete of a marked entry, and the history search after a move has no entry budget. That is a denial of service by someone who can already delete everything, and is accepted as one. A bounded cache was tried and taken out: it fetched the whole tree again on every walk, and could lose one of two saves made at once.
  • Rollback. A sealed record is not bound to where it is stored. A party that can write the store but cannot decrypt it cannot forge a record, but it can put back an older one, rolling that directory back.
LimitDefaultPast it
Entries one walk or copy may visit500,000VfsLimitError, limit: "entries"
Characters of path text one walk or copy may produce, summed32,000,000VfsLimitError, limit: "pathChars"
Depth a walk reads, below where it starts64 directoriesVfsLimitError, limit: "depth"
Depth any directory may be written, below the root64 directoriesVfsLimitError, limit: "depth"; nothing written
Places one directory may appear in a tree being copied4VfsLimitError, limit: "shared"
Name length255 bytes of UTF-8Write refused; on read, the entry is hidden
Entry id1–64 of A–Z a–z 0–9 _ -Entry hidden
Nesting inside a record32 levelsValue dropped, with a warning
Versions kept per file20Oldest dropped, and released unless still named
Record fetches in flight per walk64—
Entries between yields8,192—

Path text has a budget of its own because entries alone do not bound memory: a name may be 255 bytes, so one path can run to about 16KB.

Three can be changed when you construct VfsNamespace yourself: entries and path characters with limits?: { entries?: number; pathChars?: number } on VfsNamespaceDeps, and versions per file with historyLimit. Client passes none of them, so client.vfs always runs with the defaults.

Every VFS error extends Error and is exported from @muhkoo/connect. See also Errors.

ErrorThrown when
VfsLockedErrorThe master seed is not in memory — sign in again.
VfsNotFoundErrorA path does not exist, including a name held only by an entry this version cannot read.
VfsConflictErrorAn operation would clobber or misuse an entry: a destination that exists, a directory that is not empty, a history no key opens, a sweep that would have to guess.
VfsCorruptRecordErrorA directory record cannot be read.
VfsLimitErrorA walk or copy would pass a budget or the depth ceiling, or a write would put a directory too deep.
import { VfsCorruptRecordError } from "@muhkoo/connect";
try {
await client.vfs.walk("/apps/my-app");
} catch (err) {
if (err instanceof VfsCorruptRecordError && err.path && err.id !== "root") {
// The record has been left exactly as stored. Offer to remove it.
if (await askUser(`${err.path} cannot be read. Remove it?`)) {
await client.vfs.delete(err.path, { recursive: true });
}
}
}
  • id — the record’s id, kept raw; the record is stored at vfs/d/<id>. Escape it before printing it to a terminal. The message already does.
  • path — the path it was reached through, when known. A moved subtree can have two, and the error names whichever was asked for first.

The message reads VFS: <path, or "a directory"> (record <id>) cannot be read: <reason>. It has been left as stored; remove it with a recursive delete. The id is cut to 40 characters, and control and bidi characters in the path and id are written as \uXXXX. For the root the last sentence is instead It is the root of this filesystem, so it cannot be deleted; nothing has been changed.

The reason is one of:

  • it is not a sealed record
  • its envelope is not base64
  • its envelope is the wrong size
  • it does not decrypt with the key it should
  • it decrypts, but not to a record
  • it is not an object
  • it has format version <v>, and this version reads only 1
  • its entries are missing or not a map
import { VfsLimitError } from "@muhkoo/connect";
try {
await client.vfs.walk("/");
} catch (err) {
if (err instanceof VfsLimitError) {
console.log(err.limit, err.max, err.at); // e.g. "entries", 500000, "/junk/a/b"
}
}
  • limit — which ceiling was hit: "entries", "pathChars", "depth" or "shared".
  • max — the ceiling itself.
  • path — where the operation started, or for a write, the destination.
  • at — where the ceiling was reached: the directory being listed for entries and pathChars, the entry one level too deep for depth, and the source path of the over-shared directory for shared. You get it because walking the tree again to find the culprit would stop at the same place.

It is thrown rather than returning a partial result, because callers such as a sync read a missing path as a deleted one. The message reads VFS: stopped <walking|copying|writing> <path>: <what> (reached at <at>)., where <what> is it holds more than N entries, its paths add up to more than N characters, it is nested more than N directories deep or one directory appears in it more than N times, and at is cut to 200 characters. Walking and copying add If you do not recognise what is there, remove it with a recursive delete. Writing adds nothing: the destination is not there to remove.

These go to console.warn, prefixed [muhkoo/vfs]. They mark a real difference between what is stored and what is shown, so they are never silent.

WarningMeans
skipping <path>: it is one of its own ancestors (the tree contains a cycle)A walk left out a cycle. Once per walk.
ignoring … in <path>Entries hidden, or values dropped, when a record was read — <n> entries with an unusable name, <n> entries this version cannot read, <n> values nested too deeply to keep, whichever apply, joined with “and”. Once per record read.
ignoring <n> versions of <path> this version cannot readHistory versions left out.
leaving a directory that is above <target> in the tree alone while deleting itA recursive delete found a pointer upward, and did not follow it.
detaching <target> without clearing it out: it is a directory above it in the treeThe target itself points upward, so it was only detached.
detaching <path> without clearing it out: its record cannot be readA recursive delete of an unreadable directory. What it held is left for sweep().
skipping an unreadable directory while deleting its parentPart of a recursive delete could not be read, and was left for sweep().
removed <path> without clearing out what it held: this version cannot read itA recursive delete of a hidden entry.
detached <path> but kept what it held: a move of it was interrupted, …The other copy could not be looked for, so nothing was released.

No export was removed; VfsCorruptRecordError and VfsLimitError were added. Expect those two from walk, glob, walkEntries, eachEntry, list, stat, exists, copy, rename and the write methods, where you used to get a partial result or a generic error. What changes for existing code:

  • Walks throw instead of returning part of a tree. walk and glob used to stop quietly at 64 levels, with a warning, and return the shallower part. They now throw VfsLimitError, as they do past either budget.
  • Unreadable records throw VfsCorruptRecordError, where you used to get a TypeError, a SyntaxError or a bare decryption failure. A stored value that was not a sealed record used to read as an empty directory.
  • The root exists. exists("/") is true and stat("/") returns the root. While locked, both throw VfsLockedError; code that relied on exists("/") answering false when locked must handle it.
  • Entries this version cannot read are hidden, and a plain delete of one throws VfsConflictError naming the way out, not VfsNotFoundError.
  • Writes deeper than 64 levels throw VfsLimitError, and write nothing.
  • sweep() refuses while any entry cannot be read. Its empty-root refusal now ends sweep with force if it really is empty instead of pass { force: true } if it really is empty.
  • copy of more than 64 directories works. It used to fail with a false “contains a cycle” error whose message began VFS: VFS:.
  • Moved files keep a working history, a delete that fails part way can be retried, and a recursive delete with keepContent keeps the content of everything in the directory.