Skip to content

Architecture Decisions

Decision: Use Yjs for rich text CRDT, not Automerge.

Rationale:

  • Yjs has a larger ecosystem (TipTap, ProseMirror, CodeMirror, Monaco integrations)
  • Better performance for large documents (Yjs encodes more compactly)
  • Mature awareness protocol for cursor presence
  • Active maintenance and wide adoption

Tradeoff: Yjs is a mutable data structure (not pure-functional like Automerge). We mitigate this with the MetaBridge pattern — Yjs never writes to the NodeStore directly.

Decision: Use did:key (Ed25519) as the identity format.

Rationale:

  • Self-certifying — the public key is embedded in the DID, so verification requires no external resolver
  • No blockchain, registry, or server dependency
  • Small identifiers (~56 characters)
  • Ed25519 is widely supported and audited

Tradeoff: DIDs are not human-readable. Display names are stored as profile metadata, not in the identifier itself. Key rotation requires creating a new DID (though UCAN delegation can bridge old and new identities).

ADR-3: Field-level LWW for structured data

Section titled “ADR-3: Field-level LWW for structured data”

Decision: Use Lamport clocks with field-level last-writer-wins for structured data, not a CRDT map.

Rationale:

  • Simpler than a full CRDT for property data (titles, statuses, numbers)
  • Field-level granularity means non-conflicting fields always merge cleanly
  • Deterministic tie-breaking (DID comparison) ensures all peers converge
  • Lower overhead than maintaining CRDT metadata per field

Tradeoff: True concurrent edits to the same field result in one write being silently dropped. In practice, this is acceptable for structured properties — users rarely edit the same title simultaneously. For rich text where character-level merging matters, Yjs handles it.

Decision: Use BLAKE3 as the primary hash function.

Rationale:

  • 3-5x faster than SHA-256 on modern hardware
  • 256-bit output suitable for content addressing
  • Parallelizable (tree hashing) for large inputs
  • No length extension attacks

Tradeoff: SHA-256 has wider interoperability (most external systems expect it). We provide SHA-256 as a fallback option in @xnet/crypto for interop cases.

Decision: No default exports anywhere in the codebase.

Rationale:

  • Eliminates the “what did I import?” ambiguity
  • Better tree-shaking in bundlers
  • Consistent import style across the monorepo
  • Easier to search for usages (grep for the exact name)

ADR-6: Factory functions alongside classes

Section titled “ADR-6: Factory functions alongside classes”

Decision: Export createFoo() factory functions alongside class Foo.

Rationale:

  • Factory functions are easier to mock in tests
  • They can perform validation before construction
  • Consistent API surface (createLamportClock, createExtensionContext, createLocalAPI)
  • Classes are still available for instanceof checks when needed

Decision: Validation functions return { valid: boolean, errors: string[] } instead of throwing.

Rationale:

  • Callers can inspect and aggregate errors
  • No try-catch boilerplate for expected failures
  • Works well with UI form validation
  • Exceptions are reserved for programmer errors (missing arguments, broken invariants)

Decision: The MetaBridge syncs NodeStore → Y.Doc metadata, but not the reverse.

Rationale:

  • Prevents malicious Yjs updates from poisoning structured data
  • Property changes always go through the signed Change<T> pipeline with verification
  • Yjs metadata map is read-only from the editor’s perspective
  • Clear security boundary between the two CRDT systems

Tradeoff: Two-way sync would be simpler to implement. The one-way bridge requires property writes to go through mutate() even when the data is displayed in the editor context.

Decision: Use a single WebSocket connection with room-based pub/sub, not one connection per document.

Rationale:

  • O(1) connections regardless of document count
  • Reduces server-side resource usage
  • Simpler reconnection logic (one connection to restore)
  • Lower latency (no connection setup per document)

Tradeoff: Requires a room routing layer on top of raw WebSocket. The signaling server must track topic subscriptions.

Decision: Run the sync engine in Electron’s main process, not the renderer.

Rationale:

  • Keeps the renderer responsive (BLAKE3 + Ed25519 are CPU-intensive)
  • Survives renderer crashes
  • Can sync in the background when no window is open
  • MessagePort provides zero-copy binary transfer between processes

Tradeoff: IPC overhead for every document update. Mitigated by using MessageChannelMain for binary transfer and batching updates.