Skip to content

Configuration

The plugin's settings go in the install(Offline) { … } block, an Offline.Config, read once when the client is created. Every setting has a default that suits most apps. The samples write every setting out, each at its default but their own connectivity signal and a debug build's log level:

From the samples: SampleSupabaseClient.kt · ClientSettings.swift

/**
 * Every setting, each set to the default it would take anyway but the two the app supplies: remove
 * any line and the plugin behaves the same.
 */
private fun Offline.Config.writeEverySetting(
    networkAvailability: Flow<Boolean>,
    debugBuild: Boolean,
) {
    // Cached query results, by the length of their answers: past it, the longest-fetched go.
    cacheSizeBytes =
        QUERY_CACHE_SIZE_BYTES // the default; Offline.CACHE_SIZE_UNLIMITED never evicts
    // Downloaded photos: past it, the least recently used go, never one in use.
    fileCacheSizeBytes = PHOTO_CACHE_SIZE_BYTES // the default
    // Where the database and the downloaded files live; null is the app's own places.
    dataDirectory = null // the default: the database directory, and filesDir/supabase_offline
    // The app's own connectivity signal in place of the plugin's monitor. Both watch Android's
    // validated network; null would use the plugin's.
    this.networkAvailability = networkAvailability
    // Send queued writes when the session settles and when the network comes back.
    autoSync = true // the default; false leaves them to Sync now
    // How the cache tells rows apart: each table's key columns. `id` is the default for both.
    primaryKey(Tables.TASKS, "id")
    primaryKeys[Tables.TASK_PHOTOS] = listOf("id") // the same thing, as a map entry
    // Which answers may replace the cache: those sent and answered under one settled session.
    responseTrust = ResponseTrust.SettledSession // the default
    // The plugin's least severe level; null takes defaultLogLevel above. INFO never logs a row.
    logLevel = if (debugBuild) LogLevel.INFO else null
}
/// The settings for a client whose user's token `accessToken` returns, following
/// `networkAvailability`.
static func config(
    accessToken: @escaping @Sendable () async -> String?,
    networkAvailability: @escaping @Sendable () -> AsyncStream<Bool>
) -> SupabaseOfflineClient.Config {
    SupabaseOfflineClient.Config(
        // The schema a table is read from and written to: public, the default.
        defaultSchema: "public",
        // The signed-in user's token, asked for before each request; nil (the default) sends the publishable key alone.
        accessToken: { await accessToken() },
        // Where the database and the photo cache live: the default, spelled out.
        dataDirectory: dataDirectory,
        // How much cached query results may take: the default.
        cacheSize: queryCacheSize,
        // How much downloaded photos may take: the default.
        fileCacheSize: photoCacheSize,
        // The app's own connectivity signal, in place of the library's built-in path monitor (nil, the default).
        networkAvailability: networkAvailability,
        // Queued writes are sent by themselves when the network comes back: true, the default.
        autoSync: true,
        // The columns that tell one row from another, per table: id, the default for a table not named.
        primaryKeys: primaryKeys,
        // What the library logs: more in a debug build than .warning, the default.
        logLevel: logLevel
    )
}

The settings

Setting Type Default What it sets
cacheSizeBytes Long 100 MiB The most the cached query results may hold
fileCacheSizeBytes Long 256 MiB The most the Storage file cache may hold
dataDirectory String? null: the platform's place Where the database and the file cache live
networkAvailability Flow<Boolean>? null: the platform's monitor The connectivity signal
autoSync Boolean true Whether queued writes are sent without the app asking
primaryKeys, primaryKey(table, vararg columns) MutableMap<String, List<String>> id for every table Each table's key columns
responseTrust ResponseTrust SettledSession Which server answers may replace what the cache holds
logLevel LogLevel? null: the client's defaultLogLevel The least severe level the plugin logs at

Offline.CACHE_SIZE_UNLIMITED, -1L, turns eviction off for either cache.

cacheSizeBytes

The most the cached query results may hold, counted as the summed length of the cached responses, not the size of the database file. Past it, the results fetched longest ago are evicted, never one of up to 500 lists a live reader shows. Any other value below 4 KiB counts as 4 KiB. See Cache size and eviction.

From the samples: SampleSupabaseClient.kt · ClientSettings.swift

// Cached query results, by the length of their answers: past it, the longest-fetched go.
cacheSizeBytes =
    QUERY_CACHE_SIZE_BYTES // the default; Offline.CACHE_SIZE_UNLIMITED never evicts
// Downloaded photos: past it, the least recently used go, never one in use.
fileCacheSizeBytes = PHOTO_CACHE_SIZE_BYTES // the default
/// The most cached query results may take on disk before the ones fetched longest ago are
/// evicted: 100 MiB, the default. Rows a live read shows are never evicted.
static let queryCacheSize = SupabaseOfflineClient.CacheSize.bytes(100 * 1_024 * 1_024)

/// The most downloaded photos may take on disk before the least recently used are deleted:
/// 256 MiB, the default. Photos are the large part; `.unlimited` would keep every one.
static let photoCacheSize = SupabaseOfflineClient.CacheSize.bytes(256 * 1_024 * 1_024)

fileCacheSizeBytes

The most the Storage file cache may hold, in bytes. When a download passes it, the least recently used files are deleted until the cache is a tenth below the bound; a file being read, and one used in the last 30 seconds, are kept. Any other value below zero counts as zero.

dataDirectory

The directory for everything the plugin keeps on the device: the database, supabase_offline.db, and the Storage file cache. null keeps them where the platform's convention puts them: the database in the app's database directory and the files under <filesDir>/supabase_offline on Android, both under Library/Application Support on iOS. On iOS, a database found in Documents, where an earlier build kept it, is first moved to that default place with its files.

When the directory has no database yet and the default location has one that no client of this process holds, the first client moves the database there with its unsent writes, then the downloaded files (Project setup); when both places hold a database, it uses the configured one and logs that the other was left behind, with its files. On Android, a directory under noBackupFilesDir keeps the data out of backups (Backups).

From the samples: SampleSupabaseClient.kt · ClientSettings.swift

// Where the database and the downloaded files live; null is the app's own places.
dataDirectory = null // the default: the database directory, and filesDir/supabase_offline
/// Where the database and the photo cache live: `SupabaseOffline` in Application Support, the
/// default. The library leaves both out of the device's backups.
static let dataDirectory = URL.applicationSupportDirectory.appending(path: "SupabaseOffline", directoryHint: .isDirectory)

networkAvailability

Your own connectivity signal, true while the device is online, in place of the platform's monitor. null uses the monitor: Android's ConnectivityManager, counting a network with internet that Android has validated (it needs the ACCESS_NETWORK_STATE permission the library's manifest declares), or Network's path monitor on iOS.

The signal is a hint, never a gate: every request you make is tried whatever it says, and it decides only when the library tries by itself, whether a waiting refresh reads Paused or Stalled, and what syncStatus.value.network says (Replaying the queue). A signal that fails or ends is no longer watched (a failure is logged), and the network is then UNKNOWN, which counts as online.

From the samples: SampleSupabaseClient.kt · ConnectivitySignal.kt · ClientSettings.swift

// The app's own connectivity signal in place of the plugin's monitor. Both watch Android's
// validated network; null would use the plugin's.
this.networkAvailability = networkAvailability
// Send queued writes when the session settles and when the network comes back.
autoSync = true // the default; false leaves them to Sync now

/**
 * Whether the device's default network reaches the internet, as Android has validated it: the app's
 * own connectivity signal, handed to the plugin as `networkAvailability`. An app with a signal of
 * its own passes it, so the app and the plugin never disagree about being online.
 */
fun Context.validatedInternet(): Flow<Boolean> = callbackFlow {
    val connectivity = getSystemService(ConnectivityManager::class.java)
    val callback =
        object : ConnectivityManager.NetworkCallback() {
            override fun onCapabilitiesChanged(
                network: Network,
                capabilities: NetworkCapabilities,
            ) {
                trySend(capabilities.isValidatedInternet())
            }

            override fun onLost(network: Network) {
                trySend(false)
            }
        }
    val current = connectivity.getNetworkCapabilities(connectivity.activeNetwork)
    trySend(current?.isValidatedInternet() == true)
    connectivity.registerDefaultNetworkCallback(callback)
    awaitClose { connectivity.unregisterNetworkCallback(callback) }
}
    .distinctUntilChanged()

/** Whether this network has internet that Android has checked it can reach. */
private fun NetworkCapabilities.isValidatedInternet(): Boolean =
    hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
        hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
/// The device's connectivity from Network's path monitor, `true` while a path is usable: the
/// app's own signal, which the client follows in place of its built-in monitor. Each call
/// makes a new monitor, which stops when the client finishes the stream.
static func pathMonitorSignal() -> AsyncStream<Bool> {
    AsyncStream { continuation in
        let monitor = NWPathMonitor()
        monitor.pathUpdateHandler = { path in continuation.yield(path.status == .satisfied) }
        continuation.onTermination = { _ in monitor.cancel() }
        monitor.start(queue: DispatchQueue(label: "OfflineTasks.network-signal"))
    }
}

autoSync

Whether the plugin sends queued writes by itself: with true, the default, when the session settles into a user with writes queued, when the network comes back, and, for a client without Auth, once when it opens with writes an earlier run left queued. false stops those three, and you call syncPendingMutations() yourself; a write queued behind another is still sent as before (When the library sends by itself).

primaryKeys

Each table's primary-key columns, by table name: the name alone for a table in the client's default schema, schema.table for one in another schema. A table not named has the key id. The key is how the cache recognizes a row: where a queued or applied upsert lands, which row a server record replaces, and which rows a queued write reports in PendingMutation.rowKeys. Set it for every table whose key is not id, such as one with a composite key, with primaryKey("task_tags", "task_id", "tag"). A key with no column throws IllegalArgumentException.

From the samples: SampleSupabaseClient.kt · ClientSettings.swift

// How the cache tells rows apart: each table's key columns. `id` is the default for both.
primaryKey(Tables.TASKS, "id")
primaryKeys[Tables.TASK_PHOTOS] = listOf("id") // the same thing, as a map entry
/// How the cache tells rows apart: each table's key columns. `id`, the default for a table not
/// named, for both.
static let primaryKeys = [Tables.tasks: ["id"], Tables.taskPhotos: ["id"]]

responseTrust

Which answers from the server may replace what the cache holds. The question matters while the session is not the user's: while a stored session restores, or after its refresh failed, supabase-kt sends the anon key, and row-level security can answer with fewer rows, or none.

Value Rule
ResponseTrust.SettledSession (default) An answer replaces the cache when its request went out under a settled session (signed in, signed out, or a client without Auth) that had not changed when it answered. A session that is restoring, or whose refresh failed, never writes to the cache
ResponseTrust.AuthenticatedSessionOnly An empty answer is written only when the same user was signed in before and after the request; an answer with rows is written signed out too, under the same exclusions. For an app that reads public rows signed out and must never see an empty signed-out answer replace them

From the samples: SampleSupabaseClient.kt

// Which answers may replace the cache: those sent and answered under one settled session.
responseTrust = ResponseTrust.SettledSession // the default

The Swift package's Config has no response-trust setting.

logLevel

The least severe level the plugin logs at. The plugin logs through supabase-kt's own logging: to the logging processor the client was built with (defaultLoggingFactory in createSupabaseClient), tagged Supabase-Offline. null uses the client's defaultLogLevel, as supabase-kt's own plugins do. There is no second logging setup.

From the samples: SampleSupabaseClient.kt · ClientSettings.swift

// The plugin's least severe level; null takes defaultLogLevel above. INFO never logs a row.
logLevel = if (debugBuild) LogLevel.INFO else null
/// What the library writes to the unified log, under the subsystem `SupabaseOffline`: from
/// `.info` in a debug build, to follow each sync; from `.warning`, the default, otherwise.
static var logLevel: SupabaseOfflineClient.LogLevel {
    #if DEBUG
        .info
    #else
        .warning
    #endif
}
Level What the plugin logs Carries row data
DEBUG Each read's strategy and shape, request bodies, server responses Yes: rows, bodies, filter values
INFO Reads served from the cache, writes queued, sync passes and their counts No
WARNING Recovered surprises: a record dropped for want of its key, a refused write moved to the rejected list, a committed dry run No
ERROR Failures the library could not recover from by itself No

Messages at INFO and above name tables, operations, queued writes' ids, counts, HTTP statuses and exception types; never rows, filter values, tokens or object paths. See Security and privacy.

Read once

The plugin reads its settings when the client is created: changing supabase.offline.config afterward has no effect. The same holds for the PostgREST plugin's settings the library copies when it first needs them: the read-shape check builds its own PostgREST from yours at the first from(), so a PostgREST setting changed after that (propertyConversionMethod, say) is not followed by it.

Opening and closing

The plugin opens its database when the client is created, on the calling thread, and one database serves one client at a time: a second client over the same database or data directory throws IllegalStateException until the first is closed.

supabase.close() closes the plugin once supabase-kt has begun closing its HTTP client. supabase.offline.close() closes the plugin alone; called first, it lets the calls not yet sent go out.

What happens
At once Every new call fails with IllegalStateException, on the plugin and on any builder or bucket obtained from it. Every flow being collected completes; one collected afterward fails. syncStatus keeps its last value. The library's own work stops: refreshes, passes, syncs and Storage calls, whose callers get the closed error, never a cancellation
Left to finish A PostgREST write, a clear, a one-shot select that goes to the network, an rpc, a read of the cache, and a resumable upload. A write whose answer arrives is applied and never sent again
May still be sent Through supabase.close(), a call made while supabase-kt closes its HTTP client is either still sent or ends as it would offline: answered from the cache, Queued, or the closed error
The bound close() returns once the database is released, or after 10 seconds at most, and never throws. Past that, the database is released when the last call finishes, and a new client on it fails until then

Testing

Offline.Config.inMemory(clock), from the supabase-offline-kt-testing artifact, keeps the cache, the queue and the file cache in memory for tests. See Testing your app.