@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
1,193 lines • 46.1 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. =========
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