Skip to content

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.from("todos").select { filter { eq("done", false) } }.decodeList<Todo>()
supabase.from("todos").insert(todo)
supabase.from("todos").update({ set("done", true) }) { filter { eq("id", todo.id) } }
supabase.from("todos").delete { filter { eq("id", todo.id) } }
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, update and delete, 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, ignoreDuplicates and defaultToNull, and count.
  • The schema. from(schema, table) and the request's schema work as in supabase-kt.
  • Storage. Every BucketApi operation 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 (its cause is supabase-kt's connectivity failure); NetworkDisabledException, while network access is off; and IllegalArgumentException, for two top-level filters on one column or a pattern list the server would misread. Test for being offline with isNetworkOrConnectivityFailure(), not by catching HttpRequestException.

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:

val saved = supabase.from("todos").insert(todo) { select() }.decodeSingle<Todo>()
val saved =
    supabase.offline
        .from("todos")
        .insert(todo) { select() }
        .resultOrNull() // null when the write was queued: there is no answer yet
        ?.decodeSingle<Todo>() ?: todo

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(), the isdistinct filter operator and purgeCache.
  • 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, and ensureCachedFile refuses 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

  1. Add the dependency and install the plugin last (Project setup).
  2. Import io.github.nesfeder.supabase.offline.offline, and swap supabase.from for supabase.offline.from where a read or write should work offline.
  3. Fix the call shapes above; the compiler finds all but two filters on one column.
  4. Make new rows' ids on the device.
  5. Call clearLocalData() when a user signs out.
  6. Show the sync status and the rejected list somewhere in the app.