Vix.cpp docs moved to vixcpp.com
Skip to content

Filesystem ​

fs is the filesystem observation module of Softadastra Engine.

It provides a portable and deterministic way to observe local filesystem state.

The core rule is:

txt
FS observes changes.
FS does not sync files by itself.

The filesystem module is useful for file synchronization, backup tools, local indexing, watchers, and any feature that needs to understand what changed on disk.

Why fs exists ​

Softadastra is local-first.

Some local-first systems need to observe files:

txt
documents
folders
local app data
user files
cached assets
metadata files
sync folders

Operating systems expose filesystem events differently.

Linux uses one API. macOS uses another. Windows uses another. Polling may be needed as a fallback.

The fs module gives Softadastra one consistent model:

txt
Path
Snapshot
Diff
Event
Watcher

Instead of letting every higher-level module handle filesystem differences, fs centralizes filesystem observation.

What fs provides ​

The fs module provides:

txt
Path
Scanner
Snapshot
SnapshotBuilder
SnapshotDiff
Watcher
FileEvent
EventBatch
FileState
FileMetadata
FileType

These primitives let the engine:

  • normalize paths
  • scan directories
  • build snapshots
  • compare snapshots
  • detect created files
  • detect updated files
  • detect deleted files
  • watch folders
  • emit deterministic events

What fs does not do ​

fs must not:

  • sync files over the network
  • decide conflict resolution
  • write application data
  • own WAL persistence
  • own sync policy
  • connect peers
  • discover peers
  • own CLI command behavior

The rule is:

txt
fs observes.
sync decides.
transport sends.
store persists state.

Include ​

Use the top-level include:

cpp
#include <softadastra/fs/Fs.hpp>

Module location ​

The module lives in:

txt
modules/fs/

Typical structure:

txt
modules/fs/
├── include/
│   └── softadastra/fs/
│       ├── events/
│       ├── path/
│       ├── scanner/
│       ├── snapshot/
│       ├── state/
│       ├── types/
│       ├── utils/
│       ├── watcher/
│       └── Fs.hpp
├── platform/
│   ├── linux/
│   ├── mac/
│   └── windows/
├── src/
├── README.md
├── CMakeLists.txt
└── CHANGELOG.md

The exact structure can evolve, but the responsibility should stay stable: observe the filesystem and produce structured state or events.

Main concepts ​

The filesystem module is built around five concepts:

txt
Path
FileState
Snapshot
Diff
Watcher

The flow is:

txt
Path
  ↓
Scanner
  ↓
Snapshot
  ↓
SnapshotDiff
  ↓
FileEvent / EventBatch

For real-time behavior:

txt
Watcher
  ↓
scan current state
  ↓
compare with previous state
  ↓
emit events

Path ​

Path is the strong path abstraction.

It avoids passing raw strings everywhere.

Example:

cpp
auto root_result = softadastra::fs::path::Path::from("./data");

A path should be: validated, normalized, comparable, safe to pass across fs APIs.

Path example ​

cpp
#include <iostream>

#include <softadastra/fs/Fs.hpp>

using namespace softadastra::fs;

int main()
{
    auto result = path::Path::from("./data");

    if (result.is_err())
    {
        std::cerr << "Invalid path: "
                  << result.error().message()
                  << "\n";

        return 1;
    }

    auto root = result.value();

    std::cout << "Path: "
              << root.str()
              << "\n";

    return 0;
}

Path rules ​

Use Path instead of raw strings inside the filesystem module.

Good:

cpp
auto root = path::Path::from("./data");

Avoid passing raw strings deep into fs logic.

Bad (unless the API intentionally accepts strings for convenience):

cpp
Scanner::scan("./data");

FileMetadata ​

FileMetadata describes a file or directory.

It can include: type, size, modified timestamp.

Example conceptual shape:

txt
FileMetadata {
    type
    size
    modified
}

The metadata helps detect changes between snapshots.

FileType ​

FileType identifies what kind of filesystem entry exists.

Common types:

txt
File
Directory
Symlink
Other

The exact enum values depend on the current implementation.

Use the module helpers to convert types to strings when printing:

cpp
types::to_string(file.metadata.type)

FileState ​

FileState represents the state of one filesystem entry.

It usually includes: path, metadata, optional content hash or extra state.

Conceptual shape:

txt
FileState {
    path
    metadata
    hash
}

A snapshot is a collection of file states.

Snapshot ​

A Snapshot represents the filesystem at one moment.

It answers:

  • which files exist?
  • what paths exist?
  • what type is each entry?
  • what size is each file?
  • when was it modified?

A snapshot is useful because it gives a stable view that can be compared later.

Build a snapshot ​

cpp
#include <iostream>

#include <softadastra/fs/Fs.hpp>

using namespace softadastra::fs;

int main()
{
    auto root_result = path::Path::from("./data");

    if (root_result.is_err())
    {
        std::cerr << "Invalid path\n";
        return 1;
    }

    auto result =
        snapshot::SnapshotBuilder::build(root_result.value());

    if (result.is_err())
    {
        std::cerr << "Error: "
                  << result.error().message()
                  << "\n";

        return 1;
    }

    const auto &snap = result.value();

    for (const auto &[_, state] : snap.all())
    {
        std::cout
            << types::to_string(state.metadata.type)
            << " | "
            << state.path.str()
            << " | "
            << state.metadata.size
            << " bytes\n";
    }

    return 0;
}

Scanner ​

Scanner reads a directory and produces a snapshot.

Use it when you want a direct scan API:

cpp
auto result = scanner::Scanner::scan(root);

The scanner should return explicit errors when scanning fails.

Possible failures:

  • invalid path
  • path does not exist
  • permission denied
  • filesystem error
  • unsupported file type

Scan example ​

cpp
#include <iostream>

#include <softadastra/fs/Fs.hpp>

using namespace softadastra::fs;

int main()
{
    auto root_result = path::Path::from("./data");

    if (root_result.is_err())
    {
        std::cerr << "Invalid path\n";
        return 1;
    }

    auto root = root_result.value();

    auto result = scanner::Scanner::scan(root);

    if (result.is_err())
    {
        std::cerr << "Error: "
                  << result.error().message()
                  << "\n";

        return 1;
    }

    const auto &snapshot = result.value();

    for (const auto &[_, file] : snapshot.all())
    {
        std::cout
            << types::to_string(file.metadata.type)
            << " | "
            << file.path.str()
            << " | "
            << file.metadata.size
            << " bytes\n";
    }

    return 0;
}

SnapshotDiff ​

SnapshotDiff compares two filesystem states.

It answers:

  • what was created?
  • what was updated?
  • what was deleted?

The flow is:

txt
before snapshot
  ↓
after snapshot
  ↓
SnapshotDiff::compute
  ↓
events

Diff example ​

cpp
#include <iostream>
#include <thread>

#include <softadastra/fs/Fs.hpp>

using namespace softadastra::fs;

int main()
{
    auto root_result = path::Path::from("./data");

    if (root_result.is_err())
    {
        std::cerr << "Invalid path\n";
        return 1;
    }

    auto root = root_result.value();

    auto before_result = snapshot::SnapshotBuilder::build(root);

    if (before_result.is_err())
    {
        std::cerr << "Error building snapshot before\n";
        return 1;
    }

    auto before = std::move(before_result.value());

    std::cout << "Modify files now...\n";
    std::this_thread::sleep_for(std::chrono::seconds(5));

    auto after_result = snapshot::SnapshotBuilder::build(root);

    if (after_result.is_err())
    {
        std::cerr << "Error building snapshot after\n";
        return 1;
    }

    auto after = std::move(after_result.value());

    auto events =
        snapshot::SnapshotDiff::compute(
            before.all(),
            after.all());

    for (const auto &event : events)
    {
        std::cout << "Change: "
                  << types::to_string(event.type)
                  << " "
                  << event.current.path.str()
                  << "\n";
    }

    return 0;
}

FileEvent ​

A FileEvent represents one filesystem change.

It can describe: Created, Updated, Deleted.

Conceptual shape:

txt
FileEvent {
    type
    current
    previous
}

For created files:

txt
type = Created
current = new state
previous = empty

For updated files:

txt
type = Updated
current = new state
previous = old state

For deleted files:

txt
type = Deleted
current = deleted state or previous state
previous = old state

The exact representation depends on the current implementation.

EventBatch ​

EventBatch groups many filesystem events.

It is useful because filesystem changes often happen together.

Example:

txt
save file
  ↓
temporary file created
original file updated
temporary file deleted

A watcher can emit these changes as one batch.

Watcher ​

Watcher monitors a path and emits events.

The current model can be implemented by:

txt
build initial snapshot
  ↓
wait
  ↓
build next snapshot
  ↓
compute diff
  ↓
emit batch

Platform-specific backends can improve efficiency, but the public model should remain deterministic.

Watch example ​

cpp
#include <chrono>
#include <iostream>
#include <thread>

#include <softadastra/fs/Fs.hpp>

using namespace softadastra::fs;

int main()
{
    auto root_result = path::Path::from("./watched");

    if (root_result.is_err())
    {
        std::cerr << "Invalid path\n";
        return 1;
    }

    auto root = root_result.value();

    watcher::Watcher watcher;

    auto result =
        watcher.start(
            root,
            [](const events::EventBatch &batch)
            {
                for (const auto &event : batch.all())
                {
                    std::cout << "Event: "
                              << types::to_string(event.type)
                              << " "
                              << event.current.path.str()
                              << "\n";
                }
            });

    if (result.is_err())
    {
        std::cerr << "Error: "
                  << result.error().message()
                  << "\n";

        return 1;
    }

    std::cout << "Watching... press Ctrl+C to exit\n";

    while (true)
    {
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }

    return 0;
}

Platform backends ​

The filesystem module can support different platform backends.

Conceptual backend mapping:

txt
Linux   -> inotify
macOS   -> FSEvents
Windows -> ReadDirectoryChangesW
Fallback -> polling

The public API should remain stable even if the backend changes.

The goal is:

txt
same public model
different platform implementation

Polling fallback ​

Polling is the simplest portable model.

It works like this:

txt
scan directory
  ↓
wait interval
  ↓
scan again
  ↓
compute diff
  ↓
emit events

Polling is less efficient than native event APIs, but it is easier to reason about and can be used as a fallback.

Determinism rule ​

The filesystem module should aim for deterministic output.

Same input state should produce the same snapshot. Same before and after snapshots should produce the same diff.

txt
same before snapshot
same after snapshot
  ↓
same events

This is important for sync and tests.

Normalized path rule ​

Paths should be normalized.

Example:

txt
a/./b/../c
  ↓
a/c

This avoids treating equivalent paths as different files.

Path normalization should happen at the boundary.

Filesystem event ordering ​

When events are produced from a diff, the ordering should be stable when possible.

Recommended stable ordering:

txt
sort by path
then by event type if needed

Stable ordering makes tests easier and sync behavior easier to debug.

Filesystem and WAL ​

The fs module can produce file events. The wal module can record events.

Relationship:

txt
fs
  -> observes file change

wal
  -> records the event when required

fs should not write to the WAL by itself unless a higher-level component explicitly wires that behavior.

A good separation is:

txt
Watcher emits event
  ↓
Higher-level sync service receives event
  ↓
WAL records event
  ↓
Sync tracks operation

Filesystem and store ​

The store contains current application state. The filesystem module observes disk state.

Relationship:

txt
fs snapshot
  ↓
higher-level mapping
  ↓
store operation

For example, a file sync product may map:

txt
file created
  ↓
store put file metadata

But that mapping should not live inside the generic fs module.

Filesystem and sync ​

fs does not sync files by itself.

Instead:

txt
fs detects file change
  ↓
sync layer or higher-level service decides operation
  ↓
sync tracks operation
  ↓
transport sends later

This prevents the filesystem module from becoming too coupled.

Filesystem and transport ​

fs should not depend on transport.

Wrong:

txt
fs watcher directly sends TCP message

Better:

txt
fs watcher emits FileEvent
higher-level service converts event to operation
sync tracks operation
transport sends operation

This keeps the architecture clean.

Filesystem and discovery ​

fs should not know about discovery. Discovery finds peers. FS observes files. They are separate concerns.

Filesystem and metadata ​

Metadata can describe a node. FS can observe files. They should remain separate.

A higher-level service can combine them:

txt
node metadata
  +
filesystem snapshot
  ↓
diagnostic report

But the generic fs module should not own metadata behavior.

Filesystem and CLI ​

The CLI can expose filesystem commands or examples later.

But fs should not depend on CLI.

Correct direction:

txt
cli command
  ↓
fs module

Wrong direction:

txt
fs module
  ↓
cli output

The filesystem module should return structured data, not terminal UI.

Common use cases ​

Use fs for:

  • file sync engines
  • backup tools
  • local indexing
  • folder watchers
  • document tracking
  • cache invalidation
  • local-first file apps
  • development tools

Do not use fs when you only need key-value store operations. For key-value state, use store.

Local file sync flow ​

A file sync flow can look like this:

txt
watch folder
  ↓
file created
  ↓
FileEvent emitted
  ↓
higher-level service creates operation
  ↓
WAL records operation
  ↓
store updates metadata
  ↓
sync tracks operation
  ↓
transport sends later

The fs module is only responsible for the first part:

txt
watch folder
  ↓
file event emitted

Scan flow ​

txt
Path::from("./data")
  ↓
Scanner::scan(path)
  ↓
Snapshot
  ↓
iterate snapshot entries

Use this when you need the full state.

Diff flow ​

txt
Snapshot before
  ↓
Snapshot after
  ↓
SnapshotDiff::compute
  ↓
FileEvents

Use this when you need to detect changes between two states.

Watch flow ​

txt
Watcher::start(path, callback)
  ↓
initial snapshot
  ↓
observe changes
  ↓
event batch callback

Use this when you need ongoing monitoring.

Error handling ​

Filesystem operations should return explicit errors.

Example:

cpp
auto result = scanner::Scanner::scan(root);

if (result.is_err())
{
    std::cerr << result.error().message() << "\n";
    return 1;
}

Possible errors:

  • invalid path
  • path does not exist
  • permission denied
  • scan failed
  • watcher failed
  • unsupported platform backend
  • filesystem API error

Do not hide filesystem failures.

Invalid path ​

An invalid path should return an error:

cpp
auto result = path::Path::from("");

if (result.is_err())
{
    std::cout << result.error().message() << "\n";
}

Empty paths should not be accepted silently.

Path does not exist ​

Scanning a missing path should return an explicit error:

txt
path does not exist: ./data

This is more useful than:

txt
scan failed

Permission denied ​

Some files or directories may not be readable. The scanner should either return an error or record partial scan behavior explicitly if supported.

Do not silently ignore permission errors unless the API explicitly documents that behavior.

Watcher failure ​

A watcher can fail when: path is invalid, path does not exist, permission denied, native backend cannot start, too many watched files, platform API fails.

The watcher should report a clear error.

Deleted files ​

If a file is deleted, the current filesystem state no longer contains it. The event should still tell the caller which path was deleted.

The event model should preserve enough information to identify the deleted entry.

Updated files ​

Updated files can be detected through metadata.

Common signals: modified timestamp changed, size changed, hash changed (if supported).

If only timestamp and size are used, some changes may be missed. Hash support can improve accuracy but costs more.

Created files ​

Created files appear in the after snapshot but not the before snapshot.

Diff rule:

txt
missing before
present after
  ↓
Created

Deleted files (diff rule) ​

Present before, missing after.

txt
present before
missing after
  ↓
Deleted

Updated files (diff rule) ​

Present in both snapshots but with changed metadata.

txt
present before
present after
metadata differs
  ↓
Updated

Ignore rules ​

A future filesystem module may support ignore files.

Example:

txt
.softadastraignore

Possible ignore patterns:

txt
build/
node_modules/
*.tmp
*.log
.git/

Ignore support should be implemented carefully because it affects sync behavior.

Hash enrichment ​

A future filesystem module may enrich metadata with hashes.

This helps detect content changes more accurately.

Possible flow:

txt
scan file
  ↓
read metadata
  ↓
optionally hash content
  ↓
FileState includes hash

Hashing can be expensive, so it should be configurable.

Backpressure ​

Watchers can produce many events quickly.

A future watcher pipeline may need backpressure.

Possible strategies:

  • batch events
  • debounce rapid changes
  • limit queue size
  • drop duplicate updates safely
  • rescan when overflow happens

The public API should make overflow behavior visible.

Cross-platform behavior ​

Cross-platform filesystem behavior can be difficult.

Different systems handle: case sensitivity, symlinks, permissions, timestamps, rename events, atomic saves, watcher overflow.

The fs module should normalize what it can and expose what it cannot.

Rename behavior ​

Some platforms expose rename as a specific event.

A snapshot diff may represent rename as:

txt
deleted old path
created new path

This is acceptable for a deterministic snapshot-based model. A future optimized watcher can add rename detection if needed.

Symlink handling should be explicit.

Possible policies:

  • treat symlink as its own entry
  • follow symlink
  • ignore symlink
  • error on symlink

The current policy should be documented in implementation-specific docs when stable.

Large directory behavior ​

Scanning large directories can be expensive.

Possible improvements: incremental scanning, native watcher backends, ignore rules, hash only when needed, batching, parallel scanning.

But correctness and deterministic output should come before premature optimization.

Basic test cases ​

The fs module should test:

  • valid path creation
  • invalid path rejection
  • path normalization
  • scan empty directory
  • scan file directory
  • snapshot build
  • diff created file
  • diff updated file
  • diff deleted file
  • watcher start failure
  • event batch behavior

Diff test example ​

A good diff test flow:

txt
create temp directory
build before snapshot
create file
build after snapshot
compute diff
expect Created event
cleanup temp directory

Another:

txt
create file
build before snapshot
modify file
build after snapshot
compute diff
expect Updated event

Another:

txt
create file
build before snapshot
delete file
build after snapshot
compute diff
expect Deleted event

Watcher test strategy ​

Watcher tests can be more fragile because they depend on timing.

Recommended strategy:

  • prefer snapshot and diff unit tests
  • keep watcher tests small
  • use temporary directories
  • use short but safe waits
  • avoid platform-specific assumptions

Use native watcher tests separately if needed.

Filesystem examples ​

Current useful examples include:

txt
scan.cpp
snapshot.cpp
diff.cpp
watch.cpp

Recommended order:

  1. scan.cpp
  2. snapshot.cpp
  3. diff.cpp
  4. watch.cpp

This order moves from simple to real-time behavior.

Run examples ​

From the engine repository:

sh
cd ~/softadastra/softadastra

Build:

sh
vix build

Or with CMake:

sh
cmake --preset dev-ninja
cmake --build --preset build-ninja

Create example folders if needed:

sh
mkdir -p data
mkdir -p watched

Find binaries:

sh
find build-ninja -type f -executable

Run the relevant filesystem example binary from the build output.

Example summaries ​

scan.cpp teaches: create Path, scan directory, iterate snapshot, print file metadata. Use it when learning how to read the full filesystem state.

snapshot.cpp teaches: build snapshot, iterate entries, inspect file states. Use it when learning the snapshot model.

diff.cpp teaches: build before snapshot, wait for changes, build after snapshot, compute diff, print changes. Use it when learning change detection.

watch.cpp teaches: start watcher, receive event batches, print event type and path, keep process alive. Use it when learning real-time observation.

Design rules ​

The fs module should follow these rules:

  1. Observe filesystem state.
  2. Do not own sync decisions.
  3. Do not own transport.
  4. Use strong path types.
  5. Return explicit errors.
  6. Keep snapshots deterministic.
  7. Keep diffs stable.
  8. Keep platform differences behind backends.
  9. Make event batches inspectable.
  10. Keep examples small.

Common mistakes ​

Making fs sync files directly ​

Wrong:

txt
Watcher detects file
  ↓
fs sends network message

Better:

txt
Watcher detects file
  ↓
FileEvent emitted
  ↓
higher-level service creates sync operation

Using raw strings everywhere ​

Wrong:

cpp
scan("./data");

Better:

cpp
auto root = path::Path::from("./data");
scanner::Scanner::scan(root.value());

Ignoring scan errors ​

Wrong:

cpp
auto snapshot = scanner::Scanner::scan(root).value();

Better:

cpp
auto result = scanner::Scanner::scan(root);

if (result.is_err())
{
    return 1;
}

Treating no changes as an error ​

No changes is normal. A diff can return an empty list.

txt
before == after
  ↓
no events

Assuming watcher events are perfect ​

Native watchers can overflow or behave differently across platforms. Snapshot diff remains the safer deterministic model.

Mixing CLI output into fs ​

fs should return data. CLI should format that data.

API reference ​

AreaPurpose
pathStrong path abstraction
scannerDirectory scanning
snapshotFilesystem snapshots and diffs
stateFile state and metadata
eventsFile events and batches
watcherReal-time observation
typesFile types and event types
utilsFilesystem helpers

Main types ​

TypePurpose
PathNormalized filesystem path
ScannerScans a directory
SnapshotRepresents filesystem state
SnapshotBuilderBuilds snapshots
SnapshotDiffComputes changes
FileStateState of one file entry
FileMetadataMetadata for one file entry
FileEventOne filesystem change
EventBatchGroup of filesystem changes
WatcherWatches a path for changes
cpp
#include <softadastra/fs/Fs.hpp>

For scanning:

cpp
auto root = path::Path::from("./data");

if (root.is_err()) { return 1; }

auto snapshot = scanner::Scanner::scan(root.value());

if (snapshot.is_err()) { return 1; }

For diffing:

cpp
auto before = snapshot::SnapshotBuilder::build(root.value());
auto after  = snapshot::SnapshotBuilder::build(root.value());

auto events =
    snapshot::SnapshotDiff::compute(
        before.value().all(),
        after.value().all());

For watching:

cpp
watcher::Watcher watcher;

auto result =
    watcher.start(root.value(), [](const events::EventBatch &batch)
    {
        for (const auto &event : batch.all())
        {
            // handle event
        }
    });

Summary ​

fs is the filesystem observation module of Softadastra Engine.

It provides:

txt
Path
Scanner
Snapshot
SnapshotDiff
FileState
FileEvent
EventBatch
Watcher

The key idea is:

fs observes filesystem changes and exposes them as deterministic state or events.

It does not sync files, send network messages, or decide conflicts.

Next step ​

Continue with WAL:

Go to WAL

Released under the Apache License 2.0.