@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
350 lines • 17.9 kB
TypeScript
import { type Caller } from '../context/session_context.ts';
import type { OpRecord } from '../observe/record.ts';
import { FileChangeKind, type FileStat, PathSpec } from '../types.ts';
import { type FileCacheMixin } from './file/mixin.ts';
import type { Evicted } from './index/config.ts';
import type { IndexCacheStore } from './index/store.ts';
import { type Measured } from './types.ts';
import { VersionScope } from './versions.ts';
/**
* Post-mutation cache coherence for one mount.
*
* A backend mutation has two cache consequences: the file-cache entry
* for the path is stale, and the parent directory listing in the index
* cache (including negative knowledge that the path does not exist) is
* stale. This class discharges both, synchronously, at the mutation
* site: core backend mutators report through `cache/context.ts` so
* invalidation happens before the next command in a pipeline runs
* instead of after the whole command tree.
*/
export declare class CacheManager {
private readonly readTtl;
private readonly onGone?;
private readonly mayServeListing?;
private readonly excludedPrefixes;
private readonly conditional;
readonly versionScope: VersionScope;
private readonly fileCache;
private readonly index;
private readonly clock;
private readonly prefix;
private readonly cachesReads;
private readonly ownsPath;
private readGeneration;
private view;
private readonly written;
private readonly probed;
private probeBound;
private readonly checked;
private readonly refused;
private readonly checking;
private checkBound;
constructor(fileCache: FileCacheMixin | null, index: IndexCacheStore | null, prefix: string, cachesReads: boolean, ownsPath?: (path: string) => boolean, readTtl?: number, onGone?: ((gone: readonly Evicted[]) => Promise<void>) | undefined, mayServeListing?: ((folder: string, version: string | null) => Promise<boolean>) | undefined, excludedPrefixes?: () => readonly string[], conditional?: boolean);
private millis;
/** Drain raw backend index access before mount cache eviction. */
mutation<T>(call: () => Promise<T>): Promise<T>;
/**
* Drop this mount's listings and bodies, path unspecified.
*
* For a write that went past the dispatcher, so nothing here can say which
* file changed, only that this mount may no longer match its backend:
* native code (an external program, a remote runtime line), an account CLI
* writing to its service by id, or a mount command writing through its own
* client. Both caches go, because the two hide different writes: a stale
* listing hides a create or a delete, a stale body an edit. It also retires
* what the running command's probes saw.
*
* The index is expired rather than cleared, while this mount still owns it:
* a cleared index reads exactly like one that was never filled, so a
* backend whose index *is* its listing (github seeds the whole tree once)
* could not tell the drop from an empty repository. Over-evicts when this
* mount is the root and another mount sits beneath it, since keys are
* compared by prefix; a refetch is the safe direction to be wrong in.
* Mirrors Python's `CacheManager.drop`.
*
* @param bodies drop the bodies too; false when the caller clears the whole
* file cache itself.
*/
drop(bodies?: boolean): Promise<void>;
/**
* Retire every in-flight read and every remembered probe answer.
*
* The one step every cache drop takes: a read that began before it must not
* stamp the cache after it, and a probe answer from before it must not be
* served after it.
*/
retire(): void;
private goneLocked;
/**
* This mount's index, its backend metadata writes bound to the mount's
* lifetime; null when the mount has none.
*
* The view is reused because refill locks are keyed by index identity.
*/
scopeIndex(): IndexCacheStore | null;
private viewOptions;
private noteWritten;
/**
* Whether the running command fetched `folder`'s listing itself.
*
* Stricter than `listingTrusted`: outside a command no listing is the
* caller's own, and inside one only a listing fetched under the same command
* stamp is: not one another session fetched meanwhile, nor one a nested
* `$(...)` or function body fetched. A listing the command fetched stays its
* own until the command ends, so a long-running command does not see a name
* another client creates after it listed the folder. Mirrors Python's
* `listed_this_command`.
*/
listedThisCommand(folder: string): boolean;
/**
* Whether `folder`'s listing is recent enough to serve under fresh.
*
* Inside a command: only if it was fetched since the command started, so
* one command re-lists a folder once however often it reads it. Outside
* any command (FUSE, a programmatic op) there is no command to belong to,
* so a listing fetched within `LISTING_TRUST_WINDOW` seconds is trusted
* instead: one `ls -l` over FUSE is a burst of calls that can share a
* re-list until the window expires.
*
* Every view of the mount, shared or lock-held, records into one map, so
* a glob's write counts for the `ls` that follows it.
*/
listingTrusted(folder: string): boolean;
private pruneChecks;
/**
* Whether a version check is recent enough to answer for the caller.
*
* The rule `listingTrusted` applies to listings: inside a command, only a
* check sent after the command started, since one sent before may predate a
* change the command must see; outside any command, one sent within
* `LISTING_TRUST_WINDOW` seconds.
*/
private sentRecently;
/**
* The backend's listing version for `key`, asking at most once.
*
* A check recent enough for the caller (`sentRecently`) that answered
* `stored` is reused, so one command checks a mount once however many of
* its folders it lists. Otherwise a check in flight that is recent enough
* is shared, and only then is a new one sent; the newest in flight is the
* one later callers find. A remembered answer that differs from `stored` is
* asked again rather than trusted, since the listing may have been written
* since, unless that answer already refused this same `stored`: nothing
* about that listing changed. `check` answers null when the backend gives
* no version.
*/
checkedVersion(key: string, stored: string, check: () => Promise<string | null>): Promise<string | null>;
private sendCheck;
/**
* Remember what a probe or a direct stat got from the backend.
*
* Called only with an answer the backend gave: the reconciler's probe, and
* under any read policy a stat that asked the backend itself, since its
* point observation can expire the parent listing the next lookup would
* otherwise walk again. A stat served from an index row -- which may carry
* no content token -- never lands here. A path the backend reports gone
* records nothing: the probe asks the backend only when no answer is
* servable, so there is nothing left to take back.
*/
noteProbed(path: PathSpec, stat: FileStat): void;
private pruneProbes;
/** Mutation generation, captured before a freshness probe starts. */
get generation(): number;
/**
* The backend's answer for `path` from this command's probe.
*
* A read command stats its own operand after the probe already asked the
* backend; asking again resolves through listings the command has not
* re-checked (or that its observation expired), and re-lists every folder
* on the path. The answer is
* served only inside the command that probed, and only while no cache drop
* has landed since: a write in the command (`sed -i`, `> f`), the clear
* after an external program, and a re-list that found the path gone all
* retire it (`retire()`), so the next stat goes back to the backend.
*/
probedStat(path: PathSpec): FileStat | null;
/** Retain a live fallback row only while its predecessor still owns the slot. */
retainResolvedEntry(path: PathSpec, generation: number, predecessor: string, entry: FileStat): Promise<void>;
/**
* A view for a caller already inside `mutation`.
*
* Never share or retain it beyond that hold. A distinct refill lock
* avoids lock inversion with readers of the shared view.
*/
private lockedIndex;
/**
* Drop one directory's cached listing.
*
* Both spellings of the directory go, because a backend may have keyed
* it with or without its trailing slash and an eviction that hits no
* key is silent.
*/
private evictDir;
/**
* Cache key for a path, derived rather than inferred.
*
* Both caches this class evicts from are keyed by the mount-absolute virtual
* path, so that is what this returns: the mount prefix still attached, not
* the mount-relative spelling `mountKey` produces on the way there.
*
* Only `virtual` is read, and the key is rebuilt against this manager's own
* prefix, exactly as `Mount.call` rebuilds one before handing a path to
* a backend. The caller's `vfsPath` is deliberately ignored: it is not
* a fact this class can trust, because `PathSpec.fromStrPath` fabricates one
* ("assumed root-mounted") for any caller that does not know its mount.
*
* The earlier version inferred which convention had arrived by comparing the
* two strings, which cannot be done: under a `/d` mount a mount-relative `/d`
* and an absolute `/d` are the same characters naming different files.
* Inferring wrong is quiet rather than loud -- a key one level off simply
* evicts nothing -- which is why it survived. Deriving asks no question that
* has no answer.
*
* Mirrors Python `CacheManager._cache_key`.
*/
private cacheKey;
/** The file cache this manager may read `key` from, if any. */
private readableCache;
/** A browser fallback cannot attribute an overlapping result to a session. */
private sessionHistory;
/**
* Remember a refusal: the version it lost on stands until a read; null
* forgets it.
*/
lose(path: PathSpec, version: string | null): void;
/**
* Record that `caller`'s session found no file at `path` on a read or stat.
* It lifts a refusal the running line marked on `path` earlier, as a read
* that finds the file does. A miss no one session owns (overlapping
* sessions on the fallback storage) is recorded nowhere, so the next write
* goes as it would have. Mirrors Python's `observe_miss`.
*/
observeMiss(path: PathSpec, caller: Caller | undefined): void;
/** Record this session's exact result, never a peer's cached token. */
observe(rec: OpRecord, owner?: Caller): void;
/**
* Retain the refused condition in its session, or in the cache without a
* session. ABSENT, a refused create, is kept only in session history.
*/
keepVersion(path: PathSpec, version: Measured): Promise<void>;
/**
* Conditional mounts use the live session's own observations, with
* current-line refusals first. Without a session, and on unconditional
* mounts, use current-line records then the shared cache's token.
* Cache eviction never clears a session's history.
*/
readVersion(path: PathSpec): Promise<Measured | null>;
/** `readVersion` for many paths; session history needs no I/O. */
readVersions(paths: readonly PathSpec[]): Promise<(Measured | null)[]>;
/**
* Run a cold whole-file read and keep its bytes for the next one.
*
* For the dispatcher, which probed the cache itself. A write that lands
* while the fetch runs
* retires the generation, so the bytes it read are not kept; an answer
* that is not bytes is returned and kept nowhere. `keep`, when given, is
* asked after the fetch with the cache's mutation lock held and has the
* last say over whether the bytes are kept; the dispatcher passes "no
* renderer resolves for the read". It must be synchronous; omitted, the
* bytes are kept. Mirrors Python's `CacheManager.fill`.
*/
fill<T>(path: PathSpec, fetch: () => Promise<T>, keep?: () => boolean): Promise<T>;
/**
* Pass a cold streamed read through and keep its bytes for the next one.
*
* The streamed twin of `fill`: the bytes are kept once the last chunk is
* pulled, under the same rules (a write that lands while the stream runs
* retires the generation, and `keep` has the last say), labelled with the
* token this stream's backend recorded (`records`). A stream closed early,
* or larger than the cache's drain budget, keeps nothing, so the cache never
* reads past what the caller pulled and never holds more than it could.
* Mirrors Python's `fill_stream`.
*/
fillStream(path: PathSpec, source: AsyncIterable<Uint8Array>, records: readonly OpRecord[], keep?: () => boolean): AsyncIterable<Uint8Array>;
private filling;
private admits;
/**
* Keep a write's bytes for the next read, as a read keeps its own.
* `fingerprint` is the token the write's backend answered, null when it
* vouched for none. Mirrors Python's `keep_written`.
*/
keepWritten(path: PathSpec, data: Uint8Array, fingerprint: string | null): Promise<void>;
/**
* Store bytes a read or a write settled on, unless they went stale.
*
* The one way bytes enter the file cache. The checks run under the cache's
* mutation lock, so a name this mount stopped owning or caching keeps
* nothing, nor do bytes `admit` refuses. Bytes over the whole cache limit
* would evict every warm entry, so the copy held for the key, now stale,
* goes instead; a store that refuses the bytes (out of memory, a value over
* its size limit) costs only the next read a fetch, so the refusal is
* logged, never thrown, and the stale copy goes too. Mirrors Python's
* `_keep`.
*/
private keep;
/**
* Return each cached render's byte length, in path order, without
* revalidating or fetching the bytes.
*
* The size backfill a render-dependent backend cannot answer for itself
* (the dispatcher's stat) runs only where the backend reported no size,
* which is exactly the API mounts, so gating it would turn a stat into a
* backend stat. It answers a length rather than content, so nothing can
* serve unverified bytes through it.
*/
cachedSizes(paths: readonly PathSpec[]): Promise<(number | null)[]>;
/**
* Whether a read of `path` would be served from the cache, asked of the
* store without fetching the bytes.
*/
isCached(path: PathSpec): Promise<boolean>;
/**
* Drop what a change at `path` made stale. The kind decides how far down it
* reaches:
*
* - CREATE, UPDATE, and MOVE at its landing: the body, and the path's entry
* in its parent's listing. A CREATE that answered its new entry's stat
* (`row`) keeps that entry where the parent's listing names it instead, so
* a listing of the parent and the next level of a nested create need no
* request; a row naming another file (a store that matches names without
* case wrote the one it holds) proves nothing, so the parent is listed
* again.
* - DELETE: the body, the path's own listing and its parent entry, and what
* is cached beneath it when a listing at or under it is still cached. That
* probe is for a remover that cannot say it removed a folder (a notified
* DELETE, the vacated side of a MOVE): a folder's nested listings and
* bodies were cached under keys of their own, while a plain file must not
* pay a subtree drop, which on a Redis file cache scans the whole keyspace.
* A remover that knows nothing was below (`leaf`: unlink, rmdir) skips it.
* A folder with no listing cached at or under it keeps the bodies cached
* beneath it until their ttl.
* - UNKNOWN: everything cached at and below the path, as a push notification
* that names only a folder, a recursive delete or a directory rename needs.
*
* Then the path's ancestors drop their entries in their parents' listings: a
* keyed store materializes every missing level of a key in one put, so those
* listings gained entries too. Each affected listing becomes partial and
* keeps its unaffected siblings. Every change retires this mount's in-flight
* reads and probe answers, all but `probed`. Mirrors Python's
* `CacheManager.invalidate`.
*
* @param path the changed path; only `virtual` is read.
* @param kind what happened, which decides the reach.
* @param row a CREATE's new entry, kept in its parent's listing; null drops
* that entry.
* @param options.leaf a DELETE whose remover knows nothing was below the path.
* @param options.probed the backend's answer the change was found by, which
* this command's probe keeps serving for `path` (`probedStat`) past the
* retire.
*/
invalidate(path: string | PathSpec, kind: FileChangeKind, row?: FileStat | null, options?: {
leaf?: boolean;
probed?: FileStat | null;
}): Promise<void>;
private invalidateOwn;
private removed;
private invalidateAncestors;
private invalidateRemoved;
private dropBelow;
}
//# sourceMappingURL=manager.d.ts.map