UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

862 lines • 33.8 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 { callerSignal, currentCaller } from "../../context/session_context.js"; import { ABORT_JOIN_MS, runCancellable, hasAborted, makeAbortError } from "../../utils/abort.js"; import { MoveError } from "../../errors/types.js"; import { CONDITION_SUPPORT } from "./constants.js"; import { ByteSpool } from "../../io/spool.js"; import { chunks } from "../../io/cooperative.js"; import {} from "../../io/types.js"; import { toIsoZ } from "../../utils/dates.js"; import { VFSName } from "../../types.js"; import { eacces, enoent, enotsup, unnamedFsError } from "../../errors/fs.js"; import { rstripSlash } from "../../utils/slash.js"; import { ABSENT_WRITE, ANY_WRITE, OwnRead, } from "../../cache/types.js"; import {} from "../object_store/driver.js"; import { ConditionLostError } from "../object_store/errors.js"; import { createS3Client, isConditionLost, isDenied, isNotFound, loadS3Module, streamToBuffer, } from "./client.js"; import { CONDITION_LOST_CODES, MAX_PARTS, PART_DOUBLING, PART_SIZE, SCOPE_ERROR, } from "./constants.js"; const DELETE_BATCH = 1000; function isoOf(modified) { if (modified instanceof Date) return toIsoZ(modified); return typeof modified === 'string' ? modified : ''; } async function* listPages(conn, input) { let continuationToken; do { const page = { ...input }; if (continuationToken !== undefined) page.ContinuationToken = continuationToken; const resp = (await conn.send(new conn.mod.ListObjectsV2Command(page))); yield resp; continuationToken = resp.IsTruncated === true ? resp.NextContinuationToken : undefined; } while (continuationToken !== undefined); } function keyPrefixOf(accessor) { return accessor.config.keyPrefix ?? ''; } async function connect(accessor) { const { config } = accessor; const mod = await loadS3Module(config); const client = await createS3Client(config); const send = client.send.bind(client); return { conn: { send, mod, config }, close: () => { ; client.destroy?.(); return Promise.resolve(); }, }; } async function* listChildren(conn, pfx) { const input = { Bucket: conn.config.bucket, Prefix: pfx, Delimiter: '/' }; for await (const page of listPages(conn, input)) { for (const cp of page.CommonPrefixes ?? []) { const p = cp.Prefix; if (p === undefined) continue; const child = rstripSlash(p); if (child !== '') yield { key: child, kind: 'd' }; else yield { key: p, kind: 'marker' }; } for (const obj of page.Contents ?? []) { const key = obj.Key; if (key === undefined) continue; const relative = key.slice(pfx.length); if (relative !== '' && !relative.includes('/')) { yield { key, kind: 'f', size: obj.Size ?? null, modified: isoOf(obj.LastModified) }; } else { yield { key, kind: 'marker' }; } } } } async function* listSubtree(conn, stem) { // The prefix listing also matches sibling keys sharing the stem as a // name prefix ("data-old" under stem "data"), so each key is checked // against the exact stem or the slashed subtree. const base = stem !== '' ? `${stem}/` : ''; for await (const page of listPages(conn, { Bucket: conn.config.bucket, Prefix: stem })) { for (const obj of page.Contents ?? []) { const okey = obj.Key; if (okey === undefined) continue; if (!(okey === stem || okey.startsWith(base))) continue; yield { key: okey, size: obj.Size ?? 0, modified: isoOf(obj.LastModified) }; } } } // Quote-stripped ETag from a head or put response, '' when absent. // Returned as a possibly-empty string rather than `string | null` // because `head` needs both spellings: `fingerprint` drops an empty one // while `extra` omits the key entirely. function etagOf(resp) { return resp.ETag?.replace(/^"|"$/g, '') ?? ''; } // VersionId from a head or put response, null when unversioned. function versionOf(resp) { const revision = resp.VersionId ?? null; return revision === 'null' ? null : revision; } async function head(conn, key) { let resp; try { resp = (await conn.send(new conn.mod.HeadObjectCommand({ Bucket: conn.config.bucket, Key: key }))); } catch (err) { if (isNotFound(err)) return null; if (isDenied(err)) throw denied(key); throw err; } const etag = etagOf(resp); return { size: resp.ContentLength ?? null, modified: resp.LastModified === undefined ? null : toIsoZ(resp.LastModified), fingerprint: etag !== '' ? etag : null, revision: versionOf(resp), extra: etag !== '' ? { etag } : {}, }; } async function get(conn, key) { return (await getVersioned(conn, key, null))?.[0] ?? null; } function put(conn, key, data) { return putIf(conn, key, data, ANY_WRITE); } // Tokens are stored unquoted (`etagOf`); the wire takes the quoted form. function quoted(token) { return token.startsWith('"') ? token : `"${token}"`; } function condition(cond) { switch (cond.kind) { case 'any': return {}; case 'absent': return { IfNoneMatch: '*' }; case 'match': return { IfMatch: quoted(cond.version) }; } } /** * Copy `srcKey` onto `dstKey` within the bucket, under the destination's * condition and the version the source must still have. Mirrors Python's * `_copy_object`. */ async function copyObject(conn, srcKey, dstKey, cond = null, source = null) { const response = await conn.send(new conn.mod.CopyObjectCommand({ Bucket: conn.config.bucket, CopySource: `${conn.config.bucket}/${srcKey}`, Key: dstKey, ...(source !== null ? { CopySourceIfMatch: source } : {}), ...(cond !== null ? condition(cond) : {}), })); const copied = response.CopyObjectResult; const metadata = response.$metadata; const fingerprint = copied?.ETag?.replace(/^"|"$/g, ''); return { fingerprint: fingerprint === undefined || fingerprint === '' ? null : fingerprint, revision: versionOf(response), retried: (metadata?.attempts ?? 1) > 1, }; } function isMissingBucket(err) { const e = err; return e.name === 'NoSuchBucket' || e.Code === 'NoSuchBucket'; } /** * Whether a conditioned request lost its condition: a version sent with * `If-Match` loses on a 412, and on a 404 for its key (not a missing bucket), * since AWS answers that way for a key deleted since it was read. Mirrors * Python's `_lost_condition`. */ function lostCondition(err, matched) { return isConditionLost(err) || (matched && isNotFound(err) && !isMissingBucket(err)); } async function guarded(key, call, matched = false) { try { return await call; } catch (err) { if (lostCondition(err, matched)) throw new ConditionLostError([key], { gone: !isConditionLost(err) }); throw err; } } async function putIf(conn, key, data, cond, size = data instanceof Uint8Array ? data.byteLength : 0) { // The ETag is read through the same helper `head` uses, so the token a // write stamps and the token a later stat reports are one spelling. // A write carries no type of its own, so the mount's default is the one the // store keeps and serves back. if (cond.kind !== 'any' && conn.config.presignedUrlProvider !== undefined) throw enotsup('s3', 'conditional put', key); const contentType = conn.config.defaultContentType; const resp = (await guarded(key, conn.send(new conn.mod.PutObjectCommand({ Bucket: conn.config.bucket, Key: key, Body: data, ...(data instanceof Uint8Array ? {} : { ContentLength: size }), ...(contentType !== undefined && contentType !== '' ? { ContentType: contentType } : {}), ...condition(cond), })), cond.kind === 'match')); const etag = etagOf(resp); return { size, fingerprint: etag !== '' ? etag : null, revision: versionOf(resp), }; } async function putStream(conn, key, data, cond) { // Presigned fetch has no multipart API; custom endpoints keep conditional PUT. if (conn.config.presignedUrlProvider !== undefined || (cond.kind !== 'any' && conn.config.endpoint !== undefined && conn.config.endpoint !== '')) { const spool = new ByteSpool(); try { await spool.fill(data); if (spool.size > 5 * 1024 ** 3) throw new Error('s3: single PUT exceeds 5 GiB'); const body = await spool.body(conn.config.presignedUrlProvider === undefined ? 'sdk' : 'fetch'); return await putIf(conn, key, body, cond, spool.size); } finally { await spool.close(); } } let upload = null; const parts = []; // TODO(MIRAGE-38): spool parts past 64 MiB; this holds a whole part. let buffer = new Uint8Array(PART_SIZE); let used = 0; let size = 0; const args = { Bucket: conn.config.bucket, Key: key }; const sendPart = async (body) => { if (parts.length >= MAX_PARTS) throw new Error('s3: streamed upload exceeds 10000 parts'); const number = parts.length + 1; const response = await conn.send(new conn.mod.UploadPartCommand({ ...args, UploadId: upload, PartNumber: number, Body: body, })); if (typeof response.ETag !== 'string') throw new Error('s3: upload part returned no ETag'); parts.push({ PartNumber: number, ETag: response.ETag }); }; try { for await (const chunk of chunks(data)) { size += chunk.byteLength; let offset = 0; while (offset < chunk.byteLength) { const count = Math.min(buffer.length - used, chunk.byteLength - offset); buffer.set(chunk.subarray(offset, offset + count), used); offset += count; used += count; if (used === buffer.length) { if (upload === null) { const contentType = conn.config.defaultContentType; const opened = await conn.send(new conn.mod.CreateMultipartUploadCommand({ ...args, ...(contentType !== undefined && contentType !== '' ? { ContentType: contentType } : {}), })); if (typeof opened.UploadId !== 'string') throw new Error('s3: upload returned no id'); upload = opened.UploadId; } // Each send ends before the buffer fills again, so it goes as is. await sendPart(buffer); used = 0; const part = PART_SIZE * 2 ** Math.floor(parts.length / PART_DOUBLING); if (part > buffer.length) buffer = new Uint8Array(part); } } } if (upload === null) { return await putIf(conn, key, buffer.slice(0, used), cond); } if (used > 0) await sendPart(buffer.subarray(0, used)); const response = (await guarded(key, conn.send(new conn.mod.CompleteMultipartUploadCommand({ ...args, UploadId: upload, MultipartUpload: { Parts: parts }, ...condition(cond), })), cond.kind === 'match')); upload = null; return { size, fingerprint: etagOf(response) || null, revision: versionOf(response) }; } finally { if (upload !== null) { try { await conn.send(new conn.mod.AbortMultipartUploadCommand({ ...args, UploadId: upload })); } catch (error) { console.debug('aborting multipart upload failed:', key, error); } } } } async function getVersioned(conn, key, revision) { let resp; try { resp = (await conn.send(new conn.mod.GetObjectCommand({ Bucket: conn.config.bucket, Key: key, ...(revision !== null ? { VersionId: revision } : {}), }))); } catch (err) { if (isNotFound(err)) { if (revision === null) return null; if (isMissingBucket(err)) throw err; // A pinned revision gone from the store is no file, not absence. throw enoent(key); } if (revision === null && isDenied(err)) throw denied(key); throw err; } const etag = etagOf(resp); return [await streamToBuffer(resp.Body), etag !== '' ? etag : null]; } async function copyIf(conn, srcKey, dstKey, cond) { try { await copyObject(conn, srcKey, dstKey, cond); } catch (err) { // A 404 is lost only while the source is still there. let lost = isConditionLost(err); let probeFailed = false; if (!lost && cond.kind === 'match' && isNotFound(err) && !isMissingBucket(err)) { try { lost = (await head(conn, srcKey)) !== null; } catch (probeErr) { probeFailed = true; console.debug(`source probe failed for ${srcKey}: ${String(probeErr)}`); } } if (lost) throw new ConditionLostError([dstKey], { gone: !isConditionLost(err) }); if (probeFailed || !isNotFound(err) || isMissingBucket(err)) throw err; console.debug(`copy source ${srcKey} is missing: ${String(err)}`); return false; } return true; } async function deleteIf(conn, key, cond) { await guarded(key, conn.send(new conn.mod.DeleteObjectCommand({ Bucket: conn.config.bucket, Key: key, ...condition(cond), }))); } /** * Which end of a refused copy changed: S3 answers 412 for either condition, * so the destination is looked up, and one no longer at the version sent is * the end that lost; otherwise the source's pin did. Only a refusal pays for * the lookup. Returns the key that changed and the version it lost on, ABSENT * when it is gone. Mirrors Python's `_copy_loser`. */ async function copyLoser(conn, srcKey, dstKey, cond, source) { try { if (cond.kind === 'match') { const meta = await head(conn, dstKey); if (meta === null) return [dstKey, OwnRead.ABSENT]; if (quoted(meta.fingerprint ?? '') !== quoted(cond.version)) { return [dstKey, quoted(cond.version)]; } } if ((await head(conn, srcKey)) === null) return [srcKey, OwnRead.ABSENT]; } catch (err) { // Unknown which end changed: the source is named, and both keep their versions. console.debug(`copy loser lookup failed for ${dstKey}: ${String(err)}`); } return [srcKey, quoted(source)]; } /** * A refusal of `key`, keeping the version it lost on unless it is gone * (ABSENT). Mirrors Python's `_lost_on`. */ function lostOn(key, version, landed = false) { if (version === OwnRead.ABSENT) return new ConditionLostError([key], { landed, gone: true }); return new ConditionLostError([key], { landed, versions: new Map([[key, quoted(version)]]) }); } function deleteRejected(err) { const e = err; return ((e.name === 'PreconditionFailed' || e.Code === 'PreconditionFailed') && (e.$metadata?.attempts ?? 1) === 1); } /** Join an owned move request within the caller's cancellation grace. */ function joinMove(request, signal) { return runCancellable(request, signal, ABORT_JOIN_MS); } async function recoverCopy(conn, dstKey, receipt, absent) { if (receipt.revision !== null && receipt.retried) return [false, null, 'copy was retried; earlier copied versions may remain']; let condition; if (receipt.revision !== null) condition = { VersionId: receipt.revision }; else if (absent && receipt.fingerprint !== null) condition = { IfMatch: quoted(receipt.fingerprint) }; else return [false, null, absent ? 'copy identity unavailable' : 'destination was not confirmed new']; const controller = new AbortController(); try { const cleanup = conn.send(new conn.mod.DeleteObjectCommand({ Bucket: conn.config.bucket, Key: dstKey, ...condition }), { abortSignal: controller.signal }); await joinMove(cleanup, callerSignal() ?? currentCaller().stop?.signal); } catch (err) { if (isNotFound(err) && !isMissingBucket(err)) return [true, null, null]; console.debug('recovering a failed move copy failed:', err); return [false, err, 'conditional cleanup failed']; } finally { controller.abort(); } return [true, null, null]; } /** Give cancellation cleanup its own bounded lifetime. */ async function recoverCancelledCopy(conn, dstKey, receipt, absent) { const [recovered] = await recoverCopy(conn, dstKey, receipt, absent); if (!recovered) console.warn('S3 move cancelled; destination cleanup incomplete'); } async function moveFileIf(conn, srcKey, dstKey, cond, source) { // S3 compensation intentionally differs from GNU's cross-device mv. let pinned = source; if (pinned === null) { const meta = await head(conn, srcKey); if (meta === null) return false; pinned = meta.fingerprint ?? ''; } const match = quoted(pinned); let destination = cond; if (cond.kind === 'any') { try { if ((await head(conn, dstKey)) === null) destination = ABSENT_WRITE; } catch (err) { if (err.code !== 'EACCES') throw err; console.debug('move destination inspection refused; recovery needs a version ID'); } } let receipt; try { receipt = await copyObject(conn, srcKey, dstKey, destination, match); } catch (err) { if (!lostCondition(err, true)) throw err; if (destination.kind === 'absent') { let observed = null; try { observed = await head(conn, dstKey); } catch (probeError) { console.debug('copy destination probe failed:', probeError); } if (observed !== null) throw new ConditionLostError([dstKey]); } const [named, version] = await copyLoser(conn, srcKey, dstKey, destination, match); throw lostOn(named, version); } const signal = callerSignal() ?? currentCaller().stop?.signal; if (hasAborted(signal)) { await recoverCancelledCopy(conn, dstKey, receipt, destination.kind === 'absent'); throw makeAbortError(signal); } const controller = new AbortController(); let sourceRemoved = false; try { const deletion = conn.send(new conn.mod.DeleteObjectCommand({ Bucket: conn.config.bucket, Key: srcKey, IfMatch: match }), { abortSignal: controller.signal }); await joinMove(deletion, signal); sourceRemoved = true; if (hasAborted(signal)) throw makeAbortError(signal); } catch (err) { if (hasAborted(signal)) { if (deleteRejected(err)) await recoverCancelledCopy(conn, dstKey, receipt, destination.kind === 'absent'); else if (!sourceRemoved) console.warn('S3 move cancelled; source deletion outcome unknown, destination may remain'); throw makeAbortError(signal); } if (!lostCondition(err, true)) { if (err.name === 'AbortError') throw err; throw new MoveError(err, 'source deletion outcome is unknown'); } const gone = !isConditionLost(err); const [recovered, recoveryError, recoveryReason] = deleteRejected(err) ? await recoverCopy(conn, dstKey, receipt, destination.kind === 'absent') : [false, null, 'source deletion outcome is unknown']; if (hasAborted(signal)) { if (!recovered) console.warn('S3 move cancelled; destination cleanup incomplete'); throw makeAbortError(signal); } throw new ConditionLostError([srcKey], { landed: true, gone, ...(gone ? {} : { versions: new Map([[srcKey, match]]) }), recovered, recoveryError, recoveryReason, }); } finally { controller.abort(); } return true; } /** * Each listing page under `pfx`, every key with the version it is measured * against: the version the agent read where there is one, else the * listing's, one page at a time. * Mirrors Python's `_known_pages`. */ async function* knownPages(conn, pfx, known) { for await (const page of listPages(conn, { Bucket: conn.config.bucket, Prefix: pfx })) { const listed = []; for (const obj of page.Contents ?? []) { if (obj.Key !== undefined) listed.push([obj.Key, quoted(obj.ETag ?? '')]); } if (listed.length === 0) continue; const versions = await known(listed.map(([key]) => key)); yield listed.map(([key, token]) => { const version = versions.get(key); return [key, version !== undefined ? quoted(version) : token]; }); } } /** * The error for a key the store refuses this caller (403). Unnamed, as a * kernel's EACCES is: the key carries the mount's prefix, so the layer above * names the operand. Mirrors Python's `_denied`. */ function denied(key) { return unnamedFsError('EACCES', `S3 refused '${key}'`); } /** * The error for keys a DeleteObjects refused in the body of its 200. A key * is not a path, so it names none and the command names its operand. * Mirrors Python's `_delete_refused`. */ function deleteRefused(failed) { const message = `S3 refused to delete ${String(failed.length)} object(s), starting at '${failed[0] ?? ''}'`; return unnamedFsError('EACCES', message); } /** * Delete each listed key only while it is the version listed, returning the * keys a newer write changed (kept) and the keys the store refused for * another reason. A refusal comes back per key in the body of a 200. */ async function deleteBatch(conn, keys) { const lost = []; const failed = []; for (let start = 0; start < keys.length; start += DELETE_BATCH) { const batch = keys.slice(start, start + DELETE_BATCH); const sent = new Map(batch); const resp = (await conn.send(new conn.mod.DeleteObjectsCommand({ Bucket: conn.config.bucket, Delete: { Objects: batch.map(([Key, ETag]) => ({ Key, ETag })) }, }))); for (const err of resp.Errors ?? []) { const key = err.Key ?? ''; if (CONDITION_LOST_CODES.has(err.Code ?? '')) lost.push([key, sent.get(key) ?? OwnRead.ABSENT]); else failed.push(key); } } return [lost, failed]; } /** * Throw a walk's later failure, carrying the keys it lost before it (as a * ConditionLostError), or the failure itself when none were. Mirrors Python's * `_raise_lost_before`. */ function raiseLostBefore(lost, err, untouched) { if (lost.length > 0) { throw new ConditionLostError(lost.map(([key]) => key), { versions: new Map([...untouched, ...lost]), error: err }); } throw err; } /** * Raise for the keys a prefix op kept: lost keys keep the versions they were * measured on; a key the store refused for another reason is the error * reported, the lost keys carried with it. Mirrors Python's `_raise_kept`. */ function raiseKept(lost, failed, untouched) { if (lost.length > 0) { throw new ConditionLostError(lost.map(([key]) => key), { versions: new Map([...untouched, ...lost]), error: failed.length > 0 ? deleteRefused(failed) : null, }); } if (failed.length > 0) throw deleteRefused(failed); } async function deletePrefixIf(conn, pfx, known) { const lost = []; const failed = []; try { for await (const listed of knownPages(conn, pfx, known)) { const [pageLost, pageFailed] = await deleteBatch(conn, listed); for (const item of pageLost) lost.push(item); for (const item of pageFailed) failed.push(item); } } catch (err) { raiseLostBefore(lost, err, new Map()); } raiseKept(lost, failed, new Map()); } async function movePrefixIf(conn, srcPfx, dstPfx, known, dstKnown) { let found = false; const lost = []; const failed = []; const moved = []; const untouched = new Map(); try { for await (const listed of knownPages(conn, srcPfx, known)) { found = true; const dstOf = (key) => `${dstPfx}${key.slice(srcPfx.length)}`; const held = await dstKnown(listed.map(([key]) => dstOf(key))); for (const [key, token] of listed) { const dstKey = dstOf(key); const heldVersion = held.get(dstKey); const cond = heldVersion !== undefined && heldVersion !== '' ? { kind: 'match', version: heldVersion } : ANY_WRITE; try { await copyObject(conn, key, dstKey, cond, token); } catch (err) { if (!lostCondition(err, true)) throw err; if (cond.kind === 'match') { const loser = await copyLoser(conn, key, dstKey, cond, token); lost.push(loser); // The end left alone keeps its version, as a file mv's. if (loser[0] === key) untouched.set(dstKey, quoted(cond.version)); else untouched.set(key, quoted(token)); } else { // A source gone (404) keeps what the session last saw of it. lost.push([key, isConditionLost(err) ? token : OwnRead.ABSENT]); } continue; } moved.push([key, token]); } } // As any failed copy, a lost one keeps the whole source: deleting the // rest would split the directory across both ends. if (lost.length === 0) { // A batch at a time, so a later batch's failure keeps what one lost. for (let start = 0; start < moved.length; start += DELETE_BATCH) { const [batchLost, batchFailed] = await deleteBatch(conn, moved.slice(start, start + DELETE_BATCH)); for (const item of batchLost) lost.push(item); for (const item of batchFailed) failed.push(item); } } } catch (err) { raiseLostBefore(lost, err, untouched); } raiseKept(lost, failed, untouched); return found; } async function deleteFile(conn, key) { await conn.send(new conn.mod.DeleteObjectCommand({ Bucket: conn.config.bucket, Key: key })); } async function deletePrefix(conn, pfx) { for await (const page of listPages(conn, { Bucket: conn.config.bucket, Prefix: pfx })) { const keys = (page.Contents ?? []) .map((obj) => obj.Key) .filter((k) => k !== undefined) .map((k) => ({ Key: k })); if (keys.length > 0) { const resp = (await conn.send(new conn.mod.DeleteObjectsCommand({ Bucket: conn.config.bucket, Delete: { Objects: keys }, }))); // A refused key comes back in the body of a 200. const failed = (resp.Errors ?? []).map((e) => e.Key ?? ''); if (failed.length > 0) throw deleteRefused(failed); } } } async function copyFile(conn, srcKey, dstKey) { try { await copyObject(conn, srcKey, dstKey); } catch (err) { if (!isNotFound(err) || isMissingBucket(err)) throw err; console.debug(`copy source ${srcKey} is missing: ${String(err)}`); return false; } return true; } async function moveFile(conn, srcKey, dstKey) { // The source is classified before anything is copied rather than by // letting CopyObject fail: stores disagree about a missing source (S3 // and MinIO even spell the code differently, and a lenient // S3-compatible store accepts the copy and writes nothing), and on that // last one an error-driven fallback would delete a source whose copy // never landed. Only a classified not-found answers false; every other // failure propagates rather than reading as a directory. try { await conn.send(new conn.mod.HeadObjectCommand({ Bucket: conn.config.bucket, Key: srcKey })); } catch (err) { if (isDenied(err)) throw denied(srcKey); if (!isNotFound(err)) throw err; return false; } if (!(await copyFile(conn, srcKey, dstKey))) throw enoent(srcKey); await conn.send(new conn.mod.DeleteObjectCommand({ Bucket: conn.config.bucket, Key: srcKey })); return true; } /** * Relocate every key under `srcPfx` to the matching key under `dstPfx`. * * A directory is a key prefix plus the empty marker object mkdir writes, * and listing on the prefix returns both, so one walk moves the marker * and the whole subtree together. Returns whether any key was found * under the source prefix. */ async function movePrefix(conn, srcPfx, dstPfx) { const { bucket } = conn.config; const moved = []; for await (const page of listPages(conn, { Bucket: bucket, Prefix: srcPfx })) { for (const obj of page.Contents ?? []) { if (obj.Key === undefined) continue; await copyObject(conn, obj.Key, `${dstPfx}${obj.Key.slice(srcPfx.length)}`); moved.push({ Key: obj.Key }); } } if (moved.length === 0) return false; // Deleted only after every copy landed: a partial move that dropped the // source would lose the entries that had not been copied yet. const failed = []; for (let start = 0; start < moved.length; start += DELETE_BATCH) { const resp = (await conn.send(new conn.mod.DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects: moved.slice(start, start + DELETE_BATCH) }, }))); // DeleteObjects reports a refused key in the body of a 200, so a // response that throws nothing can still have deleted nothing. // Ignoring it would leave the source tree in place beside the copy // and call the move a success. for (const err of resp.Errors ?? []) failed.push(err.Key ?? ''); } if (failed.length > 0) { // Both trees survive, which is what GNU mv leaves behind when the // unlink half fails after the copy half succeeded. EACCES because a // refused delete is a lock or a policy in practice, and because it is // an fs error: mv reports the operand and keeps going instead of // aborting the whole command line. throw eacces(`/${srcPfx}`, `S3 refused to delete ${String(failed.length)} source object(s) after ` + `copying, starting at '${failed[0] ?? ''}'`); } return true; } async function probePrefix(conn, pfx) { let resp; try { resp = (await conn.send(new conn.mod.ListObjectsV2Command({ Bucket: conn.config.bucket, Prefix: pfx, Delimiter: '/', MaxKeys: 1, }))); } catch (err) { if (isDenied(err)) throw denied(pfx); throw err; } return (resp.CommonPrefixes ?? []).length > 0 || (resp.Contents ?? []).length > 0; } export const DRIVER = { vfs: VFSName.S3, conditionSupport: CONDITION_SUPPORT, scopeError: SCOPE_ERROR, keyPrefixOf, connect, listChildren, listSubtree, head, get, put, deleteFile, deletePrefix, moveFile, movePrefix, copyFile, probePrefix, putIf, putStream, getVersioned, copyIf, deleteIf, moveFileIf, movePrefixIf, deletePrefixIf, isNotFound: isNotFound, }; //# sourceMappingURL=driver.js.map