feat(offline): tier 2 phase 3 — write queue with optimistic UI

Offline writes now queue + apply optimistically instead of throwing.
On reconnect, the queue drains automatically; conflicts and rejections
surface as one-time SnackBars so the user knows their edit didn't land.

- Drift schema v3: `pending_writes` table (verb + payload + baseline +
  tries + last_error). Negative ids serve as cache placeholders for
  offline-created rows until replay assigns the server id.
- Each write repository (notes, tasks, projects, milestones, events)
  now catches NetworkException, applies the change to cache (with a
  fresh `nextTempId()` for creates), and enqueues the API call.
- Edits to a still-queued offline-created row coalesce into the
  original create payload — only one server call per row, in order.
- Deletes of still-queued offline-created rows drop the queue entry
  and the cache row; no server call ever happens.
- New WriteQueue service drains the queue oldest-first.
  Server-wins on `updated_at` baseline check (notes/tasks/projects);
  last-writer-wins for milestones/events (no getOne available).
  4xx → drop + surface as `rejected`; conflict → drop + `overwritten`;
  404 on update → drop + `missing`; 5xx/network → keep + retry next
  online cycle.
- Replay fires on AuthStatus → authenticated transitions
  (cold-start, came-back-online, login).
- OfflineBanner shows "Retry (N)" with the pending-writes count.
- Distinct ServerException added so 4xx no longer masquerade as
  NetworkException — the queue can drop them instead of looping.

flutter analyze clean; 21 tests pass (3 new for QueueFailure messaging).
Phase 4 (read-only UI indicators on cached/temp rows) still ahead.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 22:08:40 -04:00
parent fe10067761
commit 7df7b5ff85
14 changed files with 2090 additions and 93 deletions
+126 -1
View File
@@ -136,6 +136,29 @@ class SyncMetadata extends Table {
Set<Column> get primaryKey => {domain};
}
/// Phase 3 — generic offline-write queue. One row per pending API call;
/// drained in oldest-first order when the device comes back online.
///
/// `targetId` is the server id for updates/deletes; `tempId` is the
/// client-allocated negative placeholder for offline creates. `payloadJson`
/// is the typed args the API method needs (verb-specific shape).
/// `baselineUpdatedAt` snapshots the cached row's `updated_at` at edit
/// time so the replayer can drop the op if the server has moved on
/// (server-wins conflict resolution).
class PendingWrites extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get domain => text()();
TextColumn get verb => text()();
IntColumn get targetId => integer().nullable()();
IntColumn get tempId => integer().nullable()();
TextColumn get payloadJson => text().withDefault(const Constant('{}'))();
DateTimeColumn get baselineUpdatedAt => dateTime().nullable()();
DateTimeColumn get createdAt =>
dateTime().clientDefault(() => DateTime.now())();
IntColumn get tries => integer().withDefault(const Constant(0))();
TextColumn get lastError => text().nullable()();
}
const String kSyncDomainNotes = 'notes';
const String kSyncDomainTasks = 'tasks';
const String kSyncDomainProjects = 'projects';
@@ -143,6 +166,10 @@ const String kSyncDomainMilestones = 'milestones';
const String kSyncDomainEvents = 'events';
const String kSyncDomainConversations = 'conversations';
const String kWriteVerbCreate = 'create';
const String kWriteVerbUpdate = 'update';
const String kWriteVerbDelete = 'delete';
@DriftDatabase(tables: [
CachedNotes,
CachedTasks,
@@ -151,13 +178,14 @@ const String kSyncDomainConversations = 'conversations';
CachedCalendarEvents,
CachedConversations,
SyncMetadata,
PendingWrites,
])
class FabledDatabase extends _$FabledDatabase {
FabledDatabase() : super(_openConnection());
FabledDatabase.forTesting(super.executor);
@override
int get schemaVersion => 2;
int get schemaVersion => 3;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -171,6 +199,10 @@ class FabledDatabase extends _$FabledDatabase {
await m.createTable(cachedCalendarEvents);
await m.createTable(cachedConversations);
}
// v2 → v3: added the offline write queue.
if (from < 3) {
await m.createTable(pendingWrites);
}
},
);
@@ -448,6 +480,99 @@ class FabledDatabase extends _$FabledDatabase {
),
);
}
// ── Pending writes (Phase 3 offline queue) ───────────────────────────────
/// Allocate a fresh negative id used as a placeholder for an
/// offline-created row until its server id arrives via replay.
Future<int> nextTempId() async {
final query = customSelect(
'SELECT MIN(temp_id) AS m FROM pending_writes WHERE temp_id IS NOT NULL',
);
final row = await query.getSingleOrNull();
final current = row?.read<int?>('m');
return (current ?? 0) - 1;
}
Future<int> enqueuePending({
required String domain,
required String verb,
int? targetId,
int? tempId,
Map<String, dynamic> payload = const {},
DateTime? baselineUpdatedAt,
}) {
return into(pendingWrites).insert(
PendingWritesCompanion.insert(
domain: domain,
verb: verb,
targetId: Value(targetId),
tempId: Value(tempId),
payloadJson: Value(jsonEncode(payload)),
baselineUpdatedAt: Value(baselineUpdatedAt),
),
);
}
/// Find the queued create-op for an offline-created row so a follow-up
/// edit can be coalesced into the original payload (no separate update
/// op gets queued). Returns null if no matching create is queued.
Future<PendingWrite?> findQueuedCreate(String domain, int tempId) {
return (select(pendingWrites)
..where((t) =>
t.domain.equals(domain) &
t.verb.equals(kWriteVerbCreate) &
t.tempId.equals(tempId)))
.getSingleOrNull();
}
Future<void> updatePendingPayload(int opId, Map<String, dynamic> payload) {
return (update(pendingWrites)..where((t) => t.id.equals(opId))).write(
PendingWritesCompanion(payloadJson: Value(jsonEncode(payload))),
);
}
Future<void> deletePending(int opId) {
return (delete(pendingWrites)..where((t) => t.id.equals(opId))).go();
}
/// Pending ops oldest first — replay order.
Future<List<PendingWrite>> listPending() {
return (select(pendingWrites)
..orderBy([(t) => OrderingTerm.asc(t.id)]))
.get();
}
Future<int> queueDepth() async {
final row = await customSelect('SELECT COUNT(*) AS c FROM pending_writes')
.getSingle();
return row.read<int>('c');
}
Future<void> markPendingFailed(int opId, String error) {
return (update(pendingWrites)..where((t) => t.id.equals(opId))).write(
PendingWritesCompanion(
tries: const Value.absent(),
lastError: Value(error),
),
);
}
Future<void> incrementPendingTries(int opId) {
return customStatement(
'UPDATE pending_writes SET tries = tries + 1 WHERE id = ?',
[opId],
);
}
/// Watch queue depth — used by OfflineBanner for the "Retry (N)" affordance.
Stream<int> watchQueueDepth() {
final query = customSelect(
'SELECT COUNT(*) AS c FROM pending_writes',
readsFrom: {pendingWrites},
);
return query.watchSingle().map((row) => row.read<int>('c'));
}
}
// ── Row ↔ model converters ──────────────────────────────────────────────────