UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

1,193 lines • 46.1 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. ========= var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) { if (typeof path === "string" && /^\.\.?\//.test(path)) { return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) { return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js"); }); } return path; }; import { FileStat, FileType } from "../../types.js"; import { SystemIdSource } from "../../utils/ids.js"; import { loadOptionalPeer } from "../../utils/optional_peer.js"; import { rstripSlash } from "../../utils/slash.js"; import { globEscape } from "../file/utils.js"; import { KeyLock } from "../lock.js"; import { FileStatWireSchema, IndexDirectory, LookupStatus, } from "./config.js"; import { CHILDREN_PREFIX, DEFAULT_KEY_PREFIX, ENTRY_PREFIX, GENERATION_KEY, PATHS_KEY, REGISTRY_PAGE, TOMBSTONE_PREFIX, } from "./constants.js"; import { ListingMemo } from "./listing_memo.js"; import { IndexCacheStore } from "./store.js"; const INVALIDATE_MISSING = ` local changed, unknown = false, false local current = redis.call('GET', KEYS[3]) for i = 3, #ARGV do local path = ARGV[i] local folder = string.match(path, '^(.*)/[^/]+$') if folder == '' then folder = '/' end local key = KEYS[1] .. folder local raw = redis.call('GET', key) if raw then local listing = cjson.decode(raw) local directory = redis.call('GET', KEYS[4] .. folder) local found = false for _, child in ipairs(listing.entries) do if child == path then found = true; break end end if not current or not directory or listing.generation ~= current .. ':' .. directory or listing.expires_at <= tonumber(ARGV[1]) then unknown = true elseif listing.partial or listing.window then if not found then unknown = true end elseif not found then listing.expires_at = 0 listing.version = cjson.null local encoded = string.gsub(string.gsub(cjson.encode(listing), '"entries":{}', '"entries":[]'), '"folders":{}', '"folders":[]') redis.call('SET', key, encoded) changed = true end else unknown = true end end if changed or unknown then redis.call('SET', KEYS[2], ARGV[2]) end return changed and 1 or 0 `; const REPLACE_ENTRY = ` if redis.call('GET', KEYS[1]) ~= ARGV[1] then return 0 end redis.call('SET', KEYS[1], ARGV[2]) return 1 `; const PATH_RANGE = ` local function path_range(root) root = string.gsub(root, '/+$', '') if root == '' then root = '/' end local lower = root == '/' and '/' or root .. '/' local upper = root == '/' and '0' or root .. '0' return root, lower, upper end `; const PATH_REGISTRY = PATH_RANGE + ` local function track(registry, prefixes, paths) for _, path in ipairs(paths) do redis.call('ZADD', registry, 0, path) end end local function prune(registry, prefixes, path) for _, prefix in ipairs(prefixes) do if redis.call('EXISTS', prefix .. path) == 1 then return end end redis.call('ZREM', registry, path) end local function subtree(registry, root) local root, lower, upper = path_range(root) local paths = redis.call('ZRANGEBYLEX', registry, '[' .. lower, '(' .. upper) paths[#paths + 1] = root return paths end -- Whether a stored row, a FileStat's JSON, is a directory's. local function is_dir(row) return row.type == 'directory' end `; const LISTING_HISTORY = PATH_REGISTRY + ` local function bury(key, listing) local raw = redis.call('GET', key) local saved = raw and cjson.decode(raw) or {entries = {}, folders = {}} local positions = {} for i, path in ipairs(saved.entries) do positions[path] = i end for i, path in ipairs(listing.entries) do local position = positions[path] if not position then saved.entries[#saved.entries + 1] = path position = #saved.entries positions[path] = position end saved.folders[position] = (saved.folders[position] == 1 or listing.folders[i] == 1) and 1 or 0 end if #saved.entries > 0 then redis.call('SET', key, cjson.encode(saved)) end end local function encode_listing(listing, predecessor) local encoded = string.gsub(string.gsub(cjson.encode(listing), '"entries":{}', '"entries":[]'), '"folders":{}', '"folders":[]') local deadline = string.match(predecessor, '"expires_at"%s*:%s*([%d%.eE%+%-]+)') return (string.gsub(encoded, '"expires_at":[%d%.eE%+%-]+', '"expires_at":' .. deadline)) end local function publish_listing( key, listing, raw, global_key, directory_key, replacement) if replacement ~= '' then local current = redis.call('GET', global_key) local directory = redis.call('GET', directory_key) if current and directory and listing.generation == current .. ':' .. directory then listing.generation = current .. ':' .. replacement end redis.call('SET', directory_key, replacement) end redis.call('SET', key, encode_listing(listing, raw)) end local function remember_directory(children_prefix, tombstone_prefix, path) path = string.gsub(path, '/+$', '') if path == '' then return end local parent = string.match(path, '^(.*)/[^/]+$') if parent == '' then parent = '/' end for i, prefix in ipairs({children_prefix, tombstone_prefix}) do local key = prefix .. parent local raw = redis.call('GET', key) if raw then local listing = cjson.decode(raw) for position, child in ipairs(listing.entries) do if child == path and listing.folders[position] ~= 1 then listing.folders[position] = 1 redis.call('SET', key, i == 1 and encode_listing(listing, raw) or cjson.encode(listing)) break end end end end end `; const SEED_LISTING = LISTING_HISTORY + ` local listing = cjson.decode(ARGV[1]) for offset = 1, #listing.entries, 128 do local keys = {} local last = math.min(offset + 127, #listing.entries) for i = offset, last do keys[#keys + 1] = ARGV[5] .. listing.entries[i] keys[#keys + 1] = ARGV[2] .. listing.entries[i] end local saved = redis.call('MGET', unpack(keys)) for i = offset, last do local position = (i - offset) * 2 + 1 local row = saved[position] listing.folders[i] = (listing.folders[i] == 1 or saved[position + 1] ~= false or (row ~= false and is_dir(cjson.decode(row)))) and 1 or 0 end end publish_listing(KEYS[1], listing, ARGV[1], KEYS[2], KEYS[3], ARGV[6]) remember_directory(ARGV[2], ARGV[3], ARGV[4]) return 1 `; const PUT_ENTRY = LISTING_HISTORY + ` local old = redis.call('GET', KEYS[1]) if old and cjson.decode(old).type == 'directory' and cjson.decode(ARGV[2]).type ~= 'directory' then bury(KEYS[2], {entries = {ARGV[1]}, folders = {1}}) redis.call('ZADD', KEYS[3], 0, ARGV[3]) end redis.call('SET', KEYS[1], ARGV[2]) return 1 `; const ADD_ENTRY = LISTING_HISTORY + ` local raw = redis.call('GET', KEYS[1]) local current = redis.call('GET', KEYS[2]) local directory = redis.call('GET', KEYS[3]) local fresh = cjson.decode(ARGV[4]) if not current or not directory or fresh.generation ~= current .. ':' .. directory then redis.call('SET', KEYS[5], ARGV[6]) if raw and current and directory then local listing = cjson.decode(raw) if listing.generation == current .. ':' .. directory and tonumber(ARGV[5]) < listing.expires_at then for i, path in ipairs(listing.entries) do if path == ARGV[3] then bury(KEYS[4], {entries = {path}, folders = {listing.folders[i]}}) table.remove(listing.entries, i) table.remove(listing.folders, i) break end end listing.partial = true listing.version = cjson.null listing.window = false listing.generation = current .. ':' .. ARGV[6] redis.call('SET', KEYS[3], ARGV[6]) redis.call('SET', KEYS[1], encode_listing(listing, raw)) end end return 0 end local old = redis.call('GET', ARGV[1]) redis.call('SET', KEYS[3], ARGV[6]) local folder = cjson.decode(ARGV[2]).type == 'directory' and 1 or 0 if old and cjson.decode(old).type == 'directory' and folder == 0 then bury(KEYS[4], {entries = {ARGV[3]}, folders = {1}}) end redis.call('SET', ARGV[1], ARGV[2]) if raw then local listing = cjson.decode(raw) if current and directory and listing.generation == current .. ':' .. directory and tonumber(ARGV[5]) < listing.expires_at then listing.generation = current .. ':' .. ARGV[6] if old and cjson.decode(old).type == 'directory' and folder == 0 then listing.partial = true listing.version = cjson.null listing.window = false end local position = #listing.entries + 1 for i, path in ipairs(listing.entries) do if path == ARGV[3] then if listing.folders[i] == 1 and folder == 0 then bury(KEYS[4], {entries = {ARGV[3]}, folders = {1}}) listing.partial = true listing.version = cjson.null listing.window = false end listing.folders[i] = folder redis.call('SET', KEYS[1], encode_listing(listing, raw)) return 0 end if path > ARGV[3] then position = i break end end table.insert(listing.entries, position, ARGV[3]) table.insert(listing.folders, position, folder) redis.call('SET', KEYS[1], encode_listing(listing, raw)) return 1 end bury(KEYS[4], listing) end fresh.generation = current .. ':' .. ARGV[6] redis.call('SET', KEYS[1], encode_listing(fresh, ARGV[4])) return 1 `; const TRACK_PATHS = PATH_REGISTRY + ` local paths = {} for i = 5, #ARGV do paths[#paths + 1] = ARGV[i] end track(KEYS[1], {ARGV[1], ARGV[2], ARGV[3], ARGV[4]}, paths) return 1 `; const RECOVER_PATHS = ` local rebuilding = string.char(0) if redis.call('ZSCORE', KEYS[1], '') then return {1, ''} end if ARGV[2] == 'begin' then redis.call('ZADD', KEYS[1], 'NX', ARGV[1], rebuilding) return {0, redis.call('ZSCORE', KEYS[1], rebuilding)} end if redis.call('ZSCORE', KEYS[1], rebuilding) ~= ARGV[1] then return {-1, ''} end if ARGV[2] == 'finish' then redis.call('ZREM', KEYS[1], rebuilding) redis.call('ZADD', KEYS[1], 0, '') return {1, ''} end for i = 3, #ARGV, 2 do if redis.call('EXISTS', ARGV[i]) == 1 then redis.call('ZADD', KEYS[1], 0, ARGV[i + 1]) end end return {0, ARGV[1]} `; const DELETE_PATHS = LISTING_HISTORY + ` if not redis.call('ZSCORE', KEYS[1], '') then error('MIRAGE_INDEX_REGISTRY_MISSING') end local prefixes = {ARGV[1], ARGV[2], ARGV[3], ARGV[4]} local removed = cjson.decode(ARGV[5]) local excluded = cjson.decode(ARGV[7]) local root, lower, upper = path_range(ARGV[6]) local page = tonumber(ARGV[9]) local after = ARGV[8] == '' and '[' .. lower or '(' .. ARGV[8] local paths = redis.call('ZRANGEBYLEX', KEYS[1], after, '(' .. upper, 'LIMIT', 0, page) local cursor = #paths == page and paths[#paths] or '' if ARGV[8] == '' then paths[#paths + 1] = root end for _, path in ipairs(paths) do local protected = false for _, excluded_root in ipairs(excluded) do if path == excluded_root or string.sub(path, 1, #excluded_root + 1) == excluded_root .. '/' then protected = true break end end if not protected then if ARGV[10] == '1' then local raw = redis.call('GET', ARGV[2] .. path) if raw then bury(ARGV[3] .. path, cjson.decode(raw)) end end for _, prefix in ipairs(removed) do redis.call('DEL', prefix .. path) end prune(KEYS[1], prefixes, path) end end return cursor `; const HOLDS_SUBTREE = PATH_RANGE + ` if not redis.call('ZSCORE', KEYS[1], '') then error('MIRAGE_INDEX_REGISTRY_MISSING') end local key, lower, upper = path_range(ARGV[2]) local cursor, page = ARGV[3], tonumber(ARGV[4]) if cursor == '' and redis.call('EXISTS', ARGV[1] .. key) == 1 then return {1, ''} end local after = cursor == '' and '[' .. lower or '(' .. cursor local paths = redis.call('ZRANGEBYLEX', KEYS[1], after, '(' .. upper, 'LIMIT', 0, page) for _, path in ipairs(paths) do if redis.call('EXISTS', ARGV[1] .. path) == 1 then return {1, ''} end end return {0, #paths == page and paths[#paths] or ''} `; const DELETE_ENTRY = LISTING_HISTORY + ` local prefixes = {ARGV[1], ARGV[2], ARGV[3], ARGV[4]} track(KEYS[1], prefixes, {string.sub(KEYS[2], #ARGV[2] + 1)}) local raw = redis.call('GET', KEYS[2]) local current = redis.call('GET', KEYS[4]) local directory = redis.call('GET', KEYS[5]) local old = redis.call('GET', KEYS[6]) if (old and is_dir(cjson.decode(old))) or redis.call('EXISTS', ARGV[2] .. ARGV[5]) == 1 then bury(KEYS[3], {entries = {ARGV[5]}, folders = {1}}) end if raw then local listing = cjson.decode(raw) bury(KEYS[3], listing) for i, path in ipairs(listing.entries) do if path == ARGV[5] then table.remove(listing.entries, i) table.remove(listing.folders, i) break end end if current and directory and listing.generation == current .. ':' .. directory then listing.generation = current .. ':' .. ARGV[6] end listing.partial = true listing.version = cjson.null listing.window = false redis.call('SET', KEYS[2], encode_listing(listing, raw)) end redis.call('SET', KEYS[5], ARGV[6]) redis.call('DEL', KEYS[6]) prune(KEYS[1], prefixes, ARGV[5]) `; const WRITE_LISTING = LISTING_HISTORY + ` if ARGV[8] ~= '' and redis.call('GET', KEYS[4] .. '-observation') ~= ARGV[8] then return nil end local next = cjson.decode(ARGV[1]) local path = string.sub(KEYS[1], #ARGV[3] + 1) track(KEYS[3], {ARGV[2], ARGV[3], ARGV[4], ARGV[5]}, {path}) if next.partial then local old = redis.call('GET', KEYS[1]) if old then bury(KEYS[2], cjson.decode(old)) end else redis.call('DEL', KEYS[2]) end for i = 9, #ARGV, 2 do if next.partial then local old = redis.call('GET', ARGV[2] .. ARGV[i]) if (old and is_dir(cjson.decode(old))) or redis.call('EXISTS', ARGV[3] .. ARGV[i]) == 1 then bury(KEYS[2], {entries = {ARGV[i]}, folders = {1}}) end end redis.call('SET', ARGV[2] .. ARGV[i], ARGV[i + 1]) redis.call('ZADD', KEYS[3], 0, ARGV[i]) end publish_listing(KEYS[1], next, ARGV[1], KEYS[4], KEYS[5], ARGV[7]) remember_directory(ARGV[3], ARGV[4], path) return 1 `; const SWAP_LISTING = LISTING_HISTORY + ` if ARGV[8] ~= '' and redis.call('GET', KEYS[4] .. '-observation') ~= ARGV[8] then return nil end if not redis.call('ZSCORE', KEYS[3], '') then error('MIRAGE_INDEX_REGISTRY_MISSING') end track(KEYS[3], {ARGV[2], ARGV[3], ARGV[4], ARGV[5]}, {string.sub(KEYS[1], #ARGV[3] + 1)}) local old = redis.call('GET', KEYS[1]) local tomb = redis.call('GET', KEYS[2]) redis.call('DEL', KEYS[2]) local excluded = cjson.decode(ARGV[6]) local function protected(path) for _, prefix in ipairs(excluded) do if path == prefix or string.sub(path, 1, #prefix + 1) == prefix .. '/' then return true end end return false end local named = {} for i = 9, #ARGV, 2 do named[ARGV[i]] = is_dir(cjson.decode(ARGV[i + 1])) end local seen, gone, folders = {}, {}, {} local function drop(path, buried) if seen[path] or protected(path) then return end local row = redis.call('GET', ARGV[2] .. path) local folder = buried or redis.call('EXISTS', ARGV[3] .. path) == 1 or (row ~= false and is_dir(cjson.decode(row))) if named[path] ~= nil and not (folder and not named[path]) then return end seen[path] = true redis.call('DEL', ARGV[2] .. path) prune(KEYS[3], {ARGV[2], ARGV[3], ARGV[4], ARGV[5]}, path) gone[#gone + 1] = path folders[#folders + 1] = folder and 1 or 0 end local buried = {} local saved = tomb and cjson.decode(tomb) or false if saved then for i, path in ipairs(saved.entries) do buried[path] = saved.folders[i] == 1 end end if old then local listing = cjson.decode(old) for i, path in ipairs(listing.entries) do drop(path, buried[path] == true or listing.folders[i] == 1) end end if saved then for _, path in ipairs(saved.entries) do drop(path, buried[path]) end end for i = 9, #ARGV, 2 do drop(ARGV[i], false) end local roots = {} for i, path in ipairs(gone) do if folders[i] == 1 then roots[#roots + 1] = path end end local function remove(path) if protected(path) then return end for _, prefix in ipairs({ARGV[2], ARGV[3], ARGV[4], ARGV[5]}) do redis.call('DEL', prefix .. path) end redis.call('ZREM', KEYS[3], path) end for _, root in ipairs(roots) do for _, path in ipairs(subtree(KEYS[3], root)) do remove(path) end end for i = 9, #ARGV, 2 do redis.call('SET', ARGV[2] .. ARGV[i], ARGV[i + 1]) redis.call('ZADD', KEYS[3], 0, ARGV[i]) end publish_listing(KEYS[1], cjson.decode(ARGV[1]), ARGV[1], KEYS[4], KEYS[5], ARGV[7]) remember_directory(ARGV[3], ARGV[4], string.sub(KEYS[1], #ARGV[3] + 1)) return {gone, folders} `; const BURY_LISTING = PATH_REGISTRY + ` track(KEYS[4], {ARGV[1], ARGV[2], ARGV[3], ARGV[4]}, {string.sub(KEYS[1], #ARGV[2] + 1)}) local raw = redis.call('GET', KEYS[1]) if raw then local listing = cjson.decode(raw) local entries, folders, positions = {}, {}, {} local saved = redis.call('GET', KEYS[2]) if saved then local tomb = cjson.decode(saved) for i, path in ipairs(tomb.entries) do entries[#entries + 1] = path folders[#folders + 1] = tomb.folders[i] positions[path] = #entries end end for i, path in ipairs(listing.entries) do local row = redis.call('GET', ARGV[1] .. path) local folder = listing.folders[i] == 1 or redis.call('EXISTS', ARGV[2] .. path) == 1 or (row ~= false and is_dir(cjson.decode(row))) local position = positions[path] if not position then entries[#entries + 1] = path position = #entries positions[path] = position end folders[position] = (folder or folders[position] == 1) and 1 or 0 redis.call('DEL', ARGV[1] .. path) prune(KEYS[4], {ARGV[1], ARGV[2], ARGV[3], ARGV[4]}, path) end redis.call('SET', KEYS[2], cjson.encode({entries = entries, folders = folders})) end redis.call('DEL', KEYS[1]) redis.call('DEL', KEYS[3]) prune(KEYS[4], {ARGV[1], ARGV[2], ARGV[3], ARGV[4]}, string.sub(KEYS[1], #ARGV[2] + 1)) `; function row(raw) { return new FileStat(FileStatWireSchema.parse(JSON.parse(raw))); } // Directory records retain stale listings like RAM; Redis maxmemory eviction // can still turn any cached fact into a miss. A missing path registry is rebuilt // in client-side scan batches; prefix invalidation also yields between batches. export class RedisIndexCacheStore extends IndexCacheStore { ttl; url; providedClient; entryPrefix; childrenPrefix; tombstonePrefix; keyPrefix; pathsKey; generationKey; initializingGenerations = new Map(); clientPromise = null; seedLock = new KeyLock(); pendingSeeds = []; listings; closed = false; ids; constructor(options = {}) { super(options.clock); this.ids = options.ids ?? new SystemIdSource(); this.ttl = options.ttl ?? 600; this.url = options.url ?? 'redis://localhost:6379/0'; this.providedClient = options.client ?? null; const prefix = options.keyPrefix ?? DEFAULT_KEY_PREFIX; this.keyPrefix = prefix; this.entryPrefix = `${prefix}${ENTRY_PREFIX}`; this.childrenPrefix = `${prefix}${CHILDREN_PREFIX}`; this.tombstonePrefix = `${prefix}${TOMBSTONE_PREFIX}`; this.generationKey = `${prefix}${GENERATION_KEY}`; this.pathsKey = `${prefix}${PATHS_KEY}`; this.listings = new ListingMemo(prefix, this.clock); } entryKey(path) { return `${this.entryPrefix}${path}`; } childrenKey(path) { return `${this.childrenPrefix}${path}`; } client() { if (this.providedClient !== null) return Promise.resolve(this.providedClient); this.clientPromise ??= (async () => { const spec = 'redis'; const mod = (await loadOptionalPeer(() => import(__rewriteRelativeImportExtension(/* @vite-ignore */ spec)), { feature: 'RedisIndexCacheStore', packageName: 'redis', })); const c = mod.createClient({ url: this.url, socket: { reconnectStrategy: false }, }); await c.connect(); return c; })(); return this.clientPromise; } seed(entries, children, expiresAt, version = null) { this.pendingSeeds.push({ entries: new Map(entries), children: new Map([...children].map(([path, keys]) => [path, [...keys]])), expiresAt: expiresAt.getTime() / 1000, version, }); this.listings.wrote(); } trackPaths(pipe, paths) { pipe.eval(TRACK_PATHS, { keys: [this.pathsKey], arguments: [ this.entryPrefix, this.childrenPrefix, this.tombstonePrefix, `${this.generationKey}:`, ...paths, ], }); } async recoverPaths(c) { const prefixes = [ this.entryPrefix, this.childrenPrefix, this.tombstonePrefix, `${this.generationKey}:`, ]; for (let attempt = 0; attempt < 3; attempt++) { const candidate = String(Number.parseInt(this.ids.uuid7().replaceAll('-', '').slice(-12), 16)); const [initialStatus, token] = (await c.eval(RECOVER_PATHS, { keys: [this.pathsKey], arguments: [candidate, 'begin'], })); let status = initialStatus; if (status === 1) return; for await (const batch of c.scanIterator({ MATCH: `${globEscape(this.keyPrefix)}mirage:idx:*`, COUNT: 128, })) { const rows = []; for (const key of Array.isArray(batch) ? batch : [batch]) { const prefix = prefixes.find((candidate) => key.startsWith(candidate)); if (prefix !== undefined) rows.push(key, key.slice(prefix.length)); } for (let start = 0; start < rows.length; start += 256) { ; [status] = (await c.eval(RECOVER_PATHS, { keys: [this.pathsKey], arguments: [token, 'batch', ...rows.slice(start, start + 256)], })); if (status !== 0) break; } if (status !== 0) break; } if (status === 1) return; if (status === -1) continue; [status] = (await c.eval(RECOVER_PATHS, { keys: [this.pathsKey], arguments: [token, 'finish'], })); if (status === 1) return; } throw new Error('Redis repeatedly evicted the index path registry during recovery'); } async evalComplete(c, script, options) { for (let attempt = 0; attempt < 3; attempt++) { try { return await c.eval(script, options); } catch (error) { if (!(error instanceof Error) || !error.message.includes('MIRAGE_INDEX_REGISTRY_MISSING') || attempt === 2) throw error; await this.recoverPaths(c); } } throw new Error('Redis index path registry recovery failed'); } generation(c, key) { const pending = this.initializingGenerations.get(key); if (pending !== undefined) return pending; // Parallel directory refills in this store share one global initializer. // Do not retain it afterwards: the next read must observe invalidations. const initialized = (async () => { const current = await c.get(key); if (current !== null) return current; // A new token after eviction must never revive an old listing. const generation = this.ids.uuid7(); if (!key.startsWith(`${this.generationKey}:`)) { await c.set(key, generation, { NX: true }); } else { const pipe = c.multi(); this.trackPaths(pipe, [key.slice(this.generationKey.length + 1)]); pipe.set(key, generation, { NX: true }); await pipe.exec(); } // Even when NX loses, keep our attempted token. A later read could adopt // a replacement written by an invalidation/refill and revive old data. return generation; })().finally(() => { this.initializingGenerations.delete(key); }); this.initializingGenerations.set(key, initialized); return initialized; } flushSeed() { return this.seedLock.withLock('seed', async () => { while (this.pendingSeeds.length > 0) { const pending = [...this.pendingSeeds]; const c = await this.client(); const generation = await this.generation(c, this.generationKey); const directories = new Map(); const lastSeed = new Map(); for (const [number, seed] of pending.entries()) { for (const path of seed.children.keys()) lastSeed.set(path, number); } const paths = [...lastSeed.keys()]; if (paths.length > 0) { const current = await c.mGet(paths.map((path) => `${this.generationKey}:${path}`)); const missing = new Map(); // Keep observed tokens: rereading them after a concurrent invalidation // could stamp the pending snapshot with a replacement generation. for (const [i, path] of paths.entries()) { const token = current[i]; if (token == null) missing.set(path, this.ids.uuid7()); else directories.set(path, token); } if (missing.size > 0) { const initialize = c.multi(); this.trackPaths(initialize, [...missing.keys()]); for (const [path, token] of missing) { initialize.set(`${this.generationKey}:${path}`, token, { NX: true }); } await initialize.exec(); for (const [path, token] of missing) directories.set(path, token); } } const rows = new Map(pending.flatMap((seed) => [...seed.entries])); const pipe = c.multi(); this.trackPaths(pipe, pending.flatMap((seed) => [...seed.entries.keys(), ...seed.children.keys()])); for (const [number, seed] of pending.entries()) { for (const [path, entry] of seed.entries) { pipe.set(this.entryKey(path), JSON.stringify(entry)); } for (const [path, keys] of seed.children) { const listing = { entries: keys, folders: keys.map((key) => lastSeed.has(key) || rows.get(key)?.type === FileType.DIRECTORY ? 1 : 0), expires_at: seed.expiresAt, generation: `${generation}:${directories.get(path) ?? ''}`, partial: false, version: seed.version, window: false, }; pipe.eval(SEED_LISTING, { keys: [this.childrenKey(path), this.generationKey, `${this.generationKey}:${path}`], arguments: [ JSON.stringify(listing), this.childrenPrefix, this.tombstonePrefix, path, this.entryPrefix, lastSeed.get(path) === number ? this.ids.uuid7() : '', ], }); } } try { await pipe.exec(); } finally { this.listings.wrote(); } this.pendingSeeds.splice(0, pending.length); } }); } async entries() { await this.flushSeed(); const c = await this.client(); const entries = new Map(); for await (const batch of c.scanIterator({ MATCH: `${globEscape(this.entryPrefix)}*` })) { for (const key of Array.isArray(batch) ? batch : [batch]) { const raw = await c.get(key); if (raw !== null) entries.set(key.slice(this.entryPrefix.length), row(raw)); } } return entries; } async get(vfsPath) { await this.flushSeed(); const c = await this.client(); const cached = await this.listings.row(vfsPath, (paths) => this.getMany(paths)); if (cached !== null) return { entry: cached }; const raw = await c.get(this.entryKey(vfsPath)); if (raw === null) return { status: LookupStatus.NOT_FOUND }; return { entry: row(raw) }; } async getMany(vfsPaths) { const paths = [...new Set(vfsPaths)]; if (paths.length === 0) return new Map(); await this.flushSeed(); const c = await this.client(); const rows = await c.mGet(paths.map((path) => this.entryKey(path))); const found = new Map(); for (const [i, path] of paths.entries()) { const raw = rows[i]; if (raw != null) found.set(path, row(raw)); } return found; } async put(vfsPath, entry) { await this.flushSeed(); const c = await this.client(); const pipe = c.multi(); this.trackPaths(pipe, [vfsPath]); const folder = vfsPath.slice(0, vfsPath.lastIndexOf('/')) || '/'; pipe.eval(PUT_ENTRY, { keys: [this.entryKey(vfsPath), this.tombstonePrefix + folder, this.pathsKey], arguments: [vfsPath, JSON.stringify(entry), folder], }); try { await pipe.exec(); } finally { this.listings.rowWrote(vfsPath); } } addEntry(vfsPath, entry, expiredAt) { return this.listings.writing(async () => { await this.flushSeed(); const c = await this.client(); const folder = vfsPath.slice(0, vfsPath.lastIndexOf('/')) || '/'; const now = new Date(this.clock.now() * 1000); const lifetime = now.getTime() + this.ttl * 1000; const generation = await this.generation(c, this.generationKey); const directory = await this.generation(c, `${this.generationKey}:${folder}`); const fresh = { entries: [vfsPath], folders: [entry.type === FileType.DIRECTORY ? 1 : 0], generation: `${generation}:${directory}`, expires_at: (expiredAt == null ? lifetime : Math.min(lifetime, expiredAt.getTime())) / 1000, partial: true, version: null, window: false, }; const pipe = c.multi(); this.trackPaths(pipe, [folder, vfsPath]); pipe.eval(ADD_ENTRY, { keys: [ this.childrenKey(folder), this.generationKey, `${this.generationKey}:${folder}`, this.tombstonePrefix + folder, `${this.generationKey}:${vfsPath}`, ], arguments: [ this.entryKey(vfsPath), JSON.stringify(entry), vfsPath, JSON.stringify(fresh), String(now.getTime() / 1000), this.ids.uuid7(), ], }); await pipe.exec(); }); } async replaceIfUnchanged(path, predecessor, entry) { await this.flushSeed(); const c = await this.client(); const key = this.entryKey(path); const raw = await c.get(key); if (raw === null || JSON.stringify(row(raw)) !== predecessor) return false; try { const stored = entry; return ((await c.eval(REPLACE_ENTRY, { keys: [key], arguments: [raw, JSON.stringify(stored)] })) === 1); } finally { this.listings.rowWrote(path); } } /** * A folder's listing, read from Redis at most once per command. * * Inside a shell command, a later call for the same folder is answered * from this process's memory while no listing write to this key prefix * has finished in this process since the read began, the read is under * LISTING_TRUST_WINDOW seconds old, and the listing has not reached its * own expiry. A write by another process is therefore seen within that * window, and by every new command at its first read. Outside a command, * and on the fallback storage while more than one command is live, every * call reads Redis. On the fallback storage a call outside any command * while exactly one command is live is served from that command's memo, * under the same write counting and window. */ listDir(vfsPath, child) { return this.listings.read(vfsPath, () => this.readListing(vfsPath), child); } async readListing(vfsPath) { await this.flushSeed(); const c = await this.client(); const [raw, current, directory] = await c.mGet([ this.childrenKey(vfsPath), this.generationKey, `${this.generationKey}:${vfsPath}`, ]); if (raw == null) return [{ status: LookupStatus.NOT_FOUND }, null]; const listing = IndexDirectory.parse(JSON.parse(raw)); if (current == null || directory == null || listing.generation !== `${current}:${directory}` || this.clock.now() >= listing.expires_at) return [{ status: LookupStatus.EXPIRED }, null]; if (listing.partial) { return [{ partialEntries: listing.entries, version: listing.version }, listing.expires_at]; } return [ { entries: listing.entries, version: listing.version, window: listing.window }, listing.expires_at, ]; } async setDir(vfsPath, entries, expiredAt, options = {}) { return this.storeDir(vfsPath, entries, expiredAt, false, options.window !== true, options.excluded ?? [], options.version ?? null).then((result) => result ?? []); } async setPartialDir(vfsPath, entries, expiredAt) { await this.storeDir(vfsPath, entries, expiredAt, true, false); } storeDir(vfsPath, entries, expiredAt, partial, evict, excluded = [], version = null, refill = null) { return this.listings.writing(async () => { await this.flushSeed(); const c = await this.client(); const now = new Date(this.clock.now() * 1000); const prefix = vfsPath === '/' ? '/' : `${vfsPath}/`; const generation = await this.generation(c, this.generationKey); const directory = await this.generation(c, `${this.generationKey}:${vfsPath}`); const rows = []; for (const [name, entry] of entries) { rows.push([prefix + name, JSON.stringify(entry)]); } const listing = { entries: rows.map(([path]) => path), folders: entries.map(([, entry]) => (entry.type === FileType.DIRECTORY ? 1 : 0)), generation: `${generation}:${directory}`, expires_at: (expiredAt?.getTime() ?? now.getTime() + this.ttl * 1000) / 1000, partial, version: partial ? null : version, window: !partial && !evict, }; if (!evict) { const accepted = await c.eval(WRITE_LISTING, { keys: [ this.childrenKey(vfsPath), this.tombstonePrefix + vfsPath, this.pathsKey, this.generationKey, `${this.generationKey}:${vfsPath}`, ], arguments: [ JSON.stringify(listing), this.entryPrefix, this.childrenPrefix, this.tombstonePrefix, `${this.generationKey}:`, '[]', this.ids.uuid7(), refill ?? '', ...rows.flat(), ], }); return accepted === null ? null : []; } // One script, so no other writer lands between reading the previous // listing and replacing it; the diff is against the true predecessor. const result = (await this.evalComplete(c, SWAP_LISTING, { keys: [ this.childrenKey(vfsPath), this.tombstonePrefix + vfsPath, this.pathsKey, this.generationKey, `${this.generationKey}:${vfsPath}`, ], arguments: [ JSON.stringify(listing), this.entryPrefix, this.childrenPrefix, this.tombstonePrefix, `${this.generationKey}:`, JSON.stringify(excluded.map(rstripSlash)), this.ids.uuid7(), refill ?? '', ...rows.flat(), ], })); if (result === null) return null; const [gone, folders] = result; const dropped = []; for (const [i, path] of gone.entries()) { dropped.push({ path, folder: folders[i] === 1 }); } return dropped; }); } async refillToken() { await this.flushSeed(); return this.generation(await this.client(), `${this.generationKey}-observation`); } async invalidateMissing(paths) { await this.flushSeed(); if (paths.length === 0) return false; if (this.listings.allows(paths) && !this.listings.contradicts(paths)) return false; let changed; try { const result = await (await this.client()).eval(INVALIDATE_MISSING, { keys: [ this.childrenPrefix, `${this.generationKey}-observation`, this.generationKey, `${this.generationKey}:`, ], arguments: [String(this.clock.now()), this.ids.uuid7(), ...paths], }); changed = result === 1; } catch (err) { this.listings.wrote(); throw err; } if (changed || this.listings.contradicts(paths)) this.listings.wrote(); return changed; } refillDir(path, entries, token, expiredAt, options = {}) { return this.storeDir(path, entries, expiredAt, options.partial === true, options.partial !== true && options.window !== true, options.excluded ?? [], options.version ?? null, token); } invalidateEntry(vfsPath) { return this.listings.writing(async () => { await this.flushSeed(); const c = await this.client(); const folder = vfsPath.slice(0, vfsPath.lastIndexOf('/')) || '/'; await c.eval(DELETE_ENTRY, { keys: [ this.pathsKey, this.childrenKey(folder), this.tombstonePrefix + folder, this.generationKey, `${this.generationKey}:${folder}`, this.entryKey(vfsPath), ], arguments: [ this.entryPrefix, this.childrenPrefix, this.tombstonePrefix, `${this.generationKey}:`, vfsPath, this.ids.uuid7(), ], }); }); } invalidateDir(vfsPath) { return this.listings.writing(async () => { await this.flushSeed(); const c = await this.client(); // The child list becomes a tombstone, so the next complete listing can // still tell which children went away. await c.eval(BURY_LISTING, { keys: [ this.childrenKey(vfsPath), this.tombstonePrefix + vfsPath, `${this.generationKey}:${vfsPath}`, this.pathsKey, ], arguments: [ this.entryPrefix, this.childrenPrefix, this.tombstonePrefix, `${this.generationKey}:`, ], }); }); } async deletePaths(prefixes, vfsPath, excluded = [], remember = false) { const c = await this.client(); let cursor = ''; do { cursor = (await this.evalComplete(c, DELETE_PATHS, { keys: [this.pathsKey], arguments: [ this.entryPrefix, this.childrenPrefix, this.tombstonePrefix, `${this.generationKey}:`, JSON.stringify(prefixes), vfsPath, JSON.stringify(excluded.map(rstripSlash)), cursor, String(REGISTRY_PAGE), remember ? '1' : '0', ], })); } while (cursor !== ''); } invalidatePrefix(vfsPath, excluded = [], remember = false) { return this.listings.writing(async () => { await this.flushSeed(); await (await this.client()).set(`${this.generationKey}-observation`, this.ids.uuid7()); // Tombstones survive until the next complete listing proves removals. await this.deletePaths([this.entryPrefix], vfsPath, excluded); await this.deletePaths([this.childrenPrefix, `${this.generationKey}:`], vfsPath, excluded, remember); }); } async holdsSubtree(vfsPath) { await this.flushSeed(); const c = await this.client(); let cursor = ''; do { const [found, after] = (await this.evalComplete(c, HOLDS_SUBTREE, { keys: [this.pathsKey], arguments: [this.childrenPrefix, vfsPath, cursor, String(REGISTRY_PAGE)], })); if (found === 1) return true; cursor = after; } while (cursor !== ''); return false; } invalidate() { return this.listings.writing(async () => { await this.flushSeed(); const c = await this.client(); await c.set(`${this.generationKey}-observation`, this.ids.uuid7()); // Atomically expire listings without overwriting concurrent refills/deletions. await c.set(this.generationKey, this.ids.uuid7()); }); } clear() { return this.listings.writing(() => this.seedLock.withLock('seed', async () => { const client = await this.client(); await client.set(`${this.generationKey}-observation`, this.ids.uuid7()); this.pendingSeeds.length = 0; await this.deletePaths([this.entryPrefix], '/'); await this.deletePaths([this.childrenPrefix, this.tombstonePrefix, `${this.generationKey}:`], '/'); const c = await this.client(); await c.del(this.generationKey); })); } async close() { if (this.closed) return; try { await this.flushSeed(); } finally { this.listings.close(); } if (this.providedClient === null && this.clientPromise !== null) { const c = await this.clientPromise; const typed = c; if (typeof typed.destroy === 'function') typed.destroy(); else if (c.isOpen) await c.quit(); this.clientPromise = null; } this.closed = true; } } //# sourceMappingURL=redis.js.map