Files
atelier/domains/edge/offline-first.md
T
Jon Chery 20992883ff docs(P01): complete edge domain phase — v0.4
---ci---
project: atelier
phase: 1
milestone: v0.4
status: complete
phase_role: execution
phase_tag: v0.3.1
requirements:
  covered: [ATELIER-92, ATELIER-93, ATELIER-94, ATELIER-95, ATELIER-96]
  partial: []
---/ci---
2026-08-05 15:51:03 +00:00

354 lines
16 KiB
Markdown

# Offline-First — Derived Rules
> Derives from `domains/edge/first-principles.md`. Applies P2
> (Offline is a First-Class State) primarily, with P5 (idempotent
> queue-and-forward), P4 (bounded sync conflicts on reconnect), P7
> (partial degradation), and P10 (local-first telemetry). Cross-links
> `domains/concurrency/patterns` for the in-process bounded-buffer
> analog and `domains/observability/logging` for local-first logging.
## What Offline-First Is (P2 Offline is a First-Class State)
- Offline-first is the design discipline in which the system
continues to operate when disconnected from the center. Partition
is the norm, not the exception; reconciliation happens on
reconnect. The offline state is engineered, not a degenerate mode
the app falls into by accident.
- The boundary is per D-061: edge owns the
proximity/location/disconnection angle. An offline-first web app
is an edge concern because its defining trait is partition-survival
(P2), not generic performance. The local-first storage is the
edge device's constrained-resource reality (P3).
- Offline-first is the precondition for the bounded-conflict
discipline of `P4 Sync Conflicts are Bounded, Not Infinite`:
without offline operation there is nothing to reconcile; with it,
the reconnect reconciliation is the correctness mechanism. See
`domains/edge/sync.md` for the conflict-resolution strategies.
## Local-First Storage (P2, P3)
- Local-first storage holds the working copy on the device:
IndexedDB (browser), SQLite (mobile, embedded), or on-device file
storage (desktop, IoT gateway). The local store is the authority
while offline; the server is reconciled later, not consulted per
read.
- The local store is bounded by the device (P3 — Resources are
Constrained and Declared). A local store that grows without bound
is a defect: declare a budget (e.g., a 50 MB IndexedDB quota, a
30-day rolling window), and evict outside the budget deterministically.
- The local store is the offline state; without it the app is
online-only and crashes on disconnect (P2 violation). The store is
the reversibility mechanism (C5): every local write is reversible
on reconcile.
```typescript
// Local-first store sketch (IndexedDB). The app reads from the
// local store, never the network, while offline. Writes queue
// locally and forward on reconnect (P2, P5).
const db = await openDB("atelier-offline", 1, {
upgrade(db) {
const store = db.createObjectStore("pending-writes", {
keyPath: "id",
});
store.createIndex("by-createdAt", "createdAt");
},
});
async function readRecord(id: string) {
// Read from local store first; the network is a reconcile path,
// not the read path.
return db.get("pending-writes", id);
}
```
## Queue-and-Forward for Writes (P5 Edge Operations are Idempotent)
- Every write while offline is queued locally and forwarded to the
server on reconnect. The queue is the offline write-queue; the
forward is the reconcile. Each queued write carries an idempotency
key so a retried forward (the network is partition-prone) does not
double-apply (P5).
- The queue is bounded (P3): a queue that grows without limit on a
constrained device will exhaust it. Declare a max-queue-depth and
a max-queue-bytes; reject or evict beyond the bound with a defined
policy (oldest-first, lowest-priority-first).
- The queue is the cross-partition analog of the in-process bounded
buffer — see `domains/concurrency/patterns` (bounded buffer,
backpressure). Concurrency owns the in-process analog; edge owns
the partition-survivable analog. The failure model differs: the
in-process buffer fails by OOM; the offline write-queue fails by
partition or device loss.
```typescript
// Offline write-queue sketch. Each entry carries an idempotency
// key (P5) so a retried forward is safe. The queue is bounded by
// maxDepth (P3).
interface PendingWrite {
id: string; // local id
idempotencyKey: string; // server-side dedup key (P5)
collection: string;
payload: unknown;
createdAt: number;
}
const MAX_DEPTH = 1000;
async function queueWrite(write: Omit<PendingWrite, "id" | "idempotencyKey" | "createdAt">) {
const depth = await db.count("pending-writes");
if (depth >= MAX_DEPTH) {
// P3: bounded queue. Evict the oldest pending write or reject.
// Rejecting is correct when the write is higher-priority than
// the oldest; evicting is correct when the newest is lowest.
throw new Error("offline-queue-full");
}
const entry: PendingWrite = {
...write,
id: crypto.randomUUID(),
idempotencyKey: `${write.collection}:${crypto.randomUUID()}`,
createdAt: Date.now(),
};
await db.put("pending-writes", entry);
// The forward loop picks this up when connectivity returns.
}
async function forwardPendingWrites(server: Server) {
const pending = await db.getAllFromIndex("pending-writes", "by-createdAt");
for (const write of pending) {
// P5: idempotent — the server dedups by idempotencyKey.
await server.apply(write, write.idempotencyKey);
await db.delete("pending-writes", write.id);
}
}
```
## Conflict Detection on Reconnect (P4 Sync Conflicts are Bounded)
- On reconnect, the queued writes are forwarded; the server may
have advanced while the device was offline. A conflict is when the
local write and the server state diverge. Conflict detection is
the precondition for bounded reconciliation (P4): a write forwarded
blindly (last-write-wins with no clock) is a P4 violation waiting
to happen.
- Conflict resolution strategies (CRDT, LWW with vector clocks,
application-specific merge) are the subject of
`domains/edge/sync.md` — the CRDT-vs-LWW decision matrix there
determines which applies. Offline-first owns the *detection*; sync
owns the *resolution*.
- A reconnect that detects no conflicts when conflicts exist is a
silent correctness defect (C1, P4). Detection must be conservative:
when in doubt, flag a conflict and surface it to the merge
function or the user.
## UI for Offline State (P7 Partial Degradation is Engineered)
- The UI must reflect the offline state visibly: a "you are offline,
changes will sync when connected" banner, a pending-writes counter,
a last-synced timestamp. A UI that hides the offline state
violates P7 — the degraded mode is a designed state with a defined
contract, not a silent fall-through.
- The UI must function while offline: reads from local-first
storage, writes to the queue, navigation that does not require the
network. An app that shows a blank screen or a spinner-forever when
offline has no offline state (P2 violation) and no degradation
contract (P7 violation).
- The pending-writes counter is the local-first analog of the
messaging consumer-lag metric — see
`domains/observability/metrics` for the lag-discipline parallel.
## Service Workers (P2, P6)
- A service worker is a client-side proxy that intercepts network
requests and serves from a local cache. It is the browser's
offline-first primitive: the service worker cache is the
offline-capable store for assets; the IndexedDB store is the
offline-capable store for data.
- The service worker cache is an edge cache (P6 — Cache Invalidation
is Explicit): it must carry a TTL or explicit invalidation
strategy. A service worker that caches forever and never
invalidates is a TTL-less edge cache under partition — a P6
violation (stale-forever).
- See `domains/edge/cdn.md` for the generic edge-cache invalidation
discipline; the service worker is the on-device instance of it.
```javascript
// Service worker cache strategy: stale-while-revalidate for
// assets, network-first for data, explicit version-bump for
// breaking changes (P6).
const CACHE = "atelier-v3"; // bump on deploy to invalidate (P6)
const ASSETS = ["/", "/app.js", "/styles.css"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(ASSETS))
);
});
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.pathname.startsWith("/api/")) {
// Network-first for data; fall back to cache on partition (P2).
event.respondWith(
fetch(event.request).catch(() => caches.match(event.request))
);
} else {
// Stale-while-revalidate for assets (P6 explicit invalidation).
event.respondWith(
caches.open(CACHE).then(async (cache) => {
const cached = await cache.match(event.request);
const network = fetch(event.request).then((resp) => {
cache.put(event.request, resp.clone());
return resp;
}).catch(() => cached);
return cached || network;
})
);
}
});
self.addEventListener("activate", (event) => {
// P6: explicit invalidation. Drop old caches on activate.
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
);
});
```
## Offline Write-Queue and Conflict Detection Mapped to the Testing Pyramid (IDEATE-38, ATELIER-94)
The offline write-queue and conflict-detection patterns must be
tested at every tier of the testing pyramid. Each tier exercises a
different failure mode; skipping a tier leaves a correctness gap
(P2, P4 violations that surface only in production partitions).
| Pyramid Tier | What it exercises | What it proves |
|-------------|-------------------|----------------|
| **Unit** | Conflict detection on a merge function (pure inputs → expected merge result) | The merge logic is correct in isolation (P4) — given two divergent states, the merge returns the converged state |
| **Integration** | Reconnect reconcile against a local store (fake server, real IndexedDB/SQLite) | The queue-and-forward loop drains correctly; the local store and server converge after reconnect (P2, P5) |
| **E2e** | Partition simulation with a fake network (the app runs in a browser, the network is cut and restored) | The offline state, UI, and reconcile work end-to-end under partition (P2, P7) |
- **Unit — conflict detection on a merge function.** The merge
function is pure: given two divergent states and a clock, it
returns the converged state. Test every merge case (LWW, CRDT
register, set union, application-specific three-way merge) as a
pure function. This is the cheapest tier and the highest coverage
per test — see `domains/testing/pyramid`.
```typescript
// Unit test sketch: conflict detection on a merge function (P4).
// The merge function is pure; no network, no store. Test that
// divergent states converge and that the merge is bounded (no
// oscillation).
function mergeLWW(local: State, remote: State, clock: Clock): State {
// Last-write-wins: the state with the later vector-clock wins.
// Returns the converged state (P4).
return clock.compare(local.clock, remote.clock) >= 0 ? local : remote;
}
// Unit cases:
// - local ahead → local wins
// - remote ahead → remote wins
// - concurrent (clocks incomparable) → conflict flagged or LWW tiebreak
// - identical → no-op convergence (bounded, no oscillation)
test("mergeLWW converges when local is ahead", () => {
const local = { v: 2, clock: { a: 2 } };
const remote = { v: 1, clock: { a: 1 } };
expect(mergeLWW(local, remote, { compare: (a, b) => a.a - b.a })).toEqual(local);
});
```
- **Integration — reconnect reconcile against a local store.** A
fake server stands in for the network; the real IndexedDB (or
SQLite) holds the queue. The test fills the queue while offline,
reconnects, and asserts the queue drains and the server and local
store converge. This exercises the queue-and-forward loop (P5)
and the reconcile against real storage.
```typescript
// Integration test sketch: reconnect reconcile against a local
// store. A fake server; real IndexedDB. The queue drains; the
// server and local store converge after reconnect (P2, P5).
test("reconnect reconciles pending writes against the server", async () => {
const db = await openDB("test-offline", 1, { /* schema */ });
const server = new FakeServer();
await queueWrite(db, { collection: "docs", payload: { v: 1 } });
// Simulate offline: server is unreachable.
server.offline();
await queueWrite(db, { collection: "docs", payload: { v: 2 } });
expect(await db.count("pending-writes")).toBe(2);
// Simulate reconnect: server is reachable.
server.online();
await forwardPendingWrites(server, db);
expect(await db.count("pending-writes")).toBe(0);
expect(await server.latest("docs")).toEqual({ v: 2 });
});
```
- **E2e — partition simulation with a fake network.** The app runs
in a real browser; a fake network layer cuts and restores the
connection. The test asserts the UI shows the offline state, the
writes queue, the reconnect reconciles, and the UI returns to
online. This is the highest-fidelity tier and the lowest coverage
per test — run a small number of representative scenarios, not a
combinatorial matrix.
```typescript
// E2e test sketch: partition simulation with a fake network. The
// app runs in a browser; the network is cut and restored. Asserts
// the offline UI state, the queue, the reconcile, and the online
// recovery (P2, P7).
test("app survives a network partition and reconciles on reconnect", async () => {
await page.goto("https://app.example.com");
await page.click("text=Edit document");
await page.fill("textarea", "offline edit");
// Cut the network.
await page.setOffline(true);
await page.click("text=Save");
await expect(page.locator("text=You are offline")).toBeVisible();
await expect(page.locator("text=1 pending change")).toBeVisible();
// Restore the network.
await page.setOffline(false);
await expect(page.locator("text=All changes synced")).toBeVisible();
await expect(page.locator("text=0 pending changes")).toBeVisible();
});
```
- The three tiers are complementary: unit proves the merge logic,
integration proves the reconcile loop, e2e proves the partition
behavior. Skipping any tier leaves a correctness gap. See
`domains/testing/pyramid` for the pyramid discipline and
`domains/testing/fixtures` for the fake-server and fake-network
fixture patterns.
## Observability (P10 Edge Observability Survives Partition)
- The offline state is itself an observable signal: the
pending-writes count, the last-synced timestamp, the
reconcile-failure count. A device stuck offline for days with a
full queue is an incident; without local-first telemetry it is
invisible (P10 violation).
- Local-first logging (buffered on-device, forwarded on reconnect)
is the offline-first instance of `P10 Edge Observability Survives
Partition`. See `domains/observability/logging` for the generic
structured-logging discipline the local-first buffer builds on.
- A reconcile failure that is not logged locally is a silent defect
— the operator cannot debug what they cannot see (C7, P10).
## What Violates Offline-First Discipline
| Violation | Principle |
|-----------|-----------|
| App that crashes on disconnect (no offline state) | P2 Offline is a First-Class State |
| Unbounded offline write-queue (grows until device OOM) | P3 Resources are Constrained and Declared |
| Queued write forwarded without an idempotency key (retry doubles the effect) | P5 Edge Operations are Idempotent |
| Reconnect that detects no conflicts when conflicts exist | P4 Sync Conflicts are Bounded, Not Infinite |
| UI that hides the offline state (no banner, no pending counter) | P7 Partial Degradation is Engineered |
| Service worker cache with no TTL and no explicit invalidation | P6 Cache Invalidation is Explicit |
| Reconcile failure with no local log (silent under partition) | P10 Edge Observability Survives Partition |
| Merge function that oscillates (no convergence guarantee) | P4, `domains/edge/sync.md` |
| Local-first store with no declared budget (grows without bound) | P3, `domains/concurrency/patterns` (bounded buffer analog) |
| E2e tests that never simulate a partition (offline path untested) | P2, `domains/testing/pyramid` |