Skip to content

Feature: Offline-First Support with Automatic Conflict Resolution #52

@EricGrill

Description

@EricGrill

Feature: Offline-First Support with Automatic Conflict Resolution

Problem

Eric's workflow spans Ventress (always-on workstation), Snoke (headless server, sometimes offline), and a laptop (frequently offline). If he edits project metadata or saves a new project while offline, those changes are lost or overwritten when he reconnects because the current extension has no local queue or merge strategy.

Proposed Solution

Make the extension offline-first by treating the local globalState + a new Operation Log as the primary data store, and the remote sync backend (Gist/HTTP) as a replication target. Use CRDT-inspired last-writer-wins (LWW) registers per field with vector clocks for deterministic conflict resolution.

Data Model: Operation Log

type OpType = 'saveProject' | 'deleteProject' | 'updateTag' | 'updateColor' | 'updateNote' | 'updateStatus';

type Operation = {
  id: string;              // ULID (sortable, unique)
  machineId: MachineId;
  timestamp: number;       // local clock
  vectorClock: Record<MachineId, number>; // { ventress: 5, snoke: 2, laptop: 8 }

  op: OpType;
  projectId: string;
  payload: any;
};

type LocalLog = {
  schemaVersion: 1;
  committed: Operation[];   // ops that have been synced
  pending: Operation[];     // ops waiting for network
  snapshot: MetadataIndex;  // rolled-up state for fast reads
};

Sync Protocol

  1. Local Write: Every mutating action appends an Operation to pending and immediately applies it to snapshot. UI updates instantly.
  2. Background Sync: When online, the extension:
    • Pulls remote ops (or the full index if using Gist).
    • Merges remote ops into local committed.
    • Replays any remote ops not yet in local committed onto snapshot.
    • Pushes local pending ops to remote, clearing them on ACK.
  3. Conflict Resolution: If two machines edit the same field concurrently:
    • Compare vector clocks. If one dominates, it wins.
    • If concurrent (neither dominates), use deterministic tie-breaker: higher machineId string lexicographically wins, or prompt user if l13Projects.sync.conflictMode = 'prompt'.

Implementation Plan

  • Phase 1: Operation Log Engine
    • Create src/sync/OpLog.ts.
    • Wrap all existing state mutations (ProjectsState.add, TagsState.update, etc.) to emit Operations instead of writing directly to globalState.
    • Maintain snapshot in memory for fast reads; persist LocalLog to globalState.
  • Phase 2: Sync Queue
    • Create src/sync/SyncQueue.ts.
    • Manages pending ops. On network up, dequeues in batches of 50.
    • Implements exponential backoff on push failure (max 5 min).
    • Adds a status bar item: Projects Sync: ✅ 12 pending / ⏳ Syncing... / ❌ Offline.
  • Phase 3: CRDT Merge Logic
    • mergeOps(local: Operation[], remote: Operation[]): Operation[] — sort by vector clock, dedupe by id.
    • applyOps(snapshot: MetadataIndex, ops: Operation[]): MetadataIndex — pure function, testable.
    • Unit tests: concurrent color change, concurrent tag add/remove, offline delete vs. online update.
  • Phase 4: Gist Adapter for Ops
    • Because Gist is a single file, the op log is appended as a JSON Lines (.jsonl) file: ops.jsonl.
    • On sync, download the full ops.jsonl, merge, then upload the merged version.
    • Compaction: every 1000 ops, roll up into a new snapshot.json and truncate ops.jsonl.
  • Phase 5: Offline UI Indicators
    • Sidebar tree: projects modified offline show a pencil icon (✏️).
    • Status bar: click to see pending ops list (read-only quick-pick).
    • Command: Projects: Force Push Pending Changes (for when you know you're back online).

Acceptance Criteria

  • Eric can tag 5 projects, change 2 colors, and delete 1 project while on a plane. All changes apply instantly locally.
  • On reconnecting to Wi-Fi, all changes sync to Ventress and Snoke within 60 seconds without manual intervention.
  • If Snoke and laptop both change the same project's note offline, the deterministic tie-breaker picks a winner; no data is silently lost.
  • Sync state is visible at a glance via the status bar icon.

Open Questions

  • Should we use a real CRDT library (e.g., Yjs) for the op log, or keep it custom and lightweight? Yjs is powerful but may be overkill for ~500 projects.
  • Gist ops.jsonl compaction: should it happen automatically or via a manual Projects: Compact Sync Log command?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions