Sinkronisasi Outbox Offline-First Flutter untuk Aplikasi Lapangan

Foto oleh mikecogh via Openverse (CC BY-SA 2.0)
Pola outbox menyimpan setiap penulisan, seperti membuka order atau mencatat pembayaran, sebagai baris di tabel SQLite lokal alih-alih langsung memanggil API. UI membaca kembali penulisan lokalnya sendiri secara instan, dan proses latar belakang terpisah menguras outbox ke server saat konektivitas kembali. Ini membuat aplikasi tetap berjalan tanpa sinyal sama sekali.
connectivity_plus menyediakan stream yang memancarkan setiap kali jenis jaringan yang terjangkau berubah, dan pada versi terbaru ia memancarkan daftar hasil karena perangkat bisa memakai beberapa transport sekaligus. Aplikasi berlangganan stream itu dan mencoba menguras outbox pada setiap event non-none. Stream itu hanya petunjuk, jadi hasil HTTP sebenarnya tetap menentukan apakah tiap baris selesai atau harus mengulang.
Setiap baris outbox dibuat dengan UUID buatan klien yang sekaligus menjadi idempotency key dan tetap stabil di setiap percobaan ulang. Aplikasi Flutter mengirimnya sebagai header, dan layanan settlement NestJS menyimpan kunci itu beserta hasil penerapan sukses pertama. Setiap permintaan ulang yang membawa kunci yang sudah pernah dilihat mengembalikan hasil tersimpan alih-alih men-settle lagi, sehingga percobaan ulang yang habis waktu tidak akan pernah membuat order kedua.
Outbox dikuras dalam urutan created-at yang ketat sehingga penulisan yang terkait secara kausal, seperti order sebelum pembayarannya, diputar ulang dalam urutan yang dilakukan kru. Sebagian besar penulisan adalah penambahan yang tidak bisa berkonflik, dan sedikit field yang dapat berubah membawa versi server yang menolak penulisan usang. Invarian server seperti buku kas turunan dan stok yang dilacak sebagai pergerakan aditif menghapus seluruh kategori konflik.
Setiap fitur mendefinisikan satu kontrak repository abstrak dengan dua implementasi: ApiRepository yang berbicara ke backend NestJS melalui outbox, dan MockRepository yang didukung fixture lokal. Riverpod menyuntikkan mana pun yang dibutuhkan, sehingga aplikasi bisa menjalankan demo penuh tanpa backend dan QA bisa menguji setiap layar secara offline. Berbagi satu kontrak menjaga perilaku offline sederajat dengan jalur langsung alih-alih sekadar kasus khusus yang ditempelkan.

Foto oleh mikecogh via Openverse (CC BY-SA 2.0)
Ringkasan Utama
Aplikasi lapangan offline-first mengantrekan setiap penulisan ke outbox SQLite lokal alih-alih langsung memanggil API. connectivity_plus memantau jaringan, sebuah worker latar belakang menguras outbox saat sinyal kembali, dan idempotency key buatan klien pada tiap baris menjamin API NestJS menerapkan setiap order tepat satu kali walau terjadi percobaan ulang.
JID Carwash beroperasi di area cuci beton tempat sinyal seluler mati begitu mobil masuk ke bawah atap. Aplikasi lapangan Flutter yang tim saya rilis, dibangun di atas Flutter 3.22 dan Dart 3.4 dengan Riverpod dan go_router, tidak bisa berasumsi jaringan tersedia ketika kru menekan tombol untuk membuka order. Jika aplikasi memblokir pada panggilan HTTP, seluruh bisnis akan tersendat di balik spinner. Maka setiap penulisan bersifat offline-first: UI mencatat niat secara lokal, dan jaringan menjadi detail implementasi yang terjadi belakangan.
Artikel ini membahas pola outbox di balik perilaku itu: bagaimana penulisan disimpan secara lokal, bagaimana connectivity_plus memicu penggurasan, bagaimana API NestJS memutar ulang secara idempoten, dan mengapa setiap fitur di aplikasi ini memiliki dua repository yang berbagi satu kontrak.
Ide intinya adalah jangan pernah membiarkan UI memanggil API. Sebagai gantinya, aksi seperti membuka order atau mencatat pembayaran menulis satu baris ke tabel outbox SQLite lokal. Baris itu membawa jenis operasi, payload JSON, pengenal buatan klien, status, penghitung percobaan, dan stempel waktu dibuat. Layar langsung membaca kembali penulisan lokalnya sendiri, sehingga kru melihat order muncul terlepas dari apakah satu byte pernah meninggalkan ponsel. Menguras outbox itu ke server adalah urusan asinkron yang terpisah.
Uang disimpan sebagai bilangan bulat rupiah IDR di mana-mana, tidak pernah sebagai nilai floating-point. Cucian seharga lima puluh ribu rupiah adalah bilangan bulat 50000. Ini penting berganda saat offline: payload yang mengendap di outbox selama dua puluh menit harus terdeserialisasi ke jumlah yang persis sama dengan yang ditagihkan server, dan bilangan bulat bertahan melewati perjalanan JSON tanpa penyimpangan pembulatan yang diperkenalkan float.
// outbox row (sqflite) — one pending write, offline-first
CREATE TABLE outbox (
id TEXT PRIMARY KEY, -- client UUID, doubles as idempotency key
op_type TEXT NOT NULL, -- 'order.create' | 'payment.settle' | ...
payload TEXT NOT NULL, -- JSON; money fields are integer IDR
branch_id TEXT NOT NULL, -- every write is branch-scoped
status TEXT NOT NULL, -- pending | inflight | done | failed
attempts INTEGER NOT NULL DEFAULT 0,
next_retry_at INTEGER, -- epoch ms; backoff gate
created_at INTEGER NOT NULL
);
// draining respects creation order so replay is deterministic
final pending = await db.query(
'outbox',
where: "status = 'pending' AND (next_retry_at IS NULL OR next_retry_at <= ?)",
whereArgs: [DateTime.now().millisecondsSinceEpoch],
orderBy: 'created_at ASC',
);connectivity_plus, saat ini di versi 7.3.0, menyediakan stream yang memancarkan setiap kali jenis jaringan yang terjangkau berubah. Pada versi mayor terbaru stream itu membawa daftar hasil alih-alih satu nilai, karena ponsel bisa berada di Wi-Fi dan seluler sekaligus. Saya memperlakukan setiap entri non-none sebagai sinyal untuk mencoba menguras. Namun stream itu hanya pemicu, bukan otoritas. Konektivitas yang melaporkan sebuah transport tidak membuktikan API terjangkau, jadi drainer tetap harus menoleransi kegagalan pada panggilan HTTP yang sebenarnya.
Penggurasan juga terpicu saat aplikasi dilanjutkan dan pada timer periodik, sehingga ponsel yang ditutup paksa di tengah shift tetap menyusul. Setiap percobaan menaikkan penghitung baris dan, saat gagal, menetapkan stempel waktu percobaan berikutnya memakai exponential backoff dengan jitter. Jitter itu disengaja: lima belas ponsel kru yang menyambung ulang ke Wi-Fi depot saat tutup toko jika tidak akan menyerbu API di detik yang sama. Setelah jumlah percobaan yang dibatasi, baris berpindah ke status gagal dan muncul di layar admin alih-alih mencoba selamanya.
Sebuah event konektivitas bukan bukti server berfungsi. Perlakukan stream connectivity_plus sebagai petunjuk untuk mencoba, dan biarkan hasil HTTP yang menentukan apakah sebuah baris selesai atau harus mengulang. Menandai baris selesai hanya karena ponsel melaporkan Wi-Fi adalah cara Anda kehilangan order secara diam-diam.
Kegagalan offline klasik adalah permintaan yang berhasil di server tetapi responsnya tidak pernah sampai ke ponsel. Aplikasi habis waktu, mengulang, dan kini order yang sama ada dua kali. Perbaikannya adalah idempotency key yang dibuat di perangkat pada saat baris outbox dibuat. Karena kunci itu adalah pengenal baris itu sendiri, ia stabil di setiap percobaan ulang baris tersebut. Drainer Flutter mengirimnya sebagai header; layanan settlement NestJS mencatatnya dan memotong setiap pengulangan.
Di sisi server, settlement pembayaran berada di layanan khusus yang menyimpan setiap idempotency key beserta hasil penerapan sukses pertama. Permintaan kedua yang membawa kunci yang sudah pernah dilihat mengembalikan hasil tersimpan itu alih-alih melakukan settlement lagi. Ini jaminan yang sama yang dipakai jaringan kartu, diimplementasikan dengan satu kolom terindeks dan tanpa konsensus terdistribusi.
// Flutter drainer — the key never changes across retries
final res = await httpClient.post(
'/orders',
headers: {'Idempotency-Key': row.id}, // == outbox.id
body: row.payload,
);
// NestJS settlement.service.ts — apply once, replay the stored result
async settle(dto: SettleDto, key: string) {
const seen = await this.repo.findByIdempotencyKey(key);
if (seen) return seen.result; // safe replay, no double-charge
const result = await this.applySettlement(dto);
await this.repo.saveKey(key, result);
return result;
}Idempotency key dan pengenal baris lokal adalah UUID yang sama. Satu nilai menjalankan dua tugas: ia adalah primary key yang membuat layar membaca kembali penulisannya sendiri secara instan, dan ia adalah token yang membuat replay di sisi server aman. Lebih sedikit pengenal berarti lebih sedikit cara untuk salah menerapkan tepat-satu-kali.
Menguras dalam urutan created-at yang ketat menjaga penulisan yang terkait secara kausal tetap benar. Sebuah order harus dibuat sebelum pembayarannya di-settle, jadi jika keduanya ada di outbox mereka diputar ulang dalam urutan yang dilakukan kru. Ketika satu baris gagal dan memblokir antrean, saya tidak melompati sebuah dependensi; operasi yang bergantung menunggu di belakangnya alih-alih men-settle pembayaran atas order yang belum pernah dilihat server.
Penanganan konflik sengaja dibuat sempit karena domainnya memudahkannya. Sebagian besar penulisan adalah penambahan yang tidak disentuh orang lain: order baru, pembayaran baru, kasbon uang muka karyawan. Itu tidak bisa berkonflik, jadi mereka diputar ulang tanpa syarat. Sedikit field yang dapat berubah membawa versi yang ditetapkan server, dan API menolak penulisan usang sehingga aplikasi bisa mengambil ulang dan membiarkan manusia memutuskan. Sebagian state server bahkan tidak disimpan, yang menghapus seluruh kategori konflik sekaligus.
Dua invarian server mengecilkan permukaan konflik lebih jauh. Buku kas adalah tampilan gabungan turunan yang dihitung saat baca, tidak pernah tabel yang ditulis, sehingga tidak ada dua ponsel yang bisa berselisih tentang barisnya. Dan kuantitas stok produk hanyalah cache; angka sebenarnya adalah jumlah delta pergerakan stok. Penulisan lapangan menambahkan sebuah pergerakan alih-alih menimpa hitungan, yang berarti dua kru yang menghabiskan stok sabun yang sama menghasilkan dua pergerakan aditif, bukan pembaruan yang hilang.
Setiap fitur mengekspos antarmuka repository abstrak dengan dua implementasi: sebuah ApiRepository yang berbicara ke NestJS melalui outbox, dan sebuah MockRepository yang sepenuhnya didukung fixture lokal. Riverpod memilih mana yang disuntikkan. Ini bukan sekadar kemudahan pengujian. Mock adalah mode runtime kelas satu, sehingga demo di calon lokasi waralaba menjalankan aplikasi penuh tanpa backend, dan QA bisa menelusuri setiap layar di dalam pesawat. Karena kedua implementasi memenuhi kontrak yang sama, perilaku offline bukan cabang khusus yang ditempelkan ke jalur langsung; ia sederajat dengannya.
Admin web POS mencerminkan ini persis dengan httpClient dan mockClient yang berbagi satu file types, yang berarti kedua permukaan sepakat tentang bentuk order sebelum satu permintaan pun dikirim. Menjaga kontrak di satu tempat itulah yang membuat aplikasi offline dan admin online tetap jujur satu sama lain.
| Perhatian | Naif hanya-online | Outbox offline-first |
|---|---|---|
| Area tanpa sinyal | Terblokir di spinner | Merekam lokal, menguras nanti |
| Ulang setelah timeout | Berisiko order ganda | Idempotency key mendedup |
| Urutan penulisan | Apa pun yang tiba dulu | Replay created-at ketat |
| Demo tanpa backend | Mustahil | MockRepository jalankan aplikasi penuh |
Hasilnya adalah aplikasi yang memperlakukan jaringan sebagai tidak andal secara bawaan alih-alih sebagai jalur mulus dengan penanganan error yang ditempelkan. Kru membuka, mencuci, dan men-settle di zona mati sepanjang shift, dan order tiba di API NestJS secara utuh, berurutan, dan tepat satu kali, kapan pun sinyal memutuskan untuk kembali.