@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
793 lines • 32.3 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 { eexist, enoent, enotdir, enotsup, listingError } from "../../errors/fs.js";
import { chunks } from "../../io/cooperative.js";
import { ByteSpool } from "../../io/spool.js";
import { record, recordingActive, recordStream, revisionFor, startOp, } from "../../observe/context.js";
import { DirListing, FileStat, FileType } from "../../types.js";
import { sleep } from "../../utils/abort.js";
import { contentTypeForPath } from "../../utils/filetype.js";
import { mountPrefixOf } from "../../utils/key_prefix.js";
import { windowFor } from "../../utils/ranges.js";
import { rstripSlash, stripSlash } from "../../utils/slash.js";
import { compareCodePoints } from "../../utils/sort.js";
import { cancelUpload, graphBatch, graphDelete, GraphError, graphGet, graphGetBytes, graphList, graphPatch, graphPost, graphPostMonitor, graphPutBytes, graphStream, pollMonitor, uploadChunk, } from "./client.js";
import { BATCH_LIMIT } from "./constants.js";
import { checkItem } from "./paths.js";
const SIMPLE_UPLOAD_MAX = 4 * 1024 * 1024;
const UPLOAD_CHUNK = 10 * 327680;
export class DriveLoc {
drive;
path;
virtual;
url;
ref;
constructor(init) {
this.drive = init.drive;
this.path = init.path;
this.virtual = init.virtual;
this.url = init.url;
this.ref = init.ref;
}
item(action = '') {
return this.url(this.path, action);
}
// Any item on the same drive, addressed the way this loc addresses its
// own. The ancestor probes need it: a DriveLoc's `path` is already
// spelled the way its url callable expects, so an ancestor of it is too.
itemAt(path, action = '') {
return this.url(path, action);
}
child(name) {
return new DriveLoc({
drive: this.drive,
path: this.path !== '' ? `${this.path}/${name}` : name,
virtual: this.virtual !== '' ? `${this.virtual}/${name}` : name,
url: this.url,
ref: this.ref,
});
}
parent() {
return parentPath(this.path);
}
reference(folder = '') {
return this.ref(folder);
}
}
export function parentPath(path) {
const index = path.lastIndexOf('/');
return index < 0 ? '' : path.slice(0, index);
}
export function baseName(path) {
const stripped = rstripSlash(path);
const index = stripped.lastIndexOf('/');
return index < 0 ? stripped : stripped.slice(index + 1);
}
// The directory a listing names: a glob operand lists the folder it sits in.
export function directoryPath(path) {
return path.pattern !== null ? path.dir : path;
}
// The mount-absolute key the index files a listing or an entry under.
export function virtualKey(path) {
const target = directoryPath(path);
const prefix = mountPrefixOf(target.virtual, target.vfsPath);
return target.vfsPath !== '' ? `${prefix}/${target.vfsPath}` : prefix !== '' ? prefix : '/';
}
function asString(value) {
return typeof value === 'string' ? value : null;
}
export function asNumber(value) {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function isFolder(item) {
return item.folder !== undefined;
}
// Graph returns the folder facet's childCount by default, so `-empty` needs
// no extra request. Absent (a $select that dropped it) reads as unknown.
export function folderChildCount(item) {
const facet = item.folder;
if (facet === null || typeof facet !== 'object' || Array.isArray(facet))
return null;
return asNumber(facet.childCount);
}
function parentReference(src, dst) {
const reference = { path: dst.reference(dst.parent()) };
if (src.drive !== dst.drive && dst.drive !== '')
reference.driveId = dst.drive;
return reference;
}
async function copyOnce(config, src, dst, srcVirtual, dstVirtual) {
await checkItem(config, src, srcVirtual);
await checkItem(config, dst, dstVirtual);
try {
const monitor = await graphPostMonitor(config, src.item('/copy'), {
name: baseName(dst.path),
parentReference: parentReference(src, dst),
});
const result = await pollMonitor(monitor, config.timeout);
if (result.status === 'completed')
return null;
const error = result.error !== null && typeof result.error === 'object' && !Array.isArray(result.error)
? result.error
: {};
if (result.status === 'failed') {
return new GraphError(500, asString(error.code) ?? 'copyFailed', asString(error.message) ?? `copy ${src.path} -> ${dst.path} failed`);
}
throw new GraphError(504, 'copyTimeout', `copy ${src.path} -> ${dst.path} not confirmed`);
}
catch (error) {
if (error instanceof GraphError &&
(error.status === 409 || error.code === 'nameAlreadyExists')) {
return error;
}
throw error;
}
}
export async function copyTree(config, src, dst, srcVirtual, dstVirtual) {
const conflict = await copyOnce(config, src, dst, srcVirtual, dstVirtual);
if (conflict === null)
return;
// Status, not just code: `copyOnce` reports a monitor-reported failure
// as 500 and a thrown conflict as 409, so re-raising `conflict` as it
// arrived made the same refusal carry a different status depending on
// which of the two paths produced it. Python's `copy_tree` states the
// status outright (500 for a non-conflict code, 409 for the mixed
// file/folder refusal), and is the correct side.
if (conflict.code !== 'nameAlreadyExists') {
throw new GraphError(500, conflict.code, conflict.message);
}
const srcItem = await graphGet(config, src.item());
const dstItem = await graphGet(config, dst.item());
if (isFolder(srcItem) && isFolder(dstItem)) {
// GNU cp -r merges into an existing directory; Graph never merges
// folders, so recurse per child instead.
for (const child of await graphList(config, src.item('/children'))) {
const name = asString(child.name) ?? '';
await copyTree(config, src.child(name), dst.child(name), srcVirtual === undefined ? undefined : `${rstripSlash(srcVirtual)}/${name}`, dstVirtual === undefined ? undefined : `${rstripSlash(dstVirtual)}/${name}`);
}
return;
}
if (isFolder(srcItem) || isFolder(dstItem)) {
throw new GraphError(409, conflict.code, conflict.message);
}
await graphDelete(config, dst.item());
const secondConflict = await copyOnce(config, src, dst, srcVirtual, dstVirtual);
if (secondConflict !== null) {
throw new GraphError(500, secondConflict.code, secondConflict.message);
}
}
function moveBody(src, dst) {
const body = { name: baseName(dst.path) };
if (src.parent() !== dst.parent() || src.drive !== dst.drive) {
body.parentReference = { path: dst.reference(dst.parent()) };
}
return body;
}
/**
* Move `src` to `dst`, replacing a file or an empty folder there. Answers
* the moved driveItem and whether the move replaced something at `dst`
* that was not positively a file.
*/
export async function renameReplace(config, src, dst, srcVirtual, dstVirtual) {
await checkItem(config, src, srcVirtual);
await checkItem(config, dst, dstVirtual);
const body = moveBody(src, dst);
try {
return { moved: await graphPatch(config, src.item(), body), replacedNonFile: false };
}
catch (error) {
if (!(error instanceof GraphError) ||
(error.status !== 409 && error.code !== 'nameAlreadyExists')) {
throw error;
}
const destination = await graphGet(config, dst.item());
if (isFolder(destination)) {
const children = await graphList(config, dst.item('/children'));
if (children.length > 0)
throw error;
}
await graphDelete(config, dst.item());
const replacedNonFile = !('file' in destination);
return { moved: await graphPatch(config, src.item(), body), replacedNonFile };
}
}
async function urlItem(config, url) {
try {
return await graphGet(config, url);
}
catch (error) {
if (error instanceof GraphError && error.status === 404)
return null;
throw error;
}
}
/**
* Create one folder, naming a refusal the way mkdir(2) does.
*
* "replace" is unreliable for folders on real Graph, so the create uses
* "fail" and reads its 409: a folder already holding the name is success when
* `existOk` (a level `mkdir -p` passes through) and EEXIST otherwise, so a
* folder another client made after the lookup ran is still refused; a file
* holding it is EEXIST. Graph answers a create under a missing parent and
* under a file alike with 404, so the parent is looked up to tell ENOENT from
* ENOTDIR. Both lookups run on a refusal only. Mirrors Python's
* `create_child_folder`.
*/
export async function createChildFolder(config, parentUrl, name, target, existOk = true) {
try {
await graphPost(config, parentUrl, {
name,
folder: {},
'@microsoft.graph.conflictBehavior': 'fail',
});
}
catch (error) {
if (!(error instanceof GraphError))
throw error;
if (error.status === 409 || error.code === 'nameAlreadyExists') {
const taken = await urlItem(config, target.item);
if (existOk && (taken === null || 'folder' in taken))
return;
throw eexist(target.virtual);
}
if (error.status !== 404)
throw error;
const found = await urlItem(config, target.parent);
if (found !== null && !('folder' in found))
throw enotdir(target.virtual);
throw enoent(target.virtual);
}
}
async function uploadSessionWrite(config, sessionUrl, data) {
const session = await graphPost(config, sessionUrl, {
item: { '@microsoft.graph.conflictBehavior': 'replace' },
});
const uploadUrl = asString(session.uploadUrl);
if (uploadUrl === null)
throw new GraphError(502, 'missingUploadUrl', sessionUrl);
const total = data instanceof ByteSpool ? data.size : data.length;
let start = 0;
try {
while (start < total) {
const chunk = data instanceof ByteSpool
? await data.read(start, UPLOAD_CHUNK)
: data.slice(start, start + UPLOAD_CHUNK);
const result = await uploadChunk(config, uploadUrl, chunk, start, total);
const ranges = result.nextExpectedRanges;
const next = Array.isArray(ranges) && typeof ranges[0] === 'string'
? Number(ranges[0].split('-')[0])
: start + chunk.length;
if (!Number.isSafeInteger(next) || next <= start || next > total)
throw new Error('Graph upload session made no progress');
start = next;
}
}
catch (error) {
try {
await cancelUpload(config, uploadUrl);
}
catch (cleanup) {
console.debug('Could not cancel Graph upload session', cleanup);
}
throw error;
}
}
/**
* Replace a drive item's content, creating it and its parents, and answer how
* many bytes went. A small body is one `PUT`; anything past the simple-upload
* limit goes through an upload session. A stream is spooled first, so its size
* picks between the two; `signal` stops it.
*/
export async function writeItem(config, loc, data, signal, virtual) {
await checkItem(config, loc, virtual);
let body;
if (data instanceof Uint8Array)
body = data;
else {
const spool = new ByteSpool();
try {
await spool.fill(chunks(data, signal));
if (spool.size > SIMPLE_UPLOAD_MAX) {
await uploadSessionWrite(config, loc.item('/createUploadSession'), spool);
return spool.size;
}
body = await spool.read(0, spool.size);
}
finally {
await spool.close();
}
}
if (body.length <= SIMPLE_UPLOAD_MAX) {
await graphPutBytes(config, loc.item('/content'), body);
}
else {
await uploadSessionWrite(config, loc.item('/createUploadSession'), body);
}
return body.length;
}
function entryStat(item) {
const name = asString(item.name) ?? '';
if (isFolder(item)) {
// Graph's folder `size` is aggregate storage metadata, not the byte
// length of any rendered content: keep it out of FileStat.size (see
// CLAUDE.md FUSE rules) and expose it as `extra.size_bytes`.
return new FileStat({
name,
type: FileType.DIRECTORY,
modified: asString(item.lastModifiedDateTime),
extra: { size_bytes: asNumber(item.size), child_count: folderChildCount(item) },
});
}
return new FileStat({
name,
type: FileType.FILE,
content: contentTypeForPath(name),
size: asNumber(item.size),
modified: asString(item.lastModifiedDateTime),
fingerprint: asString(item.cTag),
extra: { id: item.id, ctag: item.cTag, etag: item.eTag },
});
}
function currentVersionId(versions) {
let current = null;
for (const version of versions) {
if (current === null ||
(asString(version.lastModifiedDateTime) ?? '') >
(asString(current.lastModifiedDateTime) ?? '')) {
current = version;
}
}
return current === null ? null : asString(current.id);
}
// The item's cTag, current version and download URL, in one GET. Callers
// fetch this before the bytes and download from the URL it returns. Graph can
// change the item between the two requests, and in this order a change can
// only pair an older token with newer bytes, which the next freshness probe
// sees as stale; the other order would label old bytes with the new token and
// serve them as fresh. `versions` also expands the version history for the
// current revision, which only a snapshot needs; without it the revision is
// null. Every shell line records, so only reads outside one (FUSE, a runtime's
// guest, `ws.vfs`) skip it.
async function captureItemMetadata(config, loc, virtual, versions) {
const params = versions ? { $expand: 'versions' } : undefined;
const item = (await checkItem(config, loc, virtual, params)) ?? (await graphGet(config, loc.item(), params));
const history = Array.isArray(item.versions)
? item.versions.filter((value) => value !== null && typeof value === 'object' && !Array.isArray(value))
: [];
return [
asString(item.cTag),
currentVersionId(history),
asString(item['@microsoft.graph.downloadUrl']),
];
}
export async function readItem(config, loc, virtual, backend, offset = 0, size = null) {
const pinned = revisionFor(virtual);
const window = windowFor(offset, size);
const timer = startOp();
let fingerprint = null;
let revision = pinned;
try {
let data;
if (pinned !== null) {
await checkItem(config, loc, virtual);
data = await graphGetBytes(config, loc.item(`/versions/${encodeURIComponent(pinned)}/content`), window);
}
else {
let downloadUrl;
[fingerprint, revision, downloadUrl] = await captureItemMetadata(config, loc, virtual, recordingActive());
data =
downloadUrl === null
? await graphGetBytes(config, loc.item('/content'), window)
: await graphGetBytes(config, downloadUrl, window, false);
}
record('read', virtual, backend, data.length, timer, { fingerprint, revision });
return data;
}
catch (error) {
if (error instanceof GraphError && error.status === 404)
throw enoent(virtual);
throw error;
}
}
export async function* streamItem(config, loc, virtual, backend) {
const pinned = revisionFor(virtual);
const rec = recordStream('read', virtual, backend);
let url = loc.item('/content');
let auth = true;
try {
if (pinned !== null) {
await checkItem(config, loc, virtual);
url = loc.item(`/versions/${encodeURIComponent(pinned)}/content`);
if (rec !== null)
rec.revision = pinned;
}
else if (rec !== null) {
let downloadUrl;
[rec.fingerprint, rec.revision, downloadUrl] = await captureItemMetadata(config, loc, virtual, true);
if (downloadUrl !== null) {
url = downloadUrl;
auth = false;
}
}
if (pinned === null && rec === null)
await checkItem(config, loc, virtual);
for await (const chunk of graphStream(config, url, auth)) {
if (rec !== null)
rec.bytes += chunk.length;
yield chunk;
}
}
catch (error) {
if (error instanceof GraphError && error.status === 404)
throw enoent(virtual);
throw error;
}
if (rec !== null)
rec.complete = true;
}
export async function* iterTree(config, loc, virtual) {
await checkItem(config, loc, virtual);
yield* walkTree(config, loc);
}
/** Walk a checked scope using canonical names returned by Graph. */
async function* walkTree(config, loc) {
for (const child of await graphList(config, loc.item('/children'))) {
const childLoc = loc.child(asString(child.name) ?? '');
const folder = isFolder(child);
yield [childLoc.virtual, child, folder];
if (folder)
yield* walkTree(config, childLoc);
}
}
/**
* The id of the folder a driveItem sits in, '' when it names none: a listing's
* item or a `/delta` feed's. Mirrors Python's `parent_id`.
*/
export function parentId(item) {
const ref = item.parentReference;
if (ref === null || typeof ref !== 'object')
return '';
const id = ref.id;
return typeof id === 'string' ? id : '';
}
/**
* Every item under a folder, each as a listing's stat renders it, from one
* read of Graph's `/delta` feed started at that folder.
*
* One request asks the folder for its id, then the feed lists every item below
* it a page at a time, where a walk sends a listing per folder. The feed names
* an item by id with its name and its parent's id, never its path, and may
* list one more than once (its last row is its state), so each is placed by
* its parents up to the folder. An item under one a listing shows as a file (a
* package's contents) is left out, as a listing of that file would leave it.
* Null when no folder is at `loc`, so the walk's readdir reports what is
* there, when Graph refuses the feed, when the feed does not list the folder
* the first request found (another folder took its path in between, and the
* feed describes that one), when the feed holds a folder shared from another
* drive, whose items only that drive's feed lists, and when a file
* comes without the `cTag` its listing row carries (OneDrive for Business and
* SharePoint leave it out of the feed), since the mount keeps the answer as
* the folders' listings. Mirrors Python's `delta_tree`.
*/
export async function deltaTree(config, loc, virtual) {
let top;
try {
top =
(await checkItem(config, loc, virtual)) ??
(await graphGet(config, loc.item(), { $select: 'id,folder' }));
}
catch (err) {
if (!(err instanceof GraphError) || err.status !== 404)
throw err;
console.debug(`graph tree of ${loc.path} left to the walk: ${err.message}`);
return null;
}
const topId = top.id;
if (!isFolder(top) || typeof topId !== 'string')
return null;
let rows;
try {
rows = await graphList(config, loc.item('/delta'));
}
catch (err) {
if (!(err instanceof GraphError))
throw err;
console.debug(`graph feed of ${loc.path} left to the walk: ${err.message}`);
return null;
}
const items = new Map();
for (const item of rows) {
if (typeof item.id !== 'string')
continue;
items.delete(item.id);
items.set(item.id, item);
}
const folders = new Map();
for (const [id, item] of items) {
if (isFolder(item) && item.deleted === undefined)
folders.set(id, item);
}
if (!folders.has(topId))
return null;
for (const item of folders.values())
if (remoteFolder(item))
return null;
for (const item of items.values()) {
const live = item.file !== undefined && item.deleted === undefined;
if (live && (typeof item.cTag !== 'string' || item.cTag === ''))
return null;
}
const places = new Map([[topId, '']]);
const found = new Map();
for (const [id, item] of items) {
const name = asString(item.name);
if (id === topId || item.deleted !== undefined || item.root !== undefined)
continue;
if (name === null || name === '')
continue;
const base = placed(parentId(item), folders, places);
if (base !== null)
found.set(base === '' ? name : `${base}/${name}`, entryStat(item));
}
return found;
}
/**
* Every item under a folder, each as its folder's listing renders it, from the
* folders' listings sent `BATCH_LIMIT` to a request through Graph's JSON
* batching, where a walk sends a request per folder.
*
* The folder's own children are its listing (`readdirItems`), so a folder that
* is not there raises what a listing raises. A listing the batch does not
* answer with its first page (Graph throttled or failed that one) is asked on
* its own, once the longest wait the batch's answers asked for has passed since
* it arrived (one wait a batch, each answer's counted from the same moment),
* and a listing's later pages are read as a listing reads them. Null when
* Graph refuses a batch, or when a
* listing below the folder fails (a folder gone, or one the account may not
* open), so the walk lists what is there and reports, or skips, each folder on
* its own; null too once it holds more than `limit` items, which no listing
* is paged past. Mirrors Python's `batch_tree`.
*/
export async function batchTree(config, loc, path, limit) {
const found = new Map();
const top = await readdirItems(config, loc, path, limit);
if (top instanceof DirListing)
return null;
let level = filed(found, '', loc, top);
while (level.length > 0) {
if (limit !== undefined && found.size > limit)
return null;
const left = limit === undefined ? undefined : limit - found.size + 1;
let got = 0;
const below = [];
for (let start = 0; start < level.length; start += BATCH_LIMIT) {
const chunk = level.slice(start, start + BATCH_LIMIT);
const urls = chunk.map(([, folder]) => folder.item('/children'));
const listed = [];
try {
const answers = await graphBatch(config, urls);
let pause = 0;
for (const [status, , wait] of answers)
if (status !== 200)
pause = Math.max(pause, wait);
if (pause > 0)
await sleep(pause * 1000);
for (const [i, url] of urls.entries()) {
if (left !== undefined && got >= left)
return null;
const [status, body] = answers[i] ?? [0, {}, 0];
const items = await batchPage(config, url, status, body, left === undefined ? undefined : left - got);
got += items.length;
listed.push(items);
}
}
catch (err) {
const failed = err instanceof GraphError || err instanceof TypeError || err instanceof DOMException;
if (!failed)
throw err;
console.debug(`graph tree of ${loc.path} left to the walk: ${String(err)}`);
return null;
}
for (const [i, [rel, folder]] of chunk.entries()) {
const rows = (listed[i] ?? [])
.map((item) => entryStat(item))
.sort((a, b) => compareCodePoints(a.name, b.name));
for (const pair of filed(found, rel, folder, rows))
below.push(pair);
}
}
if (left !== undefined && got >= left)
return null;
level = below;
}
if (limit !== undefined && found.size > limit)
return null;
return found;
}
/**
* A folder's whole listing from its answer in a batch: the page the batch held
* and the pages after it, or the listing asked again on its own when the batch
* did not answer it; no page past the one that brings `limit` items. Mirrors
* Python's `_batch_page`.
*/
async function batchPage(config, url, status, body, limit) {
if (status !== 200)
return graphList(config, url, undefined, limit);
const items = [];
if (Array.isArray(body.value)) {
for (const item of body.value) {
if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
items.push(item);
}
}
}
const rest = body['@odata.nextLink'];
if (typeof rest === 'string' && (limit === undefined || items.length < limit)) {
const more = limit === undefined ? undefined : limit - items.length;
for (const item of await graphList(config, rest, undefined, more))
items.push(item);
}
return items;
}
/**
* Put a folder's rows into a tree read, and answer the folders among them, each
* by its path below the read with its own address. Mirrors Python's `_filed`.
*/
function filed(found, rel, folder, rows) {
const folders = [];
for (const row of rows) {
const key = rel !== '' ? `${rel}/${row.name}` : row.name;
found.set(key, row);
if (row.type === FileType.DIRECTORY)
folders.push([key, folder.child(row.name)]);
}
return folders;
}
/** Whether a feed item is a folder kept on another drive. Mirrors Python's `_remote_folder`. */
function remoteFolder(item) {
const remote = item.remoteItem;
return remote !== null && typeof remote === 'object' && 'folder' in remote;
}
/**
* A folder's path below the top of a `/delta` read, null when its parents
* never reach it; each folder looked at is kept in `places`. Mirrors Python's
* `_placed`.
*/
function placed(folder, folders, places) {
const chain = [];
const seen = new Set();
let current = folder;
while (!places.has(current)) {
const item = folders.get(current);
if (item === undefined || seen.has(current)) {
places.set(current, null);
break;
}
chain.push(current);
seen.add(current);
current = parentId(item);
}
let base = places.get(current) ?? null;
for (const link of chain.reverse()) {
if (base !== null) {
const name = asString(folders.get(link)?.name) ?? '';
base = base !== '' && name !== '' ? `${base}/${name}` : name !== '' ? name : null;
}
places.set(link, base);
}
return places.get(folder) ?? null;
}
// One bounded page, not graphList: the answer is a yes/no, and graphList
// follows every @odata.nextLink, so asking it made a large folder download
// its whole listing to produce one boolean. $top through that helper is
// worse rather than better -- it only shrinks each page, so the walk pages
// more times, not fewer -- which is why this sends the request itself.
// $select drops a driveItem payload this never reads.
export async function driveRootEmpty(config, loc, virtual) {
await checkItem(config, loc, virtual);
const page = await graphGet(config, loc.item('/children'), { $top: 1, $select: 'id' });
return !(Array.isArray(page.value) && page.value.length > 0);
}
async function itemOrNull(config, loc, path) {
try {
return await graphGet(config, loc.itemAt(stripSlash(path)));
}
catch (error) {
if (error instanceof GraphError && error.status === 404)
return null;
throw error;
}
}
async function isFile(config, loc, path) {
const item = await itemOrNull(config, loc, path);
return item !== null && !('folder' in item);
}
async function isDir(config, loc, path) {
const item = await itemOrNull(config, loc, path);
return item !== null && 'folder' in item;
}
/**
* A folder's children, each as `stat` answers it; past a walk's `limit` Graph
* is paged no further, and the listing is partial. Mirrors Python's
* `readdir_items`.
*/
export async function readdirItems(config, loc, path, limit) {
await checkItem(config, loc, path.virtual);
let children;
try {
children = await graphList(config, loc.item('/children'), undefined, limit);
}
catch (error) {
if (!(error instanceof GraphError) || error.status !== 404)
throw error;
// Graph 404s a children listing for a missing item and for one under a
// file alike, so the errno comes from walking the ancestors: one item
// request per component, on this failure path only.
throw await listingError(path, loc.path, (p) => isFile(config, loc, p), (p) => isDir(config, loc, p));
}
const rows = children
.map((child) => entryStat(child))
.sort((a, b) => compareCodePoints(a.name, b.name));
if (limit !== undefined && children.length >= limit)
return new DirListing({ entries: rows, partial: true });
return rows;
}
/** One drive item's stat, from Graph. Mirrors Python's `stat_item`. */
export async function statItem(config, loc, path) {
try {
return entryStat((await checkItem(config, loc, path.virtual)) ?? (await graphGet(config, loc.item())));
}
catch (error) {
if (error instanceof GraphError && error.status === 404)
throw enoent(path);
throw error;
}
}
// Graph has no cheap HEAD for an item, so existence is a stat that tolerates
// ENOENT. Both drive backends address items differently but probe
// identically, so only their stat is injected.
export function makeExists(stat) {
return async (accessor, path) => {
try {
await stat(accessor, path);
return true;
}
catch (error) {
if (error.code === 'ENOENT')
return false;
throw error;
}
};
}
// Graph exposes no truncate, so the whole item is rewritten. A missing item
// truncates to a fresh one, matching `open(path, "w")`.
export function makeTruncate(read, write) {
return async (accessor, path, length, noCreate = false) => {
if (noCreate)
throw enotsup('msgraph', 'truncate --no-create', path);
let data;
try {
data = await read(accessor, path);
}
catch (error) {
if (error.code !== 'ENOENT')
throw error;
data = new Uint8Array();
}
const resized = new Uint8Array(length);
resized.set(data.slice(0, length));
await write(accessor, path, resized);
};
}
//# sourceMappingURL=drive.js.map