Storage
Photo uploads to Supabase Storage with per-job progress.
The Upload sample is an Example feature: pick a photo, upload it to a private Supabase Storage bucket scoped to the signed-in user, with per-job progress. It's the reference to copy when you add your own file features.
How it works
UploadsRepository talks to Supabase Storage directly (no separate data source —
there's no row-mapping to isolate) and returns an AsyncStream<UploadStatus> so
the view can render live progress:
enum UploadStatus: Sendable {
case progress(Double)
case completed(UploadedFile)
case failed(String)
}The flow: PhotosPicker in UploadView hands the picked image data to
UploadViewModel, which calls UploadsRepository.upload(data:fileName:contentType:userId:)
and drives the UI off the emitted UploadStatus values.
Where files go
Uploads write to the sample-uploads bucket under a per-user path so RLS can
scope access by folder:
sample-uploads/<user-id>/<timestamp>-<short-uuid>-<sanitized-filename>The user ID is lowercased and becomes the first path segment. Filenames are
sanitized to [A-Za-z0-9.-_], and uploads use upsert: false (each upload is a
new object).
The iOS app uses Storage only — it does not write a sample_uploads table
row (the Expo original does). If you need a queryable index of uploads, add a
table and insert after completed, mirroring the Data Layer
repository shape.
Bucket and policies
Create the private bucket (also shown in Supabase Setup):
insert into storage.buckets (id, name, public, file_size_limit)
values ('sample-uploads', 'sample-uploads', false, 52428800) -- 50 MB
on conflict (id) do nothing;Then add storage.objects policies that gate each row by its first path
segment matching the caller's UID — this is what makes the per-user folder
private:
create policy "Users can read own sample upload objects"
on storage.objects for select to authenticated
using (bucket_id = 'sample-uploads'
and (storage.foldername(name))[1] = auth.uid()::text);
create policy "Users can upload own sample upload objects"
on storage.objects for insert to authenticated
with check (bucket_id = 'sample-uploads'
and (storage.foldername(name))[1] = auth.uid()::text);
create policy "Users can update own sample upload objects"
on storage.objects for update to authenticated
using (bucket_id = 'sample-uploads'
and (storage.foldername(name))[1] = auth.uid()::text)
with check (bucket_id = 'sample-uploads'
and (storage.foldername(name))[1] = auth.uid()::text);
create policy "Users can delete own sample upload objects"
on storage.objects for delete to authenticated
using (bucket_id = 'sample-uploads'
and (storage.foldername(name))[1] = auth.uid()::text);Cleanup on account deletion
The delete-account Edge Function lists everything under
sample-uploads/<user-id>/ and removes it before deleting the auth user, so no
orphaned objects remain. A project without the bucket is treated as "nothing to
clean up". See Authentication.
Displaying images
For remote images elsewhere in the app, use RemoteImage — the thin wrapper
over Kingfisher that handles async loading, caching, and placeholder/error states.
See UI Components.
Swapping in your own buckets
- Change the bucket name by passing
bucketName:when constructingUploadsRepository(defaults tosample-uploads). - Recreate the four
storage.objectspolicies for your bucket id. - Keep the
<user-id>/…path prefix so thefoldernameRLS check still applies, or rewrite the policies to match your own layout. - For public assets, create a
publicbucket and drop the SELECT policy — but never make the user-scoped bucket public.
