Files
atelier/examples/good/edge-offline-sync.md
T
Jon Chery 29ffb42898 docs(milestone): complete v0.4 — Edge + Messaging + Language-Derived Docs
---ci---
project: atelier
phase: 0
milestone: v0.4
status: complete
requirements:
  covered: [ATELIER-92, ATELIER-93, ATELIER-94, ATELIER-95, ATELIER-96, ATELIER-97, ATELIER-98, ATELIER-99, ATELIER-100, ATELIER-101, ATELIER-102, ATELIER-103, ATELIER-104, ATELIER-105, ATELIER-106, ATELIER-107, ATELIER-108, ATELIER-109, ATELIER-110, ATELIER-111, ATELIER-112, ATELIER-113, ATELIER-114, ATELIER-115, ATELIER-116, ATELIER-117]
  partial: []
---/ci---
2026-08-05 16:23:15 +00:00

8.9 KiB

Good Example: Edge Offline-First + Sync Reconcile

A field-service tablet app that operates through a 90-minute network partition, queues writes locally, and reconciles on reconnect using the CRDT-vs-LWW decision matrix from domains/edge/sync.md. Each aspect cites the edge principle it satisfies.

The Code

// The local store: an offline-first write queue + a CRDT register
// per record. The tablet keeps working through the partition; the
// queue drains on reconnect; the merge converges (P4 bounded).

interface LWWRegister<T> {
  value: T;
  timestamp: number;   // monotonic logical clock (HLC), NOT wall time
  nodeId: string;       // tablet id — deterministic tiebreak (P4)
}

interface QueuedWrite {
  idempotencyKey: string;   // P5: retried syncs are safe
  collection: string;
  recordId: string;
  register: LWWRegister<unknown>;
  queuedAt: number;
}

class OfflineStore {
  private queue: QueuedWrite[] = [];
  private state: Map<string, LWWRegister<unknown>> = new Map();
  private telemetryBuffer: TelemetryRecord[] = [];   // P10 local-first

  // P2: write succeeds offline. The queue is the durable record of
  // intent; sync is deferred, not blocked.
  write(collection: string, recordId: string, value: unknown, key: string): void {
    const register: LWWRegister<unknown> = {
      value,
      timestamp: this.hlc.now(),         // monotonic; no wall-clock skew
      nodeId: this.nodeId,
    };
    this.state.set(`${collection}:${recordId}`, register);
    this.queue.push({
      idempotencyKey: key,               // P5: idempotent sync
      collection, recordId, register,
      queuedAt: this.hlc.now(),
    });
    // P10: buffer the write event locally; forwarded on reconnect.
    this.telemetryBuffer.push({ event: "local.write", at: Date.now(), key });
  }

  // P2: read from local state offline. The UI never blocks on the
  // network.
  read(collection: string, recordId: string): unknown {
    return this.state.get(`${collection}:${recordId}`)?.value;
  }
}
// The CRDT register merge — a state-based CvRDT (convergent). Per the
// CRDT-vs-LWW decision matrix in domains/edge/sync.md, a state-based
// CRDT is the choice when the data model fits a register and the
// network is unreliable (full-state merge tolerates dropped ops).

function mergeLWWRegister<T>(
  local: LWWRegister<T>,
  remote: LWWRegister<T>,
): LWWRegister<T> {
  // Associative, commutative, idempotent (P4 bound, P5 idempotent).
  // (timestamp, nodeId) is a total order — no oscillation.
  if (local.timestamp > remote.timestamp) return local;
  if (local.timestamp < remote.timestamp) return remote;
  return local.nodeId > remote.nodeId ? local : remote;  // deterministic tie
}
// The reconnect reconcile: drain the offline queue, pull remote
// state, three-way merge (CRDT registers), push converged state.
// Idempotent keys make a retried reconcile safe (P5).

async function reconcile(store: OfflineStore, server: SyncServer): Promise<ReconcileReport> {
  // P5: the reconcile is idempotent. The idempotency key on each
  // queued write means a retry (network flapped mid-reconcile) does
  // not double-apply.
  const queued = store.drainQueue();
  let pushed = 0, merged = 0, conflicts = 0;

  // 1. Push local writes. The server dedups by idempotencyKey (P5).
  for (const w of queued) {
    await server.applyWrite(w.idempotencyKey, w.collection, w.recordId, w.register);
    pushed++;
  }

  // 2. Pull remote state for every record we touched + every record
  //    the server changed since our last sync cursor. Merge via CRDT.
  const remoteRecords = await server.fetchChanged(store.syncCursor());
  for (const [key, remoteReg] of remoteRecords) {
    const localReg = store.localRegister(key);
    if (localReg) {
      // P4: CRDT merge converges. The merge is a pure function of
      // two inputs; the total order (timestamp, nodeId) guarantees
      // no oscillation.
      const converged = mergeLWWRegister(localReg, remoteReg);
      if (converged !== localReg) { conflicts++; }
      store.setLocal(key, converged);
      merged++;
    } else {
      store.setLocal(key, remoteReg);   // remote-only record
      merged++;
    }
  }

  // 3. P10: flush the buffered telemetry. The partition did not blind
  // the operator — the events survived on-node.
  store.flushTelemetry();

  return { pushed, merged, conflicts, converged: true };
}
// P10: local-first telemetry. Events are buffered on-node and
// forwarded on reconnect. A fire-and-forget pipeline loses data when
// the link drops; a local-first buffer survives.

interface TelemetryRecord { event: string; at: number; key: string; }

class TelemetryBuffer {
  private buffer: TelemetryRecord[] = [];

  push(rec: TelemetryRecord): void { this.buffer.push(rec); }

  // Called from reconcile() on reconnect. The buffer is the P10
  // guarantee: the operator sees the partition-window activity,
  // not a gap.
  async flush(sink: TelemetrySink): Promise<void> {
    for (const rec of this.buffer) { await sink.emit(rec); }
    this.buffer = [];
  }

  depth(): number { return this.buffer.length; }
}

The Scenario

A field-service tablet is dispatched to a basement site with no cellular coverage. The technician updates the work-order status (started, parts-ordered, completed) five times over 90 minutes. Each write lands in the local store immediately — the UI never blocks on the network. The writes are queued with an idempotency key (the work-order id + a monotonic sequence).

When the tablet reconnects, the reconcile drains the queue: the server dedups by idempotency key (a retry mid-reconcile does not double-apply). The server also returns a remote update — the dispatcher re-assigned the work order to a different technician at minute 45, then reverted at minute 60. The CRDT merge converges: the register with the later logical timestamp wins; the tiebreak by node id is deterministic. The merge terminates in one pass (P4 bounded); it does not oscillate between the dispatcher's revert and the technician's status updates. The buffered telemetry flushes, and the operator sees the full partition-window activity — no gap.

Principles Demonstrated

Offline is a First-Class State (Edge P2, C1, C5)

  • The tablet writes and reads through the partition. The UI never blocks on the network; the offline write queue is the durable record of intent. Partition is the norm, not the exception; reconciliation happens on reconnect. An app that crashes on disconnect has no offline state; this app engineers it.
  • See domains/edge/offline-first.md (offline write-queue) and domains/edge/first-principles.md P2.

Sync Conflicts are Bounded, Not Infinite (Edge P4, C1, C5)

  • The CRDT register merge is associative, commutative, and idempotent. The total order (timestamp, nodeId) guarantees convergence in one pass — no oscillation, no infinite loop. This is the P4 bound: the merge terminates. The CRDT-vs-LWW decision matrix in domains/edge/sync.md selected a state-based CRDT because the data model fits a register and the network is unreliable (full-state merge tolerates dropped ops).
  • See domains/edge/sync.md (CRDT-vs-LWW decision matrix, merge semantics) and domains/edge/first-principles.md P4.

Edge Operations are Idempotent (Edge P5, C1)

  • Every queued write carries an idempotency key; the server dedups by key. A reconcile retried mid-flap does not double-apply. The merge function is idempotent (merge(a, a) === a) — a retried merge of the same two replicas yields the same result. Sync, the retried-by-nature operation, is safe.
  • See domains/edge/sync.md (Merge Semantics, P5) and domains/edge/first-principles.md P5.

Edge Observability Survives Partition (Edge P10, C7, C5)

  • Telemetry is buffered on-node (telemetryBuffer) and flushed on reconnect. The partition did not blind the operator — the partition-window activity is forwarded, not lost. A fire-and-forget pipeline would have a 90-minute gap; the local-first buffer survives.
  • See domains/edge/offline-first.md (local-first logging) and domains/edge/first-principles.md P10.
  • domains/edge/offline-first.md — the offline write-queue pattern this app instantiates; the partition-as-norm discipline.
  • domains/edge/sync.md — the CRDT-vs-LWW decision matrix exercised here (state-based CvRDT chosen for register + unreliable network); the merge-semantics properties (associative, commutative, idempotent).
  • domains/edge/first-principles.md — P2, P4, P5, P10 are the principles demonstrated.
  • domains/concurrency/patterns — the in-process immutability analog (concurrency/P1 Immutability by Default) that makes the merge a pure function of two inputs.
  • domains/observability/metrics — the generic SLI/SLO discipline the local-first telemetry buffer builds on.
  • review/anti-patterns.md — the edge-sync-loop chaos anti-pattern is the inverse of this example's bounded CRDT merge.