One-shot reads¶
A select reads its rows once, from the server or from the device's cache, as its read strategy
says. Every list the server returns is kept for the next read, and the user's queued writes are
laid over every answer. Pull to refresh, the launch badge and Start over's lookup each use a
strategy of their own:
From the samples: OfflineTaskRepository.kt · TaskRepository.swift
/**
* Pull to refresh: the server's answer when it can be reached, else the device's copy if it is
* a day old at most; the answer says which it was.
*/
override suspend fun refreshTasks(query: TaskQuery): ReadProvenance = read {
offline
.from(Tables.TASKS)
.select(readStrategy = ReadStrategy.NetworkFirst(maxAge = REFRESH_MAX_AGE)) {
tasksMatching(query, recentWindow(clock.now())) // the shown list's own query
}
.toReadProvenance()
}
/**
* The open-task badge at launch: from the device's copy when it is a day old at most, so it
* shows at once, and from the server otherwise; null when neither can answer.
*/
override suspend fun openTaskCountNow(): Int? {
if (session.userId.value == null) return null
val window = recentWindow(clock.now())
return try {
offline
.from(Tables.TASKS)
.select(readStrategy = ReadStrategy.CacheFirst(maxAge = LAUNCH_COUNT_MAX_AGE)) {
tasksMatching(TaskQuery(), window)
}
.decodeList<TaskRow>()
.count { !it.done }
} catch (cancellation: CancellationException) {
throw cancellation
} catch (failure: Exception) {
// Offline with no copy a day old: no badge, rather than an error at launch.
val appFailure = failure.toAppFailure(LibraryCall.Read)
if (appFailure is NetworkUnavailableException) null else throw appFailure
}
}
/**
* The object paths of the photo rows whose [column] equals [value], asked of the server a page
* at a time until a page comes back empty: a project answers at most its row limit (1,000
* unless changed) to one request, so one read could miss some.
*/
private suspend fun photoPathsOnServer(column: String, value: String): List<String> {
val paths = mutableListOf<String>()
while (true) {
val from = paths.size.toLong()
val page = read {
offline
.from(Tables.TASK_PHOTOS)
.select(Columns.list(OBJECT_PATH), readStrategy = ReadStrategy.NetworkOnly) {
filter { eq(column, value) }
order(ID, Order.ASCENDING)
range(from, from + PATH_PAGE_SIZE - 1)
}
.decodeList<ObjectPathRow>()
}
paths += page.map { it.objectPath }
if (page.isEmpty()) return paths
}
}
/// Asks the server for `query`'s tasks again, as pull to refresh does: the same query as the
/// list's live read, so its answer replaces that query's cached rows and every reader of it
/// sees them. When the server cannot be reached, the saved rows answer, unless they are older
/// than ``refreshMaxAge``; either way the answer says where it came from.
func refreshTasks(_ query: TaskQuery) async throws -> ListProvenance {
let request = client.from(Tables.tasks).select(readStrategy: .networkFirst(maxAge: Self.refreshMaxAge))
let response = try await query.applied(to: request)
.order("created_at", ascending: false)
.execute([TaskRow].self)
return ListProvenance(response)
}
/// How many tasks are open, for the Tasks tab's badge at launch: from the device when it read
/// them less than an hour ago, from the server otherwise.
func openTaskCountAtLaunch() async throws -> Int {
try await client.from(Tables.tasks)
.select("id", readStrategy: .cacheFirst(maxAge: Self.badgeMaxAge))
.eq("done", value: false)
.execute([RowKey].self)
.value
.count
}
let page = try await filter(client.from(Tables.taskPhotos).select("object_path", readStrategy: .networkOnly))
.order("id", ascending: true)
.range(from: paths.count, to: paths.count + Self.pathPageSize - 1)
.execute([ObjectPathRow].self)
.value
Choosing a strategy¶
| You want | Use | What it does |
|---|---|---|
| The server's rows, and the device's when the server cannot be reached | NetworkFirst(), the default |
Falls back to the cached rows on a connectivity failure only: a server's error is thrown even with the query cached. With the query cached, a transport failure falls back at once, skipping supabase-kt's retries, while a 503 or 520 is still retried on supabase-kt's schedule; with nothing cached it retries as supabase-kt does, then throws |
| The same, but never rows older than some age | NetworkFirst(maxAge = …) |
Cached rows older than maxAge count as none: the connectivity failure is thrown instead |
| A screen that opens without waiting: the device's rows while they are fresh enough, the server's otherwise | CacheFirst(maxAge = …) |
The cached rows within maxAge, without a request; otherwise it asks the server. A list a write marked stale (a write the cache could not follow exactly) is fetched again, and when the server cannot be reached the stale rows are served after all |
| Only what is on the device, and never a request | CacheOnly() |
The cached rows; with nothing it may serve, CacheMissException |
| The server's rows or the server's failure, as supabase-kt reads | NetworkOnly |
Fails as supabase-kt does when the server cannot be reached; its answer is still cached, for the next read of another strategy |
A list's age is the time the server sent it, by the device's clock: your edit laid over a day-old
list leaves it a day old. maxAge = null, the default, accepts any age. The sample's photo lookup
reads CacheOnly:
From the samples: OfflineTaskRepository.kt · TaskRepository.swift
/**
* The photo rows of task [taskId] the device holds, from the query [allPhotos] reads, or null
* when it holds none for that query: a cache-only read never sends a request.
*/
private suspend fun cachedPhotosOf(taskId: String): List<TaskPhotoRow>? =
try {
offline
.from(Tables.TASK_PHOTOS)
.select(readStrategy = ReadStrategy.CacheOnly()) { newestFirst() }
.decodeList<TaskPhotoRow>()
.filter { it.taskId == taskId }
} catch (missing: CacheMissException) {
null // nothing saved for the query
}
/// The photo rows of task `taskID` the device holds, from the query ``photoSnapshots(readStrategy:)``
/// reads, without asking the server; `nil` when the device holds nothing for that query.
func cachedPhotos(ofTask taskID: String) async throws -> [TaskPhotoRow]? {
do {
return try await client.from(Tables.taskPhotos)
.select(readStrategy: .cacheOnly)
.order("created_at", ascending: false)
.execute([TaskPhotoRow].self)
.value
.filter { $0.taskID == taskID }
} catch OfflineError.cacheMiss {
return nil
}
}
Where the rows came from¶
Three extension properties on supabase-kt's PostgrestResult say whether the rows came from the
cache, when they were fetched, and whether a queued write changed them; the sample's pull to
refresh turns them into the caption under the list's title ("Updated just now", "From this
device, 3 minutes ago"):
From the samples: LibraryMappings.kt · TaskRepository.swift · QueueWording.swift
/** Where a one-shot read's rows came from, read from the result's three provenance fields. */
internal fun PostgrestResult.toReadProvenance(): ReadProvenance =
ReadProvenance(
isFromCache = isFromCache, // the device's copy, not this request's answer
fetchedAt = fetchedAt, // when the server last answered this query
hasPendingMutations = hasPendingMutations, // a queued write changes these rows
)
/// The provenance of a one-shot read's `response`: whether it is the device's copy, when it
/// was fetched, and whether a queued write changed it.
init<Value>(_ response: OfflineResponse<Value>) {
self.init(
isFromCache: response.isFromCache,
fetchedAt: response.fetchedAt,
hasPendingMutations: response.hasPendingMutations
)
}
/// The caption under the Tasks title: where the list shown came from, and how old it is.
static func caption(_ provenance: ListProvenance, pendingCount: Int, now: Date) -> String {
if provenance.hasPendingMutations, pendingCount > 0 {
return "\(count(pendingCount, "change", "changes")) waiting"
}
guard let fetchedAt = provenance.fetchedAt else { return "Not fetched from the server yet" }
let age = age(of: fetchedAt, now: now)
return provenance.isFromCache ? "From this device, \(age)" : "Updated \(age)"
}
The library stamps them in response headers PostgREST never sends, so a result supabase-kt
returned itself reads false, null and false, and a read the cache never holds, such as a
count, keeps the server's own headers.
Edge cases¶
One row offline. single() is never cached. Read the list and pick the row out on the device,
as the sample app's task detail does:
From the samples: OfflineTaskRepository.kt · TaskDetailModel.swift
/**
* One task, from the list's own query rather than one filtered on its id: each query is cached
* on its own, and offline a query never asked has nothing to show, while the list is.
*/
override fun task(id: String): Flow<TaskListSnapshot> =
taskList(TaskQuery(), FlowReadStrategy.CacheFirstThenNetwork) { tasks ->
tasks.filter { it.id == id }
}
A list with a total. A read that asks for a count is never cached, rows and all, so a
paginated screen with a total has nothing offline: read the rows and the count separately. The
samples count only where a connection is expected anyway, in the Sync tab's Server section and a
Try it item with the generic filter(column, operator, value):
From the samples: OfflineTaskRepository.kt · TaskRepository+Server.swift · TaskRepository.swift
/**
* How many tasks the server holds for the user: a count is never cached, so it needs the
* server.
*/
private suspend fun countTasks(): Int =
offline
.from(SCHEMA, Tables.TASKS) // the default schema named: from(table) is the same
.select(Columns.list(ID)) {
head = true // the count alone, no rows
count(Count.EXACT)
}
.countOrNull()
?.toInt() ?: 0
/** Try it: a count with the generic filter, naming its operator, `eq`, as a value. */
override suspend fun countOpenTasksOnServer(): Int = read {
offline[SCHEMA, Tables.TASKS] // from(SCHEMA, Tables.TASKS), as an index
.select(Columns.list(ID)) {
head = true
count(Count.EXACT)
filter { filter(DONE, FilterOperator.EQ, false) }
}
.countOrNull()
?.toInt() ?: 0
}
/// How many tasks the server holds for the user: an exact count, with no rows sent back. A
/// count is never cached, so under the default strategy, as under any but cache-only, it asks
/// the server, and offline it fails.
func serverTaskCount() async throws -> Int {
let response = try await client.from(Tables.tasks).select("id", head: true, count: .exact).execute()
return response.count ?? 0
}
/// How many tasks the server holds with notes, through the generic filter that names its
/// operator as text; it asks the server, so it needs the network.
func tasksWithNotesOnServer() async throws -> Int {
try await client.from(Tables.tasks)
.select("id", readStrategy: .networkOnly)
.filter("notes", operator: "not.is", value: "null")
.execute([RowKey].self)
.value
.count
}
- Two filters on one column are refused with an
IllegalArgumentExceptionbefore anything is sent, since supabase-kt would send only the first: see Coming from supabase-kt. - While a stored session restores, supabase-kt sends requests on the anon key, which row-level
security can answer with fewer rows, or none, so nothing such a request returns is cached. A
read the cache can answer for the stored user sends nothing; one that needs the server waits for
the session to settle, a few seconds at most, and asks again. If it never settles, the read
returns the anon key's answer uncached, with a warning; a
NetworkFirstread with the query cached returns that answer too, not the cached rows. - An expired session offline. A
NetworkFirstread throws supabase-kt'sTokenExpiredException, since supabase-kt cannot refresh the token, instead of serving the cache;CacheFirstandCacheOnlyreads the cache can answer send nothing and are served. - A server that answers
503for good is not offline: aNetworkFirstread waits out supabase-kt's retries and throws, with no fallback to the cache. - A session without a user id (no
user, and a token withoutsub) is not a settled identity under the default trust rule: nothing it reads is cached, and a reactive read made with it never settles. - Another schema is cached apart from the default schema's table of the same name. Name it
with
from(schema, table)or the request'sschema: a rawAccept-Profileheader reaches the server but not the cache key. - The request lambda runs more than once: once to learn the read's shape, and once for each
request sent, so it must build the same request every time
(Coming from supabase-kt). At
supabase-kt's
DEBUGlog level, the first run logs a failedGET"stopped by the offline engine's read-shape check": that request was never sent. - Another identity's cache is never read (Signing out and switching users).
Behavior matrix¶
Facts about the engine, one row per condition. Online: the server answers. Offline: the request fails to connect or times out; a server's own error (4xx or 5xx) is not offline, and every strategy that sent the request throws it. Known down: the connectivity monitor says the network is unavailable; the read is still sent, once, with supabase-kt's retries off. Warm / cold: the query's rows are cached / are not. Session restoring: the Auth plugin is still loading a stored session, so a request goes out on the anon key, and under the default trust rule nothing it returns is written to the cache. Overlay: the writes still queued, and those replayed but not yet seen in a response, laid over what a read returns.
| Condition | NetworkOnly |
NetworkFirst(maxAge) |
CacheFirst(maxAge) |
CacheOnly(maxAge) |
|---|---|---|---|---|
| Online | The server's rows with the overlay; written to the cache. | As NetworkOnly. |
A cached payload within maxAge: that payload with the overlay, no request. Otherwise as NetworkOnly. Age is the time of the last response: a local write does not refresh it. A payload marked stale — an accepted write may have changed rows it cannot show — is fetched again. |
The cached payload with the overlay; never a request. |
| Offline, warm | Throws the connectivity failure, after supabase-kt's own retries (four attempts, 7 s with the default maxRetries); in one attempt while the network is known down. |
The cached payload with the overlay, at once, marked stale or not: one attempt, no retry wait. Older than maxAge: throws. A 503 or 520 is still retried on supabase-kt's schedule. |
Within maxAge: the cached payload, no request; marked stale, the request fails and the payload is served. Older than maxAge: throws. |
The cached payload with the overlay, marked stale or not. Older than maxAge: CacheMissException. |
| Offline, cold | Throws the connectivity failure, after supabase-kt's retries; in one attempt while the network is known down. | Throws the connectivity failure, after supabase-kt's retries (four attempts, 7 s); in one attempt while the network is known down. | Throws the connectivity failure. | CacheMissException; no request. |
| Session restoring | Sends, then waits for the session (300, 900 and 2,400 ms, woken early when it settles) and asks again. Settled as the stored user: the user's answer, cached. Settled signed out: the anonymous answer; the rows cached for the last user are dropped and never served. Never settles: the last answer, uncached. | As NetworkOnly. |
Warm with the stored user's cache: the cached payload, no request. Cold, or no stored user: as NetworkOnly. |
The cached payload, no request. |
A read of another shape. single(), maybeSingle() (supabase-kt 3.8), head, a count
(with rows or without), csv(), geojson(), stripNulls() and explain() do not return a plain
list, so their answer is neither stored nor overlaid. Under every strategy but CacheOnly such a
read goes to the network, and offline throws as NetworkOnly does; under CacheOnly it sends
nothing and throws CacheMissException. On supabase-kt 3.8, maybeSingle() with no row returns
supabase-kt's empty result at once, even while the session restores. The shape is read from the
request supabase-kt builds, before anything is sent; a read whose request cannot be built (no
token to be had) fails as sending it would, and is never answered from the list.
The request lambda and schemas.