---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---
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) anddomains/edge/first-principles.mdP2.
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 indomains/edge/sync.mdselected 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) anddomains/edge/first-principles.mdP4.
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) anddomains/edge/first-principles.mdP5.
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) anddomains/edge/first-principles.mdP10.
Cross-Domain Links
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— theedge-sync-loopchaos anti-pattern is the inverse of this example's bounded CRDT merge.