@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
255 lines • 10.7 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 { conditionVersion, stale } from "../../cache/context.js";
import { enoent } from "../../errors/fs.js";
import { ByteSpool } from "../../io/spool.js";
import { BoxApiError, boxDelete, boxGet, boxGetBytes, boxGetStream, boxOptions, boxPostJson, boxPutJson, boxUploadMultipart, boxUploadSession, } from "./client.js";
import { GONE_STATUS, LOST_STATUS } from "./constants.js";
/**
* The refusal an `If-Match` request met (412 changed, 404 gone), null for any
* other or a request that went plain. Mirrors Python's `refused`.
*/
export async function refused(path, err, cond, etag) {
if (etag === null || etag === '' || !(err instanceof BoxApiError))
return null;
if (err.status !== LOST_STATUS && err.status !== GONE_STATUS)
return null;
return stale(path, { version: conditionVersion(cond) });
}
/** The header that conditions a request on `etag`, empty to go plain. */
export function ifMatch(etag) {
return etag !== null && etag !== '' ? { 'If-Match': etag } : {};
}
// Box answers a folder id that has been deleted, or was never reachable,
// with 404, and BoxApiError carries only an HTTP status. Stamping that one
// status as ENOENT here keeps a single definition of absence: stat, readdir
// and du's walk all read the POSIX code instead of sniffing a status, so a
// 401/429/5xx stays a failure rather than reading back as a missing path.
export async function absentOn404(virtual, call) {
try {
return await call();
}
catch (err) {
if (err instanceof BoxApiError && err.status === 404)
throw enoent(virtual);
throw err;
}
}
const LIST_FIELDS = 'id,name,type,size,modified_at,etag,sha1,parent';
const SEARCH_FIELDS = 'id,name,type,path_collection';
const ITEM_FIELDS = 'type,id,name,size,modified_at,etag,sha1,path_collection,item_status';
const SEARCH_PAGE = 200;
// Box search serves at most 10,000 matches across all pages; a result set
// that reaches the ceiling may be incomplete and must not narrow a scan.
const MAX_SEARCH_MATCHES = 10_000;
const EVENTS_PAGE = 500;
/**
* List every item in a Box folder, following offset pagination: `pageSize`
* items per request, and no request past the one that brings `limit` items
* other than web links (bookmarks no listing names). Mirrors Python's
* `list_folder_items`.
*/
export async function listFolderItems(tm, folderId, opts = {}) {
const pageSize = opts.pageSize ?? 1000;
const out = [];
let offset = 0;
let named = 0;
for (;;) {
const data = (await boxGet(tm, `${tm.apiBase}/folders/${folderId}/items`, {
fields: LIST_FIELDS,
limit: pageSize,
offset,
}));
for (const item of data.entries)
out.push(item);
offset += data.entries.length;
named += data.entries.filter((item) => item.type !== 'web_link').length;
if (offset >= data.total_count || data.entries.length === 0) {
break;
}
if (opts.limit !== undefined && named >= opts.limit)
break;
}
return out;
}
function nextPosition(data) {
const value = data.next_stream_position;
return value === undefined || value === null || value === '' ? null : String(value);
}
/** The current head of the user's event stream. */
export async function eventsNow(tm, streamType) {
const data = (await boxGet(tm, `${tm.apiBase}/events`, {
stream_type: streamType,
stream_position: 'now',
}));
const position = nextPosition(data);
if (position === null)
throw new Error('Box GET /events returned no next_stream_position');
return position;
}
/**
* Every user event after `streamPosition`, and the new position.
*
* Box may answer with fewer events than `limit` while more remain, so only an
* empty page ends the read. A page of events that does not move the position
* on is refused: reading it again would return the same page for as long as
* the server keeps answering that way.
*/
export async function eventsSince(tm, streamPosition, streamType, opts = {}) {
const limit = opts.limit ?? EVENTS_PAGE;
const out = [];
let position = streamPosition;
for (;;) {
const data = (await boxGet(tm, `${tm.apiBase}/events`, {
stream_type: streamType,
stream_position: position,
limit,
}));
const entries = data.entries ?? [];
const advanced = nextPosition(data);
if (entries.length === 0)
return { entries: out, position: advanced ?? position };
if (advanced === null || advanced === position) {
throw new Error('Box GET /events returned events but did not advance next_stream_position');
}
for (const item of entries)
out.push(item);
position = advanced;
}
}
/**
* The long-poll server for the user's event stream.
*
* `OPTIONS /events` hands back a `realtime_server` entry; a GET on its `url`
* with `&stream_position=<position>` blocks until Box answers `new_change`
* (read the events) or `reconnect` (ask for a new server). The loop is the
* caller's.
*/
export async function realtimeServer(tm) {
const data = (await boxOptions(tm, `${tm.apiBase}/events`));
const server = data.entries?.[0];
if (server === undefined)
throw new Error('Box OPTIONS /events returned no realtime server');
return server;
}
export async function getFolderInfo(tm, folderId) {
return (await boxGet(tm, `${tm.apiBase}/folders/${folderId}`));
}
/** One file's live metadata: its sha1, name, ancestry and status. */
export async function getFileInfo(tm, fileId) {
return (await boxGet(tm, `${tm.apiBase}/files/${fileId}`, {
fields: ITEM_FIELDS,
}));
}
export async function downloadFile(tm, fileId, window) {
return boxGetBytes(tm, `${tm.apiBase}/files/${fileId}/content`, undefined, window);
}
export async function* downloadFileStream(tm, fileId) {
for await (const chunk of boxGetStream(tm, `${tm.apiBase}/files/${fileId}/content`)) {
yield chunk;
}
}
/**
* Name+content search scoped to a folder subtree. Pages Box `/search` with
* `ancestor_folder_ids` scoping and `content_types=name,file_content` so the
* query matches file names and the server-indexed body text. Each returned
* item carries `path_collection` (its ancestor chain) for mount-relative path
* reconstruction. The boolean is true when the result reached the
* 10,000-match ceiling (a truncated set is not a trustworthy superset).
*/
export async function searchContent(tm, query, ancestorFolderId) {
const out = [];
let offset = 0;
for (;;) {
const data = (await boxGet(tm, `${tm.apiBase}/search`, {
query,
ancestor_folder_ids: ancestorFolderId,
content_types: 'name,file_content',
type: 'file',
fields: SEARCH_FIELDS,
limit: SEARCH_PAGE,
offset,
}));
for (const item of data.entries)
out.push(item);
offset += data.entries.length;
if (out.length >= MAX_SEARCH_MATCHES)
return { items: out, truncated: true };
if (offset >= data.total_count || data.entries.length === 0) {
return { items: out, truncated: false };
}
}
}
export async function uploadNewFile(tm, parentId, name, data) {
if (data instanceof ByteSpool) {
if (data.size >= 20 * 1024 * 1024)
return boxUploadSession(tm, `${tm.uploadBase}/files/upload_sessions`, { folder_id: parentId, file_name: name }, data);
data = await data.read(0, data.size);
}
return boxUploadMultipart(tm, `${tm.uploadBase}/files/content`, { name, parent: { id: parentId } }, name, data);
}
export async function uploadFileVersion(tm, fileId, name, data, etag = null) {
if (data instanceof ByteSpool) {
if (data.size >= 20 * 1024 * 1024)
return boxUploadSession(tm, `${tm.uploadBase}/files/${fileId}/upload_sessions`, { file_name: name }, data, etag);
data = await data.read(0, data.size);
}
return boxUploadMultipart(tm, `${tm.uploadBase}/files/${fileId}/content`, { name }, name, data, ifMatch(etag));
}
export async function createFolder(tm, parentId, name) {
return (await boxPostJson(tm, `${tm.apiBase}/folders`, {
name,
parent: { id: parentId },
}));
}
export async function deleteFile(tm, fileId, etag = null) {
await boxDelete(tm, `${tm.apiBase}/files/${fileId}`, undefined, ifMatch(etag));
}
export async function deleteWebLink(tm, linkId) {
await boxDelete(tm, `${tm.apiBase}/web_links/${linkId}`);
}
export async function deleteFolder(tm, folderId, recursive = true) {
await boxDelete(tm, `${tm.apiBase}/folders/${folderId}`, {
recursive: recursive ? 'true' : 'false',
});
}
export async function updateFile(tm, fileId, opts) {
const body = {};
if (opts.name !== undefined)
body.name = opts.name;
if (opts.parentId !== undefined)
body.parent = { id: opts.parentId };
return (await boxPutJson(tm, `${tm.apiBase}/files/${fileId}`, body));
}
export async function updateFolder(tm, folderId, opts) {
const body = {};
if (opts.name !== undefined)
body.name = opts.name;
if (opts.parentId !== undefined)
body.parent = { id: opts.parentId };
return (await boxPutJson(tm, `${tm.apiBase}/folders/${folderId}`, body));
}
export async function copyFile(tm, fileId, parentId, name) {
const body = { parent: { id: parentId } };
if (name !== undefined)
body.name = name;
return (await boxPostJson(tm, `${tm.apiBase}/files/${fileId}/copy`, body));
}
export async function copyFolder(tm, folderId, parentId, name) {
const body = { parent: { id: parentId } };
if (name !== undefined)
body.name = name;
return (await boxPostJson(tm, `${tm.apiBase}/folders/${folderId}/copy`, body));
}
//# sourceMappingURL=api.js.map