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 {}
// The journal's methods are DO RPC — call them on the stub, typed.
export async function POST(req) {
const { ops } = await req.json();
const journal = env.JOURNAL.get(env.JOURNAL.idFromName(userKey));
return Response.json(await journal.push(ops));
}
pull and putSnapshot are two more one-liners.
Note what you do not forward: reset(). The
DO has it, but a client reaches exactly the methods you wire to a route —
no router in the DO deciding for you. There's a
runnable example.
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.