20992883ff
---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---
16 KiB
16 KiB
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-linksdomains/concurrency/patternsfor the in-process bounded-buffer analog anddomains/observability/loggingfor 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. Seedomains/edge/sync.mdfor 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.
// 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.
// 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/metricsfor 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.mdfor the generic edge-cache invalidation discipline; the service worker is the on-device instance of it.
// 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.
// 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.
// 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.
// 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/pyramidfor the pyramid discipline anddomains/testing/fixturesfor 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. Seedomains/observability/loggingfor 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 |