Skip to content

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#

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:

FieldTypeDescription
refstringThe Storage object key.
sha256stringSHA-256 hex digest of the file bytes.
sizenumberFile size in bytes.
mediaTypestring | nullContent type, inferred or provided.
localUristringRenderable 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:

MethodWhat 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>
}

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:

FieldTypeDescription
stateTAttachmentState | 'idle'The queue state, or 'idle' when the ref is null or no queue row exists.
progressnumber0 to 100, updated while bytes move.
localUristring | nullRenderable URI once the bytes are local.
errorstring | nullThe last error message, or null.
permanentbooleantrue when the transfer budget is spent and only retry moves the row again.
attemptsnumberTransfer attempts consumed so far, out of the configured budget.
retry() => voidForgive the budget and attempt the download again.
cancel() => voidStop an in-flight transfer without spending an attempt.
remove() => voidForget the reference and its sandbox bytes.
prefetch() => voidAttempt 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:

StateWhen
queuedWaiting for the outbox to drain, waiting for the row to carry the ref, or scheduled as a peer download whose bytes nobody has viewed.
uploadingBytes are moving to Storage.
syncedThe upload was confirmed, or a download landed in the local sandbox.
downloadingBytes are being fetched from Storage.
failedThe 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.
orphanedServer 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.
evictedThis 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 own user_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_GONE means you called fromFile before inserting the row. Insert the row, then pick the file.
  • ATTACHMENT_OWNER_MISSING means the ownerColumn value is empty or is not a string. On a byOwner table 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-empty user_id, or whichever column you configured as ownerColumn, before calling fromFile.
  • ATTACHMENT_PORTS_MISSING means the JavaScript app client was created without both fileStore and transfer (see Initializing), or native KizunaSyncClient.create declared attachments without attachmentRoot. Troubleshooting has the fix for both.
  • ATTACHMENT_TRANSFER_TIMEOUT means a request outlived controlTimeoutMs or bytesTimeoutMs. The entry is failed; the next sync() retries an upload, and retry(ref) or prefetch() retries a download. Raise bytesTimeoutMs when large files on slow links time out repeatedly.
  • ATTACHMENT_UNVERIFIED means a download has neither the server's attachment_confirm metadata 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_MISMATCH means 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.
  • localUri is null for a peer means the bytes have not been fetched. The hook prefetches once per ref, and prefetch() calls resolveDownload again. retry() does the same after forgiving the budget, so it works whether or not the row is permanent. Neither retries a failed upload, which a later sync handles.
  • A transfer stopped for good (permanent: true) means the row spent its attachmentAttempts budget, and no drive claims it again. retry(ref) puts it back in the queue, and remove(ref) deletes its queue row instead. Step 6 shows both.

Next steps#

Found a problem? Edit this page on GitHub