FFalCAD docs

FalCAD collaboration protocol — v1 (draft)

This document is the public, implementation-independent contract between FalCAD clients and any collaboration server (the hosted FalCAD cloud, or a self-hosted one). The reference implementation is src/collab/ in this package. Conformance vectors live in ../test-vectors/collab, and every implementation must pass them.

Principles

  1. Validated transactions, not last-writer-wins. Edits to electrically or geometrically meaningful state are transactions of entity operations. The server checks them against the schema and referential integrity, then accepts or rejects them atomically.
  2. One authoritative order per document. The server serializes accepted transactions into a log. Every client applies the same transactions in the same order and ends up with identical documents.
  3. Entity-level conflict detection. Concurrent edits to different entities merge automatically. Concurrent edits to the same entity (or document property) are never silently overwritten: the later one is rejected with conflict, and the client rebases.
  4. No numeric identifiers. Documents, entities, transactions and snapshots are identified by random UUID v4. Sequence positions exist only inside the server.
  5. No timers. Ordering comes from the server stream. Clients don’t poll, debounce or use time-based reconciliation.
  6. Presence is ephemeral. Cursors, selections and viewports are the only last-writer-wins state. They are never persisted and carry no design meaning.

Data model

The entity model comes from @falcad/format’s DOCUMENT_MODELS, exported as schema/collections.json. For each structured document kind (schematic, board, simulation) it lists:

Operations

{ "op": "create", "collection": "components", "value": { "id": "<uuid v4>", ... } }
{ "op": "update", "collection": "components", "id": "<uuid>", "set": { "value": "4k7" }, "unset": ["footprint"] }
{ "op": "delete", "collection": "wires", "id": "<uuid>" }
{ "op": "set", "property": "name", "value": "Power stage" }

Values are stored exactly as sent once they validate. Implementations must not fill in defaults, so every implementation builds byte-identical documents from the same stream.

Transactions

{
  "id": "<uuid v4, client generated>",
  "documentId": "<uuid>",
  "base": "<id of the last transaction or snapshot the client had applied>",
  "ops": [ ... ],            // at least one
  "message": "optional"
}

A server processes a submitted transaction in this order:

  1. Validate the shape → invalid-transaction.
  2. Check documentId → wrong-document.
  3. If id matches an already accepted transaction, reply accepted (duplicate) without changing anything. This makes retries idempotent.
  4. Locate base in the log → unknown-base if absent. The client must resynchronize from a snapshot.
  5. Conflict check: compute touched keys (collection/id for every op, @property for set). If any transaction accepted after base touched one of the same keys → conflict, with conflictsWith listing those transaction ids.
  6. Apply the ops to a copy of the head document, in order. Stop at the first failing op: unknown-collection, unknown-property, duplicate-id, not-found, invalid-entity, immutable-field.
  7. Check referential integrity and whole-document constraints → integrity.
  8. Append to the log, advance head, then broadcast to every participant, including the sender. The broadcast doubles as the sender’s acknowledgement.

Steps 5–8 run under one per-document lock (or equivalent serializable transaction). A rejection changes nothing.

Client behaviour

Transport: real-time hub

The hosted service exposes a SignalR hub at /v1/realtime/collab. It authenticates with a bearer access token; WebSocket clients pass it as access_token. Messages are JSON.

Direction Method Payload Result / notes
C→S JoinDocument { projectId, documentId } { kind, snapshot, head, role, canEdit, editorSeat, participants[], sessionId, plan, seatLimit } (seatLimit is the project’s simultaneous-editor limit; null when unlimited). Requires view permission. A user with edit permission takes an editor seat if the project’s simultaneous-editor limit allows; otherwise they join read-only with editorSeat: false.
C→S LeaveDocument { documentId } Releases the seat. Disconnecting also releases it.
C→S SubmitTransaction Transaction { status: "accepted", head, duplicate } or { status: "rejected", rejection }. Requires edit permission and a held editor seat.
C→S CatchUp { documentId, base } { transactions[], hasMore } or { resync: true }. Call again from the last transaction while hasMore is true. Results are paged on the server, never unbounded.
C→S UpdatePresence Presence (with the sessionId returned by JoinDocument) Broadcast to the other participants. Not persisted.
S→C TransactionAccepted { documentId, tx, head } In log order.
S→C PresenceUpdated { userId, displayName, color, presence }
S→C ParticipantJoined { documentId, participant: { sessionId, userId, displayName, color, editorSeat } } Sent to everyone already in the document.
S→C ParticipantLeft { documentId, sessionId }
S→C AccessRevoked { projectId } Sent when the user’s access ends (membership removed, role lowered). The server also drops the user from the group.

Authorization runs on every call, not only on join. Membership and role are re-read from the database for every SubmitTransaction, so a revoked editor can’t keep editing over an open socket.

Conformance

A conforming implementation, for each test-vectors/collab/*.json:

  1. starts a log from snapshot / snapshotId for documentId,
  2. submits each steps[i].tx and gets the expected status (and rejection code),
  3. ends with a document deep-equal to final.