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.
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.