HoldSendWrite offline. Publish exactly once.GitHub

0009: One draft on two devices (design note, proposed)

Date: 2026-11-03. Status: proposed. Step 1 of the next steps is built (2026-11-04, unreleased): readDraft and ancestorsOf in @holdsend/core/drafts. The server helper is built too (2026-11-05, unreleased, review pending).

The problem

A person edits one draft on a phone and a laptop. Both are offline for a while, both save edits, both later reach the server. The outbox already makes each send happen once. It does not decide what the draft should say when two devices changed the same base version.

This is the hard part of Phase 3. We say plainly: no design here is perfect, and the aim is that no edit is silently lost, not that the two versions merge by themselves.

Options looked at

  1. Last writer wins. Simple, and wrong: the earlier edit disappears with no trace. Written time comes from a phone clock, so "last" is also unreliable (decision 0003). Rejected.
  2. Automatic merge (a CRDT or operational transform). Gives a merged text without asking, but needs a large library or a lot of new code to audit, the core is at its size budget, and a wrong merge of a person's words is hard to notice. Not for v0.3. Apps that already use one can send its updates through the outbox as ordinary items.
  3. Append-only updates with a reason, and a person resolves. Every edit is a new update that is never overwritten. The server records which version each edit was based on. When two edits share a base, both are kept and the draft is marked "conflicted" until someone picks or combines them. Chosen.

Choice (proposed)

Lives in a new subpath, @holdsend/core/drafts, so the core's size budget is untouched. Each edit is one outbox item whose payload carries:

Server side (@holdsend/server, a helper over the existing idempotency store):

What the library will not do: merge text, choose a winner, or order edits by written time. Written time is shown to the person to help them choose, and nothing more.

Honest limits

Test plan (before any code is called done)

Open questions

Built so far

readDraft(log, draftId?) returns the open heads, whether the draft is conflicted, the latest head, and any references that point nowhere (reported, never an error). An update's parents are its baseId (or else baseRev) and the ids it resolves; a head is an update nobody else has as a parent. Repeated ids count once. Input order and written time do not change the result. A scenario suite and a fault-injection suite (three devices, lost answers, retries, resolves) check that every accepted edit is reachable from a head and none appears twice; last-writer-wins, double-counting a retry and dropping a stale-base edit each make it fail.

The server helper (@holdsend/server): acceptDraftUpdate (a keyed request, key = update id, always accepted when well formed, 400 without a key), MemoryDraftLog, PostgresDraftLog with postgresDraftSchema() (one row per update, unique (owner, draft_id, id) and (owner, draft_id, rev), a per-draft advisory lock so revisions follow commit order), and readDraftState. This settles the open question on storage: one row per update. Tests run on memory and PGlite; a real-Postgres race test runs in CI. Checked to fail with a stale base refused and with the log's own duplicate guard removed.

Next steps

  1. (Done) Add the types and a pure function that, given the update log, returns the head and unresolved heads, with the scenario suite above.

  2. (Done, review pending) Add the server helper over the idempotency store (memory and Postgres).

  3. (Done 2026-11-06) Device-side helpers editLinks and resolveLinks, pure functions in @holdsend/core/drafts.

  4. Review by a separate agent before merge, since this is high stakes.

  5. (Done 2026-11-07) A conflict screen in the notes example (/drafts), with three browser tests (two offline edits from one device stay one line; two devices conflict, a person keeps one version and nothing is lost; a person writes a version using both), each checked to fail when editLinks ignores the device's own unsent edit or resolveLinks closes nothing. Still to do: an audit of the whole drafts feature. The review found a window where a sent edit has left the outbox but the log is not read again yet; the page now remembers its own unconfirmed edits for that window. The browser test for quick online saves passes with and without that fix (the window is too short to hit in a test), so it is a guard, not proof.

Edit this page on GitHub