fs-transaction
Multi-file filesystem transactions that survive a crash. Stage a set of writes, renames, removals and copies; apply them all-or-nothing; and if the power goes out halfway through, finish the job on the next run.
The files stay ordinary files. This is not a virtual filesystem and not a database — nothing here changes how your tree is read, only how it is written.
[]
= "0.1"
use ;
use Path;
Why
Single-file atomic writes are a solved problem — write a temporary sibling,
fsync it, rename it over the target. What is not solved is a change that
touches several files at once. Rename a note in a linked set and every file
pointing at it has to be rewritten too; issue those one at a time and an I/O
error partway through leaves the tree torn, updated in the files already
written and stale in the ones never reached.
A ChangeSet closes that window from both sides:
- On an error — a full disk, a permission fault — every op already applied is unwound in reverse, and the tree ends up exactly as it was.
- On a crash —
kill -9, a power cut — there is no error to catch and no chance to unwind, so a write-ahead journal written before the first file is touched survives instead. The nextrecoverreplays it forward to the fully-applied state.
Both endpoints are consistent. They are simply different consistent states, and the crate does not pretend a lost-power change never happened when its intent was already durably on disk.
What it does not do
Stated plainly, because a crate in this position should be:
- Single writer. There is no locking. Two processes applying sets against one root will race, and the stale-journal guard is a check-then-act, not a mutex. Serialize them yourself.
- A set is bounded by memory. Staged bytes and the undo buffer are both
held for the length of the apply.
FileOp::CopyFromis the escape hatch for a large payload already on disk — it journals a reference, so restoring a captured tree costs O(files) of journal rather than a second copy of every byte. The source must be immutable for that to be sound; a content-addressed blob is, by construction. - Futures are not required to be
Send. The storage port uses nativeasync fn, so a backend keeps its own future types — which also means an apply over a non-Sendbackend cannot betokio::spawned. - Symlinks are not resolved. The path guard that keeps a staged op inside the root is purely lexical.
Any backend
Everything is generic over a small async port. StdFs and an InMemoryFs ship
with the crate; an adapter for OPFS, IndexedDB, or a network store is a few
dozen mechanical lines, since the method set mirrors std::fs exactly.
Durability is declared, never assumed. A backend says what it can keep
through Capabilities, and the apply path picks the strongest protocol that
backend actually supports — rather than assuming atomic rename and silently
lying on the backends that do not have one. Every durability member defaults to
the pessimistic answer, so an adapter that forgets to override one degrades to
the defensive path instead of to a false promise.
Journal naming
The journal is one transient dotfile at the root, present only between a change
set's commit point and its completion. It defaults to .fstx-journal;
Journal::named takes your own.
Apply and recovery must agree about that name — if they disagree, nothing fails
loudly, recovery simply looks where no journal is and leaves the change
stranded. That is why both operations hang off Journal rather than taking the
name separately.
Zero dependencies
A crate whose whole job is getting bytes onto disk correctly should not make you audit anyone else's code to trust it, and should not drag a runtime into a build that already has one. The checksum is hand-rolled FNV-1a; the error type is a hand-written enum.
License
MIT or Apache-2.0, at your option.