git:20260501.ed3e92e to git:20261011.86834d4

89 added, 529 removed. Audit A to A.

---
name: capacitor-offline-first
- description: Guide to building offline-first Capacitor apps with data synchronization, caching strategies, and conflict resolution. Covers Fast SQL, service workers, and network detection. Use this skill when users need their app to work without internet.
+ description: Designs and implements offline-first data in Capacitor apps - local SQLite source of truth with @capgo/capacitor-fast-sql (`FastSQL.connect`, `KeyValueStore`), an outbox/sync queue, delta pulls, conflict resolution, @capacitor/network `networkStatusChange`, background sync with @capgo/capacitor-background-task, and why localStorage/IndexedDB get evicted and service workers break plugin injection in native WebViews. Use when the app must work without internet, data disappears after iOS storage pressure, sync duplicates or loses writes, or `Plugin not implemented` appears after adding a service worker. Do not use for migrating an existing SQLite plugin (sqlite-to-fast-sql), shipping web bundle updates offline (capgo-live-updates), HTTP security/certificate pinning (capacitor-security), or general performance (capacitor-performance).
---
# Offline-First Capacitor Apps
- Build apps that work seamlessly with or without internet connectivity.
-
- ## When to Use This Skill
-
- - User needs offline support
- - User asks about data sync
- - User wants caching
- - User needs local database
- - User has connectivity issues
-
- ## Offline-First Architecture
-
- ```
- ┌─────────────────────────────────────────┐
- │ UI Layer │
- ├─────────────────────────────────────────┤
- │ Service Layer │
- │ ┌─────────────┐ ┌─────────────────┐ │
- │ │ Online Mode │ │ Offline Mode │ │
- │ └──────┬──────┘ └────────┬────────┘ │
- ├─────────┼──────────────────┼────────────┤
- │ │ Sync Manager │ │
- │ └────────┬─────────┘ │
- ├──────────────────┼──────────────────────┤
- │ ┌───────────────┴───────────────────┐ │
- │ │ Local Database │ │
- │ │ (Fast SQL / IndexedDB) │ │
- │ └───────────────────────────────────┘ │
- └─────────────────────────────────────────┘
- ```
-
- ## Network Detection
-
- ### Using Capacitor Network Plugin
-
- ```bash
- npm install @capacitor/network
- npx cap sync
- ```
-
- ```typescript
- import { Network } from '@capacitor/network';
-
- // Check current status
- const status = await Network.getStatus();
- console.log('Connected:', status.connected);
- console.log('Connection type:', status.connectionType);
-
- // Listen for changes
- Network.addListener('networkStatusChange', (status) => {
- console.log('Network status changed:', status.connected);
-
- if (status.connected) {
- // Back online - sync data
- syncManager.syncPendingChanges();
- } else {
- // Offline - show indicator
- showOfflineIndicator();
- }
- });
- ```
-
- ### Network-Aware Service
-
- ```typescript
- import { Network } from '@capacitor/network';
-
- class NetworkAwareService {
- private isOnline = true;
-
- constructor() {
- this.init();
- }
-
- private async init() {
- const status = await Network.getStatus();
- this.isOnline = status.connected;
-
- Network.addListener('networkStatusChange', (status) => {
- this.isOnline = status.connected;
- });
- }
-
- async fetch<T>(url: string, options?: RequestInit): Promise<T> {
- if (!this.isOnline) {
- // Return cached data
- return this.getCachedData(url);
- }
-
- try {
- const response = await fetch(url, options);
- const data = await response.json();
-
- // Cache the response
- await this.cacheData(url, data);
-
- return data;
- } catch (error) {
- // Network error - try cache
- return this.getCachedData(url);
- }
- }
- }
- ```
-
- ## Local Database with Fast SQL
-
- ### Installation
-
- ```bash
- npm install @capgo/capacitor-fast-sql
- npx cap sync
- ```
-
- Before using Fast SQL in production, complete the required platform setup:
-
- - iOS: allow localhost networking for the plugin transport.
- - Android: add the localhost cleartext exception required by the plugin.
- - Web: install `sql.js` if the app needs the web fallback.
-
- Use the dedicated `sqlite-to-fast-sql` skill when you need the full platform checklist.
-
- ### Database Setup
-
- ```typescript
- import { KeyValueStore } from '@capgo/capacitor-fast-sql';
-
- class Database {
- private store: Awaited<ReturnType<typeof KeyValueStore.open>> | null = null;
-
- async open() {
- if (this.store) return;
- this.store = await KeyValueStore.open({
- database: 'myapp',
- store: 'data',
- encrypted: false,
- });
- }
-
- async set(key: string, value: any) {
- await this.open();
- await this.store!.set(key, value);
- }
-
- async get<T>(key: string): Promise<T | null> {
- await this.open();
- return this.store!.get<T>(key);
- }
-
- async remove(key: string) {
- await this.open();
- await this.store!.remove(key);
- }
-
- async keys(): Promise<string[]> {
- await this.open();
- return this.store!.keys();
- }
- }
- ```
-
- ### Offline Data Repository
-
- ```typescript
- interface Entity {
- id: string;
- updatedAt: number;
- syncStatus: 'synced' | 'pending' | 'conflict';
- }
-
- class OfflineRepository<T extends Entity> {
- constructor(
- private db: Database,
- private collection: string
- ) {}
-
- getCollection(): string {
- return this.collection;
- }
-
- async getAll(): Promise<T[]> {
- const keys = await this.db.keys();
- const items: T[] = [];
+ ## When to Use
- for (const key of keys) {
- if (key.startsWith(`${this.collection}:`)) {
- const item = await this.db.get<T>(key);
- if (item) items.push(item);
- }
- }
+ TRIGGER when:
+ - The app must read and write while offline and sync later
+ - Choosing local storage (Preferences vs SQLite vs IndexedDB) for user data
+ - Writes are duplicated, lost, or overwritten during sync
+ - Cached data vanishes on iOS after low-storage events
+ - A service worker was added and native plugins stopped working (`Plugin not implemented`)
- return items;
- }
+ Do not use:
+ - Replacing `@capacitor-community/sqlite` / Ionic secure storage: `sqlite-to-fast-sql`
+ - Getting new web code to devices (OTA): `capgo-live-updates`
+ - Encryption key storage, SSL pinning: `capacitor-security`
+ - Query speed / bridge overhead tuning: `capacitor-performance`
- async getById(id: string): Promise<T | null> {
- return this.db.get<T>(`${this.collection}:${id}`);
- }
+ ## Decisions (make these before writing code)
- async save(item: T, options?: { markPending?: boolean }): Promise<void> {
- item.updatedAt = Date.now();
- if (options?.markPending ?? true) {
- item.syncStatus = 'pending';
- }
- await this.db.set(`${this.collection}:${item.id}`, item);
- }
+ | Question | Default answer |
+ |----------|----------------|
+ | Where does UI read from? | Local DB only. Network writes into the DB; UI observes the DB. |
+ | Storage for records | SQLite via `@capgo/capacitor-fast-sql` |
+ | Storage for small settings / tokens | `@capacitor/preferences` (not for secrets; see `capacitor-security`) |
+ | localStorage / IndexedDB | Transient only: iOS can evict WebView storage under pressure |
+ | App shell offline | Already offline: Capacitor bundles web assets in the binary (or Capgo live-update bundle). No service worker needed. |
+ | Service worker in native builds | Avoid. Android SW blocks Capacitor plugin injection; iOS SW needs `WKAppBoundDomains`, which also blocks injection unless `ios.limitsNavigationsToAppBoundDomains` is set. Keep SW for the PWA build only. |
+ | IDs | Client-generated UUIDs (`crypto.randomUUID()`), so offline creates need no server round trip |
+ | Conflict policy | Ask the user/product owner: last-write-wins per record, per-field merge, or server-authoritative with user review |
+ | Deletes | Soft delete (tombstone) until the server acknowledges |
- async delete(id: string): Promise<void> {
- // Soft delete - mark for sync
- const item = await this.getById(id);
- if (item) {
- item.syncStatus = 'pending';
- (item as any).deleted = true;
- await this.db.set(`${this.collection}:${id}`, item);
- }
- }
+ ## Workflow
- async getPending(): Promise<T[]> {
- const all = await this.getAll();
- return all.filter((item) => item.syncStatus === 'pending');
- }
+ 1. **Inspect**: current storage (`localStorage`, `@capacitor/preferences`, other SQLite plugins), API shape (does it support `updated_since` / version fields / idempotency keys?), auth refresh flow, existing service worker registration.
+ 2. **Report** the plan (schema, outbox, conflict policy) to the user before invasive changes. For large apps, list each entity as a TODO.
+ 3. **Install**: `npm install @capgo/capacitor-fast-sql @capacitor/network && npx cap sync`, then the platform setup in [references/fast-sql-setup.md](references/fast-sql-setup.md) (required: iOS ATS local networking, Android localhost cleartext).
+ 4. **Schema + outbox**: [references/sync-engine.md](references/sync-engine.md).
+ 5. **Triggers for sync**: app start, `networkStatusChange` -> connected, App `resume`, after each local write (debounced), optional background task.
+ 6. **UI**: show pending count / last synced time; never block a write on the network.
+ 7. **Verify** (below).
- async markSynced(id: string): Promise<void> {
- const item = await this.getById(id);
- if (item) {
- item.syncStatus = 'synced';
- await this.db.set(`${this.collection}:${id}`, item);
- }
- }
- }
- ```
+ Only load a reference when its topic is in play.
- ## Sync Manager
+ ## Minimal shape
- ```typescript
+ ```ts
+ import { FastSQL } from '@capgo/capacitor-fast-sql';
import { Network } from '@capacitor/network';
-
- class SyncManager {
- private isSyncing = false;
- private syncQueue: Array<() => Promise<void>> = [];
-
- constructor(private repositories: OfflineRepository<any>[]) {
- this.setupNetworkListener();
- }
-
- private setupNetworkListener() {
- Network.addListener('networkStatusChange', async (status) => {
- if (status.connected) {
- await this.syncAll();
- }
- });
- }
-
- async syncAll() {
- if (this.isSyncing) return;
- this.isSyncing = true;
-
- try {
- for (const repo of this.repositories) {
- await this.syncRepository(repo);
- }
- } finally {
- this.isSyncing = false;
- }
- }
-
- private async syncRepository(repo: OfflineRepository<any>) {
- const pending = await repo.getPending();
-
- for (const item of pending) {
- try {
- if ((item as any).deleted) {
- await this.deleteRemote(item);
- } else {
- await this.syncToRemote(item);
- }
- await repo.markSynced(item.id);
- } catch (error) {
- console.error('Sync failed for item:', item.id, error);
- // Keep as pending for retry
- }
- }
-
- // Pull remote changes
- await this.pullRemoteChanges(repo);
- }
-
- private async syncToRemote(item: any) {
- await fetch(`/api/${item.collection}/${item.id}`, {
- method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(item),
- });
- }
-
- private async deleteRemote(item: any) {
- await fetch(`/api/${item.collection}/${item.id}`, {
- method: 'DELETE',
- });
- }
-
- private async pullRemoteChanges(repo: OfflineRepository<any>) {
- const lastSync = await this.getLastSyncTime(repo);
- const collection = repo.getCollection();
- const response = await fetch(
- `/api/${collection}?since=${lastSync}`
- );
- const remoteItems = await response.json();
-
- for (const remoteItem of remoteItems) {
- const localItem = await repo.getById(remoteItem.id);
-
- if (!localItem) {
- // New item from server
- await repo.save({ ...remoteItem, syncStatus: 'synced' }, { markPending: false });
- } else if (localItem.syncStatus === 'synced') {
- // No local changes - update from server
- await repo.save({ ...remoteItem, syncStatus: 'synced' }, { markPending: false });
- } else {
- // Conflict - local has pending changes
- await this.resolveConflict(localItem, remoteItem, repo);
- }
- }
-
- await this.setLastSyncTime(repo, Date.now());
- }
-
- private async resolveConflict(
- local: any,
- remote: any,
- repo: OfflineRepository<any>
- ) {
- // Last-write-wins strategy
- if (local.updatedAt > remote.updatedAt) {
- // Keep local, re-sync to server
- local.syncStatus = 'pending';
- await repo.save(local);
- } else {
- // Server wins
- await repo.save({ ...remote, syncStatus: 'synced' }, { markPending: false });
- }
- }
- }
- ```
-
- ## Service Worker Caching
-
- ### Register Service Worker
-
- ```typescript
- // src/main.ts
- if ('serviceWorker' in navigator) {
- navigator.serviceWorker.register('/sw.js');
- }
- ```
-
- ### Service Worker with Workbox
-
- ```typescript
- // public/sw.js
- import { precacheAndRoute } from 'workbox-precaching';
- import { registerRoute } from 'workbox-routing';
- import { StaleWhileRevalidate, CacheFirst, NetworkFirst } from 'workbox-strategies';
-
- // Precache static assets
- precacheAndRoute(self.__WB_MANIFEST);
-
- // Cache API responses
- registerRoute(
- ({ url }) => url.pathname.startsWith('/api/'),
- new NetworkFirst({
- cacheName: 'api-cache',
- networkTimeoutSeconds: 5,
- })
- );
-
- // Cache images
- registerRoute(
- ({ request }) => request.destination === 'image',
- new CacheFirst({
- cacheName: 'image-cache',
- plugins: [
- {
- expiration: {
- maxEntries: 100,
- maxAgeSeconds: 7 * 24 * 60 * 60, // 1 week
- },
- },
- ],
- })
- );
-
- // Cache fonts
- registerRoute(
- ({ request }) => request.destination === 'font',
- new CacheFirst({
- cacheName: 'font-cache',
- })
- );
- ```
-
- ## Optimistic UI Updates
-
- ```typescript
- class TodoService {
- constructor(
- private repo: OfflineRepository<Todo>,
- private syncManager: SyncManager
- ) {}
-
- async addTodo(text: string): Promise<Todo> {
- const todo: Todo = {
- id: crypto.randomUUID(),
- text,
- completed: false,
- updatedAt: Date.now(),
- syncStatus: 'pending',
- };
-
- // Save locally immediately
- await this.repo.save(todo);
-
- // Trigger sync in background
- this.syncManager.syncAll().catch(console.error);
-
- return todo;
- }
-
- async toggleComplete(id: string): Promise<Todo> {
- const todo = await this.repo.getById(id);
- if (!todo) throw new Error('Todo not found');
-
- todo.completed = !todo.completed;
- await this.repo.save(todo);
+ import { App } from '@capacitor/app';
- this.syncManager.syncAll().catch(console.error);
+ const db = await FastSQL.connect({ database: 'app' });
+ await db.execute(`CREATE TABLE IF NOT EXISTS outbox (
+ id TEXT PRIMARY KEY, entity TEXT NOT NULL, op TEXT NOT NULL,
+ payload TEXT NOT NULL, created_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0
+ )`);
- return todo;
- }
+ export async function saveTodo(todo: Todo) {
+ await db.transaction(async (tx) => {
+ await tx.run('INSERT OR REPLACE INTO todos (id, text, done, updated_at, dirty) VALUES (?, ?, ?, ?, 1)',
+ [todo.id, todo.text, todo.done ? 1 : 0, Date.now()]);
+ await tx.run('INSERT INTO outbox (id, entity, op, payload, created_at) VALUES (?, ?, ?, ?, ?)',
+ [crypto.randomUUID(), 'todo', 'upsert', JSON.stringify(todo), Date.now()]);
+ });
+ void sync(); // fire and forget; sync() is single-flight
}
- ```
- ## Queue Failed Requests
-
- ```typescript
- class RequestQueue {
- private queue: QueuedRequest[] = [];
-
- constructor(private storage: Database) {
- this.loadQueue();
- }
-
- private async loadQueue() {
- this.queue = await this.storage.get<QueuedRequest[]>('requestQueue') || [];
- }
-
- private async saveQueue() {
- await this.storage.set('requestQueue', this.queue);
- }
-
- async enqueue(request: QueuedRequest) {
- this.queue.push(request);
- await this.saveQueue();
- }
-
- async processQueue() {
- const status = await Network.getStatus();
- if (!status.connected) return;
-
- while (this.queue.length > 0) {
- const request = this.queue[0];
-
- try {
- await fetch(request.url, {
- method: request.method,
- headers: request.headers,
- body: request.body,
- });
-
- this.queue.shift();
- await this.saveQueue();
- } catch (error) {
- // Stop processing on failure
- break;
- }
- }
- }
- }
+ Network.addListener('networkStatusChange', (s) => { if (s.connected) void sync(); });
+ App.addListener('resume', () => void sync());
```
- ## Best Practices
-
- ### 1. Show Sync Status
-
- ```tsx
- function SyncIndicator() {
- const { isOnline, pendingChanges, isSyncing } = useSyncStatus();
-
- if (!isOnline) {
- return <Badge color="warning">Offline</Badge>;
- }
-
- if (isSyncing) {
- return <Badge color="info">Syncing...</Badge>;
- }
-
- if (pendingChanges > 0) {
- return <Badge color="warning">{pendingChanges} pending</Badge>;
- }
+ The data row and its outbox entry are written in one transaction so a crash cannot leave one without the other.
- return <Badge color="success">Synced</Badge>;
- }
- ```
+ ## Traps
- ### 2. Handle Conflicts Gracefully
+ - `Network.getStatus().connected === true` does not mean the API is reachable (captive portals, VPN, server down). Treat fetch failures as offline; use `connected` only as a hint to retry.
+ - `networkStatusChange` can fire several times in a row; make `sync()` single-flight.
+ - Send an idempotency key (the outbox row id) with every mutation; retries after a timeout otherwise duplicate records.
+ - Process the outbox in order per entity; a delete must not overtake its create.
+ - Advance the pull cursor using the **server's** timestamp/version from the response, never the device clock.
+ - Auth tokens expire while offline: refresh before draining the outbox, and keep entries on 401 instead of dropping them.
+ - Do not store large blobs in SQLite rows; save files with `@capacitor/filesystem` and keep the path in the DB.
+ - iOS background execution is opportunistic. Background tasks help but cannot guarantee sync timing; foreground sync on `resume` is the reliable path.
- ```typescript
- async function handleConflict(local: Todo, remote: Todo): Promise<Todo> {
- // Option 1: Last write wins
- return local.updatedAt > remote.updatedAt ? local : remote;
+ ## Verification
- // Option 2: Merge changes
- return {
- ...remote,
- ...local,
- updatedAt: Math.max(local.updatedAt, remote.updatedAt),
- };
+ 1. Airplane mode on device: create, edit, delete records; kill and relaunch the app; data is still there.
+ 2. Turn network back on: outbox drains to 0, server reflects all changes once (no duplicates).
+ 3. Conflict test: edit the same record on two devices offline, reconnect both; result matches the chosen policy.
+ 4. Flaky network: Android emulator network throttling / iOS Network Link Conditioner (Settings -> Developer); retries back off, no data loss.
+ 5. Native checks:
- // Option 3: Ask user
- const choice = await showConflictDialog(local, remote);
- return choice === 'local' ? local : remote;
- }
+ ```bash
+ grep -rn "serviceWorker.register" src/ | grep -v "isNativePlatform" # should be empty or web-guarded
+ /usr/libexec/PlistBuddy -c "Print :NSAppTransportSecurity:NSAllowsLocalNetworking" ios/App/App/Info.plist
+ grep -n "networkSecurityConfig\|usesCleartextTraffic" android/app/src/main/AndroidManifest.xml
```
- ### 3. Validate Before Sync
+ 6. Inspect the DB: `SELECT COUNT(*) FROM outbox` exposed in a debug screen, or pull the file (`adb exec-out run-as com.example.app ls databases/`).
- ```typescript
- function validateTodo(todo: Todo): boolean {
- if (!todo.id || !todo.text) return false;
- if (todo.text.length > 500) return false;
- return true;
- }
+ ## Error Handling
- async function syncTodo(todo: Todo) {
- if (!validateTodo(todo)) {
- throw new Error('Invalid todo');
- }
- // Proceed with sync
- }
- ```
+ | Error / symptom | Cause | Fix |
+ |-----------------|-------|-----|
+ | `"CapgoCapacitorFastSql" plugin is not implemented on android` / `Plugin not implemented` | Service worker or `WKAppBoundDomains` blocking injection, or missing `npx cap sync` | Remove SW from native build / set `ios.limitsNavigationsToAppBoundDomains`; run `npx cap sync` |
+ | iOS: `The resource could not be loaded because the App Transport Security policy requires the use of a secure connection` | Fast SQL localhost transport blocked by ATS | Add `NSAllowsLocalNetworking` |
+ | Android: `CLEARTEXT communication to localhost not permitted by network security policy` | Cleartext to localhost blocked | Add `network_security_config.xml` localhost exception |
+ | `SQLITE_CONSTRAINT: UNIQUE constraint failed` during pull | Server row id already exists locally | Use `INSERT ... ON CONFLICT(id) DO UPDATE` |
+ | `database is locked` | Parallel writers / long transaction | Serialize writes; keep transactions short; single-flight sync |
+ | Data gone after iOS storage pressure | Stored in localStorage / IndexedDB | Move to SQLite |
+ | Duplicate server records after reconnect | Retried POST without idempotency key | Send outbox id as `Idempotency-Key`; server dedupes |
## Resources
- - Capacitor Network: https://capacitorjs.com/docs/apis/network
- - Workbox: https://developer.chrome.com/docs/workbox
- - IndexedDB: https://developer.mozilla.org/docs/Web/API/IndexedDB_API
- - Offline First Manifesto: http://offlinefirst.org
+ - Fast SQL plugin: https://capgo.app/docs/plugins/fast-sql/
+ - Capacitor storage guide: https://capacitorjs.com/docs/guides/storage
+ - Network API: https://capacitorjs.com/docs/apis/network
+ - Background task plugin: https://capgo.app/docs/plugins/background-task/
+ - iOS troubleshooting (WKAppBoundDomains): https://capacitorjs.com/docs/ios/troubleshooting