Milestone 2: Postgres tables, RLS, and fetching the feedswift-6.4/ios-26
Lesson 3 / 7
Unit 25 · Snapgram

Milestone 2: Postgres tables, RLS, and fetching the feed

Coming from
Note

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.

Note

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 Kotlin

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() }
    }
}
Tip

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.