|
| 1 | +# Fusion Framework CouchDB State Replication Cookbook |
| 2 | + |
| 3 | +A comprehensive cookbook demonstrating real-time state synchronization between React applications and CouchDB using the Fusion Framework state module with PouchDB replication. |
| 4 | + |
| 5 | +## 🎯 Learning Objectives |
| 6 | + |
| 7 | +After working through this cookbook, you will understand: |
| 8 | + |
| 9 | +- How to configure CouchDB replication with the Fusion Framework state module |
| 10 | +- How to set up local CouchDB using Docker for development |
| 11 | +- How to implement bidirectional state synchronization between apps and databases |
| 12 | +- How to handle offline-first scenarios with automatic sync when online |
| 13 | +- Best practices for conflict resolution and error handling in distributed state |
| 14 | + |
| 15 | +## 🏗️ Setup |
| 16 | + |
| 17 | +### Prerequisites |
| 18 | + |
| 19 | +- Node.js 18+ |
| 20 | +- pnpm package manager |
| 21 | +- Docker and Docker Compose |
| 22 | +- Basic knowledge of React hooks and CouchDB concepts |
| 23 | + |
| 24 | +### Quick Start |
| 25 | + |
| 26 | +1. **Install dependencies:** |
| 27 | + ```bash |
| 28 | + pnpm install |
| 29 | + ``` |
| 30 | + |
| 31 | +2. **Start the development server:** |
| 32 | + ```bash |
| 33 | + pnpm dev |
| 34 | + ``` |
| 35 | + |
| 36 | +3. **Access CouchDB Admin UI:** |
| 37 | + - URL: http://localhost:5984/_utils |
| 38 | + - Username: `admin` |
| 39 | + - Password: `admin` |
| 40 | + |
| 41 | +4. **Test the replication:** |
| 42 | + - Open the app in multiple browser tabs |
| 43 | + - Make changes on `profile` or `todos` page and watch them appear in others! |
| 44 | + |
| 45 | +### Docker Commands |
| 46 | + |
| 47 | +```bash |
| 48 | +# Start CouchDB |
| 49 | +pnpm couchdb:start |
| 50 | + |
| 51 | +# Stop CouchDB |
| 52 | +pnpm couchdb:stop |
| 53 | + |
| 54 | +# View CouchDB logs |
| 55 | +pnpm couchdb:logs |
| 56 | + |
| 57 | +# Clean up (stop, remove container and volume) |
| 58 | +pnpm couchdb:clean |
| 59 | +``` |
| 60 | + |
| 61 | +## 📚 Key Concepts |
| 62 | + |
| 63 | +### State Replication Architecture |
| 64 | + |
| 65 | +This cookbook demonstrates a complete offline-first architecture: |
| 66 | + |
| 67 | +``` |
| 68 | +┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ |
| 69 | +│ React App │ │ PouchDB │ │ CouchDB │ |
| 70 | +│ │ │ (Local Store) │ │ (Remote DB) │ |
| 71 | +│ ┌─────────────┐ │ │ │ │ │ |
| 72 | +│ │ useAppState │◄┼────┼► Local Storage │◄───┼► Remote Storage │ |
| 73 | +│ │ Hooks │ │ │ │ │ │ |
| 74 | +│ └─────────────┘ │ │ • Offline-first │ │ • Persistence │ |
| 75 | +│ │ │ • Instant UI │ │ • Multi-user │ |
| 76 | +│ • UI Updates │ │ • Auto-sync │ │ • Backup │ |
| 77 | +│ • User Actions │ │ • Conflict res. │ │ • HTTP API │ |
| 78 | +└─────────────────┘ └──────────────────┘ └─────────────────┘ |
| 79 | +``` |
| 80 | + |
| 81 | +### Configuration Overview |
| 82 | + |
| 83 | +The application's CouchDB replication is configured in [`src/config.ts`](./src/config.ts). This file sets up both the local PouchDB instance (for offline-first storage) and the remote CouchDB database (for synchronization), and wires them into the Fusion Framework state module. |
| 84 | + |
| 85 | +Key steps in the configuration: |
| 86 | + |
| 87 | +- **Local Database:** A PouchDB instance named `cookbook_app_state` is created for browser storage with auto-compaction enabled. |
| 88 | +- **Remote Database:** A remote PouchDB instance connects to CouchDB at `http://localhost:5984/cookbooks_app_state` using admin credentials. |
| 89 | +- **Module Integration:** The custom state module is enabled with these databases, allowing automatic bidirectional sync. |
| 90 | +- **Navigation:** The navigation module is also enabled for multi-page support. |
| 91 | + |
| 92 | +See the full configuration in [`src/config.ts`](./src/config.ts) for implementation details. |
| 93 | + |
| 94 | +### State Management with useAppState |
| 95 | + |
| 96 | +Use the `useAppState` hook just like React's `useState`, but with automatic persistence and replication: |
| 97 | + |
| 98 | +```typescript |
| 99 | +import { useAppState } from '@equinor/fusion-framework-react-app/state'; |
| 100 | + |
| 101 | +const [state, setState] = useAppState<MyState>('my.state.key', { |
| 102 | + defaultValue: { |
| 103 | + value: '', |
| 104 | + updatedAt: new Date().toISOString(), |
| 105 | + }, |
| 106 | +}); |
| 107 | + |
| 108 | +// Any updates to state are persisted and replicated automatically |
| 109 | +setState(prev => ({ |
| 110 | + ...prev, |
| 111 | + value: 'New Value', |
| 112 | + updatedAt: new Date().toISOString(), |
| 113 | +})); |
| 114 | +``` |
| 115 | + |
| 116 | +## 🔍 Code Structure |
| 117 | + |
| 118 | +```text |
| 119 | +src/ |
| 120 | +├── App.tsx # Main application entry point |
| 121 | +├── config.ts # Fusion Framework and CouchDB setup |
| 122 | +├── index.ts # App bootstrap |
| 123 | +├── components/ |
| 124 | +│ ├── ProfileManager/ # Profile management |
| 125 | +│ ├── SyncStatus/ # Replication status and logs |
| 126 | +│ └── Todo/ # Todo list |
| 127 | +└── modules/ |
| 128 | + └── app-state-with-replication/ |
| 129 | + ├── configurator.ts # Allow configuration of local and remote db |
| 130 | + ├── provider.ts # Override base provider to setup sync |
| 131 | + ├── module.ts # Override setup of configurator and provider |
| 132 | + └── observe-sync.ts # Helper to listen and aggregate sync events |
| 133 | +``` |
| 134 | + |
| 135 | +## 🧪 Examples in This Cookbook |
| 136 | + |
| 137 | +### 1. Profile Manager |
| 138 | + |
| 139 | +Demonstrates basic state management with CouchDB replication: |
| 140 | + |
| 141 | +- **User profile data** - name, email, preferences |
| 142 | +- **Reducer pattern** - manage complex state transitions |
| 143 | +- **Real-time updates** - changes sync across browser tabs |
| 144 | +- **Optimistic updates** - UI responds immediately |
| 145 | +- **Conflict resolution** - last-write-wins strategy |
| 146 | + |
| 147 | +### 2. Sync Status Monitor |
| 148 | + |
| 149 | +Provides real-time monitoring of replication status: |
| 150 | + |
| 151 | +- **Connection status** - online, offline, syncing, error |
| 152 | +- **Sync events log** - detailed replication activity |
| 153 | +- **Manual sync trigger** - force sync when needed |
| 154 | +- **Error handling** - graceful degradation on failures |
| 155 | + |
| 156 | +**Visual Indicators:** |
| 157 | +- 🟢 Online - Connected and synced |
| 158 | +- 🔵 Syncing - Data transfer in progress |
| 159 | +- 🔴 Offline/Error - Connection issues |
| 160 | + |
| 161 | +### 3. Todo List |
| 162 | + |
| 163 | +Complex state management with real-world patterns: |
| 164 | + |
| 165 | +- **CRUD operations** - create, read, update, delete todos |
| 166 | +- **Optimistic updates** - instant UI feedback |
| 167 | +- **Conflict resolution** - handles concurrent edits |
| 168 | +- **Data relationships** - nested object structures |
| 169 | + |
| 170 | +## 📦 Module - App State With Replication |
| 171 | + |
| 172 | +The `app-state-with-replication` module encapsulates the logic for integrating CouchDB replication into the Fusion Framework state system. Here’s a summary of its key components and how they work together: |
| 173 | + |
| 174 | +- **`configurator.ts`** |
| 175 | + Provides a configurator for setting up both local (PouchDB) and remote (CouchDB) databases. It allows you to specify database names, URLs, and sync options, making the module flexible for different environments. |
| 176 | + |
| 177 | +- **`provider.ts`** |
| 178 | + Extends the base state provider to initialize and manage replication. It sets up listeners for replication events (like sync, error, paused, active) and exposes sync status to the app, enabling UI components to react to connectivity and conflict states. |
| 179 | + |
| 180 | +- **`module.ts`** |
| 181 | + Registers the custom configurator and provider with the Fusion Framework. This ensures that when the module is enabled, your app state is automatically wired for replication and sync monitoring. |
| 182 | + |
| 183 | +- **`observe-sync.ts`** |
| 184 | + Implements utilities to observe and aggregate sync events from PouchDB. It provides hooks and helpers to track replication status, errors, and progress, which can be used to display real-time sync indicators in your UI. |
| 185 | + |
| 186 | +- **`types.ts`** |
| 187 | + Defines TypeScript types for configuration, sync status, and events. This ensures type safety and clarity when working with the module’s APIs. |
| 188 | + |
| 189 | +- **`enable-module.ts`** |
| 190 | + Exposes a helper to easily enable the module in your Fusion Framework app, streamlining integration. |
| 191 | + |
| 192 | +- **`index.ts`** |
| 193 | + Entry point that exports the module’s public API, including hooks and configuration helpers. |
| 194 | + |
| 195 | +**Key Takeaways:** |
| 196 | +- The module abstracts away the complexity of CouchDB/PouchDB replication, exposing a simple API for state management with robust sync. |
| 197 | +- Sync status and events are observable, allowing you to build responsive UIs that reflect real-time connectivity and conflict resolution. |
| 198 | +- Configuration is centralized and type-safe, making it easy to adapt for different deployment scenarios (dev, prod, per-user DBs, etc.). |
| 199 | +- The modular design means you can swap out or extend replication logic as your app’s needs evolve. |
| 200 | + |
| 201 | +Refer to the [source files](src/modules/app-state-with-replication/) for implementation details and customization options. |
| 202 | + |
| 203 | +## 🔧 Advanced Configuration |
| 204 | + |
| 205 | +### Custom Sync Options |
| 206 | + |
| 207 | +Fine-tune replication behavior: |
| 208 | + |
| 209 | +```typescript |
| 210 | +const syncOptions = { |
| 211 | + live: true, // Enable continuous replication |
| 212 | + retry: true, // Retry on connection failure |
| 213 | + heartbeat: 10000, // Heartbeat interval (ms) |
| 214 | + timeout: 30000, // Request timeout (ms) |
| 215 | + batch_size: 100, // Batch size for bulk operations |
| 216 | + batches_limit: 10, // Maximum parallel batches |
| 217 | + filter: 'app/by_user', // Filter specific documents |
| 218 | + since: 'now', // Start replication from now |
| 219 | +}; |
| 220 | +``` |
| 221 | + |
| 222 | +### Security Configuration |
| 223 | + |
| 224 | +For production environments: |
| 225 | + |
| 226 | +```typescript |
| 227 | +// Enable SSL/TLS for production |
| 228 | +const remoteDb = PouchDbStorage.CreateDb(remoteDbUrl, { |
| 229 | + fetch: (url, opts) => { |
| 230 | + return fetch(url, { |
| 231 | + ...opts, |
| 232 | + headers: { |
| 233 | + ...opts.headers, |
| 234 | + 'Authorization': 'Bearer ' + authModule.getToken(), |
| 235 | + }, |
| 236 | + }); |
| 237 | + }, |
| 238 | +}); |
| 239 | +``` |
| 240 | + |
| 241 | +### Database Per User |
| 242 | +#### Per-User Database Routing Example |
| 243 | + |
| 244 | +To route requests to a remote CouchDB instance and automatically prefix the database or document path with the current user's username, you can use an Express route as a proxy. This approach ensures each user's data is isolated in their own database or document namespace. |
| 245 | + |
| 246 | +Example Express proxy route: |
| 247 | + |
| 248 | +```typescript |
| 249 | +const httpProxy = require('http-proxy'); |
| 250 | +const url = require('url'); |
| 251 | + |
| 252 | +// Create proxy server |
| 253 | +const proxy = httpProxy.createProxyServer({ |
| 254 | + target: 'http://couch-db-remote:5984', |
| 255 | + changeOrigin: true |
| 256 | +}); |
| 257 | + |
| 258 | +// Handle proxy path rewriting |
| 259 | +proxy.on('proxyReq', (proxyReq, req, res) => { |
| 260 | + const parsedUrl = url.parse(req.url); |
| 261 | + const dbName = parsedUrl.pathname.split('/').pop(); |
| 262 | + const userId = 'user123'; // Simulated user ID |
| 263 | + const newPath = `/${userId}_${dbName}${parsedUrl.search || ''}`; |
| 264 | + proxyReq.path = newPath; |
| 265 | +}); |
| 266 | +``` |
| 267 | +**Usage:** |
| 268 | +Mount this proxy route in your Express app. |
| 269 | +When you POST to `/proxy-to-remote/:dbName`, the route automatically prefixes the CouchDB database path with the current user's username, isolating each user's data. |
| 270 | +Example: |
| 271 | +A POST to `/proxy-to-remote/todos` for user `alice` will proxy to `/alice_todos` on CouchDB. |
| 272 | + |
| 273 | +### Common Issues |
| 274 | + |
| 275 | +1. **CouchDB Connection Refused** |
| 276 | + ```bash |
| 277 | + # Check if CouchDB is running |
| 278 | + curl http://localhost:5984/ |
| 279 | + |
| 280 | + # Restart CouchDB |
| 281 | + pnpm couchdb:down && pnpm couchdb:up |
| 282 | + ``` |
| 283 | + |
| 284 | +2. **CORS Errors** |
| 285 | + - Check browser developer tools for detailed error messages |
| 286 | + |
| 287 | +3. **Authentication Errors** |
| 288 | + - Verify credentials: `admin` / `admin` |
| 289 | + - Check CouchDB admin interface: http://localhost:5984/_utils |
| 290 | + |
| 291 | +## 💡 Best Practices |
| 292 | + |
| 293 | +### 1. State Key Organization |
| 294 | + |
| 295 | +Use hierarchical naming for better organization: |
| 296 | + |
| 297 | +```typescript |
| 298 | +// ✅ Good - hierarchical, descriptive |
| 299 | +'user.profile.personal' |
| 300 | +'user.preferences.theme' |
| 301 | +'app.settings.notifications' |
| 302 | +'feature.dashboard.filters' |
| 303 | + |
| 304 | +// ❌ Avoid - flat, unclear |
| 305 | +'userdata' |
| 306 | +'settings' |
| 307 | +'stuff' |
| 308 | +``` |
| 309 | + |
| 310 | +### 2. Use strong typing |
| 311 | + |
| 312 | +```typescript |
| 313 | +// ✅ Good - strong typing |
| 314 | +interface UserProfile { |
| 315 | + id: string; |
| 316 | + name: string; |
| 317 | + email: string; |
| 318 | +} |
| 319 | + |
| 320 | +const [user, setUser] = useAppState<UserProfile>('user.profile'); |
| 321 | + |
| 322 | +// ❌ Avoid - weak typing |
| 323 | +const [user, setUser] = useAppState('user.profile'); |
| 324 | +``` |
| 325 | + |
| 326 | +### 3. Validate Complex Schemas |
| 327 | +```typescript |
| 328 | + |
| 329 | +const userSchema = z.object({ |
| 330 | + id: z.string().uuid(), |
| 331 | + name: z.string().min(2).max(100), |
| 332 | + email: z.string().email(), |
| 333 | +}); |
| 334 | + |
| 335 | +type UserProfile = z.infer<typeof userSchema>; |
| 336 | + |
| 337 | +// ✅ Good - strong typing with validation |
| 338 | +const useMyUser = () => { |
| 339 | + const [value, setValue] = useAppState<UserProfile>('user.profile'); |
| 340 | + const setUser = useCallback((user: UserProfile) => { |
| 341 | + if (userSchema.safeParse(user).success) { |
| 342 | + setValue(user); |
| 343 | + return true; |
| 344 | + } else { |
| 345 | + console.warn('Provided user is invalid'); |
| 346 | + return false; |
| 347 | + } |
| 348 | + }, [setValue]); |
| 349 | + if(!userSchema.safeParse(value).success) { |
| 350 | + console.warn('Current user state is invalid'); |
| 351 | + return null; |
| 352 | + } |
| 353 | + return value; |
| 354 | +}; |
| 355 | + |
| 356 | +const [user, setUser] = useMyUser(); |
| 357 | + |
| 358 | +// ❌ Avoid - no validation |
| 359 | +const [user, setUser] = useAppState<UserProfile>(); |
| 360 | +console.log(value.name); |
0 commit comments