Data, safety and extensions
Files
Store and read files in S3, Cloudflare R2 and other object stores, with versions for safe concurrent writes, per-tenant views and permission-gated file tools for agents.
mayura/files stores files by key in an object store. A key is a /-separated path such as reports/2026/q3.csv.
Every file has a version tag, its etag, so a write can say "only if this file does not exist yet" or "only if it
has not changed since I read it". S3 and S3-compatible services (Cloudflare R2, MinIO, Backblaze B2 and others) are
built in; other services come as @mayurajs/filestorage-* packages.
import { createFileStore, s3Files } from 'mayura/files';
const files = createFileStore(s3Files({
bucket: 'acme-reports',
region: 'us-east-1',
credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '', secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? '' },
}), { maxFileBytes: 10 * 1024 * 1024 });
const written = await files.put('reports/q3.csv', new TextEncoder().encode('order,total\nord-1001,42.00\n'), { contentType: 'text/csv' });
const file = await files.get('reports/q3.csv');
console.log(written.etag, file?.size);Store options
| Option | Notes |
|---|---|
maxFileBytes |
Required. The largest file written or read, up to 5 GiB. Files are held in memory while they are stored and read. |
maxListLimit |
The most files one list call returns; 1,000 by default and at most. |
timeoutMs |
How long one call may take; 60 seconds by default. |
prefix |
Keep every key under this prefix, as within(prefix) does. |
Reading and writing
| Method | Notes |
|---|---|
put(key, data, options?) |
Writes the bytes. Options: contentType, metadata (up to 16 short ASCII entries), ifNoneMatch: '*', ifMatch, signal. Returns the file's information with its new etag. |
get(key, options?) |
The file with its bytes, or undefined when there is none. Options: range: { offset, length? }, ifMatch, maxBytes, signal. |
head(key) |
The file's size, etag, media type and metadata, without the bytes, or undefined. |
list({ prefix?, cursor?, limit? }) |
One page of files in key order, and a cursor for the next page when there is one. |
delete(key, options?) |
Deletes the file. Deleting a file that is not there succeeds. |
within(prefix) |
The same store seen under a prefix. |
A key cannot start or end with /, contain empty, . or .. parts, backslashes or control characters, and is at
most 1,024 bytes of UTF-8. Mayura checks every key, size and option before a request is sent, and checks what the
service answers before you see it.
Safe concurrent writes
Two writers that read a file, change it and write it back can lose one of the changes. Use the version tag:
import { createFileStore, memoryFiles } from 'mayura/files';
const files = createFileStore(memoryFiles(), { maxFileBytes: 1024 * 1024 });
// Create a file only if no file has this key yet.
await files.put('orders/ord-1001.json', new TextEncoder().encode('{"status":"new"}'), { ifNoneMatch: '*' });
// Change it only if nobody else has changed it since it was read.
const current = await files.get('orders/ord-1001.json');
if (current) {
await files.put('orders/ord-1001.json', new TextEncoder().encode('{"status":"paid"}'), { ifMatch: current.etag });
}A write whose precondition fails, because the file exists or has another version, fails with CONFLICT and changes
nothing. Read the file again and retry, or report the conflict. Not every service can keep these preconditions: a
store's conditionalWrites and conditionalDelete say whether it can, and a store that cannot refuses ifMatch and
ifNoneMatch with INVALID_INPUT rather than ignore them.
One store per tenant
within(prefix) returns a view in which keys are relative to the prefix and nothing outside it can be read, listed,
written or deleted. Derive the prefix from the caller's verified identity, never from input:
import { createFileStore, memoryFiles, type FileStore } from 'mayura/files';
const files = createFileStore(memoryFiles(), { maxFileBytes: 1024 * 1024 });
const filesFor = (tenantId: string): FileStore => files.within(`tenants/${tenantId}`);
await filesFor('acme').put('notes.txt', new TextEncoder().encode('Acme only'));
console.log(await filesFor('globex').get('notes.txt')); // undefinedS3 and S3-compatible services
s3Files signs requests with AWS Signature Version 4 and sends them with fetch, so it runs on Node, Bun, Deno,
Cloudflare Workers and Vercel Edge without the AWS SDK. Nothing is read from the environment, AWS config files or
instance metadata: give the credentials, or a function that returns fresh ones (for example from your own STS call).
| Option | Notes |
|---|---|
bucket |
Required. |
region |
Required, such as us-east-1; auto for Cloudflare R2. |
credentials |
Required. { accessKeyId, secretAccessKey, sessionToken? }, or a function returning them, called for each request. |
endpoint |
Another S3-compatible service, such as https://<account>.r2.cloudflarestorage.com: an https origin, or an http one on this machine (http://127.0.0.1:9000) for a local server. |
addressing |
virtual (bucket in the host name) or path. Virtual on AWS, path with an endpoint or a bucket name containing dots. |
conditionalDelete |
Send ifMatch on deletes. Off by default: turn it on only for a service that honours If-Match on DeleteObject. |
import { createFileStore, s3Files } from 'mayura/files';
// Cloudflare R2 over its S3 API.
const r2 = createFileStore(s3Files({
bucket: 'uploads',
region: 'auto',
endpoint: `https://${process.env.CF_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: { accessKeyId: process.env.R2_ACCESS_KEY_ID ?? '', secretAccessKey: process.env.R2_SECRET_ACCESS_KEY ?? '' },
}), { maxFileBytes: 50 * 1024 * 1024 });Other services
Provider packages give createFileStore a backend for other services:
| Package | Service | Preconditions |
|---|---|---|
built in: s3Files |
S3 and S3-compatible services: Cloudflare R2, Backblaze B2, MinIO, RustFS and others | writes; deletes with conditionalDelete where the service honours them |
@mayurajs/filestorage-gcs |
Google Cloud Storage, over its JSON API with an OAuth token source. A file's etag is its generation. |
writes and deletes |
@mayurajs/filestorage-azure-blob |
Azure Blob Storage, over its REST API with an account key (Shared Key), a Microsoft Entra ID token source or a SAS. |
writes and deletes |
@mayurajs/filestorage-r2 |
Cloudflare R2 through a Workers binding (env.FILES): no keys or endpoints. Outside Workers, use s3Files. |
writes (the binding cannot delete conditionally) |
@mayurajs/filestorage-vercel-blob |
A private Vercel Blob store, with its read-write token. Reads bypass the CDN cache. No custom metadata. |
writes and deletes |
@mayurajs/filestorage-google-drive |
One Google Drive folder (or the app's hidden folder), files named by their keys, with an OAuth token source. Listings read the whole folder; deletes go to the trash by default. |
none: ifMatch and ifNoneMatch are refused |
@mayurajs/filestorage-agentfs |
An AgentFS filesystem (Turso's SQLite filesystem for agents), keys as paths under a root. A file's etag is the SHA-256 of its content. | with singleWriter: true, writes and deletes; otherwise none |
@mayurajs/filestorage-files-sdk |
Any Files SDK provider: Supabase, Netlify Blobs, Dropbox, OneDrive, Box, SFTP, WebDAV and its other adapters. | what the Files SDK adapter declares |
@mayurajs/filestorage-mesa |
A Mesa repository: each write a change committed on a bookmark, rebased when another writer moved it. Files up to 128 KB; no media type or metadata. | none: ifMatch and ifNoneMatch are refused |
import { createFileStore } from 'mayura/files';
import { gcsFiles } from '@mayurajs/filestorage-gcs';
const files = createFileStore(gcsFiles({
bucket: 'acme-reports',
// An OAuth access token source, such as google-auth-library's () => auth.getAccessToken().
token: async () => process.env.GCS_ACCESS_TOKEN ?? '',
}), { maxFileBytes: 10 * 1024 * 1024 });File tools for agents
fileTools(store, { name }) gives an agent tools over one store: <name>.read and <name>.list, and with
write: true also <name>.write and <name>.delete. Reading requires the permission files:<name>:read; writing and
deleting require files:<name>:write and are write effects. Text files come back as text, anything else as base64;
large files are read in parts.
import { createFileStore, fileTools, memoryFiles } from 'mayura/files';
const files = createFileStore(memoryFiles(), { maxFileBytes: 1024 * 1024 });
const workspace = fileTools(files.within('runs/run-42'), { name: 'workspace', write: true });
// Grant: 'tool:workspace.read', 'tool:workspace.list', 'files:workspace:read',
// 'tool:workspace.write', 'tool:workspace.delete', 'files:workspace:write', 'effect:write'.
console.log(workspace.map(tool => tool.id));| Option | Notes |
|---|---|
name |
Names the tools and their permissions. |
write |
Also make the write and delete tools. Off by default. |
overwrite |
Let the write tool replace existing files. Off by default: it only creates new files. |
maxReadBytes |
The most bytes one read returns; 256 KiB by default. |
maxWriteBytes |
The largest file one write stores; 1 MiB by default. |
Give the tools a view made with within, so an agent can only reach the files of its tenant or run.
Errors
A failed call raises FileStoreError (code STORAGE_UNAVAILABLE) with a reason: authentication,
rate_limited, unavailable, timeout, rejected or invalid_response. Messages are fixed text: nothing the
service wrote reaches them. A failed precondition is CONFLICT, a file or range over the limit LIMIT_EXCEEDED, and a
call cancelled through its signal CANCELLED.
Writing a backend
A backend implements FileBackend: put, get, head, list and delete over already-checked keys, and says
whether it keeps preconditions with conditionalWrites and conditionalDelete. memoryFiles() is a complete
backend for tests. fileStoreConformance in mayura/testing is the file store contract as test cases; a backend
package runs it against its service or an emulator.