Skip to main content

What It Does

Files on a mounted backend change outside your workspace all the time: a teammate uploads to Nextcloud, a pipeline rewrites an S3 object, or a cron job deletes a report. watch turns those changes into an async event stream.
No setup call is needed. The watch runtime attaches lazily on first use, and an idle workspace carries no watch state. Mirage runs no server and no background loop. Detection is yours—a webhook receiver or a small poll loop—while Mirage handles cache invalidation, scope matching, and delivery.

Scopes

The root’s shape defines the depth, using GNU shell glob semantics. * never crosses /, and matching happens at delivery time, so newly created files can match.

The Event

FileMetadata carries fingerprint, size, and modified. Producers fill only what their signal honestly knows. A listing walk usually supplies all three; a webhook payload may supply none. Events are level-triggered: an event says what is dirty, not every intermediate edit. Read current content through the workspace after receiving one. Mirage invalidates the changed path and every cached ancestor listing up to its mount root before the event reaches a subscriber. unknown is the overflow signal. If a burst exceeds the queue cap, pending events collapse into one unknown event per watch root, meaning “re-inventory this subtree.” Precision degrades, but dirtiness is not lost.

Push Mode

Your application hosts the endpoint; mirage opens no socket and runs no watcher. What arrives is the provider’s own payload, and turning that into a path is the only real work. Three backends ship a mapper for it:
You import the mapper for the backend you mounted rather than asking the mount for one, because a push payload has no vendor-neutral shape: the code that builds the call already names the backend, so a generic accessor would buy nothing. A mapper answers with zero or more FileEvents and never invents a path it was not told about. When a notification names only a scope, it returns UNKNOWN on that directory, which the watcher reads as “re-inventory everything below”. Slack is where the mapping earns its place. A message maps to channels/<name>__<CID>/<YYYY-MM-DD>/chat.jsonl, and the day is bucketed in UTC while Slack’s client shows local time, so a hand-written mapper names tomorrow’s directory for a fifth of every day and never errors, because notifying a path the mount does not serve evicts nothing. A thread reply is the other trap: chat.jsonl renders conversations.history, which returns parents only, so a reply appears in no day file at all. What changed is the parent’s reply_count, in the parent’s day. For a backend with no mapper, build the FileEvent yourself:
The provider receiver stays in your application, so it can use your existing HTTP framework, authentication, retries, and deployment model.

Pull Mode

Backends that implement deltaHook() answer one question: what changed under this root since the last checkpoint? Eleven resource families ship one today, listed in the Watch Matrix with the fingerprint each one compares on. A baseline pull (checkpoint === null) establishes state and emits no changes.
A runnable version needing no credentials is examples/typescript/disk/watch.ts: it writes to the mount’s directory behind mirage’s back, pulls the diff, and reads the changed file back through the workspace. Pull is self-healing because current backend state is compared with the saved checkpoint. Events missed while your service was down appear on the next pull. A common production shape is push-first with a startup pull for recovery, or a webhook used only as a doorbell that triggers one pull.

Queues

Each watch() owns its own queue. notify fans an event out to every matching queue, so overlapping watches consume independently and a slow consumer only overflows its own queue. The default RAMWatchQueue keeps one pending change per path. Create followed by update stays create; create followed by delete cancels; delete followed by create becomes update. Overflow policy is collapse (default), drop_oldest, or error. Attach a custom runtime before the first watch() or notify() call:
Any object satisfying WatchQueue (push, pop, pending, clear, and close) can replace the RAM queue. This is the extension point for a durable Redis, SQS, or database-backed queue. Call await ws.detachWatchRuntime() to close subscriber queues and end active watch loops cleanly without closing the workspace. The next watch() or notify() lazily creates a fresh default runtime.

Scope Notes

  • Watch roots are fixed at subscription time. Start a new watch() to change the scope.
  • One watch may span mounts, including nested mounts. Each event is invalidated on the longest-prefix mount that owns its path.
  • Delivery works on every backend. Pull detection ships for eleven resource families; see the Watch Matrix.
  • Events are in-memory and at-most-once per subscriber unless you provide a durable queue implementation.