Offline Sync — API Reference
The offline-sync family is an offline-first sync layer for supabase-kmp. Each
table runs a pull → merge → push cycle: changed rows are pulled from the
remote since the last position, merged into a local database (resolving
conflicts row-by-row), then queued local edits are pushed back. Incremental
pulls resume from a keyset cursor on the composite (updatedAt, id), so the
sync stays stable even when many rows share the same modification timestamp.
The layer is split across four Maven artifacts so you only depend on what you use:
| Artifact | Provides |
|---|---|
io.github.androidpoet:supabase-sync-core | The transport-agnostic engine, the domain model, and the LocalStore / RemoteSource / TableAdapter seams. No Supabase or database dependency. |
io.github.androidpoet:supabase-sync-sqldelight | An on-device LocalStore backed by SQLDelight — durable synced rows, pull cursors, and the outbox across iOS, macOS, and the JVM. |
io.github.androidpoet:supabase-sync | A RemoteSource over Supabase PostgREST (pull/push) and Realtime (live deltas). |
io.github.androidpoet:supabase-sync-paging | A Paging 3 bridge: a typed per-table façade exposing local-first PagingData streams and optimistic writes. |
The engine talks only to the LocalStore and RemoteSource interfaces, so the
same logic that drives Supabase today drives anything else behind those seams
tomorrow. Mix-and-match: core alone is usable with a hand-written store and
source.
supabase-sync-core
Artifact: io.github.androidpoet:supabase-sync-core. All symbols live under the
package io.github.androidpoet.supabase.sync.
Record
The transport-neutral form of a synced row.
data class Record(
val id: String,
val updatedAt: Long,
val deleted: Boolean = false,
val fields: JsonObject,
)id— the row’s primary key as a string.updatedAt— the server-set modification time (epoch millis). This is what last-write-wins compares and what the cursor keyset advances on.deleted— a soft-delete tombstone. Deletions propagate as tombstoned rows so an incremental “changed since” pull can still observe them; a hard delete would be invisible to such a query.fields— the domain payload as akotlinx.serializationJsonObject.
Cursor
A per-table pull position.
data class Cursor(
val updatedAt: Long,
val id: String = "",
)updatedAt— the high-water modification time reached so far (epoch millis).id— the tie-breaker id at that timestamp; defaults to the empty string (the low bound), so a cursor of just anupdatedAtis valid.
Incremental pulls resume after this point using a keyset on (updatedAt, id):
rows where updatedAt > cursor.updatedAt, plus rows at exactly
cursor.updatedAt whose id > cursor.id. The composite (rather than a bare
timestamp high-water mark) is what keeps the pull from skipping or re-looping
rows written in the same millisecond.
ChangeKind
enum class ChangeKind { UPSERT, DELETE }The kind of local mutation queued in the outbox.
PendingChange
A local mutation waiting to be pushed to the remote.
data class PendingChange(
val record: Record,
val kind: ChangeKind,
)record— the row payload to push (for aDELETE, a tombstone).kind— whether the change is an upsert or a delete.
PullResult
What a single RemoteSource.pull returned.
data class PullResult(
val changed: List<Record>,
val nextCursor: Cursor?,
)changed— the rows that changed since the supplied cursor.nextCursor— the position to resume from next time, ornullto leave the stored cursor unchanged (e.g. when nothing changed). Returningnullmust never force a full re-sync.
PushResult
Per-id outcome of a push.
data class PushResult(
val accepted: List<String>,
val rejected: List<String> = emptyList(),
)accepted— ids the server accepted; the engine clears these from the outbox.rejected— ids the server rejected; left pending. Defaults to empty.
SyncResult
Summary of one full SyncEngine.sync cycle.
data class SyncResult(
val pulled: Int,
val pushed: Int,
val rejected: Int,
)pulled— rows merged in from the remote across all drained pages.pushed— local changes the server accepted.rejected— local changes the server rejected.
PullProgress
Outcome of one SyncEngine.pullPage.
data class PullProgress(
val pulled: Int,
val hasMore: Boolean,
)pulled— how many rows this page carried.hasMore— whether another page likely follows (true only when the server both advanced the cursor and returned rows).
Page<T>
One page of rows from a LocalStore offset list query.
data class Page<T>(
val items: List<T>,
val offset: Long,
val limit: Long,
val total: Long,
) {
val hasMore: Boolean
}items— the rows in this page.offset/limit— echo the request.total— the full live row count for the table (for “page x of y”).hasMore— computed:truewhenoffset + items.size < total.
LocalStore
The local-database side of sync: it stores synced rows, remembers each table’s pull cursor, and holds the outbox of local changes still to push.
interface LocalStore {
suspend fun upsert(table: String, records: List<Record>)
suspend fun get(table: String, id: String): Record?
suspend fun cursor(table: String): Cursor?
suspend fun setCursor(table: String, cursor: Cursor?)
suspend fun pending(table: String): List<PendingChange>
suspend fun enqueue(table: String, change: PendingChange)
suspend fun clearPending(table: String, ids: List<String>)
suspend fun page(table: String, limit: Long, offset: Long): Page<Record>
suspend fun pageAfter(table: String, afterId: String?, limit: Long): List<Record>
suspend fun count(table: String): Long
}upsert— the pull path: applies remoterecords. Implementations apply a monotonic guard per row (a record older than the stored one is skipped), so re-applying an earlier pull never clobbers newer local state. Not for local edits — useenqueue.get— the rowidintable, ornullif absent.cursor— the last pull position stored fortable, ornullif it has never synced.setCursor— persists the pull position (nullclears it).pending— local changes fortablestill waiting to be pushed.enqueue— the optimistic write path: in one transaction it applies the row (unguarded, so the user’s edit always wins locally) and appends the change to the outbox. The outbox holds one entry per row id (latest intent wins), so re-editing a row before it syncs replaces its pending entry.clearPending— removes outbox entries foridsafter they were pushed or superseded.page— offset page of live (non-tombstone) rows, ordered by id. DrivesSyncStore.paged.pageAfter— keyset page: the nextlimitlive rows afterafterId(null= first page), ordered by id.count— total live row count fortable.
RemoteSource
The remote-API side of sync.
interface RemoteSource {
suspend fun pull(table: String, since: Cursor?): PullResult
suspend fun push(table: String, changes: List<PendingChange>): PushResult
fun changes(table: String): Flow<Record>
}pull— fetches rows changed sincesince(all rows whennull) and reports the cursor to resume from.push— sends localchangesand reports which were accepted.changes— a live stream of remote deltas, oneRecordper change.
TableAdapter
The seam that makes a consumer’s own typed table the single source of truth for a
synced table, on any local database. A LocalStore reads and writes domain rows
only through this adapter; the adapter handles only the domain JsonObject
fields, while sync metadata (server updatedAt, the tombstone) stays the store’s
responsibility. Methods are synchronous; the store calls them from its suspend
functions.
interface TableAdapter {
val table: String
fun upsert(id: String, fields: JsonObject)
fun get(id: String): JsonObject?
fun delete(id: String)
fun page(limit: Long, offset: Long): List<Pair<String, JsonObject>>
fun pageAfter(afterId: String?, limit: Long): List<Pair<String, JsonObject>>
fun count(): Long
}table— the table name as the engine/remote knows it.upsert— inserts or replaces the rowidwithfields.get— the rowid’s fields, ornullif absent.delete— removes the rowid.page— offset pagination, rows ordered by id as(id, fields)pairs.pageAfter— keyset pagination: the nextlimitrows with id greater thanafterId(null= start).count— total row count (feedsPage.total).
ConflictResolver
Decides the winner when a local pending change and an incoming remote record touch the same row.
fun interface ConflictResolver {
fun resolve(local: Record, remote: Record): Record
}Implement it (it is a functional interface) for field-level merges or any custom
policy, and register per table on a ResolverRegistry.
LastWriteWins
The default resolver.
object LastWriteWins : ConflictResolver {
override fun resolve(local: Record, remote: Record): Record
}Higher Record.updatedAt wins; ties go to the remote, since the server is
authoritative.
ResolverRegistry
Maps tables to their resolver, falling back to a default for unregistered tables.
class ResolverRegistry(default: ConflictResolver = LastWriteWins) {
fun register(table: String, resolver: ConflictResolver): ResolverRegistry
fun forTable(table: String): ConflictResolver
}- constructor
default— the fallback resolver for unregistered tables (LastWriteWinsby default). register— registersresolverfortable; returnsthisfor chaining.forTable— the resolver fortable, or the default if none was registered.
SyncEngine
Orchestrates one offline-first sync cycle per table. Transport-agnostic — it
talks only to a LocalStore and a RemoteSource.
class SyncEngine(
local: LocalStore,
remote: RemoteSource,
resolvers: ResolverRegistry = ResolverRegistry(),
) {
suspend fun sync(table: String): SyncResult
suspend fun pullPage(table: String): PullProgress
fun observe(table: String): Flow<Record>
}- constructor
local/remote— the store and source halves. - constructor
resolvers— per-table conflict resolvers; defaults to last-write-wins for every table. sync— runs a full pull → merge → push cycle and returns aSyncResult. It drains every pending remote page (bounded internally against a server that never lowershasMore) before pushing the outbox, then clears accepted ids. If the remote version wins a conflict the local edit is dropped; otherwise the resolved winner is re-queued so the merged value — not the stale original — is what gets pushed.pullPage— pulls and merges a single page (no push), advancing the table’s cursor; returns aPullProgress. Call repeatedly to walk a large table page-by-page. PowersSyncStore.pagedSynced.observe— a liveFlowof the table’s remote changes, each merged into the local store as it arrives through the same conflict resolver assync. Collect it alongside periodicsynccalls; still re-runsyncon reconnect to backfill deltas missed while disconnected.
pull and push propagate transport/server failures by throwing — a failed
push leaves the outbox intact for the next cycle. Wrap sync / pullPage calls
accordingly, or use the SyncStore façade in supabase-sync-paging, which folds
failures into a SyncStatus.ERROR state.
createSyncEngine
The convention-matching factory that mirrors the SDK’s other create* entry
points.
fun createSyncEngine(
local: LocalStore,
remote: RemoteSource,
resolvers: ResolverRegistry = ResolverRegistry(),
): SyncEngineEquivalent to calling the SyncEngine constructor directly; prefer it for
consistency.
val engine = createSyncEngine(
local = openOfflineSyncStore(driver),
remote = createSupabaseRemoteSource(database, realtime),
resolvers = ResolverRegistry().register("notes", LastWriteWins),
)
val result = engine.sync("notes") // SyncResult(pulled, pushed, rejected)supabase-sync-sqldelight
Artifact: io.github.androidpoet:supabase-sync-sqldelight. All symbols live under
the package io.github.androidpoet.supabase.sync.store. The store is
driver-agnostic: you bring your own SQLDelight SqlDriver, so the same code runs
on iOS, macOS, and the JVM.
The generated SQLDelight database type (the ...sync.store.db package) is an
internal implementation detail and is not part of the public API. Construct the
store from a SqlDriver via openOfflineSyncStore or the public
SqlDelightLocalStore(driver, ...) constructor — never from the generated type.
ColumnKind
The SQLite storage class of a column, and how its value maps to and from JSON.
enum class ColumnKind { TEXT, INTEGER, REAL, BOOL, JSON }BOOL is stored as INTEGER (0/1); JSON columns are stored as TEXT,
matching the schema the SQLDelight generator emits.
Column
One column of a synced table.
data class Column(
val name: String,
val kind: ColumnKind,
val nullable: Boolean = false,
)name— the column name.kind— its storage/JSON mapping.nullable— whether it acceptsnull(defaults tofalse).
SqlDelightTableAdapter
A generic TableAdapter over any SQLite table reachable through a SqlDriver,
driven entirely by a column descriptor. All JSON ↔ typed-column conversion lives
here, so codegen only emits the trivial descriptor. Reads and writes go straight
to the same SQLite table your .sq CREATE TABLE defines, so your typed queries
and the sync engine share one set of rows.
class SqlDelightTableAdapter(
driver: SqlDriver,
table: String,
columns: List<Column>,
pk: String,
) : TableAdapterdriver— the SQLDelight driver to read/write through.table— the SQLite table name.columns— the column descriptor for the table.pk— the primary-key column name.
Implements every TableAdapter member (table, upsert, get, delete,
page, pageAfter, count) against that table.
SqlDelightLocalStore
A LocalStore backed by SQLDelight, so synced rows, pull cursors, and the outbox
survive process restarts on every Kotlin/Native and JVM target. For each table you
can register a TableAdapter: that table then becomes a single source of truth
(rows live in your own typed table, with only sync metadata kept in a sidecar).
Tables with no adapter fall back to a generic JSON blob cache — zero-config.
class SqlDelightLocalStore(
driver: SqlDriver,
adapters: Map<String, TableAdapter> = emptyMap(),
) : LocalStoredriver— your SQLDelight driver. The schema must already be created on it (useopenOfflineSyncStore, which does this for you).adapters— per-table adapters; tables not in the map use the blob cache.
Implements all of LocalStore (upsert, get, cursor, setCursor,
pending, enqueue, clearPending, page, pageAfter, count) with the
monotonic-apply guard on the pull path and an atomic apply-plus-enqueue on the
optimistic-write path.
openOfflineSyncStore
Creates the schema on the driver and returns a ready-to-use store.
fun openOfflineSyncStore(
driver: SqlDriver,
adapters: Map<String, TableAdapter> = emptyMap(),
): SqlDelightLocalStoredriver— your SQLDelight driver; the offline-sync schema is created on it.adapters— optional per-table adapters.
val store = openOfflineSyncStore(
driver = driver,
adapters = mapOf(
"notes" to SqlDelightTableAdapter(
driver = driver,
table = "notes",
columns = listOf(
Column("id", ColumnKind.TEXT),
Column("body", ColumnKind.TEXT),
Column("pinned", ColumnKind.BOOL),
),
pk = "id",
),
),
)supabase-sync
Artifact: io.github.androidpoet:supabase-sync. All symbols live under the
package io.github.androidpoet.supabase.sync.remote. This module provides the
RemoteSource half over a supabase-kmp DatabaseClient (PostgREST) and
RealtimeClient.
SyncColumns
Names of the three columns the engine needs on every synced Supabase table.
data class SyncColumns(
val id: String = "id",
val updatedAt: String = "updated_at",
val deleted: String = "deleted",
)id— the primary-key column (stays a domain field, since it is part of the row). Defaults to"id".updatedAt— the modification-time column. Must be an integer epoch-millis column (bigint) so it compares as theLongthe cursor keyset uses; atimestamptzcolumn won’t compare correctly. Stripped out as sync metadata. Defaults to"updated_at".deleted— abooleansoft-delete column defaulting tofalse. Deletes are soft so an incremental “changed since” pull can still observe them. Stripped out as sync metadata. Defaults to"deleted".
SupabaseRemoteSource
The RemoteSource backed by Supabase: incremental pulls and bulk upserts over
PostgREST and a live delta stream over Realtime.
class SupabaseRemoteSource(
database: DatabaseClient,
realtime: RealtimeClient,
columns: SyncColumns = SyncColumns(),
schema: String = "public",
pageSize: Int = 1000,
) : RemoteSource {
suspend fun pull(table: String, since: Cursor?): PullResult
suspend fun push(table: String, changes: List<PendingChange>): PushResult
fun changes(table: String): Flow<Record>
}database— the PostgREST client for pulls and pushes.realtime— the Realtime client for the live stream.columns— the id / updated-at / deleted column names (defaults toSyncColumns()).schema— the Postgres schema the tables live in ("public"by default).pageSize— max rows fetched perpull; if more changed, the advanced cursor lets the nextsyncpick up the remainder. Defaults to1000.pull— fetches rows whose(updatedAt, id)is greater thansince(all rows whennull), ordered by that keyset and capped atpageSize, and returns the new high-water cursor (ornullwhen nothing changed). Throws on a transport/server error.push— bulk-upserts the outbox records withon_conflicton the id column. A push is all-or-nothing per request: on success every id is accepted; an error throws and leaves the outbox intact. Deletes are soft (the record carriesdeleted = true).changes— a coldFlowof the table’s Realtimepostgres_changes, each emitted as aRecord(a DELETE surfaces as a tombstone). Connects the client if needed and tears the channel down when collection stops.
Build database and realtime from the same client and share one auth token so
Row Level Security applies to both the pulled rows and the Realtime subscription.
createSupabaseRemoteSource
The convention-matching factory mirroring the SDK’s other create* entry points.
fun createSupabaseRemoteSource(
database: DatabaseClient,
realtime: RealtimeClient,
columns: SyncColumns = SyncColumns(),
schema: String = "public",
pageSize: Int = 1000,
): SupabaseRemoteSourceSame parameters and defaults as the constructor; prefer it for consistency.
val remote = createSupabaseRemoteSource(
database = client.database,
realtime = client.realtime,
columns = SyncColumns(updatedAt = "updated_at_ms"),
)supabase-sync-paging
Artifact: io.github.androidpoet:supabase-sync-paging. All symbols live under the
package io.github.androidpoet.supabase.sync.paging. This module is the Paging 3
bridge: a typed per-table façade an app binds directly to its UI.
The Paging 3 surface is Int-keyed (page sizes are Int), matching the
Paging 3 API, while the underlying LocalStore pages with Long limits/offsets
and the engine advances Long-based keyset cursors. The Int/Long split at this
boundary is intentional — the façade bridges the Int-based Paging 3 API onto the
Long-based store and cursor model.
SyncStatus
enum class SyncStatus { IDLE, SYNCING, ERROR }Where the store is in its current sync cycle — bind it to a spinner or error banner.
DefaultJson
The lenient, default-encoding Json used when no custom instance is supplied.
val DefaultJson: JsonConfigured with ignoreUnknownKeys = true and encodeDefaults = true.
SyncStore<T>
The one always-in-sync model an app touches per table: a value of type T is the
same on screen, in the local database, and on the wire. The UI binds paged or
observe and calls upsert / delete; it never touches the network or SQL.
Reads reflect a local write immediately (optimistic), and the change is pushed in
the background through the engine’s outbox. Writes are single-flighted: a burst of
edits collapses to at most one follow-up sync.
class SyncStore<T : Any>(
table: String,
local: LocalStore,
engine: SyncEngine,
serializer: KSerializer<T>,
idOf: (T) -> String,
scope: CoroutineScope,
now: () -> Long,
json: Json = DefaultJson,
) {
val status: StateFlow<SyncStatus>
fun observe(id: String): Flow<T?>
fun paged(pageSize: Int = 20): Flow<PagingData<T>>
fun pagedSynced(pageSize: Int = 20): Flow<PagingData<T>>
suspend fun get(id: String): T?
suspend fun upsert(value: T)
suspend fun delete(id: String)
suspend fun sync(): SyncResult
}table— the table name to sync.local/engine— the store and engine this façade drives.serializer— thekotlinx.serializationserializer forT(the row must serialize to a JSON object).idOf— extracts the primary-key string from a value ofT.scope— the coroutine scope the background sync loop runs on.now— a wall-clock epoch-millis source (e.g.{ Clock.System.now().toEpochMilliseconds() }), stamped on optimistic writes so last-write-wins resolves correctly across devices.json— theJsonused to encode/decode rows (defaults toDefaultJson).status— aStateFlowof the latestSyncStatus(IDLE/SYNCING/ERROR).observe— a reactive single row that re-emits whenever the row changes locally or after a sync; emitsnullif absent or tombstoned.paged— a Paging 3PagingDatastream over the local store, loading onepageSizewindow at a time (never the whole table) and invalidating on any local write or completed sync. Drops into a ComposeLazyColumnviacollectAsLazyPagingItems().pageSizedefaults to20.pagedSynced— likepaged, but backed by a Paging 3RemoteMediatorthat pulls the next backend page on demand as the user scrolls (viaSyncEngine.pullPage) and writes it to the local store, so it is then available offline.pageSizedefaults to20.get— a one-shot local read of the rowid, ornullif absent or tombstoned.upsert— optimistically inserts/replacesvalue: visible to reads at once, queued to push on the next sync.delete— optimistically deletesidvia a soft-delete tombstone, queued to push on the next sync.sync— runs one pull → merge → push cycle, updatingstatus, and returns theSyncResult. Serialized so a manual call and the background trigger never overlap.
val notes = SyncStore(
table = "notes",
local = store,
engine = engine,
serializer = Note.serializer(),
idOf = { it.id },
scope = appScope,
now = { Clock.System.now().toEpochMilliseconds() },
)
val list: Flow<PagingData<Note>> = notes.paged(20) // local-first, auto-invalidating
val one: Flow<Note?> = notes.observe(id)
notes.upsert(Note(id = "n1", body = "buy milk"))
notes.delete("n1")