UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

249 lines • 10.3 kB
// ========= 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