Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6864f12
Add Prisma 8 + Supabase blog post and extension docs guide
ankur-arch Aug 5, 2026
0a3a9d7
Rewrite Supabase blog post problem-first at half length; fix docs CI
ankur-arch Aug 6, 2026
a4500ba
Drop /docs prefix from internal using-extensions link
ankur-arch Aug 6, 2026
8eebf5a
Reposition blog post as Prisma 8 RC1 with outcome-led sections
ankur-arch Aug 6, 2026
6205212
Rewrite Supabase docs guide as a step-by-step RC1 walkthrough
ankur-arch Aug 6, 2026
b374b5a
Restructure blog by user demand, swap static diagrams for Code Hike
ankur-arch Aug 6, 2026
a334c3a
Fix renderer freeze on cross-language step in RLS walkthrough
ankur-arch Aug 6, 2026
a9f13a8
Merge branch 'main' into ankur/prisma-8-supabase-blog-guide
ankur-arch Aug 6, 2026
3ff083b
Merge branch 'main' into ankur/prisma-8-supabase-blog-guide
ankur-arch Aug 6, 2026
9acccb6
Restructure post around the reader's pain story per Will's feedback
ankur-arch Aug 6, 2026
68e6988
blog: lead the Supabase RLS post with the outcome
ankur-arch Aug 6, 2026
4636e83
Restructure post value-first, swap to prisma@next, highlight Prisma 8…
ankur-arch Aug 19, 2026
0c0ece9
Rewrite Supabase post problem-first for existing Supabase developers
ankur-arch Aug 19, 2026
3bb7c1b
docs: correct the migrations layout note after live E2E validation
ankur-arch Aug 19, 2026
3e13eee
Restructure Supabase post: progressive disclosure, plainer headings, …
ankur-arch Aug 19, 2026
592ce77
Merge origin/main into ankur/prisma-8-supabase-blog-guide
ankur-arch Aug 28, 2026
b721a3a
Rewrite Supabase post value-first on prisma@latest rc.12; new-brand c…
ankur-arch Aug 28, 2026
ffe5c3e
Supabase post: frontload value, cite supabase-js issues, re-validate …
ankur-arch Sep 7, 2026
6b7dd6d
Supabase post: add transactions, value types, and error handling to t…
ankur-arch Sep 15, 2026
b48c474
Supabase post: show the schema policy in the RLS demo, fix code overl…
ankur-arch Sep 15, 2026
1df0ae1
Merge origin/main into ankur/prisma-8-supabase-blog-guide
ankur-arch Sep 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { highlightPrisma8 } from "./highlight-prisma8";

const BEFORE = `supabase/migrations/
├── 20250514_add_notes_policy.sql
├── 20250602_update_notes_policy.sql
└── 20250618_fix_notes_policy.sql

+ dashboard edits outside git`;

const AFTER = `namespace public {
model Note {
userId Uuid
user supabase:auth.AuthUser @relation(fields: [userId], references: [id])

@@rls
}

policy_select note_owner_read {
roles = [authenticated]
using = "\\"userId\\"::uuid = auth.uid()"
}
}`;

export async function OneContractGraph() {
const after = await highlightPrisma8(AFTER);
return (
<div className="contract-graph not-prose">
<div className="contract-graph-cols">
<div className="contract-graph-col">
<span className="contract-graph-tag">RLS with SQL migrations</span>
<div className="contract-graph-card">
<pre className="contract-graph-plain">{BEFORE}</pre>
</div>
</div>
<div className="contract-graph-col" data-after="true">
<span className="contract-graph-tag">RLS in the Prisma schema</span>
{/* Highlighted with the same extended prisma grammar the MDX code
fences use; colors resolve through the --ch-N variables. */}
<div className="contract-graph-card" dangerouslySetInnerHTML={{ __html: after }} />
</div>
</div>
<div className="contract-graph-footer">One file to edit, migrate, and review.</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { highlight, type HighlightedCode } from "codehike/code";
import { RlsFlowDemoClient } from "./RlsFlowDemoClient";
import { highlightPrisma8 } from "./highlight-prisma8";

const SNIPPETS: { value: string; lang: string }[] = [
{
lang: "typescript",
value: `app.get('/notes', async (c) => {
const auth = c.req.header('authorization');
const jwt = auth?.startsWith('Bearer ')
? auth.slice(7)
: undefined;
});`,
},
{
lang: "typescript",
value: `app.get('/notes', async (c) => {
const auth = c.req.header('authorization');
const jwt = auth?.startsWith('Bearer ')
? auth.slice(7)
: undefined;

const db = await getDb();
const bound = await db.asUser(jwt);
});`,
},
{
lang: "typescript",
value: `app.get('/notes', async (c) => {
const auth = c.req.header('authorization');
const jwt = auth?.startsWith('Bearer ')
? auth.slice(7)
: undefined;

const db = await getDb();
const bound = await db.asUser(jwt);

const notes = await bound.orm.public.Note
.select('id', 'title', 'body')
.all()
.toArray();

return c.json({ notes });
});`,
},
];

// The policy Postgres enforces in step 4, as it appears in the Prisma schema.
const POLICY = `policy_select note_owner_read {
target = Note
roles = [authenticated]
using = "\\"userId\\"::uuid = auth.uid()"
}`;

export async function RlsFlowDemo() {
const highlighted = (await Promise.all(
SNIPPETS.map(({ value, lang }) => highlight({ value, lang, meta: "" }, "github-from-css")),
)) as HighlightedCode[];
const policyHtml = await highlightPrisma8(POLICY);
return <RlsFlowDemoClient snippets={highlighted} policyHtml={policyHtml} />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
"use client";

import { Component, createRef, Fragment, useEffect, useRef, useState, type RefObject } from "react";
import { Pre, type HighlightedCode } from "codehike/code";
import {
calculateTransitions,
getStartingSnapshot,
type TokenTransitionsSnapshot,
} from "codehike/utils/token-transitions";
import { ChevronLeft, ChevronRight, Pause, Play } from "lucide-react";

type Actor = "client" | "prisma" | "postgres";

type Phase = {
step: number;
label: string;
shortLabel: string;
actor: Actor;
detail: string;
};

const STEP_HOLD_MS = 6500;

const ACTORS: { id: Actor; label: string }[] = [
{ id: "client", label: "Client" },
{ id: "prisma", label: "Prisma" },
{ id: "postgres", label: "Postgres" },
];

const PHASES: Phase[] = [
{
step: 0,
label: "The request carries a token",
shortLabel: "Request",
actor: "client",
detail:
"Every request arrives with the user's Supabase Auth session token, a signed JWT, in the Authorization header.",
},
{
step: 1,
label: "db.asUser(jwt)",
shortLabel: "Verify + bind",
actor: "prisma",
detail:
"asUser verifies the token's signature against your project's public signing keys, then binds the user's role and id to the database session. A forged or expired token never reaches Postgres.",
},
{
step: 2,
label: "Query with no user filter",
shortLabel: "Query",
actor: "prisma",
detail:
"The handler selects notes without a where userId clause. The client has no query methods until a role is bound, so this step cannot be skipped.",
},
{
step: 3,
label: "Postgres applies the policy",
shortLabel: "Enforce",
actor: "postgres",
detail:
"Postgres evaluates the select policy for every row and returns only those where userId matches the token's auth.uid(). This is the policy_select block from the schema, which Prisma migrated as CREATE POLICY.",
},
];

class SmoothPre extends Component<{ code: HighlightedCode }> {
preRef: RefObject<HTMLPreElement | null> = createRef();

getSnapshotBeforeUpdate() {
if (!this.preRef.current) return null;
return getStartingSnapshot(this.preRef.current);
}

componentDidUpdate(
_prev: { code: HighlightedCode },
_ps: unknown,
snap: TokenTransitionsSnapshot | null,
) {
if (!this.preRef.current || !snap) return;
const transitions = calculateTransitions(this.preRef.current, snap);
transitions.forEach(({ element, keyframes, options }) => {
element.animate(keyframes, {
duration: options.duration * 1000,
delay: options.delay * 1000,
easing: options.easing,
fill: options.fill,
});
});
}

render() {
return <Pre ref={this.preRef} code={this.props.code} />;
}
}

type Props = {
snippets: HighlightedCode[];
/** The schema policy block, pre-highlighted, shown on the Enforce step. */
policyHtml: string;
};

export function RlsFlowDemoClient({ snippets, policyHtml }: Props) {
const [phaseIndex, setPhaseIndex] = useState(0);
const [playing, setPlaying] = useState(true);
const [inView, setInView] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
const el = containerRef.current;
if (!el || typeof IntersectionObserver === "undefined") {
setInView(true);
return;
}
const obs = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting), {
threshold: 0.25,
});
obs.observe(el);
return () => obs.disconnect();
}, []);

useEffect(() => {
if (!playing || !inView) return;
const id = setInterval(() => {
setPhaseIndex((i) => (i + 1) % PHASES.length);
}, STEP_HOLD_MS);
return () => clearInterval(id);
}, [playing, inView]);

const phase = PHASES[phaseIndex];
const code = snippets[phaseIndex] as HighlightedCode | undefined;

function goTo(index: number) {
setPlaying(false);
setPhaseIndex(((index % PHASES.length) + PHASES.length) % PHASES.length);
}

return (
<div ref={containerRef} className="bloom-demo rls-flow not-prose">
<div className="bloom-demo-header">
<span className="bloom-demo-step" aria-hidden="true">
{phase.step + 1} / {PHASES.length}
</span>
<span className="bloom-demo-label">{phase.label}</span>
<div className="bloom-demo-nav">
<button
type="button"
className="bloom-demo-toggle"
onClick={() => goTo(phaseIndex - 1)}
aria-label="Previous step"
>
<ChevronLeft size={14} />
</button>
<button
type="button"
className="bloom-demo-toggle"
onClick={() => setPlaying((p) => !p)}
aria-label={playing ? "Pause demo" : "Play demo"}
>
{playing ? <Pause size={14} /> : <Play size={14} />}
</button>
<button
type="button"
className="bloom-demo-toggle"
onClick={() => goTo(phaseIndex + 1)}
aria-label="Next step"
>
<ChevronRight size={14} />
</button>
</div>
</div>

<div className="bloom-demo-steps" role="tablist" aria-label="RLS request flow steps">
{PHASES.map((p, i) => (
<button
key={p.step}
type="button"
role="tab"
aria-selected={i === phaseIndex}
data-active={i === phaseIndex ? "true" : undefined}
className="bloom-demo-step-pill"
onClick={() => goTo(i)}
>
<span className="bloom-demo-step-pill-num">{i + 1}</span>
<span className="bloom-demo-step-pill-label">{p.shortLabel}</span>
</button>
))}
</div>

<div className="bloom-demo-body">
<div className="bloom-demo-code">
{code ? (
<SmoothPre key={code.lang} code={code} />
) : (
// The Enforce step shows the policy block from the schema, highlighted
// with the Prisma 8 grammar, rather than another codehike snippet.
<div className="rls-flow-policy" dangerouslySetInnerHTML={{ __html: policyHtml }} />
)}
</div>

<div className="rls-flow-captions">
<div className="rls-flow-rail" aria-hidden="true">
{ACTORS.map((actor, i) => (
<Fragment key={actor.id}>
{i > 0 && <span className="rls-flow-rail-arrow" />}
<span
className="rls-flow-rail-node"
data-active={actor.id === phase.actor ? "true" : undefined}
>
{actor.label}
</span>
</Fragment>
))}
</div>
<div className="rls-flow-caption">
<p>{phase.detail}</p>
</div>
{phase.step >= 2 && (
<div className="rls-flow-footer">
No <code>where</code> clause in application code. The policy is the filter.
</div>
)}
</div>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { createHighlighter, type Highlighter, type ThemeRegistration } from "shiki";
import { prisma8Language } from "@prisma-docs/ui/lib/prisma8-language";

// Maps the GitHub scopes to the same --ch-N variables codehike's
// github-from-css theme uses, so shiki output follows light/dark mode
// exactly like the codehike-rendered snippets elsewhere on the page.
const chCssTheme: ThemeRegistration = {
name: "ch-css",
type: "dark",
fg: "var(--ch-4)",
bg: "transparent",
settings: [
{ settings: { foreground: "var(--ch-4)" } },
{
scope: ["comment", "punctuation.definition.comment"],
settings: { foreground: "var(--ch-1)" },
},
{
scope: ["keyword", "keyword.operator", "storage.type", "storage.modifier"],
settings: { foreground: "var(--ch-7)" },
},
{ scope: ["entity.name", "entity.name.function"], settings: { foreground: "var(--ch-5)" } },
{
scope: ["support", "support.type", "variable.language", "constant.language"],
settings: { foreground: "var(--ch-2)" },
},
{ scope: ["string", "punctuation.definition.string"], settings: { foreground: "var(--ch-8)" } },
{
scope: ["variable.parameter", "variable.other.property"],
settings: { foreground: "var(--ch-3)" },
},
],
};

let highlighterPromise: Promise<Highlighter> | undefined;

export async function highlightPrisma8(code: string): Promise<string> {
highlighterPromise ??= createHighlighter({
themes: [chCssTheme],
langs: [prisma8Language as never],
});
const highlighter = await highlighterPromise;
return highlighter.codeToHtml(code, { lang: "prisma", theme: "ch-css" });
}
Loading
Loading