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
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
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
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
| 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.