7df7b5ff85
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>
386 lines
14 KiB
Dart
386 lines
14 KiB
Dart
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);
|
|
}
|