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.
Where you are
Section titled “Where you are”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 cwdawait client.vfs.readText("/other.txt"); // absolute, unaffectedawait client.vfs.cd(".."); // climbs; stops at the root
client.vfs.resolve("src/App.tsx"); // "/apps/scratch/src/App.tsx" — pure, no fetchcd 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.
Reading
Section titled “Reading”await client.vfs.list("/apps/my-app"); // immediate children, dirs firstawait 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-firstawait client.vfs.walkEntries("/apps"); // files and directories, as VfsStatawait client.vfs.readText("/apps/my-app/App.tsx");await client.vfs.readFile("/apps/my-app/logo.png"); // Uint8Arrayawait 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.
Working with manifests directly
Section titled “Working with manifests directly”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 aVfsStat.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.
statManifest(path)
Section titled “statManifest(path)”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.
Walking a tree
Section titled “Walking a tree”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
VfsLimitErrorinstead 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
VfsCorruptRecordErrorrather than being skipped, for the same reason.
How a walk is fetched
Section titled “How a walk is fetched”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.
Writing
Section titled “Writing”await client.vfs.writeFile("/apps/my-app/src/App.tsx", source); // parents createdawait client.vfs.writeFile("/apps/my-app/logo.png", pngBytes); // binary is fineawait client.vfs.mkdir("/apps/my-app/assets");await client.vfs.rename("/apps/my-app", "/archive/my-app"); // also movesawait 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.
Checking before you write
Section titled “Checking before you write”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
Section titled “Moving”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
Section titled “Copying”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
VfsLimitErrornaming 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.
Deleting
Section titled “Deleting”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
/junknaming 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 throwsis not empty. keepContent: truekeeps 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
VfsCorruptRecordErrorfrom a plain delete. A recursive delete detaches it without clearing it out, leaving what it held forsweep(). 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.
Versions
Section titled “Versions”Every write keeps the previous manifest, so you get undo that survives a reload.
await client.vfs.history("/apps/my-app/App.tsx"); // newest firstawait 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.
Locking
Section titled “Locking”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.
Several tabs
Section titled “Several tabs”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.
Live changes
Section titled “Live changes”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.
Maintenance
Section titled “Maintenance”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()throwsVfsConflictError(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.
How keys are arranged
Section titled “How keys are arranged”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.
Reading an untrusted filesystem
Section titled “Reading an untrusted 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
VfsCorruptRecordErrorfrom 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,globandcopy, 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
VfsLimitErrorpast them rather than return part of the tree. - What reaches your code is well-formed.
mtime,sizeandversions— in listings, walks,stat("/")andhistory()— are always usable numbers: a time outside aDate’s range, or a size or count that is not a whole number of zero or more, reads as 0. Earlier, a plantedmtimeof"yesterday"crashedmuhkoo 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.
Entries this version cannot read
Section titled “Entries this version cannot read”What each operation does with a name held by a hidden entry:
| Operation | Result |
|---|---|
list, walks, glob, copy | Left out. |
stat, exists, cd | As if absent: VfsNotFoundError, or false. |
writeFile, writeManifest to that exact name | Replaces it. |
rename, copy, mkdir onto it — including the parents writeFile creates | VfsConflictError: <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.
Removing what you do not recognise
Section titled “Removing what you do not recognise”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.
What this does not cover
Section titled “What this does not cover”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
sweeprefuse, 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 behindsweep, 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.
Limits
Section titled “Limits”| Limit | Default | Past it |
|---|---|---|
| Entries one walk or copy may visit | 500,000 | VfsLimitError, limit: "entries" |
| Characters of path text one walk or copy may produce, summed | 32,000,000 | VfsLimitError, limit: "pathChars" |
| Depth a walk reads, below where it starts | 64 directories | VfsLimitError, limit: "depth" |
| Depth any directory may be written, below the root | 64 directories | VfsLimitError, limit: "depth"; nothing written |
| Places one directory may appear in a tree being copied | 4 | VfsLimitError, limit: "shared" |
| Name length | 255 bytes of UTF-8 | Write refused; on read, the entry is hidden |
| Entry id | 1–64 of A–Z a–z 0–9 _ - | Entry hidden |
| Nesting inside a record | 32 levels | Value dropped, with a warning |
| Versions kept per file | 20 | Oldest dropped, and released unless still named |
| Record fetches in flight per walk | 64 | — |
| Entries between yields | 8,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.
Errors
Section titled “Errors”Every VFS error extends Error and is exported from @muhkoo/connect. See also
Errors.
| Error | Thrown when |
|---|---|
VfsLockedError | The master seed is not in memory — sign in again. |
VfsNotFoundError | A path does not exist, including a name held only by an entry this version cannot read. |
VfsConflictError | An 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. |
VfsCorruptRecordError | A directory record cannot be read. |
VfsLimitError | A walk or copy would pass a budget or the depth ceiling, or a write would put a directory too deep. |
VfsCorruptRecordError
Section titled “VfsCorruptRecordError”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 atvfs/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 recordits envelope is not base64its envelope is the wrong sizeit does not decrypt with the key it shouldit decrypts, but not to a recordit is not an objectit has format version <v>, and this version reads only 1its entries are missing or not a map
VfsLimitError
Section titled “VfsLimitError”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 forentriesandpathChars, the entry one level too deep fordepth, and the source path of the over-shared directory forshared. 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.
Warnings
Section titled “Warnings”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.
| Warning | Means |
|---|---|
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 read | History versions left out. |
leaving a directory that is above <target> in the tree alone while deleting it | A 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 tree | The target itself points upward, so it was only detached. |
detaching <path> without clearing it out: its record cannot be read | A recursive delete of an unreadable directory. What it held is left for sweep(). |
skipping an unreadable directory while deleting its parent | Part 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 it | A 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. |
Upgrading from 0.14.0-alpha.8
Section titled “Upgrading from 0.14.0-alpha.8”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.
walkandglobused to stop quietly at 64 levels, with a warning, and return the shallower part. They now throwVfsLimitError, as they do past either budget. - Unreadable records throw
VfsCorruptRecordError, where you used to get aTypeError, aSyntaxErroror a bare decryption failure. A stored value that was not a sealed record used to read as an empty directory. - The root exists.
exists("/")istrueandstat("/")returns the root. While locked, both throwVfsLockedError; code that relied onexists("/")answeringfalsewhen locked must handle it. - Entries this version cannot read are hidden, and a plain
deleteof one throwsVfsConflictErrornaming the way out, notVfsNotFoundError. - Writes deeper than 64 levels throw
VfsLimitError, and write nothing. sweep()refuses while any entry cannot be read. Its empty-root refusal now endssweep with force if it really is emptyinstead ofpass { force: true } if it really is empty.copyof more than 64 directories works. It used to fail with a false “contains a cycle” error whose message beganVFS: VFS:.- Moved files keep a working history, a delete that fails part way can be
retried, and a recursive
deletewithkeepContentkeeps the content of everything in the directory.