Coming from supabase-kt¶
A call written against supabase.from(table) compiles against supabase.offline.from(table) with
only the receiver changed and the accessor imported. Writes return a MutationOutcome instead of
the server's result, and a few call shapes need an edit, listed below.
The swap¶
val open =
supabase.offline.from("todos").select { filter { eq("done", false) } }.decodeList<Todo>()
supabase.offline.from("todos").insert(todo)
supabase.offline.from("todos").update({ set("done", true) }) { filter { eq("id", todo.id) } }
supabase.offline.from("todos").delete { filter { eq("id", todo.id) } }
Import io.github.nesfeder.supabase.offline.offline. Storage works the same way:
supabase.storage.from(bucket) becomes supabase.offline.storage.from(bucket).
What stays the same¶
- The verbs and their overloads.
select,selectAsFlow,insert,upsert,updateanddelete, with JSON bodies, typed values and lists, and the update DSL (set,setTo,setToNull). Typed values are encoded by your PostgREST plugin's serializer, as supabase-kt encodes them. - The request DSL. Filters,
order,range,limit,select()on a write,onConflict,ignoreDuplicatesanddefaultToNull, andcount. - The schema.
from(schema, table)and the request'sschemawork as in supabase-kt. - Storage. Every
BucketApioperation is there under its name; downloads are cached. - The errors. When the server refuses a request, or a request cannot be sent and the library
does not answer it from the cache or the queue, you get supabase-kt's own exception
(
PostgrestRestException,RestException,HttpRequestException) unchanged. A write sent at once that the server refuses throws, as it does in supabase-kt. The library adds three of its own:MutationNotQueueableException, for a write the queue cannot hold that cannot be sent (itscauseis supabase-kt's connectivity failure);NetworkDisabledException, while network access is off; andIllegalArgumentException, for two top-level filters on one column or a pattern list the server would misread. Test for being offline withisNetworkOrConnectivityFailure(), not by catchingHttpRequestException.
The offline choices are named parameters with defaults, placed before the trailing lambda:
readStrategy on reads, mutationStrategy on writes. You add them only where you want something
other than the default.
What a write returns¶
A write returns MutationOutcome: Applied(result) when the server applied it,
Queued(pendingMutation) when it waits in the mutation queue, with no answer from the server yet.
Ignore it (a screen built on a live read shows the write either way, at once), take the result if
there is one with resultOrNull(), or match both cases with an exhaustive when.
Call shapes that need an edit¶
supabase-kt's verbs are inline; these are not, because the library keeps the request lambda and
runs it again: to learn the read's shape, to queue a write and to replay it. That, and the return
type, make these call shapes differ:
| Call | What to change |
|---|---|
A write whose result is read, as in insert(row) { select() }.decodeSingle<Row>() |
Read it through resultOrNull(), and decide what to show while the write is queued |
A suspending call, or an early return, inside a request lambda |
Make the call, or return, before the verb; use its result inside the lambda |
A request lambda inside the parentheses, as in insert(row, { select() }) |
The second parameter is mutationStrategy (the first, for delete): name the lambda request = { … }, or move it after the parentheses |
Realtime's selectAsFlow(…) on a table |
The builder's own selectAsFlow is the offline one: keep Realtime calls on supabase.from(table) |
The typed rpc(function, parameters) |
Import io.github.nesfeder.supabase.offline.rpc in place of io.github.jan.supabase.postgrest.rpc |
Bucket management (createBucket, listBuckets, emptyBucket and the rest) |
Keep it on supabase.storage |
A write whose result is read:
A suspending call inside a request lambda:
// supabase-kt: the verb is inline, so the lambda may suspend
supabase.from("todos").delete { filter { eq("id", currentTodoId()) } }
// supabase-offline-kt: make the call first, then use its result in the lambda
val id = currentTodoId()
supabase.offline.from("todos").delete { filter { eq("id", id) } }
A request lambda inside the parentheses:
// supabase-kt: the second parameter is the request
supabase.from("todos").insert(todo, { select() })
// supabase-offline-kt: the second parameter is mutationStrategy, so name the lambda
supabase.offline.from("todos").insert(todo, request = { select() })
// or move it after the parentheses
supabase.offline.from("todos").insert(todo) { select() }
The typed rpc, with the library's rpc imported:
supabase.postgrest.rpc("archive_done", ArchiveDone(before = "2026-10-01"))
supabase.offline.rpc("archive_done", ArchiveDone(before = "2026-10-01"))
The request lambda must build the same request every time. A read runs it once to learn its shape and once for each request it sends; a write that may be queued runs it up to three times. Keep side effects out of it, and read no clock or counter inside it.
Two filters on one column¶
supabase-kt sends only the first value of each top-level query parameter, so
filter { gte("age", 1); lte("age", 10) } sends age=gte.1 and nothing else: every row from 1
up. The library refuses such a call with an IllegalArgumentException, before anything is sent
or queued, for reads, updates and deletes. A read would show the wrong rows; a queued delete would
replay later and remove more than you asked. Write the conditions as one and { } group, which
supabase-kt sends whole:
From the samples: TaskQueries.kt · OfflineTaskRepository.kt · TaskQuery.swift
/**
* Made within [window]: two conditions on one column, in one `and` group. Written as two top-level
* filters, supabase-kt would send only the first, so the library refuses them.
*/
private fun PostgrestFilterBuilder.madeIn(window: RecentWindow) {
and {
gte(CREATED_AT, window.from.toString())
lt(CREATED_AT, window.until.toString())
}
}
/**
* Try it: two top-level filters on one column. supabase-kt would send only the first, so the
* library refuses the read before anything is sent, online and offline alike.
*/
override suspend fun readWithTwoFiltersOnOneColumn() {
val week = recentWindow(clock.now())
read {
offline.from(Tables.TASKS).select {
filter {
gte(CREATED_AT, week.from.toString())
lt(CREATED_AT, week.until.toString()) // refused: write them in one `and`
}
}
}
}
/// The tasks created within `window`: two conditions on one column, which the client sends
/// together, in one group, so the server reads both.
static func created<Request: OfflinePostgrestFilterBuilder>(within window: DateInterval, _ request: Request) -> Request {
request
.gte("created_at", value: window.start)
.lt("created_at", value: window.end)
}
Swift sends two conditions on one column together, in one and group, so nothing is refused.
Two cases the library cannot see, because supabase-kt keeps one value by replacing the first
rather than adding a second: two top-level or { } groups (only the last is sent), and a text
search on a column after a filter on the same column (only the text search is sent). Combine
those into one group too.
What supabase-kt 3.6 does not have¶
The library is built against supabase-kt 3.6 (why), which lacks a few things 3.8 has:
dryRun(). Add the header yourself,headers.append("Prefer", "tx=rollback")in the request block (supabase-kt marks it experimental). The library never queues such a write, and on a hosted Supabase project a dry run commits (Writes and the offline queue).maybeSingle(), theisdistinctfilter operator andpurgeCache.- Storage object names with
%,?or#, which supabase-kt 3.6 puts in the URL as written, so a?or#can reach a different object. The library never caches such a name, andensureCachedFilerefuses it (Storage caching).
What is never cached or queued¶
The library caches PostgREST lists and Storage downloads, and queues PostgREST writes; everything
else goes to the server as supabase-kt sends it, and offline fails as there. That is rpc (a
function may change data, so it is neither cached nor queued), Realtime, Auth and Functions
(feed Realtime's records into the cache with applyServerRecord),
Storage writes, and reads of another shape (single(), a count, head, csv(), geojson(),
stripNulls(), explain(); read one row offline from the list, by its key). The samples' two
database functions say so offline: Mark all done takes no parameters, the Sync tab's Server
section takes them typed and as JSON, and a function's changes reach the device's lists as another
device's do, through Realtime or the next read:
From the samples: OfflineTaskRepository.kt · TaskRepository+Server.swift
/**
* Mark all done: one database function, on the server. Its changes reach the device's lists as
* any other device's do: through Live updates, or the next refresh.
*/
override suspend fun markAllDone(): Int = read {
offline.rpc("complete_all_tasks").decodeAs<Int>() // how many it changed
}
/** The Server section: a count, and a database function called with its parameters twice. */
override suspend fun serverCounts(): ServerCounts = read {
val since = recentWindow(clock.now()).from
ServerCounts(
// Typed parameters, encoded by the client's serializer; null counts every task.
done =
offline
.rpc("tasks_done_count", DoneCountParameters(createdSince = null))
.decodeAs(),
total = countTasks(),
// The same function with parameters as JSON.
doneThisWeek =
offline
.rpc(
"tasks_done_count",
buildJsonObject { put("created_since", since.toString()) },
)
.decodeAs(),
)
}
/// Marks every task of the user done, in one call to the database function
/// `complete_all_tasks()`. The device's lists learn of it when they are read again, or from
/// Realtime when Live updates is on.
func markAllDone() async throws {
try await client.rpc("complete_all_tasks").execute()
}
/// How many of the user's tasks the server holds as done, in all and among those created
/// since `since`, from the database function `tasks_done_count`, each decoded as the number it
/// returns: its parameters once typed, once as JSON values.
func serverDoneCounts(since: Date) async throws -> (all: Int, since: Int) {
// A typed value, encoded as JSON; a nil date counts every task.
let all = try await client.rpc("tasks_done_count", params: DoneCountParameters(createdSince: nil))
.execute(Int.self)
.value
// The same function, its parameter written as a JSON value.
let recent = try await client.rpc("tasks_done_count", params: ["created_since": .string(since.formatted(.iso8601))])
.execute(Int.self)
.value
return (all, recent)
}
The full table is Supported features.
Behavior to expect¶
select falls back by default: NetworkFirst reads as supabase-kt does online and serves the
cached rows offline instead of throwing. A write made while an earlier one of the same user waits
is queued behind it, online too, returns Queued, and is sent a moment later. A queued write the
server refuses on replay is not thrown but goes to the rejected list for your app to show
(Conflicts and failures). The client holds a database: one client per
process, and clearLocalData() on sign-out.
Checklist¶
- Add the dependency and install the plugin last (Project setup).
- Import
io.github.nesfeder.supabase.offline.offline, and swapsupabase.fromforsupabase.offline.fromwhere a read or write should work offline. - Fix the call shapes above; the compiler finds all but two filters on one column.
- Make new rows' ids on the device.
- Call
clearLocalData()when a user signs out. - Show the sync status and the rejected list somewhere in the app.