UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

255 lines • 10.7 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { 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