Skip to content

Never lose an edit to another save: project revisions, live sync and a conflict choice - #29

Open
pallaoro wants to merge 6 commits into
mainfrom
project-revisions
Open

pallaoro wants to merge 6 commits into
mainfrom
project-revisions

Conversation

@pallaoro

@pallaoro pallaoro commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

The problem

A project has more writers than the editor: an agent working over the API, Ask and Auto-cut on the server, a teammate, the same person in a second tab. Saves were last-write-wins, and an open editor never re-read the project. So if an agent changed the cut while the editor was open, the next click in the editor saved the old document over it, and nobody saw it happen. Ask and Auto-cut also wrote back a document they had read up to a minute earlier. And going back to Projects, or closing the tab, within 700 ms of an edit dropped that edit.

What changes

  • Revisions. Every write adds one to edit_projects.revision (an additive ALTER TABLE ... ADD COLUMN). PUT /api/projects/:id, instruct and autocut take an optional revision and answer 409 { error: "conflict", revision } when it is stale. Writes are guarded WHERE revision = <read> and read the result from RETURNING, so concurrent saves on one base produce exactly one winner, and an AI pass cannot overwrite a save made while it ran. The guard reads rows rather than the changes count because not every storage backend @clawnify/db supports reports a count, and a missing count reads as 0, which would refuse every save. Leaving revision out keeps the old behaviour, so existing callers still work.
  • Live sync. GET /api/projects/:id sends the revision as an ETag. An open editor checks every 5 s while the tab is visible, and gets an empty 304 when nothing changed. With nothing unsaved, a newer version shows up straight away as one undo step, so the person sees an agent's change land and can undo it.
  • The editor never takes a newer version while a pointer is held: drags keep an element's index from press to release.
  • Conflict choice. If the editor has unsaved edits when a newer version arrives, it stops saving and shows a bar with two buttons: Show the saved version (the cut on screen stays one undo away) or Keep mine. Export is disabled until one is chosen.
  • Saves run one at a time. Ask and Auto-cut save what is on screen before they send it.
  • Leaving inside the 700 ms wait now saves: the editor saves when it unmounts, and closing the tab sends a keepalive request (it asks first when that cannot be sent).
  • agent.md: agents send back the revision they read, and re-read on a 409.

Verified

  • tsc, vitest (92, including the new test/sync.test.ts), vite build.

  • HTTP against local D1 + wrangler dev, 12/12: ETag/304, stale PUT returns 409 and changes nothing, a PUT without revision still wins, a stale instruct is refused before any model call, 6 concurrent PUTs on one base give exactly one 200.

  • In a browser on the same local stack (the held-pointer check: no swap for 8 s while held, taken on release):

    • an agent-style GET→PUT while the editor is open shows up within 5 s, with no extra save; undo saves the earlier cut at the next revision;
    • an agent save that meets unsaved edits shows the bar; Show the saved version and Keep mine each leave the server holding the expected version;
    • leaving for Projects and reloading the tab right after an edit both keep it;
    • Ask saves pending edits before its request.
  • Deployed to a test instance:

    • the schema reconcile added the column to the existing database (old projects read revision: 0);

    • stale PUT and stale instruct return 409;

    • the 304 passes through the app router;

    • an agent-style save showed up in the open editor once the tab was visible, and the next checks returned 304s.

    • after the RETURNING change, 6 concurrent PUTs on real D1 gave one 200 and five 409s, and a PUT to a deleted project returns 404.

    The deployed run turned up one thing, fixed in the second commit. The edge compresses the JSON and hands the ETag back weakened (W/"0"), so a client echoing it got a full 200. If-None-Match now uses weak comparison, as HTTP defines for that header.

Known edges

  • Undo covers the cut. A name or brief typed before choosing Show the saved version is replaced by the saved one.
  • Leaving for Projects with an unresolved conflict discards the unsaved version.
  • The 5 s check is a fixed poll while the tab is visible. It is marked in the code as the thing to replace with a push channel if request counts ever matter.

…a conflict choice

A project has more writers than the editor: an agent over the API, Ask and
Auto-cut on the server, a teammate, the same person in another tab. Saves
were last-write-wins and the editor never re-read the project, so the next
edit in an open editor silently erased whatever had been saved elsewhere.

- Every write adds one to edit_projects.revision. PUT, instruct and autocut
  take an optional revision and answer 409 conflict when it is stale; their
  writes are guarded on the revision they read, so an AI pass that took a
  minute cannot overwrite a save made meanwhile.
- GET /api/projects/:id carries the revision as an ETag. An open editor asks
  every 5 s while visible (an empty 304 when nothing changed) and shows a
  newer version at once when it has nothing unsaved, as one undo step.
- With unsaved edits it stops saving and asks: show the saved version (the
  cut on screen stays one undo away) or keep mine.
- Saves run one at a time; Ask and Auto-cut save what is on screen first.
- Going back to Projects or closing the tab inside the 700 ms wait no longer
  drops the last edit.
- agent.md: send back the revision you read.
… the document under a held drag

Not every storage this app can run on reports a changes count, and a missing
count reads as 0, which would have refused every save. A drag holds an
element's index from press to release, so the editor no longer takes a newer
version while a pointer is down. A save to a deleted project answers 404.
# Conflicts:
#	agent.md
#	schema.sql
#	src/client/edit.tsx
# Conflicts:
#	agent.md
#	schema.sql
#	src/client/edit.tsx
@pallaoro

pallaoro commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

Merged main (8e3ae80) in. Conflicts: agent.md and schema.sql took both sides; edit.tsx kept this branch's persistence, which already saves to the editor's own project on the way out and is now one editor per project (main's key). One fix the merge needed: asking for highlights with a new brief now adds a revision, so an open editor picks up the brief instead of saving its old one over it. Typecheck, 163 tests, client build.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant