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---
354 lines
16 KiB
Markdown
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` | |