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:
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user