TinyStore

Blobs / Uploads

Uploads

A file is written whole or not at all. put writes one in a call, from bytes, text or a stream; upload writes one a piece at a time, for bytes that arrive over time, and publishes it only at commit. Nobody ever sees half a file: not while it is written, not after a crash.

const reports = store.files('reports', { maxFileSize: '2GiB' })

await reports.put('2026-10/summary.txt', 'Revenue: 12,400 EUR') // FileInfo
await reports.put('2026-10/data.csv', request.body!, { contentType: 'text/csv', size: Number(request.headers.get('content-length')) })
reports = store.files("reports", max_file_size="2GiB")

await reports.put("2026-10/summary.txt", "Revenue: 12,400 EUR")  # FileInfo
await reports.put("2026-10/data.csv", request.stream(), content_type="text/csv", size=int(request.headers["content-length"]))
reports, err := blobs.OpenFiles(store, "reports", blobs.MaxFileSize(2<<30))

_, err = reports.Put(ctx, "2026-10/summary.txt", strings.NewReader("Revenue: 12,400 EUR"))
_, err = reports.Put(ctx, "2026-10/data.csv", r.Body, blobs.ContentType("text/csv"), blobs.Size(r.ContentLength))
let reports = store.files("reports").max_file_size(2 << 30).open()?;

reports.put("2026-10/summary.txt", b"Revenue: 12,400 EUR")?;
reports.key("2026-10/data.csv").content_type("text/csv").size(length).put(&body)?;

put replaces whatever the path held, at the moment it returns. What it takes:

LanguageBytes, text or a stream
TypeScriptstring, Uint8Array, ArrayBuffer, Blob (a File, Bun.file), ReadableStream, an async iterable of Uint8Array
Pythonbytes, str, a file object, an async iterator of bytes
Goan io.Reader
Rustbytes; an upload for a reader

An upload a piece at a time

await using upload = await reports.upload('2026-10/report.csv', { contentType: 'text/csv' })
for await (const chunk of rows) await upload.write(chunk)
await upload.commit() // the file appears now; leaving the block without commit aborts
async with reports.upload("2026-10/report.csv", content_type="text/csv") as upload:
    async for chunk in rows:
        await upload.write(chunk)
    await upload.commit()  # leaving the block without it aborts
upload, err := reports.Upload(ctx, "2026-10/report.csv", blobs.ContentType("text/csv"))
defer upload.Abort() // does nothing after Commit
_, err = io.Copy(upload, rows)
info, err := upload.Commit(ctx)
let mut upload = reports.key("2026-10/report.csv").content_type("text/csv").upload()?;
std::io::copy(&mut rows, &mut upload)?;
let info = upload.commit()?; // dropped without it, the upload aborts

What an upload promises, step by step:

  1. Its bytes go to a place of their own as they come; their hash and size are counted on the way. Nothing a reader can see changes.
  2. commit checks the size, syncs the bytes and publishes the file in one commit of the store. Before it returns, readers see what the path held; after, the new file; never part of it (contract BLOB-01).
  3. An upload aborted, dropped, or left by a process that died is no file. Its bytes are removed at once, or, after a crash, when the store next opens.
  4. A commit whose outcome is unknown publishes nothing it cannot account for: the next open removes bytes no file names.

An upload's memory does not grow with the file: a few buffers of 64 KiB to 1 MiB, as the store's memory allows. A put with a stream is an upload in one call.

Sizes

OptionSays
sizehow long this body is, from Content-Length: longer or shorter is invalid
maxFileSizethe largest file the set takes: past it is limit
files reports: "2026-10/data.csv": a body of 5 bytes, shorter than its size of 10: invalid
files reports: "huge.bin": a file of 2097152 bytes, past the files' bound of 1048576: limit

Either way nothing is written. maxFileSize bounds one file and is not a quota: nothing already stored is removed to make room, and there is no bound unless set. A size is a number of bytes or text in KiB, MiB, GiB or TiB; '2GB' is invalid, since it is 10⁹ bytes to some and 2³⁰ to others.

The store keeps 1 GiB of the disk free unless told otherwise: keep_free in Rust's Options, tinystore serve --keep-free 10GiB for a server. A file that would leave less is limit and leaves nothing; an upload asks the disk as its first bytes arrive and every 64 MiB after.

What a file carries

await reports.put('invoice.pdf', pdf, { contentType: 'application/pdf', meta: { name: 'Счёт №42.pdf' } })
await reports.put("invoice.pdf", pdf, content_type="application/pdf", meta={"name": "Счёт №42.pdf"})
_, err = reports.Put(ctx, "invoice.pdf", bytes.NewReader(pdf), blobs.ContentType("application/pdf"),
	blobs.Meta(map[string]string{"name": "Счёт №42.pdf"}))
reports.key("invoice.pdf").content_type("application/pdf").meta("name", "Счёт №42.pdf").put(&pdf)?;
FieldMeaning
pathits path, relative to the folder it was asked from
sizebytes
etagthe SHA-256 of its bytes in hex, quoted: an HTTP ETag as it is
contentTypethe type it was written with, application/octet-stream without one
lastModifiedwhen this version was written
expireswhen it expires, if it does
metaa few strings of yours, 2 KiB in all: the name it was uploaded as

A file carries what serving it needs. Its owner, album or caption are rows of your database that name the file by its path.

Folders

const media = store.files('media')
await media.folder('users', 42).put('photos/1.jpg', photo) // the file users/42/photos/1.jpg
const page = await media.folder('users', 42).list({ prefix: 'photos/' }) // { files, next }
const { count, size } = await media.folder('users', 42).usage()
await media.folder('users', 42).clear() // every file of user 42, at once
media = store.files("media")
await media.folder("users", 42).put("photos/1.jpg", photo)
page = await media.folder("users", 42).list(prefix="photos/")
usage = await media.folder("users", 42).usage()
await media.folder("users", 42).clear()
media, err := blobs.OpenFiles(store, "media")
_, err = media.Folder("users", 42).Put(ctx, "photos/1.jpg", photo)
page, err := media.Folder("users", 42).List(ctx, blobs.Prefix("photos/"))
usage, err := media.Folder("users", 42).Usage(ctx)
err = media.Folder("users", 42).Clear(ctx)
let media = store.files("media").open()?;
media.folder("users").folder(42).key("photos/1.jpg").put(&photo)?;
let page = media.folder("users").folder(42).list().prefix("photos/").page()?;
let usage = media.folder("users").folder(42).usage()?;
media.folder("users").folder(42).clear()?;
  • Each argument of folder is one whole segment: folder('users', 42) never holds users/420's files. An empty argument is invalid, so a missing id never names users/ and its clear never empties every user's.
  • A prefix in list is text: prefix: 'photos/1' finds photos/10.jpg too.
  • list reads a page of at most 1,000 files, sub-folders included, in the byte order of the paths; all() walks every page.
  • clear removes a folder however many files it holds, at once for every reader; the bytes go in the background. Keep a user's files in a folder of theirs and deleting an account is one call.

Paths

A path is segments between /: none empty, none . or .., at most 1 KiB and 16 segments. Anything else is invalid, so no path leaves its folder. Paths name nothing on disk, since bytes are stored under numbers: Photo.jpg and photo.jpg are two files on every system, and CON is a path like any other (contract BLOB-08).

See also

Edit this page on GitHub Updated 19 October 2026

esc
↑↓ navigate ↵ open ⇥ filter 0 results