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
+385
View File
@@ -0,0 +1,385 @@
import 'dart:async';
import 'dart:convert';
import '../../core/exceptions.dart';
import '../api/events_api.dart';
import '../api/milestones_api.dart';
import '../api/notes_api.dart';
import '../api/projects_api.dart';
import '../api/tasks_api.dart';
import '../local/database.dart';
import '../models/note.dart';
import '../models/project.dart';
import '../models/task.dart';
/// Reasons a queued write was dropped during replay. Surfaced to the UI as
/// a one-time SnackBar so the user knows their offline edit didn't land.
enum QueueFailureReason { overwritten, missing, rejected }
class QueueFailure {
final QueueFailureReason reason;
final String domain;
final String? title;
final String? detail;
const QueueFailure({
required this.reason,
required this.domain,
this.title,
this.detail,
});
String get message {
final label = title?.isNotEmpty == true ? '"$title"' : 'an offline edit';
return switch (reason) {
QueueFailureReason.overwritten =>
'$label was overwritten by a newer change on the server.',
QueueFailureReason.missing =>
'$label was deleted on the server before your offline edit could be saved.',
QueueFailureReason.rejected =>
'Failed to save $label: ${detail ?? 'rejected by server.'}',
};
}
}
/// Drains the offline write queue (Phase 3). Owns the network → server
/// dispatch logic for every queued op; repos write to `pending_writes`,
/// this service replays them.
///
/// Replay semantics:
/// - oldest-first, single in-flight `replay()` call
/// - on `NetworkException` (or 5xx): leave op in place, stop the loop
/// - on conflict (server `updated_at` newer than op baseline): drop +
/// surface as `overwritten`
/// - on 404: for delete → silent success; for update → drop + `missing`
/// - on other 4xx: drop + `rejected`
class WriteQueue {
final FabledDatabase _db;
final NotesApi _notesApi;
final TasksApi _tasksApi;
final ProjectsApi _projectsApi;
final MilestonesApi _milestonesApi;
final EventsApi _eventsApi;
final StreamController<QueueFailure> _failures =
StreamController<QueueFailure>.broadcast();
bool _replaying = false;
WriteQueue({
required FabledDatabase db,
required NotesApi notesApi,
required TasksApi tasksApi,
required ProjectsApi projectsApi,
required MilestonesApi milestonesApi,
required EventsApi eventsApi,
}) : _db = db,
_notesApi = notesApi,
_tasksApi = tasksApi,
_projectsApi = projectsApi,
_milestonesApi = milestonesApi,
_eventsApi = eventsApi;
Stream<QueueFailure> get failures => _failures.stream;
void dispose() => _failures.close();
/// Drain the queue. Reentrant calls are no-ops while a replay is in
/// flight — the in-flight one will see new entries on its next iteration.
Future<void> replay() async {
if (_replaying) return;
_replaying = true;
try {
while (true) {
final ops = await _db.listPending();
if (ops.isEmpty) break;
final op = ops.first;
final keepGoing = await _replayOne(op);
if (!keepGoing) break;
}
} finally {
_replaying = false;
}
}
Future<bool> _replayOne(PendingWrite op) async {
final payload = jsonDecode(op.payloadJson) as Map<String, dynamic>;
await _db.incrementPendingTries(op.id);
try {
switch (op.domain) {
case kSyncDomainNotes:
await _replayNote(op, payload);
case kSyncDomainTasks:
await _replayTask(op, payload);
case kSyncDomainProjects:
await _replayProject(op, payload);
case kSyncDomainMilestones:
await _replayMilestone(op, payload);
case kSyncDomainEvents:
await _replayEvent(op, payload);
default:
// Unknown domain — drop so we don't loop forever.
await _db.deletePending(op.id);
return true;
}
await _db.deletePending(op.id);
return true;
} on NetworkException catch (e) {
// Still offline — leave op for next replay trigger.
await _db.markPendingFailed(op.id, e.message);
return false;
} on ServerException catch (e) {
if (e.statusCode >= 500) {
// Likely transient — keep + stop the loop, retry next time.
await _db.markPendingFailed(op.id, e.message);
return false;
}
// 4xx — non-retryable. Drop + surface.
await _db.deletePending(op.id);
_failures.add(QueueFailure(
reason: QueueFailureReason.rejected,
domain: op.domain,
title: payload['title'] as String?,
detail: e.message,
));
return true;
} on NotFoundException {
// Target gone server-side. Update → tell user; delete → silent success.
await _db.deletePending(op.id);
if (op.verb != kWriteVerbDelete) {
_failures.add(QueueFailure(
reason: QueueFailureReason.missing,
domain: op.domain,
title: payload['title'] as String?,
));
}
// Clean the cache so the optimistic row doesn't linger.
await _evictCache(op.domain, op.targetId ?? op.tempId);
return true;
} on _OverwrittenSignal catch (s) {
await _db.deletePending(op.id);
_failures.add(QueueFailure(
reason: QueueFailureReason.overwritten,
domain: op.domain,
title: s.title,
));
return true;
} on AppException catch (e) {
// Auth or other — surface and drop so the queue can drain.
await _db.deletePending(op.id);
_failures.add(QueueFailure(
reason: QueueFailureReason.rejected,
domain: op.domain,
title: payload['title'] as String?,
detail: e.message,
));
return true;
}
}
Future<void> _evictCache(String domain, int? id) async {
if (id == null) return;
switch (domain) {
case kSyncDomainNotes:
await _db.deleteNote(id);
case kSyncDomainTasks:
await _db.deleteTask(id);
case kSyncDomainProjects:
await _db.deleteProject(id);
case kSyncDomainMilestones:
await _db.deleteMilestone(id);
case kSyncDomainEvents:
await _db.deleteEvent(id);
}
}
// ── Notes ──────────────────────────────────────────────────────────────────
Future<void> _replayNote(PendingWrite op, Map<String, dynamic> p) async {
switch (op.verb) {
case kWriteVerbCreate:
final note = await _notesApi.create(
p['title'] as String? ?? '',
p['body'] as String? ?? '',
tags: _stringList(p['tags']),
projectId: p['project_id'] as int?,
noteType: p['note_type'] as String? ?? 'note',
);
if (op.tempId != null) await _db.deleteNote(op.tempId!);
await _db.upsertNote(note);
case kWriteVerbUpdate:
await _conflictGuard<Note>(
baseline: op.baselineUpdatedAt,
fetch: () => _notesApi.getOne(op.targetId!),
updatedAt: (n) => n.updatedAt,
title: (n) => n.title,
syncCache: (n) => _db.upsertNote(n),
);
final note = await _notesApi.update(
op.targetId!,
p['title'] as String? ?? '',
p['body'] as String? ?? '',
tags: _stringList(p['tags']),
projectId: p['project_id'] as int?,
clearProject: p['clear_project'] as bool? ?? false,
noteType: p['note_type'] as String? ?? 'note',
);
await _db.upsertNote(note);
case kWriteVerbDelete:
try {
await _notesApi.delete(op.targetId!);
} on NotFoundException {/* already gone */}
await _db.deleteNote(op.targetId!);
}
}
// ── Tasks ──────────────────────────────────────────────────────────────────
Future<void> _replayTask(PendingWrite op, Map<String, dynamic> p) async {
switch (op.verb) {
case kWriteVerbCreate:
final task = await _tasksApi.create(
title: p['title'] as String? ?? '',
description: p['description'] as String?,
status: TaskStatusExtension.fromString(p['status'] as String?),
priority: TaskPriorityExtension.fromString(p['priority'] as String?),
dueDate: _parseDate(p['due_date']),
projectId: p['project_id'] as int?,
parentId: p['parent_id'] as int?,
);
if (op.tempId != null) await _db.deleteTask(op.tempId!);
await _db.upsertTask(task);
case kWriteVerbUpdate:
await _conflictGuard<Task>(
baseline: op.baselineUpdatedAt,
fetch: () => _tasksApi.getOne(op.targetId!),
updatedAt: (t) => t.updatedAt,
title: (t) => t.title,
syncCache: (t) => _db.upsertTask(t),
);
final task = await _tasksApi.update(op.targetId!, p);
await _db.upsertTask(task);
case kWriteVerbDelete:
try {
await _tasksApi.delete(op.targetId!);
} on NotFoundException {/* already gone */}
await _db.deleteTask(op.targetId!);
}
}
// ── Projects ───────────────────────────────────────────────────────────────
Future<void> _replayProject(PendingWrite op, Map<String, dynamic> p) async {
switch (op.verb) {
case kWriteVerbCreate:
final project = await _projectsApi.create(
title: p['title'] as String? ?? '',
description: p['description'] as String?,
goal: p['goal'] as String?,
color: p['color'] as String?,
status: p['status'] as String? ?? 'active',
);
if (op.tempId != null) await _db.deleteProject(op.tempId!);
await _db.upsertProject(project);
case kWriteVerbUpdate:
await _conflictGuard<Project>(
baseline: op.baselineUpdatedAt,
fetch: () => _projectsApi.getOne(op.targetId!),
updatedAt: (proj) => proj.updatedAt,
title: (proj) => proj.title,
syncCache: (proj) => _db.upsertProject(proj),
);
final project = await _projectsApi.update(op.targetId!, p);
await _db.upsertProject(project);
case kWriteVerbDelete:
try {
await _projectsApi.delete(op.targetId!);
} on NotFoundException {/* already gone */}
await _db.deleteProject(op.targetId!);
}
}
// ── Milestones ─────────────────────────────────────────────────────────────
Future<void> _replayMilestone(
PendingWrite op, Map<String, dynamic> p) async {
final projectId = p['project_id'] as int;
switch (op.verb) {
case kWriteVerbCreate:
final milestone = await _milestonesApi.create(
projectId,
title: p['title'] as String? ?? '',
description: p['description'] as String?,
orderIndex: p['order_index'] as int? ?? 0,
);
if (op.tempId != null) await _db.deleteMilestone(op.tempId!);
await _db.upsertMilestone(milestone);
case kWriteVerbUpdate:
// Milestones API has no getOne — skip the conflict pre-check and
// let the PUT apply unconditionally. If two clients fight over the
// same milestone, last-writer-wins is acceptable here.
final milestone = await _milestonesApi.update(
projectId,
op.targetId!,
Map<String, dynamic>.from(p)..remove('project_id'),
);
await _db.upsertMilestone(milestone);
case kWriteVerbDelete:
try {
await _milestonesApi.delete(projectId, op.targetId!);
} on NotFoundException {/* already gone */}
await _db.deleteMilestone(op.targetId!);
}
}
// ── Events ─────────────────────────────────────────────────────────────────
Future<void> _replayEvent(PendingWrite op, Map<String, dynamic> p) async {
switch (op.verb) {
case kWriteVerbCreate:
final event = await _eventsApi.createEvent(p);
if (op.tempId != null) await _db.deleteEvent(op.tempId!);
await _db.upsertEvent(event);
case kWriteVerbUpdate:
// Events API also has no getOne — skip the pre-check; last-writer-wins.
final event = await _eventsApi.updateEvent(op.targetId!, p);
await _db.upsertEvent(event);
case kWriteVerbDelete:
try {
await _eventsApi.deleteEvent(op.targetId!);
} on NotFoundException {/* already gone */}
await _db.deleteEvent(op.targetId!);
}
}
// ── Helpers ────────────────────────────────────────────────────────────────
/// Server-wins conflict guard. Fetch the target, compare its `updated_at`
/// against the baseline captured when the user made the offline edit;
/// if the server is newer, sync cache and signal the caller to drop.
Future<void> _conflictGuard<T>({
required DateTime? baseline,
required Future<T> Function() fetch,
required DateTime Function(T) updatedAt,
required String Function(T) title,
required Future<void> Function(T) syncCache,
}) async {
if (baseline == null) return;
final server = await fetch();
if (updatedAt(server).isAfter(baseline)) {
await syncCache(server);
throw _OverwrittenSignal(title(server));
}
}
List<String> _stringList(dynamic raw) =>
raw is List ? raw.map((e) => e.toString()).toList() : const [];
DateTime? _parseDate(dynamic raw) {
if (raw is String && raw.isNotEmpty) return DateTime.tryParse(raw);
return null;
}
}
class _OverwrittenSignal implements Exception {
final String title;
const _OverwrittenSignal(this.title);
}