Server — the log
Extend the Durable Object and export it. One instance per user is the
whole design: idFromName(userKey) buys serialized writes
and natural isolation, for free.
// worker.ts
import { SyncJournal } from "durable-sync/server";
export class Journal extends SyncJournal {}
// wrangler.jsonc
{
"durable_objects": {
"bindings": [{ "name": "JOURNAL", "class_name": "Journal" }]
},
"migrations": [
{ "tag": "v1", "new_sqlite_classes": ["Journal"] }
]
}
Then forward POST and GET from your own route.
Note what you do not route: DELETE (reset).
The Durable Object serves it, but it stays reachable only through the
binding — expose exactly what you want public.
Client — the outbox
Commit locally, queue the op, let the network catch up. apply
must be idempotent: an op can arrive more than once.
import { createSync, localStorageCursor }
from "durable-sync/client";
export const sync = createSync({
endpoint: "/api/sync",
outbox: dexieOutbox(db.outbox),
cursor: localStorageCursor("myapp.cursor"),
stateKey: "myapp.syncState",
// Idempotent: an op can arrive twice.
async apply(op) {
if (op.kind !== "note") return false;
const note = op.payload as Note;
if (await db.notes.get(note.id)) return false;
await db.notes.put(note);
return true;
},
// Pushing is always safe. Pulling might not be.
canPull: async () => !(await inProgress()),
});
// Writing is local-first.
await db.notes.put(note);
await sync.enqueue({ opId: note.id, kind: "note", payload: note });
void sync.now({ force: true }); // never await on a user path
The outbox must be durable. Until a push is
acknowledged it may hold the only copy of a write, and memory does not
survive iOS killing a backgrounded PWA. Put it in IndexedDB.