Skip to content

supabase-offline-kt

supabase-offline-kt is a plugin for supabase-kt that keeps a Kotlin Multiplatform app working without a network, on Android and iOS. You install it beside PostgREST and reach it as supabase.offline, and it takes supabase-kt's own calls. The server stays the source of truth: the device holds what the app has read, and the writes it has not sent yet.

What it does

  • Cached reads. A read is answered by the server, and the answer is kept on the device. When the server cannot be reached, the kept rows are served instead, with the writes still waiting laid over them. A live read emits again whenever those rows change.
  • Queued writes. An insert, upsert, update or delete is saved on the device first, then sent at once when nothing of the same user waits ahead of it. Offline, it waits in the mutation queue and is sent, in the order it was made, when the network comes back.
  • Cached files. A Storage download is kept on disk and served from there when the network is down.

An example

From the samples: SampleSupabaseClient.kt · OfflineTaskRepository.kt · AppModel.swift · TaskRepository.swift

return createSupabaseClient(supabaseUrl = url, supabaseKey = publishableKey) {
    // Warnings and errors only: request logs name filter values.
    defaultLogLevel = LogLevel.WARNING
    install(Auth) // the anonymous session; the cache belongs to one user at a time
    install(Postgrest) // required
    install(Storage) // for offline.storage
    install(Realtime) // for Live updates
    // Last, so no plugin after it can fail once it has claimed its database.
    install(Offline) { writeEverySetting(networkAvailability, debugBuild) }
}

/** Adds a task; [needsConnection] picks the write's strategy. */
override suspend fun addTask(
    title: String,
    notes: String?,
    needsConnection: Boolean,
): NewTaskOutcome {
    val row = newTaskRow(title, notes)
    val outcome =
        write(Tables.TASKS, TaskMutationOperation.Insert, title) {
            offline.from(Tables.TASKS).insert(row, mutationStrategyFor(needsConnection)) {
                select() // the stored row back, with the times the server set
            }
        }
    return when (outcome) {
        is MutationOutcome.Applied -> {
            val stored = outcome.result.decodeSingle<TaskRecord>()
            // An online-only write is never patched into the cache: the stored row goes there.
            if (needsConnection) offline.applyServerRecord(Tables.TASKS, stored)
            NewTaskOutcome.Saved(outcome.result.decodeSingle<TaskRow>().toTask(emptySet()))
        }
        is MutationOutcome.Queued -> NewTaskOutcome.SavedOnDevice(row.id) // sent when it can be
    }
}
// The one client the app has, made at launch and shared: it learns who the user is
// from the token alone, and follows the app's own connectivity signal.
let client = SupabaseOfflineClient(
    supabaseURL: url,
    supabaseKey: publishableKey,
    config: ClientSettings.config(
        accessToken: { await session.accessToken() },
        networkAvailability: ClientSettings.pathMonitorSignal
    )
)

/// Adds task `id` (a new one unless given) for the signed-in user, with the device's clock as
/// its creation time, and says what happened: the server stored it, at the `updated_at` it set
/// and gives back, or it waits in the queue on this device.
func addTask(id: String = DeviceID.make(), title: String, notes: String?, needsConnection: Bool = false) async throws -> NewTaskResult {
    let row = NewTaskRow(
        id: id,
        userID: try signedInUserID(),
        title: title,
        notes: notes,
        done: false,
        createdAt: .now
    )
    let outcome = try await write(taskTitle: title) {
        try await client.from(Tables.tasks)
            .insert(row, mutationStrategy: Self.mutationStrategy(needsConnection: needsConnection))
            .select()
            .single()
            .execute(StoredTaskRow.self)
    }
    switch outcome {
    case .applied(let result):
        // A write sent only straight to the server is never put into the device's lists:
        // the app puts in the row the server stored, as it would a row Realtime reports.
        if needsConnection { try await client.applyServerRecord(table: Tables.tasks, record: result.value) }
        return .saved(storedAt: result.value.updatedAt)
    case .queued(let pendingMutation):
        return .savedOnDevice(pendingMutationID: pendingMutation.id)
    }
}

A write returns a MutationOutcome rather than the server's result, because a queued write has no answer yet. That is the one difference in what a call returns.

Where next

  • Project setup: add the dependency and make the first offline read and write.
  • Coming from supabase-kt: what stays the same, and the few call shapes that need an edit.
  • Behaviors: what each read and write does online, offline and in between.
  • Sample apps: a to-do app with photos that shows all of it.
  • API reference: every public declaration, in the Kotlin reference for both modules beside the Swift reference for the Swift package.

Platforms

Android API 23 and later
iOS 15 and later, iosArm64 and iosSimulatorArm64, from Kotlin
supabase-kt 3.6, the one minor each release is built and tested against (why)
Other Kotlin targets none: no JVM, JS or Wasm

The library is not affiliated with Supabase.