Skip to content

Storage caching

A download through the offline bucket goes to the network first and is kept on disk; when the network fails, the kept copy is served instead. Writes (uploads, updates, deletes, moves and copies) are online only and never queued, and one that reaches the server drops the cached copies it made stale.

From the samples: TaskPhotoFiles.kt · TaskRepository+Photos.swift

/**
 * The offline bucket. An app adds a header here when its gateway wants one on every Storage
 * request, such as a proxy's key; this one names the app, which Storage ignores. The bucket
 * `setHeader` returns still keeps downloads on the device.
 */
private val bucket: OfflineBucketApi =
    offline.storage.from(PHOTOS_BUCKET).setHeader("X-Client-Name", "offline-tasks-android")
/// The bucket every photo lives in, through the offline client: its downloads are kept on the
/// device and served from there offline.
var photoBucket: OfflineBucketApi {
    client.storage.from(PhotoPath.bucket)
}

The Swift package's bucket has no setHeader.

supabase.offline.storage.from(bucket) is an OfflineBucketApi: every operation of supabase-kt's BucketApi, under its name, plus the file cache. Bucket management stays on supabase.storage.

Downloads

// The server's bytes, kept on disk; the kept copy when the network fails
val bytes = photos.downloadAuthenticated(path)

downloadAuthenticated and downloadPublic, to bytes or to a channel, go to the network first. A success is written to the cache, whole or not at all (a failure to write the copy is logged and never fails the download); a "not found" drops the cached copy and is rethrown; any other failure is answered from the cached copy, or thrown when there is none. An image transform is cached per transform, apart from the original. A channel download streams through a file on disk rather than memory, and its answer's status is checked first: an error answer throws as the bytes download does, and nothing of it is cached or handed on.

Cached files

From the samples: TaskPhotoFiles.kt · LibraryMappings.kt · TaskRepository+Photos.swift

/** The copy on this device, or null; never touches the network. */
suspend fun cachedFile(path: String): PhotoFile? = bucket.cachedFile(path)?.toPhotoFile()

/**
 * The copy on this device, downloaded into the cache first when there is none. A copy is
 * returned however old it is; the photos never change in place but through Replace photo, which
 * drops the copy.
 */
suspend fun ensureCachedFile(path: String): PhotoFile =
    bucket.ensureCachedFile(path).toPhotoFile() // isPublic = false: a private bucket


/** A cached file's facts in the app's terms: where it is, when it was fetched, how large. */
internal fun CachedFile.toPhotoFile(): PhotoFile = PhotoFile(path, fetchedAt, sizeBytes)
/// Where a photo's file stands on this device: kept, or fetched now and kept when it is not
/// and the server can be asked.
///
/// A private file is asked for only once a user is signed in: without the user's token,
/// Storage answers "not found", which would drop a copy kept earlier.
func photoFile(for photo: TaskPhoto) async -> PhotoFileState {
    do {
        if let file = try await photoBucket.cachedFile(path: photo.objectPath) { return .onDevice(file) }
        guard session.userID != nil, canReachNetwork else { return .notOnDevice }
        return .onDevice(try await photoBucket.ensureCachedFile(path: photo.objectPath))
    } catch {
        switch SampleFailure.storage(error) as? SampleFailure {
        case .needsNetwork?: return .notOnDevice
        case .photoMissing?: return .missing
        default: return .unavailable
        }
    }
}

cachedFile(path) returns the cached copy, or null, and never touches the network; ensureCachedFile(path) first streams the object to disk when there is no copy. Only an untransformed download or ensureCachedFile caches the copy these return: a CachedFile, the cache's own file at an absolute path, with fetchedAt and sizeBytes. Read it or hand it to an image loader, but do not move or write it: the cache may delete it when the object is uploaded, updated, deleted, moved or copied over through the offline bucket, when the server reports it gone, when the cache passes its bound and the file has not been used for 30 seconds, when the cache passes to another user, and when local data is cleared. Open it soon, or copy it if it must outlive those. Nothing revalidates a cached file: compare fetchedAt with your own rule, and download again when it is too old.

On Android, three helpers give the cached copy in the platform's terms: ensureLocalUri and ensureLocalFile are ensureCachedFile as a Uri or a File, throwing what it throws, and localUriIfCached is cachedFile as a Uri; on iOS, ensureLocalPath(path) and localPathIfCached(path) give the absolute path the same way. The sample draws thumbnails from localUriIfCached and the viewer from ensureLocalUri, and Share copies the file from ensureLocalFile out of the cache, since another app reads it later and a file: URI cannot leave the app:

From the samples: TaskPhotoFiles.kt · Sharing.kt · TaskRepository+Photos.swift

/** A thumbnail's copy as a `file:` URI when it is on this device; never touches the network. */
suspend fun uriIfCached(path: String): String? = bucket.localUriIfCached(path)?.toString()

/**
 * The copy as a `file:` URI for the image loader, downloaded into the cache first if needed.
 */
suspend fun uri(path: String): String = bucket.ensureLocalUri(path).toString()

/** The copy as a [File], downloaded first if needed: Share copies it out of the cache. */
suspend fun file(path: String): File = bucket.ensureLocalFile(path)


/**
 * Opens the system's share sheet with the JPEG at [path], the library's cached copy. Another app
 * reads it later, and the cache may drop its own copy at any time, so it is copied out first and
 * handed over through the app's file provider: a `file:` URI cannot leave the app.
 */
suspend fun Context.shareJpeg(path: String) {
    val shared =
        withContext(Dispatchers.IO) {
            val folder = File(cacheDir, SHARED_FOLDER).apply { mkdirs() }
            File(path).copyTo(File(folder, "photo.jpg"), overwrite = true)
        }
    val uri = FileProvider.getUriForFile(this, "$packageName.files", shared)
    val send =
        Intent(Intent.ACTION_SEND).apply {
            type = "image/jpeg"
            putExtra(Intent.EXTRA_STREAM, uri)
            addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
        }
    startActivity(Intent.createChooser(send, null))
}
/// The device's copy of `photo`'s file, fetched first when there is none, for the share sheet:
/// its ``CachedFile/url`` is a file URL any app can be handed. The file is the cache's own, to
/// read and share, never to move or write.
func shareableFile(for photo: TaskPhoto) async throws -> URL {
    do {
        return try await photoBucket.ensureCachedFile(path: photo.objectPath).url
    } catch {
        throw SampleFailure.storage(error)
    }
}

Writes

Uploads, updates, deletes, moves and copies are online only, and nothing is queued. Offline, a write is tried whatever the connectivity monitor says and fails with supabase-kt's own connectivity error; with network access off, every Storage call but a download throws NetworkDisabledException and sends nothing, and a download is answered from the file cache (or throws NetworkDisabledException with nothing cached). A write that reaches the server drops the cached copies it made stale, a resumable upload once it finishes. An upload does not put its bytes in the cache, so a file uploaded just before the device goes offline is not available offline until it is downloaded. The sample uploads a photo with progress, from its own queue of photos waiting to upload (below), replaces a file in place, and moves or copies one to another task's folder, writing the photo's row too, since the schema ties a row to its path:

From the samples: TaskPhotoFiles.kt · OfflineTaskRepository.kt · TaskRepository+Photos.swift

/** Uploads a JPEG to [path], handing [onProgress] the fraction sent so far. */
suspend fun upload(path: String, jpeg: ByteArray, onProgress: (Float) -> Unit) {
    bucket
        .uploadAsFlow(path, jpeg) {
            upsert = false // a path is never reused, so an existing file is an error
            contentType = ContentType.Image.JPEG // the only type the bucket takes
        }
        .collect { status ->
            if (status is UploadStatus.Progress && status.contentLength > 0) {
                onProgress(status.totalBytesSend.toFloat() / status.contentLength)
            }
        }
}


/** Replaces the file at [path] in place; the cached copy goes, so the next read downloads. */
suspend fun replace(path: String, jpeg: ByteArray) {
    bucket.update(path, jpeg) {
        upsert = false // replace only: there must be a file there already
        contentType = ContentType.Image.JPEG
    }
}


/** The file moves to the other task's folder first, then the row follows it, in one update. */
override suspend fun movePhoto(photo: TaskPhoto, taskId: String): TaskMutationOutcome =
    detached {
        requireNetwork()
        val newPath = photoObjectPath(photo.userId, taskId, photo.id)
        storage(photo.objectPath) { files.move(photo.objectPath, newPath) }
        write(Tables.TASK_PHOTOS, TaskMutationOperation.Update, taskTitle = null) {
                offline.from(Tables.TASK_PHOTOS).update({
                    set(TASK_ID, taskId)
                    set(OBJECT_PATH, newPath) // the schema ties the path to the task
                }) {
                    filter { eq(ID, photo.id) }
                }
            }
            .toTaskMutationOutcome()
    }


/** A copy of the file in the other task's folder, under a new id, then a row for it. */
override suspend fun copyPhoto(photo: TaskPhoto, taskId: String): TaskMutationOutcome =
    detached {
        requireNetwork()
        val copyId = newId()
        val copyPath = photoObjectPath(photo.userId, taskId, copyId)
        storage(photo.objectPath) { files.copy(photo.objectPath, copyPath) }
        insertPhotoRow(copyId, photo.userId, taskId, copyPath, photo.width, photo.height)
        TaskMutationOutcome.Applied
    }
/// Uploads the JPEG of `pending`, a photo picked on this device, to the path its record names.
///
/// - Throws: ``SampleFailure/needsNetwork`` offline, and Storage's refusal as
///   ``SampleFailure/storageRefused(status:message:)``.
func uploadFile(of pending: PendingPhoto, jpeg: Data) async throws {
    do {
        try await photoBucket.upload(
            pending.objectPath,
            data: jpeg,
            options: OfflineBucketApi.UploadOptions(
                // The bucket takes JPEGs only.
                contentType: pending.contentType,
                // A path is never reused, so there is nothing to write over.
                upsert: false,
                // Kept with the file, for Storage's own listing and the Details sheet.
                userMetadata: ["task_id": .string(pending.taskID)]
            )
        )
    } catch let error as OfflineError where error.httpStatus == 409 || error.serverError?.code == "Duplicate" {
        // Already there: the path holds an id this device made, so only an earlier attempt at
        // this photo, whose answer was lost, can have stored it.
    } catch {
        throw SampleFailure.upload(error)
    }
}

/// Writes `replacement` over `photo`'s file, at the same path, and its new size into the row.
/// The device's copy of the old file is dropped, so the next look downloads the new one.
func replacePhoto(_ photo: TaskPhoto, with replacement: EncodedPhoto) async throws {
    guard canReachNetwork else { throw SampleFailure.needsNetwork }
    do {
        try await photoBucket.update(
            photo.objectPath,
            data: replacement.jpeg,
            options: OfflineBucketApi.UploadOptions(contentType: "image/jpeg")
        )
    } catch {
        throw SampleFailure.storage(error)
    }
    try await write(taskTitle: nil) {
        try await client.from(Tables.taskPhotos)
            .update(["width": .int(replacement.width), "height": .int(replacement.height)])
            .eq("id", value: photo.id)
            .execute()
    }
}

/// Moves `photo` to task `taskID`: the file to the path the schema gives the other task, then
/// the row to match. A row the server refuses puts the file back.
func movePhoto(_ photo: TaskPhoto, toTask taskID: String) async throws {
    guard canReachNetwork else { throw SampleFailure.needsNetwork }
    let destination = PhotoPath.objectPath(userID: photo.userID, taskID: taskID, photoID: photo.id)
    do {
        try await photoBucket.move(from: photo.objectPath, to: destination)
    } catch {
        throw SampleFailure.storage(error)
    }
    do {
        try await write(taskTitle: nil) {
            try await client.from(Tables.taskPhotos)
                .update(["task_id": .string(taskID), "object_path": .string(destination)])
                .eq("id", value: photo.id)
                .execute()
        }
    } catch {
        try? await photoBucket.move(from: destination, to: photo.objectPath)
        throw error
    }
}

/// Copies `photo` to task `taskID`, as a new photo: the file to a new path, then its row.
func copyPhoto(_ photo: TaskPhoto, toTask taskID: String) async throws {
    guard canReachNetwork else { throw SampleFailure.needsNetwork }
    let photoID = DeviceID.make()
    let destination = PhotoPath.objectPath(userID: photo.userID, taskID: taskID, photoID: photoID)
    do {
        try await photoBucket.copy(from: photo.objectPath, to: destination)
    } catch {
        throw SampleFailure.storage(error)
    }
    let row = TaskPhotoRow(
        id: photoID,
        userID: photo.userID,
        taskID: taskID,
        objectPath: destination,
        width: photo.width,
        height: photo.height,
        createdAt: .now
    )
    try await insertRow(of: row)
}

Signed URLs, listings, info and exists always ask the server; publicUrl and the other URL builders send nothing. The sample's Details sheet, Share link, Share all photos of this task and the Sync tab's Storage section make each of them. A signed URL with an image transform serves Storage's resized copy, which needs image transformations on the project (Pro plans and up on hosted Supabase); without them the link answers with an error.

From the samples: TaskPhotoFiles.kt · TaskRepository+Photos.swift

/** What Storage and this device know about the file at [path]. */
suspend fun details(path: String): PhotoDetails {
    val exists = bucket.exists(path) // always asks the server
    val info = if (exists) bucket.info(path) else null // size, type, times, etag
    return PhotoDetails(
        existsInBucket = exists,
        sizeBytes = info?.size ?: 0,
        contentType = info?.rawContentType,
        createdAt = info?.createdAt,
        updatedAt = info?.updatedAt,
        etag = info?.etag,
        publicUrl = bucket.publicUrl(path), // a private bucket refuses it
        authenticatedUrl = bucket.authenticatedUrl(path), // needs the user's token
        cachedFile = cachedFile(path), // the device's copy: when fetched, how large
    )
}


/** A link anyone can open for an hour, to a copy 512 pixels wide that Storage renders. */
suspend fun shareLink(path: String): String =
    bucket.createSignedUrl(path, SHARE_LINK_LIFETIME) {
        transform {
            width = SHARE_LINK_WIDTH // the height follows the photo's shape
            quality = SHARE_LINK_QUALITY
        }
    }


/** Links anyone can open for an hour, one per file, in one request. */
suspend fun shareLinks(paths: List<String>): List<String> =
    bucket.createSignedUrls(SHARE_LINK_LIFETIME, paths).map { it.signedURL }


/** A page of [userId]'s task folders, from the [offset]th, with each folder's files. */
suspend fun list(userId: String, offset: Int): BucketListing {
    val folders =
        bucket.list(userId) {
            limit = FOLDERS_PER_PAGE + 1 // one more, to tell whether a page follows
            this.offset = offset
            sortBy("name", SortOrder.ASC) // a folder is named for its task's id
        }
    val page = folders.take(FOLDERS_PER_PAGE)
    return BucketListing(
        folders =
            page.map { folder -> BucketFolder(folder.name, filesIn("$userId/${folder.name}")) },
        hasMore = folders.size > FOLDERS_PER_PAGE,
        nextOffset = offset + page.size,
    )
}

/** The files in [folder], newest first. */
private suspend fun filesIn(folder: String): List<BucketFile> =
    bucket
        .list(folder) {
            limit = FILES_PER_FOLDER
            sortBy("created_at", SortOrder.DESC)
            search = "" // the start of a name to match; empty matches every file
        }
        .map { it.toBucketFile() }


/**
 * Uploads a JPEG to [path] through a signed upload URL: the URL's token, not the session, lets
 * it in, so an app can hand such an upload to code that holds no session.
 */
suspend fun uploadThroughSignedUrl(path: String, jpeg: ByteArray) {
    val signed = bucket.createSignedUploadUrl(path) // the server checks the policies here
    bucket.uploadToSignedUrl(signed.path, signed.token, jpeg) {
        contentType = ContentType.Image.JPEG
    }
}
/// What Storage and the device know about `photo`'s file. The bucket's own facts need the
/// network and are left out without it; the device's copy and the addresses need none.
func photoDetails(for photo: TaskPhoto) async -> PhotoDetails {
    // The device's copy and the two addresses send nothing, so they are there offline too.
    var details = PhotoDetails(
        publicURL: try? await photoBucket.publicURL(path: photo.objectPath),
        authenticatedURL: try? await photoBucket.authenticatedURL(path: photo.objectPath),
        onDevice: try? await photoBucket.cachedFile(path: photo.objectPath)
    )
    guard canReachNetwork else { return details }
    async let info = photoBucket.info(path: photo.objectPath)
    async let exists = photoBucket.exists(path: photo.objectPath)
    details.inBucket = try? await info
    details.existsInBucket = try? await exists
    return details
}

/// A link to a 512-pixel-wide copy of `photo` that works for an hour, for anyone who has it:
/// Storage signs it, and resizes the image when the link is opened. It needs the network.
func shareLink(for photo: TaskPhoto) async throws -> URL {
    try await photoBucket.createSignedURL(
        path: photo.objectPath,
        expiresIn: Self.shareLinkLifetimeSeconds,
        transform: OfflineBucketApi.ImageTransformation(width: Self.shareLinkWidth, resize: .contain, quality: 80)
    )
}

/// Links to every photo of `photos`, each working for an hour, signed in one request; a photo
/// Storage could not sign is left out.
func shareLinks(for photos: [TaskPhoto]) async throws -> [URL] {
    try await photoBucket.createSignedURLs(paths: photos.map(\.objectPath), expiresIn: Self.shareLinkLifetimeSeconds)
        .filter { $0.error == nil }
        .map(\.signedURL)
}

/// The entries of the user's folder `folder` in the bucket (a task's photos, or, for `""`, a
/// folder per task), a page at a time: Storage filters by name, sorts and pages them.
func bucketEntries(in folder: String, page: BucketPage) async throws -> [OfflineBucketApi.FileObject] {
    let userID = try signedInUserID()
    let path = folder.isEmpty ? userID : "\(userID)/\(folder)"
    return try await photoBucket.list(
        path: path,
        options: OfflineBucketApi.StorageListFilter(
            limit: page.limit,
            offset: page.offset,
            sortBy: OfflineBucketApi.StorageListFilter.SortBy(column: page.sortColumn, order: page.newestFirst ? .desc : .asc),
            search: page.search.isEmpty ? nil : page.search
        )
    )
}

/// Adds `photo` to task `taskID` through a signed upload URL: the app asks Storage for a
/// one-time URL and token, then sends the file to it. The way to hand an upload to code that
/// holds no session, such as a share extension.
func attachPhotoThroughSignedURL(taskID: String, photo: EncodedPhoto) async throws {
    guard canReachNetwork else { throw SampleFailure.needsNetwork }
    let userID = try signedInUserID()
    let photoID = DeviceID.make()
    let objectPath = PhotoPath.objectPath(userID: userID, taskID: taskID, photoID: photoID)
    let uploaded: OfflineBucketApi.FileUploadResponse
    do {
        let upload = try await photoBucket.createSignedUploadURL(path: objectPath)
        uploaded = try await photoBucket.uploadToSignedURL(
            upload.path,
            token: upload.token,
            data: photo.jpeg,
            options: OfflineBucketApi.UploadOptions(contentType: "image/jpeg")
        )
    } catch {
        throw SampleFailure.storage(error)
    }
    // The row names the file where Storage says it put it.
    try await insertRow(of: TaskPhotoRow(photoID: photoID, userID: userID, taskID: taskID, objectPath: uploaded.path, photo: photo))
}

Photos picked offline: the app's own queue

The library never queues a Storage write, so an app that lets a user add a photo offline keeps the photo in a queue of its own until the upload lands, and then references the library's cached copy. Both samples do this; the queue is theirs, not the library's, and Storage writes still need a connection, as Writes says.

  • A directory the app owns, never the library's data directory or file cache: per photo, a JPEG named by the photo's id (made on the device, encoded as every upload is) and, written after it, a small JSON record with the task's id, the photo's id, its Storage path (<user id>/<task id>/<photo id>.jpg), when it was picked, its state (waiting, uploading, or failed with the app's sentence and the number of tries) and its content type.
  • Every photo goes through it: New task's Add photo, several at once with their thumbnails, files its photos as waiting once the task is added (a queued insert offline), and the task detail's Add photo files one the same way; online it uploads at once.
  • Shown from the device at once. The task's photos and the Photos tab merge the task_photos rows with the records, each photo once; a pending photo shows from its own file, marked "Waiting to upload", "Uploading" or "Upload failed". A failed one offers Retry (switched off when no try can succeed) and Remove, a waiting one Remove, not while it uploads. The viewer opens it from its file and hides Details, Share link, Replace, Move and Duplicate, which need the object in the bucket, with a line saying so.
  • One uploader watches syncStatus. While the network is neither OFFLINE nor DISABLED (so Simulate offline pauses it, as it pauses the mutation queue) and a user is signed in, it uploads the waiting photos one at a time, oldest first: upload of the file; only once Storage has it, the task_photos row through the library, a write the queue holds like any other, so it waits behind its task's queued insert; then ensureCachedFile (if that fails the photo is stored all the same, and downloaded when next shown); then the app's file and record are deleted. The server never holds a row that names a missing object. Uploads run while the app runs.
  • Failures. A failure marks the photo failed and keeps its file, for the next online transition or Retry; one for want of the network ends the pass. A refusal no try can change (Storage's policies answering 403, say, or the row refused because its task is gone) stays failed until Remove. A lost answer never stores anything twice: a retry first asks Storage whether an earlier try stored the file, Storage's "already exists" counts as stored (the path names a photo id this device made), and the row is an upsert that ignores a duplicate. Against hosted Storage the exists check is what catches it: supabase-kt reports a duplicate there as HTTP 400, not 409.
  • Deletes and clears. Deleting a task deletes its pending photos, and Remove deletes one. Clear local cache and Start over delete the whole directory, which clearLocalData() never touches. A photo removed any of these ways while its upload is on the wire gets no row: once the upload returns, its file is deleted from the bucket. Start over first waits for an upload in progress and holds new ones, so none lands after its deletes, and clears the directory with the sign-out step.

From the samples: PendingPhoto.kt · OfflineTasksApplication.kt · PendingPhotoQueue.swift

/**
 * A photo picked on this device that is not in the bucket yet, as its JSON record beside its JPEG
 * in [PendingPhotoDirectory]. The device makes the ids, so the photo's Storage path is known before
 * anything is sent.
 *
 * @property objectPath where the file goes in the bucket: `<user id>/<task id>/<photo id>.jpg`.
 * @property failure why the last try failed, as the app says it; null unless [state] is
 *   [PendingPhotoState.Failed].
 * @property attempts how many uploads of it were started.
 * @property retryable false once the server refused it in a way another try cannot change.
 */
@Serializable
data class PendingPhoto(
    @SerialName("photo_id") val photoId: String,
    @SerialName("task_id") val taskId: String,
    @SerialName("user_id") val userId: String,
    @SerialName("object_path") val objectPath: String,
    @SerialName("picked_at") val pickedAt: Instant,
    val state: PendingPhotoState = PendingPhotoState.Waiting,
    val failure: String? = null,
    val attempts: Int = 0,
    val retryable: Boolean = true,
    @SerialName("content_type") val contentType: String = PHOTO_CONTENT_TYPE,
    val width: Int,
    val height: Int,
)

/** Where a pending photo stands; stored in its record by these names. */
@Serializable
enum class PendingPhotoState {
    @SerialName("waiting") Waiting,
    @SerialName("uploading") Uploading,
    @SerialName("failed") Failed,
}


/**
 * Where photos picked on this device wait for their upload: `<filesDir>/pending-photos`, the
 * app's own, beside the library's `<filesDir>/supabase_offline` and never inside it. The backup
 * rules leave it out, as they leave out every file.
 */
private fun pendingPhotoDirectory(): PendingPhotoDirectory =
    PendingPhotoDirectory(File(filesDir, PENDING_PHOTOS_DIRECTORY))
/// A photo picked on this device whose file is not on the server yet. Its record is kept as JSON
/// beside the photo's JPEG, both named by the photo's id, until its upload lands.
nonisolated struct PendingPhoto: Codable, Hashable, Identifiable, Sendable {

    /// Where its upload stands.
    enum State: Codable, Hashable, Sendable {

        /// Waiting for the network, or for the photos picked before it.
        case waiting

        /// Being uploaded now.
        case uploading

        /// The last attempt failed, for `reason`, in the app's words. When `isRetryable` is false
        /// the server refused it in a way another attempt cannot change.
        case failed(reason: String, isRetryable: Bool)

        /// The failure's keys in the record.
        enum FailedCodingKeys: String, CodingKey {
            case reason
            case isRetryable = "retryable"
        }
    }

    /// The photo's id, made on the device: its row's id, and its files' names.
    let id: String

    /// The task it is attached to.
    let taskID: String

    /// The user who picked it.
    let userID: String

    /// The path its file will have in the bucket.
    let objectPath: String

    /// When it was picked: photos upload in this order.
    let pickedAt: Date

    /// The file's content type; the bucket takes JPEGs only.
    let contentType: String

    /// Its width in pixels, for its row.
    let width: Int

    /// Its height in pixels.
    let height: Int

    /// Where its upload stands.
    var state: State = .waiting

    /// How many attempts to upload it have failed or been cut short.
    var attempts = 0

    /// The record's keys in its JSON file.
    enum CodingKeys: String, CodingKey {
        case id = "photo_id"
        case taskID = "task_id"
        case userID = "user_id"
        case objectPath = "object_path"
        case pickedAt = "picked_at"
        case contentType = "content_type"
        case width
        case height
        case state
        case attempts
    }
}

The iOS sample keeps the directory in Application Support, beside the library's data directory.

Picking a photo files it, and the uploader is told; nothing waits for the network:

From the samples: TaskDetailViewModel.kt · OfflineTaskRepository.kt · PendingPhotoDirectory.kt · TaskDetailModel.swift · TaskRepository+PendingPhotos.swift

/**
 * Add photo: re-encodes the image at [contentUri] and files it in the app's pending-photo
 * queue, where it shows at once and waits for the network if it must.
 */
fun addPhoto(contentUri: String) {
    writeScope.launchReporting(failures) {
        repository.addPhoto(taskId, photoEncoder.encode(contentUri))
    }
}


/**
 * Add photo, online or offline: the photo is filed in the app's own queue on this device, under
 * the path it will have in the bucket, and the uploader is told; nothing waits for the network.
 */
override suspend fun addPhoto(taskId: String, photo: EncodedPhoto) {
    val userId = signedInUserId()
    val photoId = newId() // the device's, so the path is known before the upload
    val record =
        PendingPhoto(
            photoId = photoId,
            taskId = taskId,
            userId = userId,
            objectPath = photoObjectPath(userId, taskId, photoId),
            pickedAt = clock.now(),
            width = photo.width,
            height = photo.height,
        )
    pendingPhotos.add(record, photo.jpeg) // shown from here at once
    uploader.uploadSoon() // offline, it waits for the network to come back
}


/**
 * Files [record] with its [jpeg]: the JPEG first, then the record, so a record always has its
 * file. Nothing is sent: the uploader takes it from here.
 */
suspend fun add(record: PendingPhoto, jpeg: ByteArray) = change {
    root.mkdirs()
    fileOf(record.photoId).writeBytes(jpeg)
    write(record)
    it + record
}
/// Re-encodes the picked image as a JPEG and files it for the task, to wait on the device until
/// it uploads: at once when online.
func attach(_ item: PhotosPickerItem) {
    preparingCount += 1
    Task {
        defer { preparingCount -= 1 }
        await failures.perform {
            guard let data = try await item.loadTransferable(type: Data.self) else {
                throw SampleFailure.photoUnreadable
            }
            try await repository.filePhoto(try await PhotoEncoder.encode(data), forTask: taskID)
        }
    }
}

/// Files `photo` for task `taskID` as waiting: its JPEG and its record are written to the
/// device before anything else, and the uploader sends it when the network allows. Nothing is
/// sent here, so it works offline.
@discardableResult
func filePhoto(_ photo: EncodedPhoto, forTask taskID: String) async throws -> PendingPhoto {
    let userID = try signedInUserID()
    let photoID = DeviceID.make()
    let pending = PendingPhoto(
        id: photoID,
        taskID: taskID,
        userID: userID,
        objectPath: PhotoPath.objectPath(userID: userID, taskID: taskID, photoID: photoID),
        pickedAt: .now,
        contentType: "image/jpeg",
        width: photo.width,
        height: photo.height
    )
    try await pendingPhotos.add(pending, jpeg: photo.jpeg)
    return pending
}

The uploader's one photo: the file, then the row, then the hand-over to the cache:

From the samples: OfflineTaskRepository.kt · TaskRepository+PendingPhotos.swift · TaskRepository+Photos.swift

/**
 * Uploads one pending photo: its file; then, only once Storage has it, its row; then its file
 * into the library's cache, so it shows offline once the device's copy is deleted.
 */
private suspend fun uploadPendingPhoto(
    photo: PendingPhoto,
    jpeg: ByteArray,
    onProgress: (Float) -> Unit,
) {
    val path = photo.objectPath
    // A retry asks first: an earlier try may have stored the file and lost the answer.
    val storedBefore = photo.attempts > 1 && storage(path) { files.exists(path) }
    if (!storedBefore) uploadUnlessStored(path, jpeg, onProgress)
    if (!pendingPhotos.contains(photo.photoId)) { // removed while it uploaded
        deleteFiles(listOf(path)) // and no row will name the file
        return
    }
    writePendingPhotoRow(photo) // queued behind its task's insert when that waits too
    cacheUploadedFile(path)
}

/**
 * The photo's row, through the queue like any write, so that writing it again (a retry after a
 * lost answer) changes nothing. A refusal at once leaves a file no row will name: it goes too.
 */
private suspend fun writePendingPhotoRow(photo: PendingPhoto) {
    try {
        write(Tables.TASK_PHOTOS, TaskMutationOperation.Upsert, taskTitle = null) {
            offline.from(Tables.TASK_PHOTOS).upsert(photo.toNewTaskPhotoRow()) {
                onConflict = ID
                ignoreDuplicates = true // a row an earlier try wrote stays as it is
            }
        }
    } catch (refused: TaskWriteRefusedException) {
        deleteUploadedFile(photo.objectPath, refused) // its task is gone, say
        throw refused
    }
}
/// Uploads `pending`, then writes its row, then has the library's file cache download it, and
/// then deletes the device's own copy. The row is written only once the file is stored, so the
/// server never holds a row whose file is missing; a row the server refuses at once takes the
/// file with it, and a queued row waits behind its task's queued insert.
func uploadPendingPhoto(_ pending: PendingPhoto) async throws {
    if await !isStoredByAnEarlierTry(pending) {
        try await uploadFile(of: pending, jpeg: try await pendingPhotos.jpeg(of: pending))
    }
    guard pendingPhotos.photo(id: pending.id) != nil else {
        // Removed while its file was on the way (Remove, Delete task, Clear): no row, no file.
        try? await photoBucket.remove(paths: [pending.objectPath])
        return
    }
    try await insertRow(of: TaskPhotoRow(pending))
    // The upload did not put its bytes in the cache; this downloads them, so the photo shows
    // offline from the library's copy. If it fails, the photo loads like any other when online.
    _ = try? await photoBucket.ensureCachedFile(path: pending.objectPath)
    try pendingPhotos.remove(id: pending.id)
}

/// Whether an earlier try at `pending` stored its file, its answer lost on the way: asked of
/// Storage on a retry only. Storage's "already exists" to the upload counts as stored too.
private func isStoredByAnEarlierTry(_ pending: PendingPhoto) async -> Bool {
    guard pending.attempts > 0 else { return false }
    return (try? await photoBucket.exists(path: pending.objectPath)) == true
}

/// Uploads the JPEG of `pending`, a photo picked on this device, to the path its record names.
///
/// - Throws: ``SampleFailure/needsNetwork`` offline, and Storage's refusal as
///   ``SampleFailure/storageRefused(status:message:)``.
func uploadFile(of pending: PendingPhoto, jpeg: Data) async throws {
    do {
        try await photoBucket.upload(
            pending.objectPath,
            data: jpeg,
            options: OfflineBucketApi.UploadOptions(
                // The bucket takes JPEGs only.
                contentType: pending.contentType,
                // A path is never reused, so there is nothing to write over.
                upsert: false,
                // Kept with the file, for Storage's own listing and the Details sheet.
                userMetadata: ["task_id": .string(pending.taskID)]
            )
        )
    } catch let error as OfflineError where error.httpStatus == 409 || error.serverError?.code == "Duplicate" {
        // Already there: the path holds an id this device made, so only an earlier attempt at
        // this photo, whose answer was lost, can have stored it.
    } catch {
        throw SampleFailure.upload(error)
    }
}

/// Writes a photo's row once its file is in the bucket, as an upsert that ignores a row already
/// there: a retry after a lost answer writes nothing twice. A row the server refuses at once
/// (its task deleted on the server meanwhile, say) takes its file with it, as far as Storage
/// allows; a row that is only queued keeps its file.
func insertRow(of row: TaskPhotoRow) async throws {
    do {
        try await write(taskTitle: nil) {
            try await client.from(Tables.taskPhotos).upsert(row, onConflict: "id", ignoreDuplicates: true).execute()
        }
    } catch let refusal as SampleFailure {
        if case .writeRefused = refusal { try? await photoBucket.remove(paths: [row.objectPath]) }
        throw refusal
    }
}

Through the Swift package Storage's refusal keeps its own Duplicate code whatever the HTTP status, so the upload's "already exists" counts as stored there too.

ensureCachedFile downloads the photo the device has just sent.

The rows and the records, merged for a task, or for every task on the Photos tab:

From the samples: OfflineTaskRepository.kt · PendingPhoto.kt · TaskRepository+PendingPhotos.swift

/**
 * These photo rows with the app's pending photos of [taskId] (of every task when null), in
 * [order]: a photo picked here shows at once, from its file on the device, with where its
 * upload stands.
 */
private fun Flow<PhotoListSnapshot>.withPendingPhotos(
    taskId: String?,
    order: Comparator<TaskPhoto>,
): Flow<PhotoListSnapshot> =
    combine(this, pendingPhotos.photos, uploader.progress) { snapshot, pending, progress ->
        val ofTask = pending.filter { taskId == null || it.taskId == taskId }
        val shown = ofTask.map { photo ->
            photo.toTaskPhoto(pendingPhotos.fileOf(photo.photoId).path, progress[photo.photoId])
        }
        snapshot.plusPendingPhotos(shown, order) // each photo once: the pending one wins
    }


/**
 * These rows and the [pending] photos as one list in [order], each photo once. A pending photo
 * stands for its row until the upload has finished: until the library's cache holds its file, the
 * device's own copy is the one that shows offline.
 */
fun PhotoListSnapshot.plusPendingPhotos(
    pending: List<TaskPhoto>,
    order: Comparator<TaskPhoto>,
): PhotoListSnapshot {
    if (pending.isEmpty()) return this
    val pendingIds = pending.mapTo(HashSet()) { it.id }
    val stored = value.orEmpty().filterNot { it.id in pendingIds }
    return copy(value = (stored + pending).sortedWith(order))
}
/// Task `taskID`'s photos as the screens show them, oldest first: the rows the library holds,
/// with a mark on each whose row waits in the mutation queue (`queuedRowIDs`), and the photos
/// picked on this device that wait to upload (`pending`). A photo in both is shown from its
/// record until its local copy is deleted, so it never shows twice.
static func photos(
    ofTask taskID: String,
    rows: [TaskPhotoRow],
    queuedRowIDs: Set<String>,
    pending: [PendingPhoto]
) -> [TaskPhoto] {
    let waiting = pending.filter { $0.taskID == taskID }
    let waitingIDs = Set(waiting.map(\.id))
    let stored = rows
        .filter { $0.taskID == taskID && !waitingIDs.contains($0.id) }
        .map { $0.photo(pendingIDs: queuedRowIDs) }
    return (stored + waiting.map(\.photo)).sorted { $0.createdAt < $1.createdAt }
}

/// Every photo the Photos screen shows, newest first: the rows read so far, and every photo
/// that waits to upload, merged as ``photos(ofTask:rows:queuedRowIDs:pending:)`` merges one
/// task's.
static func allPhotos(rows: [TaskPhotoRow], queuedRowIDs: Set<String>, pending: [PendingPhoto]) -> [TaskPhoto] {
    let waitingIDs = Set(pending.map(\.id))
    let stored = rows.filter { !waitingIDs.contains($0.id) }.map { $0.photo(pendingIDs: queuedRowIDs) }
    return (stored + pending.map(\.photo)).sorted { $0.createdAt > $1.createdAt }
}

What Clear local cache and Start over remove, the library's data and then the app's directory:

From the samples: OfflineTaskRepository.kt · TaskRepository.swift

/**
 * Clear local cache: everything the library keeps here, queued writes included, never sent;
 * then the app's pending photos, which `clearLocalData()` does not know of.
 */
override suspend fun clearLocalData() {
    offline.clearLocalData() // an open list fetches again by itself once the network allows
    pendingPhotos.removeAll() // the app's own directory, outside the library's data
}


/** Start over: clear first, so no queued write lands after the deletes; then a new user. */
override suspend fun startOver() {
    // A new anonymous user needs the server, so nothing is deleted or cleared without it.
    if (!canReachNetwork()) throw NetworkUnavailableException()
    detached {
        uploader.pausedWhile {
            val userId = session.userId.value
            offline.clearLocalData() // stops a replay already running, and drops the queue
            userId?.let { deleteEverythingOf(it) }
            // A pending photo's file may be in the bucket already, its row not written yet.
            deleteFiles(pendingPhotos.photos.first().map { it.objectPath })
            pendingPhotos.removeAll() // outside what clearLocalData() deletes
            session.signInAsNewUser()
        }
    }
}
/// Clear local cache: deletes everything this device holds, the library's cached rows and
/// files, queued and refused writes, and then the photos waiting to upload. Open lists then
/// read their rows again from the server, once the network allows. Start over clears the same.
func clearLocalData() async throws {
    try await client.clearLocalData()
    // The pending photos are the app's own files, outside the library's data directory, so
    // its clearLocalData() leaves them: the app deletes them itself.
    try pendingPhotos.removeAll()
}

Start over clears the directory at its own clear step, right after the library's clearLocalData().

Paths that are never cached

An object path that cannot be mapped safely onto a file (a leading /, an empty, . or .. segment, a \, a control character) still downloads and writes, uncached, and ensureCachedFile refuses it with IllegalArgumentException. So does a name holding %, ? or # on supabase-kt 3.6, which puts the name in the URL as written: a ? or # can fetch or overwrite a different object (x?y.png fetches x, and an upload of it writes x), and a % that does not begin a percent-escape (100%.png) cannot be put in a URL at all, so every call naming it throws IllegalArgumentException before anything is sent. Keep these characters out of object names.

Whose files they are

With Auth installed, the file cache belongs to one user at a time, like the query cache, and clearLocalData() deletes every file (Signing out and switching users). Its bound is fileCacheSizeBytes (Cache size and eviction); where the files live is on On-device data.

Behavior matrix

Facts about the engine; one row per condition, the conditions those of the one-shot read matrix, with warm and cold saying whether the object is cached. Writes are upload, update, delete, move, copy and the resumable and signed-URL uploads.

Condition downloadAuthenticated / downloadPublic ensureCachedFile cachedFile Writes
Online The server's bytes, written to the cache whole or not at all. Not found: the cached copy is dropped and the error thrown. The cached file; if there is none, the object is downloaded to disk once. The cached file or null; never a request. Sent; the cached copy of what they change is dropped. A write the server rejected keeps it.
Offline, warm The cached bytes. The cached file, no request. The cached file. Throw the connectivity failure; the cache is left as it was.
Offline, cold Throws the failure. Throws the failure. null. As offline, warm.
Session restoring Not held for the session (nor after its refresh failed): sent at once on the anon key, and its answer cached. A 401 or 403 keeps the cached copy; cold, the refusal is thrown. Storage answers an object the anon key may not read as "not found", which keeps the cached copy too and is answered from it; cold, it is thrown: download private objects after supabase.auth.awaitInitialization(). As a download. Unchanged: never a request. Sent at once on the anon key; a refusal keeps the cached copy.