Docs Attachments
Media attachments
Attach a photo, drawing, or document to a synced row, upload the bytes after the row reaches the server, and show the file to a peer who has never seen it. The row column holds a short reference string. The bytes travel separately through the engine's file store and transfer ports, and never enter your row data.
Steps 1 to 4, Declare the attachment column through Import the file and write the ref, move a file from the user's device into a synced row. Steps 5 and 6, Bytes upload after the outbox drains and Give up after too many failures, cover the queue that carries the bytes and what happens when it stops trying. Steps 7 and 8, Display the attachment and Verify it worked, render the file and confirm the round trip. Storage policies, Platform notes, and Common errors are the reference you come back to afterwards.
Before you begin#
- An app client in
src/kizunasync.ts, built withkizunasync/supabaseoverkizunasyncand a driver (kizunasync/weborkizunasync/expo). Addkizunasync/reactorkizunasync/vuewhen you want the hooks. Svelte, Solid, Angular, and every other JavaScript UI callkizunasync.attachmentson the app client through the Vanilla / other tab. - Swift and Kotlin apps call
KizunaSyncClient.fromFileonce you setattachmentRootoncreate.attachmentRootis the directory the client owns for attachment bytes, and the client creates it if it is missing. Declaringattachmentswithout that root throwsATTACHMENT_PORTS_MISSING. React Native over UniFFI omits the same setting, spelledattachment_rootthere, so the JavaScript host's attachment queue owns the bytes instead. See Swift and Kotlin and Kotlin: Attach a file. - A Supabase Storage bucket, with the policies that let your users write to it. Supabase covers those in Storage access control, and Kizuna adds only the object key shape those policies read. The examples below use a Storage bucket named
todos. - The
kizunasyncSQL pack applied to your project. Runkizunasync initif you have not provisioned it.
1. Declare the attachment column in your config#
Add an attachments map to the table entry in your defineConfig call. The attachment(bucket, { ownerColumn }) helper names the Storage bucket and the row column whose value owns the object key. The file below is the whole src/kizunasync.ts of a web app with one attachment column, and step 2 explains its last three statements.
// src/kizunasync.ts
import { attachment, byOwner, defineConfig } from 'kizunasync'
import { createSupabaseKizunaSync, createSupabaseTransfer } from 'kizunasync/supabase'
import { createWebFileStore, createWebWorkerDriver } from 'kizunasync/web'
import type { Database } from './database.types'
import { supabase } from './supabase-client'
const config = defineConfig<Database>({
tables: {
todos: {
sync: 'read-write',
bucket: byOwner('user_id'),
attachments: {
image_path: attachment('todos', { ownerColumn: 'user_id' }),
},
},
},
})
const fileStore = createWebFileStore()
const transfer = createSupabaseTransfer({ client: supabase, fileStore })
export const kizunasync = createSupabaseKizunaSync({ supabase, driver: createWebWorkerDriver('todos.db'), config, fileStore, transfer })image_path stays an ordinary synced column. It holds the Storage object key the engine writes, never the bytes.
Omit ownerColumn and the table's byOwner bucket column is used instead. When neither resolves, creating the app client throws an Error naming the column that needs an owner.
You should now see the config compile with no type error, and attachment() accepted on the column you named.
2. Wire the file and transfer ports#
The engine needs two ports for attachments: an IFileStore for the local content-addressed sandbox, built by createWebFileStore on the web and openExpoFileStore on native, and an ITransfer for Supabase Storage, built by createSupabaseTransfer. The file in step 1 builds both and passes them to createSupabaseKizunaSync next to the driver. createWebFileStore() returns the store at once and opens its sandbox on the first attachment call that reads or writes bytes, so the module stays safe to import during a static render.
The driver, createWebWorkerDriver, names the local database, and createSupabaseKizunaSync builds the remote that carries pull and push over your Supabase client, so the two attachment ports are the only extra wiring an attachment column needs. Initializing documents every option the app client takes. On Expo, the driver is the synchronous openExpoDriver('todos.db'), and Expo: File store builds the native file store and the transfer whose downloads go through expo/fetch.
A table that declares attachments while either port is missing throws ATTACHMENT_PORTS_MISSING. With no attachment column configured and no ports passed, kizunasync.attachments is still there, and each of its methods rejects with that code.
You should now see the module import without an error, and kizunasync.attachments.getStatus(ref) answer null for a reference the queue has never seen, rather than reject with ATTACHMENT_PORTS_MISSING.
3. Insert the row before picking a file#
fromFile reads the row to find the owner value for the object key, so the row has to exist locally first. insert commits it to local SQLite and queues it in the outbox in one transaction. The insert leaves user_id out: on a byOwner table the engine fills the owner column with the id of the user who signed in on this device, offline too.
// src/add-todo.ts
import { kizunasync } from './kizunasync'
export async function addTodo(title: string): Promise<string> {
const id = crypto.randomUUID()
// image_path stays null until a file is attached
await kizunasync.from('todos').insert({ id, title, image_path: null })
return id
}You should now see the row in the UI with image_path unset, and one more entry in the outbox.
4. Import the file and write the ref#
Call attachments.fromFile with the table, column, primary key, and the file URI your platform's picker returned. It writes result.ref into the row column itself, so there is no update to make after it.
// src/attach-image.ts
import { kizunasync } from './kizunasync'
// pickedUri is the file URI your platform's picker returned
export async function attachImage(todoId: string, pickedUri: string): Promise<string> {
const result = await kizunasync.attachments.fromFile({
table: 'todos',
column: 'image_path',
pk: todoId,
uri: pickedUri,
mediaType: 'image/jpeg', // optional; inferred from the file when omitted
})
return result.ref
}fromFile copies the picked file into a local content-addressed sandbox, computes a SHA-256 digest, mints a per-upload key of the form ${owner}/${pk}/${uploadId}.${ext}, enqueues the upload, and writes the key into image_path as an ordinary synced column update. Both writes commit locally, so neither needs the network. That is the same optimistic path every write takes, described in Offline writes.
The queue row comes first and the column update right after it, so each drive checks that the row still carries the reference before it uploads. A row that is gone, tombstoned, carries a different reference, or has not received this one yet is released back to queued instead of being orphaned, and a later run picks the upload back up once the row names it.
result has this shape:
| Field | Type | Description |
|---|---|---|
ref | string | The Storage object key. |
sha256 | string | SHA-256 hex digest of the file bytes. |
size | number | File size in bytes. |
mediaType | string | null | Content type, inferred or provided. |
localUri | string | Renderable URI for the sandbox copy, available before any upload. |
You should now see result.localUri render the picked file before any upload starts, and the row column holding result.ref.
5. Bytes upload after the outbox drains#
sync() pushes the outbox first. It drives attachment uploads only once that outbox is empty. The ref column therefore reaches the server before the bytes it points at. Queue rows, TUS session URLs, and progress live in SQLite. Engine startup returns any unfinished row to the candidate queue. The automated tests simulate interruption and resumption. The repository has no browser or native process-kill test, so a pass is evidence for the engine path and not for crash safety. Project status states it in the same terms.
The Supabase transfer chooses by byte size. A file of at most 6 MiB takes a single-shot standard upload. Kizuna issues that call from the queue rather than from your component. The upload therefore happens on a sync run, not at the moment the user picked the file. A larger file takes a resumable TUS upload in 6 MiB chunks. Kizuna persists the session URL before the first chunk. A later attempt reuses that URL and asks the server for its authoritative offset. A single-shot attempt restarts from zero. The adapter reads the whole sandbox file into memory before it picks a path, so resumability does not make a large file cheap in memory, whichever direction moves the bytes. Mocked unit tests cover both paths and the resume. The live Storage suite runs only when you set KSYNC_TUS_E2E=1 and supply credentials. A default test run is therefore not evidence of a live upload. Apply a client-side size and type check to enforce your own maximum, and set the Storage bucket's file_size_limit and allowed_mime_types as well. Kizuna does not cap the size or the media type of the file it hands to Storage.
Every network wait carries a deadline, so one hung request cannot hold the scheduler forever. Control requests use controlTimeoutMs, 30000 by default. That deadline covers the session lookup, which Kizuna bounds rather than awaiting indefinitely. It also covers the TUS create and offset probe, the signed URL, and attachment_confirm. It covers attachment_vacuum, the Storage remove, and the metadata lookup as well. Requests that carry bytes use bytesTimeoutMs, 120000 by default: the single-shot upload, each TUS chunk, and the download. Pass 0 for either to disable that deadline.
// src/kizunasync.ts (excerpt)
import { createSupabaseTransfer } from 'kizunasync/supabase'
import { createWebFileStore } from 'kizunasync/web'
import { supabase } from './supabase-client'
const fileStore = createWebFileStore()
const transfer = createSupabaseTransfer({
client: supabase,
fileStore,
controlTimeoutMs: 30_000,
bytesTimeoutMs: 300_000, // a 6 MiB chunk on a slow mobile link
})A blown deadline rejects with ATTACHMENT_TRANSFER_TIMEOUT. The queue records the attachment as failed and retries on the next sync, the same as any other transient failure. A TUS session survives a chunk timeout, because its upload URL stays valid for up to 24 hours, so the retry resumes rather than restarting. The single-shot path is the exception: storage-js exposes no abort signal, so a timed-out request may land on the server regardless. That costs nothing, because the retry re-uploads with upsert.
Supabase lists the status codes a failed Storage call returns in Storage error codes. The transfer branches on the status: a 404 NotFound on a peer's download means the bytes are not uploaded, so Kizuna returns the entry to queued instead of failing it, and the attempt still counts against the budget. A 401 means Storage refused the session rather than this object, so Kizuna returns the entry to queued as well, this time without charging the attempt: the same session refusal a resumable upload's offset probe answers. Every other status fails the attempt.
You should now see the attachment move from queued to uploading to synced across one sync run, with progress reaching 100.
6. Give up after too many failures, and offer a way back#
A transfer that keeps failing does not retry forever. attachmentAttempts in defineConfig sets the budget, five attempts by default, and the claim that discovers the budget spent is not itself counted. Past it, the row lands failed with permanent: true: no drive claims it again, and it drops out of the candidate list get-status and the queue's own drive loop both read from.
// src/kizunasync.ts (excerpt)
import { defineConfig } from 'kizunasync'
import type { Database } from './database.types'
const config = defineConfig<Database>({
tables: { /* ... */ },
attachmentAttempts: 3, // default 5
})Three controls read and change a stopped row, all on kizunasync.attachments:
| Method | What it does |
|---|---|
retry(ref) | Forgives the budget: the row returns to queued with permanent off and attempts cleared, so the next drive or resolve takes it again. It wakes the automatic loop, so an upload needs no sync() call. |
cancel(ref) | Stops an in-flight transfer at the app's own request. The row lands failed and stays retryable, and nothing is charged against the budget, so backing out of an upload never costs a later retry. |
remove(ref) | Forgets the reference and the sandbox bytes it cached, without touching the Storage object. Vacuum attachments is what removes that, once no row column names it. |
getStatus and watch answer two more fields alongside the four from step 7: permanent and attempts. React: useAttachment and Vue: useAttachment surface both, plus cancel and remove, next to the retry action they already expose.
// src/components/todo-thumb.tsx
import { useAttachment } from 'kizunasync/react'
function TodoThumb({ imageRef }: { imageRef: string | null }) {
const { state, permanent, attempts, retry, remove } = useAttachment(imageRef)
if (state !== 'failed' || !permanent) return null
return (
<div>
<span>Image stopped after {attempts} tries</span>
<button onClick={retry}>Retry</button>
<button onClick={remove}>Remove</button>
</div>
)
}You should now see a row that keeps failing stop after the configured number of attempts, permanent turn true, and retry() put it back in the queue.
7. Display the attachment with useAttachment#
useAttachment subscribes to one ref's status in the queue. The uploading device has localUri from the sandbox right away. For a peer, the hook prefetches the bytes once on first view through resolveDownload. The transfer verifies the downloaded bytes' SHA-256 against the attachment_confirm metadata when the server already holds it, or against this device's own recorded hash otherwise. With neither hash known, the download fails closed with ATTACHMENT_UNVERIFIED and fetches nothing, retryable through retry(ref). A mismatch between the downloaded bytes and the expected hash fails with ATTACHMENT_HASH_MISMATCH instead, and nothing is written to the sandbox.
// src/components/todo-image.tsx
import { useAttachment } from 'kizunasync/react'
function TodoImage({ imageRef }: { imageRef: string | null }) {
const { state, progress, localUri, error } = useAttachment(imageRef)
if (imageRef == null || state === 'idle') return null
if (localUri !== null) return <img src={localUri} alt="todo" />
if (state === 'failed') return <span>{error ?? 'Attachment transfer failed'}</span>
if (state === 'uploading') return <span>Uploading {progress}%</span>
return <span>Syncing…</span>
}<!-- src/components/TodoImage.vue -->
<script setup lang="ts">
import { useAttachment } from 'kizunasync/vue'
const props = defineProps<{ imageRef: string | null }>()
const { state, progress, localUri, error } = useAttachment(props.imageRef)
</script>
<template>
<img v-if="localUri" :src="localUri" alt="todo" />
<span v-else-if="state === 'failed'">{{ error ?? 'Attachment transfer failed' }}</span>
<span v-else-if="state === 'uploading'">Uploading {{ progress }}%</span>
</template>// src/components/todo-image.tsx
import { Image, Text } from 'react-native'
import { useAttachment } from 'kizunasync/react'
function TodoImage({ imageRef }: { imageRef: string | null }) {
const { state, progress, localUri, error } = useAttachment(imageRef)
if (imageRef == null || state === 'idle') return null
if (localUri !== null) return <Image source={{ uri: localUri }} accessibilityLabel="todo" />
if (state === 'failed') return <Text>{error ?? 'Attachment transfer failed'}</Text>
if (state === 'uploading') return <Text>Uploading {progress}%</Text>
return <Text>Syncing…</Text>
}// TodoApp/TodoListView.swift (excerpt)
import Foundation
import KizunaSync
// Insert the row first; fromFile copies the picked file into the sandbox and writes its reference onto the row.
func attachPhoto(todoId: String, imageURL: URL) async throws -> String? {
try await kizunasync.apply(
table: "todos",
pk: todoId,
op: .insert,
columns: ["title": "works on a plane", "image_path": NSNull()]
)
let result = try await kizunasync.fromFile(
table: "todos",
column: "image_path",
pk: todoId,
sourcePath: imageURL.path,
mediaType: "image/jpeg"
)
return try await kizunasync.resolveDownload(result.reference)
}// app/src/main/kotlin/com/example/todo/TodoScreen.kt (excerpt)
package com.example.todo
import com.kizunasync.kizunasync.KizunaSyncOp
// Insert the row first; fromFile copies the picked file into the sandbox and writes its reference onto the row.
suspend fun attachPhoto(todoId: String, sourcePath: String): String? {
kizunasync.apply(
table = "todos",
pk = todoId,
op = KizunaSyncOp.Insert,
columns = mapOf("title" to "works on a plane", "image_path" to null),
)
val result = kizunasync.fromFile(
table = "todos",
column = "image_path",
pk = todoId,
sourcePath = sourcePath,
mediaType = "image/jpeg",
)
return kizunasync.resolveDownload(result.reference)
}// src/todo-image.ts
import { kizunasync } from './kizunasync'
// Enqueue with the app client; drive the UI from attachment status yourself.
export async function attachTodoImage(todoId: string, fileUri: string) {
const { ref } = await kizunasync.attachments.fromFile({
table: 'todos',
column: 'image_path',
pk: todoId,
uri: fileUri,
})
// After the upload, read a local URI back through getStatus or resolveDownload.
return kizunasync.attachments.getStatus(ref)
}The Swift and Kotlin tabs use Swift: Attach a file and Kotlin: Attach a file. Both use the app client kizunasync and leave user_id out of the insert, which the byOwner bucket fills as in step 3. Native fromFile also writes the reference onto the row, and that local write wakes the app's syncScheduler (Swift: Host scheduler), whose next run uploads the bytes. Swift: Resolve a download and Kotlin: Resolve a download carry the full resolveDownload signature.
Both JavaScript bindings return the same ten fields, and Vue: useAttachment has the Vue return table:
| Field | Type | Description |
|---|---|---|
state | TAttachmentState | 'idle' | The queue state, or 'idle' when the ref is null or no queue row exists. |
progress | number | 0 to 100, updated while bytes move. |
localUri | string | null | Renderable URI once the bytes are local. |
error | string | null | The last error message, or null. |
permanent | boolean | true when the transfer budget is spent and only retry moves the row again. |
attempts | number | Transfer attempts consumed so far, out of the configured budget. |
retry | () => void | Forgive the budget and attempt the download again. |
cancel | () => void | Stop an in-flight transfer without spending an attempt. |
remove | () => void | Forget the reference and its sandbox bytes. |
prefetch | () => void | Attempt the download again, without touching the budget. |
Vue wraps state, progress, localUri, error, permanent, and attempts in refs. retry, cancel, remove, and prefetch are plain functions in both.
Attachment state lifecycle#
TAttachmentState, declared in JavaScript: Types, is a closed union of seven values:
| State | When |
|---|---|
queued | Waiting for the outbox to drain, waiting for the row to carry the ref, or scheduled as a peer download whose bytes nobody has viewed. |
uploading | Bytes are moving to Storage. |
synced | The upload was confirmed, or a download landed in the local sandbox. |
downloading | Bytes are being fetched from Storage. |
failed | The last attempt failed. While permanent is false, the next sync retries an upload and retry retries a download; once the transfer budget is spent, permanent turns true and only retry moves the row again. |
orphaned | Server evidence, an applied push or a pulled row or tombstone, says this device's own object no longer belongs to any row, and no local row or queued write still names it. vacuum removes the object. |
evicted | This device dropped the reference with no such evidence, another user's object or one that only left this device, or gave up on an orphaned removal Storage refused or that spent its attempt budget. vacuum deletes only the cached bytes; the Storage object is left as it is. |
'idle' is not part of the union. The hooks return it when the ref is null or the queue has no row for it.
You should now see the placeholder while the bytes are missing and the image once localUri is set, without a reload.
8. Verify it worked#
After fromFile and a sync, read the status back with getStatus, or subscribe with watch when you want every change:
// src/log-attachment.ts
import { kizunasync } from './kizunasync'
export async function logAttachment(ref: string): Promise<void> {
const status = await kizunasync.attachments.getStatus(ref)
console.log(status)
// { state: 'synced', progress: 100, localUri: '...', error: null, ... }
}You should now see state: 'synced', and the object in the todos bucket of the Supabase dashboard at the path ${user_id}/${todo_id}/${uploadId}.jpg. The metadata row lands in kizunasync.attachments at the same time.
Storage policies#
Storage objects are authorized by policies on storage.objects, which Supabase documents in Storage access control. Those policies are ordinary Row Level Security policies, and Kizuna neither writes nor relaxes them. It adds only the key format: ${user_id}/${todo_id}/${uploadId}.ext puts the owner and the referenced row in the path, so a policy can check both by reading the name.
The demo todos bucket comes from 0002_example.sql, whose RLS keeps writes owner-scoped, with an exception for anonymous-owned rows any visitor can edit, while every visitor can read every row. Its policies are deliberately loose so the example runs unattended:
- The bucket is public and any user can read any todo image.
- Insert, update, and delete call
public.todo_image_writable(name), which checks two things. The object key's owner segment must name the referenced todo's ownuser_id, never merely the caller's. And the caller must be someone who may mutate that todo: its own owner, or, in the current helper, anyone editing a todo whose owner is anonymous.
Copy neither the public-read bucket nor that helper unchanged into a production project. Adapt todo_image_writable in 0002_example.sql to your own ownership rules, and use a private bucket with signed URLs where reads need authorization. Local Supabase explains which migrations are demo fixtures and which one is the installable pack.
Platform notes#
Web: attachment bytes live in an OPFS subtree named kizunasync-attachments, separate from the SQLite database file. createWebFileStore() calls navigator.storage.persist() on a best-effort basis, and the browser may decline. Render from localUri, which the configured file store produces.
Expo (React Native): bytes live under documentDirectory/kizunasync-attachments/, not the cache directory, through openExpoFileStore. Transfers run in the foreground, and queue state is recovered on the next engine start as long as the app and the operating system preserved that directory.
Native Swift and Kotlin: set attachmentRoot on KizunaSyncClient.create, then call fromFile, resolveDownload, and watch. There is no useAttachment hook on these platforms, so drive the UI from watch or getStatus.
Declaring attachments without attachmentRoot throws ATTACHMENT_PORTS_MISSING. React Native over UniFFI must omit attachment_root so the JavaScript host's attachment queue owns the bytes.
Common errors#
ATTACHMENT_ROW_GONEmeans you calledfromFilebefore inserting the row. Insert the row, then pick the file.ATTACHMENT_OWNER_MISSINGmeans theownerColumnvalue is empty or is not a string. On abyOwnertable the engine fills the owner column only once a user has signed in on the device, so an insert made before that leaves it empty. Give the row a non-emptyuser_id, or whichever column you configured asownerColumn, before callingfromFile.ATTACHMENT_PORTS_MISSINGmeans the JavaScript app client was created without bothfileStoreandtransfer(see Initializing), or nativeKizunaSyncClient.createdeclaredattachmentswithoutattachmentRoot. Troubleshooting has the fix for both.ATTACHMENT_TRANSFER_TIMEOUTmeans a request outlivedcontrolTimeoutMsorbytesTimeoutMs. The entry isfailed; the nextsync()retries an upload, andretry(ref)orprefetch()retries a download. RaisebytesTimeoutMswhen large files on slow links time out repeatedly.ATTACHMENT_UNVERIFIEDmeans a download has neither the server'sattachment_confirmmetadata nor a hash this device already recorded to check the bytes against. It fails closed rather than accepting unverified bytes;retry(ref)tries again once the metadata exists.ATTACHMENT_HASH_MISMATCHmeans the downloaded bytes do not match the expected SHA-256, and nothing is written to the sandbox. A peer's own upload should not produce this; treat it as evidence of a corrupted or substituted object.- Upload refused at the bucket means the bucket's size limit, its allowed media types, or its access-control policies rejected the write. Kizuna reports the platform's own status, so read it against Storage error codes.
localUriis null for a peer means the bytes have not been fetched. The hook prefetches once per ref, andprefetch()callsresolveDownloadagain.retry()does the same after forgiving the budget, so it works whether or not the row ispermanent. Neither retries a failed upload, which a later sync handles.- A transfer stopped for good (
permanent: true) means the row spent itsattachmentAttemptsbudget, and no drive claims it again.retry(ref)puts it back in the queue, andremove(ref)deletes its queue row instead. Step 6 shows both.
Next steps#
- Offline writes: the ref column is an ordinary synced column, so the outbox and verdict rules apply to it like any other field.
- Sync rules and buckets: the table bucket, ownership column, and conflict mode for the row that holds the ref.
- Create the Storage transfer: every option
createSupabaseTransfertakes. - Attach a file, Get attachment status, and Resolve a download: the three queue entry points, parameter by parameter.
- Retry an attachment, Cancel an attachment, and Remove an attachment: the three controls over a row already in the queue.
- SQL pack: the metadata table and the three attachment RPCs the transfer calls.
- CLI:
kizunasync initandkizunasync doctorfor provisioning and checking your project.
Found a problem? Edit this page on GitHub