Milestone 2: Postgres tables, RLS, and fetching the feed
Unit 25 · Project 5, Milestone 2. With auth working, this milestone builds the database: the tables, the RLS policies that secure them, and the typed fetch that turns rows into a feed. Milestone 3 adds photo upload on top.
This is the milestone that separates a toy from a real backend. Anyone can make a table; the skill is making a table that's safe — where the database itself refuses to hand a user rows they shouldn't see or let them write rows they don't own. That's Row-Level Security, and it's why the public anon key in your app is not a liability.
The schema
Three tables. profiles mirrors each auth user (Supabase's auth.users is managed; you keep app
data in your own profiles row keyed by the same id). posts holds each photo post. likes is a
join row per (user, post).
create table profiles ( id uuid primary key references auth.users(id) on delete cascade, username text not null unique, created_at timestamptz not null default now() ); create table posts ( id uuid primary key default gen_random_uuid(), author_id uuid not null references profiles(id) on delete cascade, image_path text not null, caption text, created_at timestamptz not null default now() ); create table likes ( user_id uuid not null references profiles(id) on delete cascade, post_id uuid not null references posts(id) on delete cascade, created_at timestamptz not null default now(), primary key (user_id, post_id) );
The composite primary key on likes means a user can like a post at most once — the database
enforces it, no client check required.
Row-Level Security
Enable RLS on every table, then write policies. A policy is a rule the database applies to each row
for each operation. auth.uid() is the id of the authenticated caller, derived from their token —
the client can't forge it.
alter table profiles enable row level security; alter table posts enable row level security; alter table likes enable row level security; -- Anyone signed in can read the feed and profiles. create policy "read profiles" on profiles for select using (true); create policy "read posts" on posts for select using (true); create policy "read likes" on likes for select using (true); -- You may only insert a post you author. create policy "insert own post" on posts for insert with check (auth.uid() = author_id); -- You may only delete your own post. create policy "delete own post" on posts for delete using (auth.uid() = author_id); -- You may only like/unlike as yourself. create policy "insert own like" on likes for insert with check (auth.uid() = user_id); create policy "delete own like" on likes for delete using (auth.uid() = user_id);
Read the two clause types carefully: using filters which existing rows an operation can see or
touch (select/delete), while with check validates the new row an insert/update tries to
write. "You can only insert a post where author_id is you" is a with check; "you can only
delete a post that is yours" is a using.
A table with RLS enabled and no policies denies everything. That's the safe default — enabling
RLS without a matching policy means no one can read or write, which fails loudly in development.
The dangerous state is the opposite: RLS disabled, which silently allows everyone. Always enable,
then add exactly the policies you intend. Verify by trying to insert a post with someone else's
author_id from the SQL editor as an anon role — it must be rejected.
Codable rows
The Supabase client returns JSON; decode it into structs. Postgres uses snake_case, so either
name your properties to match or use CodingKeys / a key-decoding strategy. Keep the model a
struct (value semantics, Sendable).
import Foundation struct Post: Identifiable, Codable, Sendable, Hashable { let id: UUID let authorId: UUID let imagePath: String let caption: String? let createdAt: Date enum CodingKeys: String, CodingKey { case id case authorId = "author_id" case imagePath = "image_path" case caption case createdAt = "created_at" } }
Fetching the feed
Query through the client's Postgres builder. Behind a protocol, as always, so the view model tests against a stub.
import Supabase protocol FeedRepository: Sendable { func fetchFeed(limit: Int) async throws -> [Post] } struct SupabaseFeedRepository: FeedRepository { let client: SupabaseClient func fetchFeed(limit: Int) async throws -> [Post] { try await client .from("posts") .select() .order("created_at", ascending: false) .limit(limit) .execute() .value // decoded into [Post] via Codable } }
.execute().value decodes the response body into the inferred [Post]. Because RLS is on and the
select policy is using (true), an authenticated user gets the whole feed — but a request without a
valid token gets nothing, and no one can write a row they don't own, all enforced by the database.
Render it with a @MainActor view model exactly like Reeler's search — a state enum, an injected
repository, results in a List.
Coming from Android + a REST backend, you'd hand-write endpoints and put authorization in your
server's controllers (if (post.authorId != currentUser.id) throw Forbidden). RLS moves that
check into the database, so it can't be forgotten in one endpoint and applies uniformly to every
query. You're writing authorization as data policy, not as scattered imperative guards.
Stories
Stories sit across the top of the feed. On the backend they're another table — a stories row
(author_id, image_path, created_at, expires_at) under the same read-anyone / write-your-own
RLS, queried exactly like the feed but filtered to the last 24 hours. On the client they're a
horizontal bar of rings; the "seen" state is local (or a small story_views table).
The ring is an avatar with a gradient stroke when unseen, a muted stroke when seen:
struct StoryRing: View { let story: Story var size: CGFloat = 66 var body: some View { Avatar(user: story.user, size: size - 8) .padding(4) .overlay( Circle().strokeBorder( story.seen ? AnyShapeStyle(Color.secondary.opacity(0.4)) : AnyShapeStyle(LinearGradient( colors: [.orange, .pink, .purple], startPoint: .topTrailing, endPoint: .bottomLeading)), lineWidth: 2.5) ) .frame(width: size, height: size) } }
The bar is a horizontal ScrollView of rings; the first is "Your story". Tapping one hands the
story up to the feed screen, which presents the viewer:
struct StoryBar: View { let stories: [Story] var onTap: (Story) -> Void var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: 14) { ForEach(Array(stories.enumerated()), id: \.element.id) { index, story in Button { onTap(story) } label: { VStack(spacing: 5) { StoryRing(story: story) Text(index == 0 ? "Your story" : story.user.handle) .font(.caption2).lineLimit(1) } .frame(width: 68) } .buttonStyle(.plain) } } .padding(.horizontal, 14).padding(.vertical, 10) } } }
Present the viewer as a fullScreenCover(item:), driven by an @State var openStory: Story? the
bar sets. The viewer is a full-bleed image with a progress bar, an author header, and a reply bar —
tap anywhere to dismiss:
struct StoryViewer: View { let story: Story @Environment(\.dismiss) private var dismiss var body: some View { ZStack { // the story image, full-bleed VStack(spacing: 0) { Capsule().fill(.white.opacity(0.9)).frame(height: 2).padding(.horizontal).padding(.top, 10) HStack(spacing: 10) { Avatar(user: story.user, size: 32) Text(story.user.handle).font(.subheadline.bold()).foregroundStyle(.white) Spacer() Button { dismiss() } label: { Image(systemName: "xmark").foregroundStyle(.white) } } .padding() Spacer() // reply bar… } } .contentShape(Rectangle()) .onTapGesture { dismiss() } } }
The stories bar is the same lesson as the feed, one level up: a query filtered to the last day, an
RLS-protected table, and a SwiftUI list — plus one new piece, fullScreenCover(item:) for the
viewer. A social app is a handful of these tables read a handful of ways; you're building the
vocabulary, not a special case.
Checkpoint
Your app should now: read a feed of posts from Postgres into [Post], render them with a stories
bar across the top, open a full-screen story on tap, and — verified in the Supabase SQL editor —
reject any attempt to insert a post as another user or delete a post you don't own. Create a couple
of posts and stories by hand in the dashboard so the feed has content before upload exists.
Stretch: join the author's username into the feed query (a select("*, profiles(username)"))
and decode it into a nested field on Post.
Knowledge check
Q: What's the difference between a policy's using clause and its with check clause?
using decides which existing rows an operation can see or act on (select, delete, the read side
of update). with check validates the new row values an insert or update tries to write. "Only
delete your own post" is using; "only insert a post authored by you" is with check.
Q: You enabled RLS on posts but wrote no policies, and now the feed is empty. Why?
RLS with no matching policy denies everything by default. You need an explicit
for select using (...) policy before any row is readable. That deny-by-default is the safe
behavior — the unsafe state would have been leaving RLS off entirely.