Skip to content

Offline-First Architecture

Edit page

How the ReCursor app works without connectivity and syncs when reconnected.


Data TypeStorageRationale
Conversations, tasks, agent configsDrift (SQLite)Type-safe queries, migrations, reactive streams, relational integrity
UI preferences, cached tokens, session stateHiveFast key-value for ephemeral data
File content cacheFile systemLarge blobs don’t belong in SQLite

UI Layer (Riverpod providers)
|
Repository Layer (abstracts local vs. remote)
|
+-- Local Data Source (Drift / Hive)
+-- Remote Data Source (Bridge WebSocket)
  • Repository reads from local DB first (instant UI response).
  • Fetches from bridge in background and updates local state.
  • Drift’s reactive queries (watch()) automatically update the UI when local data changes.

When offline, mutations go into a local queue:

// SyncQueue table (Drift)
class SyncQueue extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get operation => text()(); // "send_message", "approve_tool", "git_command"
TextColumn get payload => text()(); // JSON: full operation
TextColumn get sessionId => text().nullable()();
DateTimeColumn get createdAt => dateTime()();
BoolColumn get synced => boolean().withDefault(const Constant(false))();
IntColumn get retryCount => integer().withDefault(const Constant(0))();
TextColumn get lastError => text().nullable()();
}
class SyncService {
final SyncQueueDao _queueDao;
final WebSocketService _ws;
// Enqueue mutation when offline
Future<void> enqueue(String operation, Map<String, dynamic> payload) async {
await _queueDao.insert(SyncQueueCompanion(
operation: Value(operation),
payload: Value(jsonEncode(payload)),
createdAt: Value(DateTime.now()),
));
}
// Flush queue on reconnect
Future<void> flushQueue() async {
final pending = await _queueDao.getPending();
for (final item in pending) {
try {
await _sendToBridge(item);
await _queueDao.markSynced(item.id);
} catch (e) {
await _queueDao.incrementRetry(item.id, e.toString());
}
}
}
}

class ConflictResolver {
T resolve(T local, T remote) {
// Compare updated_at timestamps
if (local.updatedAt.isAfter(remote.updatedAt)) {
return local; // Local wins
}
return remote; // Remote wins
}
}

For destructive operations (git push, file overwrite), prompt user:

Future<ConflictResolution> resolveCriticalConflict({
required SyncConflict conflict,
}) async {
// Show dialog to user
return showDialog<ConflictResolution>(
context: context,
builder: (_) => ConflictDialog(conflict: conflict),
);
}
enum ConflictResolution {
useLocal,
useRemote,
merge,
cancel,
}

class NetworkService {
final Connectivity _connectivity;
final WebSocketService _ws;
Stream<ConnectionStatus> get status {
return _connectivity.onConnectivityChanged
.asyncMap((result) => _mapToStatus(result));
}
Future<ConnectionStatus> _mapToStatus(ConnectivityResult result) async {
if (result == ConnectivityResult.none) {
return ConnectionStatus.offline;
}
// Ping bridge to confirm reachability
final reachable = await _pingBridge();
return reachable
? ConnectionStatus.online
: ConnectionStatus.bridg
Unreachable;
}
}
StateDescriptionBehavior
onlineConnected to bridgeSync queue, real-time updates
offlineNo connectivityQueue mutations locally
bridge_unreachableNetwork but no bridgeRetry with backoff, queue mutations

  1. User action (send message, approve tool)
  2. Save to local DB
  3. Try to send via WebSocket
  4. If failed, add to SyncQueue
  5. Show “pending” state in UI
  1. On reconnect, request all events since last sync
  2. Merge with local state
  3. Resolve conflicts
  4. Update UI
class EventReplay {
Future<void> replaySince(DateTime lastSync) async {
final events = await _bridge.getEventsSince(lastSync);
for (final event in events) {
await _applyEvent(event);
}
}
}

class RetryPolicy {
final int maxRetries = 5;
final List<Duration> backoffDelays = [
Duration(seconds: 1),
Duration(seconds: 2),
Duration(seconds: 5),
Duration(seconds: 10),
Duration(seconds: 30),
];
Future<T> withRetry<T>(Future<T> Function() operation, int attempt) async {
try {
return await operation();
} catch (e) {
if (attempt >= maxRetries) rethrow;
await Future.delayed(backoffDelays[attempt]);
return withRetry(operation, attempt + 1);
}
}
}

Data TypeMax SizeCleanup Strategy
SyncQueue1000 itemsFIFO eviction
Messages30 daysArchive to file
Sessions90 daysSoft delete
File cache100 MBLRU eviction

If sync complexity grows, consider:

  • PowerSync — integrates with Drift, handles bidirectional sync automatically
  • Couchbase Lite — built-in conflict resolution
  • Both have commercial licensing but eliminate custom sync engine maintenance.


Last updated: 2026-03-17