juice_sync 0.1.2
juice_sync: ^0.1.2 copied to clipboard
Offline outbox / mutation queue with durable persistence, ordering, retries and dead-lettering, as a Juice bloc.
juice_sync #
An offline outbox / mutation queue as a Juice bloc — durably persist writes, then flush them to a backend when online, with ordering, retries, and dead-lettering.
What it owns #
The durable queue of pending writes and their delivery. It does not own the
transport (the MutationExecutor seam), offline reads / caching (that's
juice_network), or your optimistic local read model (your repository layer).
Install #
dependencies:
juice_sync: ^0.1.0
juice_storage: ^1.2.0 # for the durable StorageSyncStore
Use #
final sync = SyncBloc.withConfig(SyncConfig(
store: StorageSyncStore(storageBloc), // durable (survives app kill)
executor: (m) => api.replay(m), // your transport
onlineSignal: connectivityOnlineStream, // adapt ConnectivityBloc.isOnline
));
// Returns once DURABLY queued; throws if it couldn't be persisted.
await sync.enqueue('createTodo', {'title': 'Buy milk'});
When offline, mutations queue durably; the queue auto-flushes on the next
false→true online edge.
The executor seam (your backend) #
juice_sync is transport-free — you wire ~10 lines:
Future<void> myExecutor(Mutation m) async {
try {
await dio.request(
'/api/${m.type}',
data: m.payload,
options: Options(method: 'POST', headers: {'Idempotency-Key': m.id}),
);
} on DioException catch (e) {
final code = e.response?.statusCode ?? 0;
if (code >= 400 && code < 500 && code != 429) {
throw PermanentSyncError('rejected: $code'); // → dead-letter
}
rethrow; // → retry with backoff
}
}
Delivery is at-least-once. The adapter must send m.id as an idempotency key
and the server must dedupe on it — a crash between a successful send and the
durable delete causes a replay.
Ordering #
Strict FIFO per orderingKey; independent keys (or null) don't block each
other:
await sync.enqueue('updateDoc', {...}, orderingKey: 'doc:42'); // ordered with…
await sync.enqueue('updateDoc', {...}, orderingKey: 'doc:42'); // …this one
await sync.enqueue('ping', {}); // independent
A transient failure holds back only its own partition (bounded by
maxAttempts); a different doc keeps flowing.
Retries & dead-letter #
Retryable failures back off exponentially up to maxAttempts (default 8), then
move to the dead-letter set (state.failed). retryFailed(id?) revives
them; discard(id) drops one for good. A poison mutation can never wedge the
queue.
Crash-safety #
inFlight is persisted before the send and the durable delete completes before
the queue head advances. On the next launch the queue is reloaded (seq-ordered)
and any recovered inFlight is re-sent. If the store can't be read, the bloc
enters an error status — it never silently starts with an empty queue.
State #
| Field / getter | Meaning |
|---|---|
pending |
queued + retrying (seq order) |
failed |
dead-letter |
status |
loading / idle / syncing / error |
online |
last onlineSignal value |
processedCount |
sent this session |
pendingCount / failedCount / isSyncing / hasFailures |
derived |
Rebuild groups: sync:status, sync:queue, sync:failed,
sync:mutation:<id>. (Per-mutation groups target discrete events cleanly; within
a single flush burst the touched mutations' rebuilds coalesce.)
License #
MIT License — see LICENSE.