@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
253 lines • 10.5 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 { Delta, FileChangeKind, } from "../../types.js";
import { underPath } from "../../utils/key_prefix.js";
import { sortedJson } from "../../utils/sort.js";
import { eventsUnder, lostAt } from "../../events/delta.js";
import { eventAt, field } from "../../events/payload.js";
import { eventsNow, eventsSince } from "./api.js";
import { EVENT_REPLAY_DAYS, EVENT_STREAM, PLACE_EVENTS, TRASH_EVENTS } from "./constants.js";
import { mountRelativeKey } from "./resolve.js";
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* The key an item is remembered by: its type and its Box id.
*
* Files and folders are addressed through separate endpoints, and nothing
* promises their ids never coincide, so the type rides along.
*/
function refOf(item) {
return `${item.type}:${item.id}`;
}
/** `key` moved from under `old` to under `next`, if it was there. */
function rebase(key, old, next) {
return underPath(key, old) ? next + key.slice(old.length) : key;
}
/**
* The file or folder an event is about, or null.
*
* User events carry the full item as `source`, with the `path_collection`
* that places it. Web links, users and collaborations are not paths on the
* mount.
*/
function sourceOf(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value))
return null;
const source = value;
if (source.type !== 'file' && source.type !== 'folder')
return null;
if (typeof source.id !== 'string' || source.id === '')
return null;
return source;
}
/**
* Each event once, in the order it happened.
*
* Box may send an event more than once or out of order: a repeat carries the
* same `event_id`, and `created_at` gives the order. A tie keeps the order Box
* sent, and an event with no stamp stays behind the one before it.
*/
function ordered(events) {
const seen = new Set();
const stamped = [];
let stamp = Number.NEGATIVE_INFINITY;
for (const event of events) {
const eventId = event.event_id;
if (eventId !== undefined && eventId !== '') {
if (seen.has(eventId))
continue;
seen.add(eventId);
}
const created = event.created_at === undefined ? Number.NaN : Date.parse(event.created_at);
if (!Number.isNaN(created))
stamp = created;
stamped.push({ stamp, event });
}
stamped.sort((a, b) => (a.stamp < b.stamp ? -1 : a.stamp > b.stamp ? 1 : 0));
return stamped.map((row) => row.event);
}
/**
* What changed on a Box mount since the last stream position, for the
* consumer to call when Box pings.
*
* Box's push (`realtime_server`'s long poll answering `new_change`) carries no
* change, so each ping is answered by one read of the user event stream from
* the stored position, each event mapped as `BoxEventHook` maps it, with the
* places it remembers kept in the checkpoint. A baseline takes the stream's
* present position and lists nothing.
*
* Box never refuses an old `stream_position`: user events are kept for two
* weeks to two months, and a stale position replays whatever is left, so a
* checkpoint older than `EVENT_REPLAY_DAYS` answers UNKNOWN on the event root
* and starts again at the present. Box may also send an event twice or out of
* order, so each pull maps its events once each, in `created_at` order.
*
* Mirrors Python `BoxDeltaHook` (`core/box/events.py`).
*/
export class BoxDeltaHook {
accessor;
constructor(accessor) {
this.accessor = accessor;
}
async pull(root, checkpoint) {
const tm = this.accessor.tokenManager;
const state = decode(checkpoint);
const observed = new Date();
if (state === null) {
const position = await eventsNow(tm, EVENT_STREAM);
return new Delta({ changes: [], checkpoint: encode(position, observed, {}) });
}
if (observed.getTime() - state.read.getTime() > EVENT_REPLAY_DAYS * DAY_MS) {
const fresh = await eventsNow(tm, EVENT_STREAM);
return new Delta({
changes: [lostAt(root, observed)],
checkpoint: encode(fresh, observed, {}),
});
}
const { entries, position } = await eventsSince(tm, state.position, EVENT_STREAM);
const mapper = new BoxEventHook(this.accessor, state.paths);
const events = [];
for (const event of ordered(entries)) {
for (const item of await mapper.toEvents(root, event.event_type ?? '', event))
events.push(item);
}
return new Delta({
changes: eventsUnder(root, events),
checkpoint: encode(position, observed, mapper.paths),
});
}
}
function encode(position, read, paths) {
return sortedJson({ box: { paths, position, read: read.toISOString() } });
}
/**
* The stream position, when it was read and the remembered places a
* checkpoint holds, null for a baseline. Mirrors Python's `_decode`.
*/
function decode(checkpoint) {
if (checkpoint === null)
return null;
const data = JSON.parse(checkpoint);
const state = data.box ?? {};
return {
position: state.position !== undefined && state.position !== '' ? state.position : 'now',
read: new Date(state.read ?? ''),
paths: { ...(state.paths ?? {}) },
};
}
/**
* Map one Box user event onto mount paths.
*
* The consumer owns the long poll: `realtimeServer` gives the URL, a
* `new_change` answer means read `eventsSince` from the last position, and
* each event read goes through `toEvents` with its `event_type`. Nothing here
* runs a loop.
*
* An event names the item's new place only, and a trashed item's place is the
* Trash, so the hook remembers where each item it has mapped was, and moves
* or forgets a folder's contents along with it. An item it has never seen
* gets the honest answer instead: a move or rename of one is UNKNOWN on the
* directory it landed in, and a trash of one maps to nothing and rides the
* index TTL, as Slack's unmapped deletes do.
*
* A folder stands for everything below it, which Box sends no events for, so
* a place a folder leaves or lands on is UNKNOWN, which takes the whole subtree
* whatever the index holds; only a folder created empty (`ITEM_CREATE`) is a
* CREATE. The mount root has no path of its own, so its trash or restore is
* UNKNOWN on the whole mount.
*
* Upload of a new version and of a new file are the same `ITEM_UPLOAD`, so the
* split between CREATE and UPDATE is also whether the item was seen before.
*/
export class BoxEventHook {
accessor;
places;
constructor(accessor, paths = {}) {
this.accessor = accessor;
this.places = new Map(Object.entries(paths));
}
/** Where each item the hook has mapped is, by ref. */
get paths() {
return Object.fromEntries(this.places);
}
toEvents(root, eventType, payload) {
return Promise.resolve(this.map(root, eventType, payload));
}
/** Forget an item that left the mount, and report where it was. */
leave(root, ref, old, isDir) {
if (old === undefined)
return [];
if (isDir) {
this.places = new Map([...this.places].filter(([, path]) => !underPath(path, old)));
return [eventAt(root, old, FileChangeKind.UNKNOWN)];
}
this.places.delete(ref);
return [eventAt(root, old, FileChangeKind.DELETE)];
}
/** Follow an item that moved within the mount. */
move(root, ref, old, next, isDir) {
if (isDir) {
this.places = new Map([...this.places].map(([r, path]) => [r, rebase(path, old, next)]));
return [
eventAt(root, old, FileChangeKind.UNKNOWN),
eventAt(root, next, FileChangeKind.UNKNOWN),
];
}
this.places.set(ref, next);
return [eventAt(root, next, FileChangeKind.MOVE, old)];
}
map(root, eventType, payload) {
const source = sourceOf(field(payload, 'source'));
if (source === null)
return [];
const isDir = source.type === 'folder';
const mountRoot = this.accessor.rootFolderId;
if (isDir && source.id === mountRoot) {
if (TRASH_EVENTS.has(eventType) || eventType === 'ITEM_UNDELETE_VIA_TRASH') {
return [eventAt(root, '', FileChangeKind.UNKNOWN)];
}
return [];
}
const ref = refOf(source);
const old = this.places.get(ref);
if (TRASH_EVENTS.has(eventType))
return this.leave(root, ref, old, isDir);
if (!PLACE_EVENTS.has(eventType))
return [];
const relative = mountRelativeKey(source, mountRoot);
if (relative === null)
return this.leave(root, ref, old, isDir);
if (old !== undefined && old !== relative)
return this.move(root, ref, old, relative, isDir);
this.places.set(ref, relative);
if (old === undefined && (eventType === 'ITEM_MOVE' || eventType === 'ITEM_RENAME')) {
const cut = relative.lastIndexOf('/');
return [eventAt(root, cut === -1 ? '' : relative.slice(0, cut), FileChangeKind.UNKNOWN)];
}
let kind;
if (isDir) {
kind = eventType === 'ITEM_CREATE' ? FileChangeKind.CREATE : FileChangeKind.UNKNOWN;
}
else if (old !== undefined || eventType === 'ITEM_MAKE_CURRENT_VERSION') {
kind = FileChangeKind.UPDATE;
}
else {
kind = FileChangeKind.CREATE;
}
return [eventAt(root, relative, kind)];
}
}
export function buildDeltaHook(accessor) {
return new BoxDeltaHook(accessor);
}
//# sourceMappingURL=events.js.map