bethel-nz
/R2 is not a filesystem. I still needed it to act like one.
/essay6 min read
/essay6 min read

August 3, 2026

R2 is not a filesystem. I still needed it to act like one.

How I made each user’s memory file portable while keeping it separate in Cloudflare R2.

I did not set out to build a storage package called Checkpoint.

I was trying to make one idea in Greppa less hand-wavy: if an agent has memory, whose memory is it? I did not want the answer to be “everyone's, but we promise to remember the tenant filter.” That is a normal enough architecture until the one retrieval path that forgets the filter is the interesting one.

So I went in the opposite direction. One user or organisation gets one SQLite file. The file holds that scope's text, vectors and indexes. When Greppa needs context, it resolves the scope first, opens only that file, and searches it. Another person's memory is not removed from a result later. Their file was never opened.

That gives a hard privacy line and a unit you can move:

scopes/{scopeId}/memory.sqlite

Then the part diagrams skip: R2 is durable and cheap. It is not a local DB because the key ends in .sqlite. No file handle, no “what does a reader see during a write,” no stop on two servers both uploading “the” next version.

Checkpoint is that lifecycle layer — not a memory engine, not “R2 with a new name.” What happens to a scoped file while it is hot on a server.

“One file per user” sounds simple until the second concurrent request.

The local file cannot be the remote file

The first shape in my head was exactly what you would expect:

download → open SQLite → do work → upload

It is fine in a diagram. Diagrams do not have a search still reading the file while a document-ingest job opens it for a write. They also do not have two nodes that both downloaded version E1, made different changes, and now feel equally entitled to upload.

The obvious local implementation is to mutate the cached file in place. That would make the cache convenient, but it means a reader can observe a file while a writer is changing it. SQLite has its own locking story, but Checkpoint should not only be useful for SQLite. It needs to make a simpler promise: a reader gets one complete version of a file for the duration of its work.

So writes do not touch the generation a reader borrowed. A write starts from a private copy, runs its mutation there, then promotes the candidate only when the callback completes. A reader already using v11 keeps v11. New work can use v12. The old generation goes away only after the last reader lets it go.

reader ─────── reads v11 ───────────────────────────────┐
writer ── clone v11 → mutate candidate → promote v12 ───┘

That is the copy-on-write part. It is not there because CoW sounds expensive and serious in a README. It is there because I do not want a search request to depend on the timing of somebody else's write.

typescript
1import { Checkpoint, key, path } from "checkpoint"
2
3const checkpoint = new Checkpoint({
4 storage,
5 cacheDir: "/var/lib/greppa/scopes",
6 basePath: path("production/scopes"),
7 maxOpen: 128,
8 idleMs: 10 * 60_000,
9 flush: { mode: "interval", intervalMs: 15 * 60_000 },
10})
11
12const memory = key("acme/memory.sqlite")
13
14await checkpoint.read(memory, async (localPath) => {
15 // Open this scope's SQLite file read-only and search it.
16})
17
18await checkpoint.write(memory, async (localPath) => {
19 // Make one local mutation against a private candidate.
20})

key() and path() are not a TypeScript party trick. They make the boundary visible: callers give Checkpoint a key inside the scope it owns; the instance supplies an optional storage prefix. In JavaScript they are still strings. In TypeScript, an unscoped string has to become an explicit decision.

R2 gets the final say

Local copy-on-write fixes the reader/writer problem inside one server. It does not fix two servers that started from the same remote object.

If A and B both hydrate an R2 object with ETag E1, A can publish first and create E2. B should not get to upload its candidate blindly. “Last writer wins” is not conflict handling. It is a polite name for losing A's work.

Checkpoint sends the ETag it started from with the upload. If R2 rejects it, the local candidate is no longer based on reality. Checkpoint hydrates the remote winner, reruns the local mutation against that fresh generation, and tries again. If it loses twice, it surfaces the conflict. A third blind attempt would not suddenly become a strategy.

That is why the callback has to be safe to replay. It is a small constraint, but it is an honest one. It lets the package preserve both pieces of work without pretending a binary SQLite file can be merged after the fact.

The cache has its own opinion too. A cold scope hydrates when somebody needs it. Clean, idle scopes can leave based on a count and byte budget. Dirty scopes do not get evicted just to make a graph look nice. A cache manager that discards acknowledged local work to hit a budget is a data-loss feature wearing a performance badge.

A checkpoint is the cost boundary

The confusing word in all of this is write. SQLite can write several times to the local file during an active session. R2 sees exactly nothing until the whole generation is published back to it.

That made the flush policy worth pulling into Checkpoint instead of sprinkling debounce timers through Greppa. In immediate mode, write() resolves only after the ETag-guarded R2 upload succeeds. It is the cleanest contract: if the call returned, remote storage has the new version.

For memory, that can also be needlessly chatty. One session might make several small updates. Uploading the whole SQLite file after every one is a very expensive way to demonstrate that I know how object storage works.

So Checkpoint can flush on an interval or manually at an application checkpoint. The number of R2 writes then follows active sessions or chosen boundaries, rather than every local SQLite mutation. That is the bit that changes the cost model for Greppa.

There is no magic hidden in that trade-off. Deferred changes are local until they flush. If the node disappears, they can disappear too. SQLite WAL can recover a local database; it cannot teleport an unuploaded file to R2. For an operation that cannot lose that window, the host needs an idempotent event or outbox before it accepts the mutation.

That is where Checkpoint is today: a durable layer for scoped files, not a writable distributed filesystem pretending to be a library. It still publishes whole objects. Huge, constantly-changing scopes will eventually need another shape—coalescing, a hot/cold split, or a database service that owns the writes.

But Greppa no longer has to choose between a shared memory store guarded by convention and a pile of downloaded files guarded by hope. It has a scoped file that can become local when it is needed, keep a stable view for a reader, and return to R2 as a new complete generation when it is safe.