@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
249 lines • 10.3 kB
JavaScript
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
import { SystemClock } from "../../utils/clock.js";
import { underPath } from "../../utils/key_prefix.js";
import { parseLimit } from "../../utils/size.js";
import { rstripSlash } from "../../utils/slash.js";
import { RAMVFS } from "../../vfs/ram/ram.js";
import { KeyLock } from "../lock.js";
import { CacheEntry, Holds } from "./entry.js";
import { Invalidation, withFillGuard } from "./invalidation.js";
import { validateMaxDrainBytes } from "./mixin.js";
import { tokenOrNull } from "./utils.js";
export class RAMFileCacheStore extends RAMVFS {
entries = new Map();
lock = new KeyLock();
limit;
clock;
size = 0;
invalidation = new Invalidation();
maxDrainBytesValue = null;
/**
* @param options.limit capacity, as bytes or a size string.
* @param options.maxDrainBytes cap on a background drain's buffer; null derives it from `limit`.
* @param options.clock the clock every entry's age is measured on; undefined means the real one.
*/
constructor(options = {}) {
super();
this.limit = parseLimit(options.limit ?? '32MB', 'limit');
this.clock = options.clock ?? new SystemClock();
this.maxDrainBytes = options.maxDrainBytes ?? null;
}
seconds() {
return Math.floor(this.clock.now());
}
get maxDrainBytes() {
return this.maxDrainBytesValue;
}
set maxDrainBytes(value) {
validateMaxDrainBytes(this.limit, value);
this.maxDrainBytesValue = value;
}
get cacheSize() {
return this.size;
}
get cacheEntries() {
return this.entries.size;
}
get cacheLimit() {
return this.limit;
}
snapshotEntries() {
return [...this.entries.entries()].map(([key, entry]) => ({ key, entry }));
}
loadEntry(key, data, entry) {
this.store.files.set(key, data);
this.entries.set(key, entry);
this.size += entry.size;
}
async get(key) {
return (await this.getVersioned(key))?.[0] ?? null;
}
async getVersioned(key) {
// The data, and whether an expiry left a version to evict around.
const [data, kept] = await this.lock.withLock(key, () => {
const entry = this.entries.get(key);
if (!entry?.hasBytes)
return Promise.resolve([null, false]);
if (entry.isExpired(this.seconds())) {
this.dropEntry(key);
// A kept version outlives its bytes, as redis's meta key does.
if (entry.holds === Holds.BYTES_AND_VERSION &&
entry.fingerprint !== null &&
entry.fingerprint !== '') {
this.putVersion(key, entry.fingerprint);
return Promise.resolve([null, true]);
}
return Promise.resolve([null, false]);
}
this.entries.delete(key);
this.entries.set(key, entry);
const bytes = this.store.files.get(key);
return Promise.resolve([bytes === undefined ? null : [bytes, entry.fingerprint], false]);
});
if (kept)
await this.evict();
return data;
}
// The cache's own key test, part of `FileCacheMixin` and not a driver verb:
// the key is the cache entry's, not a path the mount resolves.
exists(key) {
const k = typeof key === 'string' ? key : key.mountPath;
const entry = this.entries.get(k);
return Promise.resolve(entry !== undefined && entry.hasBytes && !entry.isExpired(this.seconds()));
}
async set(key, data, options = {}) {
// Stamped before waiting on the lock: bytes read before an
// invalidation are stale even when the lock was granted after it.
await withFillGuard(this.invalidation, [key], async (isLive) => {
await this.lock.withLock(key, async () => {
if (!isLive(key))
return;
this.dropEntry(key);
const entry = new CacheEntry({
size: data.byteLength,
cachedAt: this.seconds(),
fingerprint: tokenOrNull(options.fingerprint),
ttl: options.ttl ?? null,
});
this.entries.set(key, entry);
this.store.files.set(key, data);
this.size += entry.size;
return Promise.resolve();
});
});
await this.evict();
}
async remove(key, options = {}) {
await this.lock.withLock(key, () => {
// Advanced here, when the removal takes effect, not when it was
// called: a writer queued behind it took its stamp before this ran,
// and only a later invalidation tells it its bytes predate the
// removal. Per key: a fill of another key still hashing is not this
// removal's business.
this.invalidation.invalidate(key);
this.dropEntry(key);
this.lock.discard(key);
return Promise.resolve();
});
if (options.subtree !== true)
return;
const prefix = rstripSlash(key) + '/';
const excluded = options.excluded ?? [];
// Before the removals below: a fill in flight under the prefix has no
// entry yet, so only its registration can name it.
this.invalidation.invalidatePrefix(prefix, excluded);
const keys = [...this.entries.keys()].filter((k) => k.startsWith(prefix) && !excluded.some((boundary) => underPath(k, boundary)));
for (const below of keys)
await this.remove(below);
}
sizes(keys) {
const now = this.seconds();
return Promise.resolve(keys.map((key) => {
const entry = this.entries.get(key);
return entry !== undefined && entry.hasBytes && !entry.isExpired(now) ? entry.size : null;
}));
}
fingerprints(keys) {
return Promise.resolve(keys.map((key) => this.entries.get(key)?.fingerprint ?? null));
}
async keepFingerprints(fingerprints) {
for (const [key, fingerprint] of Object.entries(fingerprints)) {
await this.setVersion(key, fingerprint);
}
}
async setVersion(key, fingerprint) {
await withFillGuard(this.invalidation, [key], async (isLive) => {
await this.lock.withLock(key, () => {
if (!isLive(key))
return Promise.resolve();
const entry = this.entries.get(key);
if (entry !== undefined && entry.hasBytes && !entry.isExpired(this.seconds())) {
if (entry.fingerprint === fingerprint) {
this.entries.set(key, new CacheEntry({
size: entry.size,
cachedAt: entry.cachedAt,
fingerprint: entry.fingerprint,
ttl: entry.ttl,
holds: Holds.BYTES_AND_VERSION,
}));
}
return Promise.resolve();
}
this.dropEntry(key);
this.putVersion(key, fingerprint);
return Promise.resolve();
});
});
await this.evict();
}
dropEntry(key) {
const entry = this.entries.get(key);
if (entry !== undefined) {
this.size -= entry.size;
this.entries.delete(key);
}
this.store.files.delete(key);
}
putVersion(key, fingerprint) {
const version = new CacheEntry({
size: key.length + fingerprint.length,
cachedAt: this.seconds(),
fingerprint,
holds: Holds.VERSION,
});
this.entries.set(key, version);
this.size += version.size;
}
isFresh(key, remoteFingerprint) {
const entry = this.entries.get(key);
if (!entry?.hasBytes)
return Promise.resolve(false);
// An entry that carries no token verifies against nothing, and says
// so here rather than relying on the caller to ask only when it holds
// one. Without the first clause a caller arriving with no remote
// token compares null to null and is told the copy is fresh; the
// redis store, whose meta key is simply absent, would answer false
// for the same pair.
return Promise.resolve(entry.fingerprint !== null && entry.fingerprint === remoteFingerprint);
}
isUnbounded(key) {
const entry = this.entries.get(key);
return Promise.resolve(entry !== undefined && entry.hasBytes && entry.ttl === null);
}
clear() {
this.invalidation.invalidateAll();
this.entries.clear();
this.store.files.clear();
this.size = 0;
this.lock.clear();
return Promise.resolve();
}
async evict() {
while (this.size > this.limit && this.entries.size > 0) {
const firstKey = this.entries.keys().next().value;
if (firstKey === undefined)
break;
await this.lock.withLock(firstKey, () => {
const entry = this.entries.get(firstKey);
if (entry === undefined)
return Promise.resolve();
this.dropEntry(firstKey);
return Promise.resolve();
});
this.lock.discard(firstKey);
}
}
}
//# sourceMappingURL=ram.js.map