0007. A frame edit is a list of ops, rebased by the server onto the latest head
Context: a frame (motion node) is one MotionDoc per row of motion_revisions, written with an
optimistic version. Two people in the same frame, or a person and the motion agent / an MCP
agent, wrote whole docs: the second writer got a 409 and lost its edit, or — the agent turn —
retried at the new head with its own whole doc and silently erased what the person typed during
the turn.
Decision: an edit travels as a list of small typed ops (src/lib/motion/doc-ops.ts), addressed
by id, never by position:
doc.set/doc.entry— a top-level field, or one key ofcomps,components,links;track.add/track.remove/track.move/track.set;clip.add/clip.remove/clip.move/clip.set, andclip.entryfor one key of a clip’sprops,keyframesortransform.
Ops are not written by hand at each call site: diffDoc(before, after) derives them from the two
docs an edit produced. Every existing doc function (the ~90 motion tools, the editor’s clip ops,
move-element) is therefore already an op producer, with no rewrite.
The server rebases by re-applying: commitOps reads the head, applies the ops to it, validates
the result with parseMotionDoc, and appends head + 1; a unique-key race re-reads and retries.
Applying is tolerant by construction: an op on a clip or track that no longer exists is dropped
(delete wins over edit), an index is clamped, an add of an id that exists is skipped. So ops on
different clips, or different props/keyframes of one clip, commute; two writes to the same field
resolve last-writer-wins. A merge that breaks a doc-wide rule (duration, resolution) is refused as
invalid, never written.
commitDoc(base, doc) is the same path for a writer that holds whole docs: the agent turn, MCP
runMotionTool. Their edits land on top of the person’s, not over them.
Broadcast is the revision itself, not a realtime message. Each write already patches
nodes.data.docHeadRevision, which Realtime publishes; an open editor reads the head and rebases
its pending local ops on it (rebase(base, mine, theirs)). A missed broadcast message would be a
lost edit; a missed row change is caught up by the next one, because the head is durable.
Consequences:
- No op-log table and no migration: the history is the revisions, and the ops between two of
them are
diffDocof their docs. Revisions stay coalesced by the editor’s save debounce. - Not a CRDT: concurrent text edits of one string are not merged character by character — the last write wins on the field. Accepted: a frame’s strings are short labels, and a CRDT library plus its storage format is the cost this avoids.
- Clip order inside a track is last-writer-wins when both sides reorder it.
- The old whole-doc
saveaction stays as a fallback while the client moves toops. - Undo becomes per user: undoing applies the inverse of my own ops to the current doc, so it never reverts someone else’s edit.