HoldSendWrite offline. Publish exactly once.GitHub

One draft on two devices

For 0.3, unreleased. Design: decision 0009. This is the hard part of offline writing, and nothing here is perfect: the aim is that no edit is silently lost. The library never merges text and never picks a winner.

How it works

Each edit is one update, sent through the outbox with the update id as the Idempotency-Key. The server stores every update, gives it the next revision number (not a device clock), and never refuses it for being stale. readDraft from @holdsend/core/drafts reads the log and returns the open heads. One head means no conflict. Two or more mean two edits share a base, and a person has to settle them with an update whose resolves lists the ids it closes.

Server

import { MemoryIdempotencyStore, MemoryDraftLog, acceptDraftUpdate, readDraftState } from "@holdsend/server";

const store = new MemoryIdempotencyStore();
const log = new MemoryDraftLog();

// PUT /drafts: the Idempotency-Key header is the update id.
const answer = await acceptDraftUpdate({
  store, log, owner: session.userId, headers: req.headers,
  update: { draftId: "d1", baseRev: 3, reason: "edit", writtenAt, payload: { text } },
});
// answer.body is { id, draftId, rev }

// GET /drafts/d1
const state = await readDraftState(log, session.userId, "d1");
// state.conflicted, state.heads, state.latest

For Postgres, run postgresDraftSchema() once and use new PostgresDraftLog({ db }) with PostgresIdempotencyStore. The update and its idempotency record commit together.

Rules for your app

On the device

editLinks and resolveLinks from @holdsend/core/drafts build the link fields, so an app does not hand-write them:

import { readDraft, editLinks, resolveLinks } from "@holdsend/core/drafts";

const state = readDraft(logFromServer, "d1");
// A normal edit. Pass this device's own latest unsent update as `mine`.
const links = editLinks(state, lastOwnUpdate);
// A person settled a conflict: base on their chosen head, close the others.
const settle = resolveLinks(state, chosenId); // { baseId, baseRev, resolves }

editLinks never closes a head. resolveLinks does, so call it only after a person has seen the heads side by side.

Limits

Edit this page on GitHub