AI 资讯
The Supabase RLS gotcha nobody warns you about: infinite recursion in multi-tenant policies
You're building teams/workspaces on Supabase. A row belongs to a workspace, and a user can touch it if they're a member. Simple enough — until your policies start throwing infinite recursion and you have no idea why. Here's the trap and the pattern that avoids it. The setup A membership table, with RLS on it too (of course): create table workspace_members ( workspace_id uuid references workspaces , user_id uuid references auth . users , primary key ( workspace_id , user_id ) ); alter table workspace_members enable row level security ; create policy "read own memberships" on workspace_members for select using ( user_id = ( select auth . uid ())); The trap Now you scope a tenant table by asking "which workspaces is this user in?" — by querying workspace_members inside the policy : -- ⚠️ this can recurse create policy "members read projects" on projects for select using ( workspace_id in ( select workspace_id from workspace_members where user_id = ( select auth . uid ()) ) ); The policy on projects queries workspace_members , which has its own RLS policy, which re-evaluates… you've built a loop. The fix: a security-definer function Resolve membership with a function that runs with the definer's rights, so reading workspace_members doesn't re-trigger RLS. The policy becomes a plain IN check — no recursion: create or replace function public . user_workspace_ids () returns setof uuid language sql security definer set search_path = '' -- pin it: this is the safety bit as $$ select workspace_id from public . workspace_members where user_id = auth . uid (); $$ ; create policy "members read projects" on projects for select using ( workspace_id in ( select public . user_workspace_ids ()) ); Why it's safe: the function is read-only, returns only the caller's own workspace ids, and search_path = '' prevents search-path hijacking (the classic SECURITY DEFINER footgun). It never exposes another user's memberships. Do this for every tenant table (reads and writes — remember WITH CH
AI 资讯
RLS recursion infinite loop: why I gave up policies and bet everything on a JWT custom claims hook
Episode 1/4 — 3 incidents, one root: default GRANTs open more than you think — [CANONICAL URL EPISODE 1: fill in after push] Episode 2/4 — await mutation() lies when nobody opens the { error } envelope — [CANONICAL URL EPISODE 2: fill in after push] The morning Françoise sees zero rows, again It's a Tuesday in April 2026. I've just added the agent_readonly role to the authenticated membership — a one-liner, meant to share a GRANT for a reporting job. First SELECT on cours , Sentry receives infinite recursion detected in policy for relation "user_roles" , code 42P17 . From the office next door, Françoise is already on the phone with the Maisons-Laffitte branch: "So they can't see anything over there — is that normal?" Foreman tone, not really a question. I read the error on my screen. The difference from episode 1: this time Postgres is talking. What came out of Sentry was no longer a silent empty set — it was an explicit error. That difference saved me two days. When Postgres shouts, you listen. The trap is that what it says isn't where you're looking. I won't pretend this is obscure. A policy on user_roles that queries user_roles to decide who can read user_roles is a loop. You avoid it, you work around it with SECURITY DEFINER , you move on. The problem: my user_roles policy didn't reference user_roles . I had already cleaned it up three weeks earlier. The recursion was coming from somewhere else. The diagnostic that targets the wrong object First reflex: re-read the user_roles policy. It's clean, reads auth.email() , never calls itself. Second reflex: disable policies one by one to find the culprit. Wrong angle. -- supabase/migrations/20260420_admin_write_cours_v1.sql -- "Admin write cours" policy — original version that loops CREATE POLICY "Admin write cours" ON public . cours FOR ALL TO authenticated USING ( EXISTS ( SELECT 1 FROM public . user_roles WHERE email = auth . email () AND role IN ( 'admin' , 'super_admin' ) ) ); The recursion doesn't come from a fau
AI 资讯
The PostgREST query that silently ORDER BY ctid: a Supabase week, distilled
The fourth call of the week Catherine calls from the Maisons-Laffitte site on a Tuesday afternoon in early May. "It's broken, but it's a quick fix." That's her line — I know it, and she's usually right. She describes it in three sentences: the newsletter export for the enrolled-students segment comes back with ninety-two names, the planning view shows ninety-two active courses, but the counter page shows eighty-nine. Three enrolled students missing. She'd checked the database directly — they're all there. "Why three steps for that?" She's not asking for my benefit. She's asking for herself. Except this time, hanging up, I realize it's the fourth time this week I've hung up thinking the same thing. Four Supabase incidents, four fixes, four closed tickets. And not a single exception raised by the database. I reopen the three previous ones and lay all four side by side on screen. This isn't four bugs. It's one failure mode, declinated four times. The first three Episode 1 was about the default GRANT s Supabase places on functions and policies. A SQL function created without an explicit REVOKE inherits anon access that nobody wrote in the migration, and that nobody caught in review because the diff doesn't show it. The function works. It's just callable from outside. [CANONICAL URL EPISODE 1: to fill in after publication of #48 — "3 Supabase security incidents, one shared root cause: SECURITY DEFINER inherits EXECUTE TO PUBLIC"] Episode 2, an ON DELETE SET NULL cascade coupled with a CHECK NOT NULL on the target column. The parent DELETE attempts the SET NULL , the CHECK rejects it, and the transaction surfaces an error we read as a deletion failure — while it actually masks a consistency assumption we'd held for three months. The query fails loudly, which is more charitable than the other three cases, but the diagnosis heads in the wrong direction because nobody had declared that the two constraints lived in tension. [CANONICAL URL EPISODE 2: to fill in after publicati
AI 资讯
3 Supabase security incidents, one shared root cause: SECURITY DEFINER inherits EXECUTE TO PUBLIC
Episode 1/4 of the mini-series The week Supabase lied to me 4 times . The three following episodes cover a mutation silently swallowed by the SDK [CANONICAL URL EPISODE 2: to fill in after push], an RLS recursion resolved by a JWT hook [CANONICAL URL EPISODE 3: to fill in after push], and a query that stops at exactly 1000 rows without saying so [CANONICAL URL EPISODE 4: to fill in after push]. The Tuesday the security probe spoke It's 9:12am on a Tuesday in May. The daily drift probe has been running automatically for three weeks — an aclexplode query across all public objects, filtered on anon . I don't open it every morning. That morning, it's waiting for me with a row that has no business being there. Niran sets a coffee on the corner of my desk without a word. He reads the output over my shoulder. A PII backup table — personal data in plaintext, created two days earlier for a bulk reclassification — shows up in the list with SELECT , INSERT , UPDATE , DELETE granted to anon . Accessible to any unauthenticated curl request. He lets three seconds pass and says: "It's not RLS." Then he goes back to his hoodie. He's right. It's not an RLS bug. The table itself is open, at the GRANT layer, before RLS even applies. Three objects, three doors, one mechanism That week, I realize I'm not dealing with an isolated incident. Three distinct objects, in three different migrations, each open a door nobody thought they'd opened. The backup table first. Then a policy set TO public because the public landing page needs it, which lets a POST {} from anon through with an HTTP 400 NOT NULL response instead of 401 Unauthorized . And finally four SECURITY DEFINER functions written to execute transactional operations with their owner's privileges, all invocable by anon because EXECUTE defaults to TO PUBLIC at CREATE time. Three objects, three superficially distinct mechanisms, yet one shared root. At every CREATE , Postgres completes the migration with an implicit GRANT the author nev
AI 资讯
Your OTP regex assumes six digits. Supabase magic links don't.
Sign-in worked flawlessly in dev. Then a real user pasted a real code and got "invalid format" — before the code ever reached Supabase. The credential was fine. My regex was wrong. Here's the one-line assumption that broke auth for every human who wasn't me. I run a Discord-native Company Brain. Teams /save docs and /ask grounded answers; access is gated by a magic-link claim that emails a one-time code. Standard GoTrue OTP flow. The client shows a box, you paste the code, the server verifies it. Boring — which is exactly what auth should be. The bug: a six-digit assumption in a validation guard The claim handler did a cheap client-side sanity check before calling verifyOtp : // The bug. Looks reasonable. Rejects every real code. const OTP = /^ \d{6} $/ ; function normalize ( input : string ): string { const code = input . trim (); if ( ! OTP . test ( code )) throw new Error ( " Enter the 6-digit code from your email. " ); return code ; } Every OTP tutorial uses \d{6} . Every code demo shows six digits. So I typed six digits into the test and it passed. In dev I was generating my own codes and never actually reading the email. Supabase's GoTrue emits an eight-digit code on this project. ^\d{6}$ rejects eight digits outright. The user's perfectly valid credential got thrown out by my own front door with a lie for an error message — "enter the 6-digit code" when the email plainly showed eight. Why it happens: OTP length is a setting, not a constant The length of a GoTrue email OTP is configurable — GOTRUE_MAILER_OTP_LENGTH (Dashboard → Authentication → Email). It defaults to six in many setups and to eight in others depending on when and how the project was provisioned. The number in the tutorial is that author's project setting , not a property of OTPs. Hardcoding 6 couples your client to a server config you don't control and might change. Bump the length for security later and every client silently starts rejecting valid codes. No error in your logs — the rejection
AI 资讯
I Moved My Next.js Dashboard Logic Into Postgres. My Frontend Got Boring (And That's the Point).
My dashboard had a useMemo doing arithmetic it had no business doing. It was a Pokémon TCG Pocket collection tracker, but that part doesn't matter. What matters is that the home page needed to show three things: overall completion, completion per set, and which set you were closest to finishing. The way I'd built it, the browser was fetching every card and every owned record, then grinding through the math on each render to figure all of that out. It worked. It also got slower and harder to read every time the data grew or I added a metric. So I moved the aggregation out of React and into Postgres, and the surprising result was that my frontend got boring . Fewer hooks, less state, almost nothing left to break. That's the whole argument of this post: aggregation belongs in the database, and when you put it there, the React code that's left over is the kind of boring you actually want in the layer your users touch. What "fetching everything into React" actually looks like Here's the shape of the original dashboard. Load all the cards, load the user's owned rows, then derive everything on the client. const [ cards , setCards ] = useState < Card [] > ([]); const [ owned , setOwned ] = useState < OwnedCard [] > ([]); useEffect (() => { ( async () => { const { data : allCards } = await supabase . from ( " cards " ). select ( " * " ); const { data : ownedRows } = await supabase . from ( " user_cards " ) . select ( " card_id " ); setCards ( allCards ?? []); setOwned ( ownedRows ?? []); })(); }, []); const ownedIds = useMemo (() => new Set ( owned . map (( o ) => o . card_id )), [ owned ]); const perSet = useMemo (() => { const groups : Record < string , { total : number ; have : number } > = {}; for ( const card of cards ) { const g = ( groups [ card . set_id ] ??= { total : 0 , have : 0 }); g . total += 1 ; if ( ownedIds . has ( card . id )) g . have += 1 ; } return groups ; }, [ cards , ownedIds ]); const overall = useMemo (() => { const total = cards . length ; const ha
AI 资讯
I had real backend auth. The browser just walked around it.
Here's the thing nobody warns you about when you put Supabase behind a "real" backend. My stack is React + FastAPI + Supabase Postgres. Every write goes through FastAPI. Every endpoint checks the user, the role, the ownership. I audited that backend HARD — rate limits, JWT validation, RLS, the whole thing. I was proud of it. And none of it mattered for the two holes I actually shipped. Because the Supabase anon key lives in the browser. It HAS to — that's how supabase-js talks to your project. Which means every logged-in user is holding a key that talks to Postgres directly . Not through my FastAPI. Around it. That anon key is a SECOND API. And I'd spent months hardening the first one while the second one sat there, wide open, the whole time. Hole #1 — the answers were just... readable Quiz questions live in quiz_options , one is_correct boolean per option. My backend never sends is_correct to a student before they submit. Obviously. But the browser doesn't have to ask my backend. // any logged-in student, straight from the console: const { data } = await supabase . from ( ' quiz_options ' ) . select ( ' question_id, label, is_correct ' ) // <- the answer key. all of it. The RLS policy said "authenticated users can read quiz_options ." Totally true for the rows. It just also handed back the column that decides the grade. The answer key. To anyone with a login and ten seconds of curiosity. Fix: column-level REVOKE SELECT from the client role, and let the backend be the only thing that ever reads is_correct . (PR #775.) Hole #2 — they could WRITE things they shouldn't Same class of bug, bigger blast radius. The default Postgres grants let the client role insert/update far more than I'd realized — including a path toward forging a certificate. Nobody did it. But "nobody did it yet" is not a security model! So I stopped patching table by table and flipped the whole thing: -- kill the client's entire write surface, then grant back the ONE thing it needs ALTER DEFAULT PRI
AI 资讯
I Built 'Chat With Your Docs' From Scratch — Supabase + pgvector + a Free Local Embedder
"Chat with your PDF / your notes / your docs" is everywhere. Today we build it from scratch and you'll see it's just three moves : retrieve, then generate — with one prompt trick that stops the hallucinations. This is Day 46 of TechFromZero. Yesterday (Day 45) we built the retrieval half with pgvector. Today we add the answer half and host it on Supabase. RAG in one line Find the relevant chunks of your documents, paste them into the prompt, and tell the model to answer using only those. That's Retrieval-Augmented Generation. The "augmented" part is just stuffing real context into the prompt so the model isn't guessing from memory. 1. Storage: Supabase is Postgres, so pgvector is one click Supabase is hosted Postgres with an auto-generated API. Because it's just Postgres , vector search needs no separate database: create extension if not exists vector ; create table documents ( id bigserial primary key , content text , embedding vector ( 384 ) ); -- one RPC the app calls to get the closest chunks create function match_documents ( query_embedding vector ( 384 ), match_count int ) returns table ( id bigint , content text , similarity float ) language sql stable as $$ select id , content , 1 - ( embedding <=> query_embedding ) as similarity from documents order by embedding <=> query_embedding limit match_count ; $$ ; 2. Ingest: chunk → embed → store Split your docs into paragraph-sized chunks, embed each with a free local model (all-MiniLM-L6-v2 via Transformers.js — no key, nothing leaves your machine), and insert the row + vector: const embedding = await embed ( chunk ); // 384 numbers await supabase . from ( " documents " ). insert ({ content : chunk , embedding }); Chunk size matters: too big buries the answer in noise, too small loses meaning. A few hundred characters is a good start. 3. Retrieve + Generate (the payoff) Embed the question with the same model, ask Supabase for the closest chunks, then hand them to the LLM: const query_embedding = await embed ( que
AI 资讯
I Cut My Next.js + Supabase App Load Time by 73% - Here Are the 5 Techniques That Actually Worked
I Cut My Next.js + Supabase App Load Time by 73% - Here Are the 5 Techniques That Actually Worked Last month, our SaaS dashboard was embarrassingly slow . 4.2 seconds to load the main page. Users were complaining. Conversion rates were tanking. Today? 1.1 seconds . 73% faster. Here's exactly what worked (and what didn't). The Problem: Death by a Thousand Database Calls Our dashboard showed user projects, team members, recent activity, and notifications. Sounds simple, right? Wrong. Each component was making its own database calls. The projects list fetched projects, then made separate calls for each project's stats. The activity feed loaded events, then fetched user details for each event. Classic N+1 query problem, but worse. Technique #1: Strategic Data Fetching Consolidation Before: 47 database calls to load the dashboard After: 3 database calls The fix wasn't fancy. We consolidated related data into single queries using Supabase's nested select syntax: // ❌ Before: Multiple separate calls const projects = await supabase . from ( ' projects ' ). select ( ' * ' ) const stats = await Promise . all ( projects . map ( p => supabase . from ( ' project_stats ' ). select ( ' * ' ). eq ( ' project_id ' , p . id )) ) // ✅ After: Single consolidated call const projects = await supabase . from ( ' projects ' ) . select ( ` *, project_stats(*), team_members(count), recent_activity:activities(*, user:users(name, avatar_url)) ` ) . limit ( 10 ) Result: Dashboard load time dropped from 4.2s to 2.8s (33% improvement) Technique #2: Aggressive Caching with Smart Invalidation Most dashboard data doesn't change every second. We implemented a three-tier caching strategy: // Static data: Cache indefinitely const categories = await supabase . from ( ' categories ' ) . select ( ' * ' ) . cache ({ revalidate : false }) // Semi-static data: Cache with revalidation const userProjects = await supabase . from ( ' projects ' ) . select ( ' * ' ) . eq ( ' user_id ' , userId ) . cache ({ revali
AI 资讯
Next.js + Supabase Performance Optimization: From Slow to Lightning Fast
Next.js + Supabase Performance Optimization: From Slow to Lightning Fast Last month, I optimized a Next.js + Supabase application that was frustratingly slow. Initial page load took 4.2 seconds, Lighthouse performance score was 62, and users were complaining. After applying these optimization techniques, we achieved: 70% faster load times (4.2s → 1.3s) Lighthouse score of 96 (up from 62) LCP improved by 65% (3.8s → 1.3s) 50% reduction in database queries Here's exactly how we did it. The Starting Point: Measuring Performance Before optimizing anything, we measured current performance using: Lighthouse (Chrome DevTools): Performance: 62 First Contentful Paint (FCP): 2.1s Largest Contentful Paint (LCP): 3.8s Total Blocking Time (TBT): 420ms Cumulative Layout Shift (CLS): 0.18 Real User Monitoring: Average page load: 4.2s Time to Interactive: 5.1s Database query time: 850ms average The Problems: Unoptimized database queries No caching strategy Large JavaScript bundles Unoptimized images Blocking render paths Too many client-side fetches Let's fix each one. 1. Database Query Optimization Problem: N+1 Queries The biggest performance killer was N+1 queries. We were fetching posts, then fetching the author for each post individually. // ❌ Bad: N+1 queries (1 + N database calls) async function getPosts () { const { data : posts } = await supabase . from ( ' posts ' ) . select ( ' id, title, author_id ' ) // Fetching author for each post = N queries const postsWithAuthors = await Promise . all ( posts . map ( async ( post ) => { const { data : author } = await supabase . from ( ' users ' ) . select ( ' name, avatar ' ) . eq ( ' id ' , post . author_id ) . single () return { ... post , author } }) ) return postsWithAuthors } Impact: 50 posts = 51 database queries (850ms total) Solution: Use Joins // ✅ Good: Single query with join (1 database call) async function getPosts () { const { data : posts } = await supabase . from ( ' posts ' ) . select ( ` id, title, author:users(nam
开源项目
Database Migration Strategies for Next.js and Supabase Production Apps
Database Migration Strategies for Next.js and Supabase Production Apps You've built your Next.js app with Supabase. It works perfectly in development. Now you need to deploy to production and realize: how do I safely change the database schema without breaking everything? Database migrations are how you version control your schema and deploy changes safely. This guide covers everything from basic migrations to zero-downtime production deployments. Prerequisites Supabase project (local and production) Supabase CLI installed Next.js application Git for version control Understanding Migrations A migration is a SQL file that changes your database schema: -- supabase/migrations/20260314120000_add_posts_table.sql CREATE TABLE posts ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4 (), title TEXT NOT NULL , content TEXT , user_id UUID REFERENCES auth . users ( id ), created_at TIMESTAMPTZ DEFAULT NOW () ); ALTER TABLE posts ENABLE ROW LEVEL SECURITY ; CREATE POLICY "Users can view own posts" ON posts FOR SELECT USING ( auth . uid () = user_id ); Migrations are: Versioned: Timestamped filenames ensure order Tracked: Supabase knows which migrations have run Repeatable: Same migrations produce same result Reversible: You can write rollback logic Setting Up Migrations Initialize Supabase locally: npx supabase init This creates: supabase/ config.toml seed.sql migrations/ Link to your remote project: npx supabase link --project-ref your-project-ref Creating Your First Migration Create a new migration: npx supabase migration new create_posts_table This creates: supabase / migrations / 20260314120000 _create_posts_table . sql Write your schema changes: -- Create posts table CREATE TABLE posts ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4 (), title TEXT NOT NULL , content TEXT NOT NULL , slug TEXT UNIQUE NOT NULL , user_id UUID REFERENCES auth . users ( id ) ON DELETE CASCADE , published BOOLEAN DEFAULT FALSE , created_at TIMESTAMPTZ DEFAULT NOW (), updated_at TIMESTAMPTZ DEFAULT NOW
AI 资讯
7 Things I Wish I Knew Before Scaling Next.js + Supabase to 100K Users
7 Things I Wish I Knew Before Scaling Next.js + Supabase to 100K Users Six months ago, we launched our SaaS with Next.js and Supabase. The stack was perfect for our MVP: fast development, great DX, and it just worked. Then we hit 10K users. Then 50K. Then 100K. Everything that worked beautifully at small scale started breaking. Database queries that took 50ms now took 5 seconds. Our Supabase bill went from $25/month to $800/month. Users complained about slow page loads. Here's what I wish someone had told me before we started. 1. RLS Policies Are Not Optional (Even in Development) We skipped RLS in development. "We'll add it before launch," we said. Launch day came. We enabled RLS on all tables. The app broke in 47 different places. Queries that worked suddenly returned empty arrays. Inserts failed with permission errors. We spent 12 hours fixing RLS policies while users waited. What I'd do differently: Enable RLS from day one. Write policies as you create tables: CREATE TABLE posts ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4 (), title TEXT NOT NULL , user_id UUID REFERENCES auth . users ( id ) ); -- Enable RLS immediately ALTER TABLE posts ENABLE ROW LEVEL SECURITY ; -- Write policies now, not later CREATE POLICY "Users can view own posts" ON posts FOR SELECT USING ( auth . uid () = user_id ); Test with RLS enabled. If it works in development, it'll work in production. 2. Database Indexes Are Not Premature Optimization "We'll add indexes when we need them." We needed them on day 3. Our posts feed query went from 50ms to 8 seconds as we hit 10K posts. Users complained. We scrambled to add indexes during peak traffic. The query: const { data } = await supabase . from ( ' posts ' ) . select ( ' *, profiles(*) ' ) . eq ( ' published ' , true ) . order ( ' created_at ' , { ascending : false }) . limit ( 20 ) The fix: CREATE INDEX posts_published_created_at_idx ON posts ( published , created_at DESC ) WHERE published = true ; Query time dropped to 12ms. What I'd do di
AI 资讯
Self-Host Postgres or Use Supabase? Here's How to Decide
Short answer first: use Supabase if you want Postgres plus auth, realtime, storage, and a dashboard as one managed bundle. Self-host Postgres – or use a managed Postgres – if you mostly need a database and your app already handles its own auth and logic. The choice is not really "Postgres vs Supabase". It's whether you need the extra layers Supabase puts on top of Postgres. Supabase is not a database Supabase runs on PostgreSQL, but it's a stack of services around it: Postgres – the actual database Auth – user signup, login, JWT tokens Realtime – live updates over websockets Storage – an S3-style file store Edge Functions – serverless functions Studio – dashboard + auto-generated REST/GraphQL API So "self-host Postgres or use Supabase" compares a plain database to a full backend. The honest question: do you need those extra layers, or just the database underneath them? A quick test: You use Supabase Auth, Storage, and Realtime → Supabase earns its place. You use one of them → it's replaceable. You use none and treat it as "Postgres with a nice dashboard" → you want plain Postgres. Side-by-side comparison Factor Supabase (managed) Self-hosted Supabase Plain Postgres (managed or self-hosted) Database engine PostgreSQL PostgreSQL PostgreSQL Built-in auth Yes Yes No (bring your own) Realtime / websockets Yes Yes No File storage Yes Yes No Dashboard + auto API Yes Yes No (use any SQL client) Backups Managed (limits by plan) You manage Managed or you manage Cost shape Metered, grows with usage Server cost + your time Database only Self-host effort None High (many containers) Low–medium Lock-in Medium–high Medium Very low The lock-in point decides it for many teams. Your data is standard Postgres in every option ( pg_dump portable). The lock-in is everything else: Auth tokens, Storage paths, Supabase-specific RLS policies, Edge Function code. The more Supabase-specific features you adopt, the harder the exit. When each option wins Pick managed Supabase when: You're startin
AI 资讯
How to build a credit system for a Next.js AI app (Stripe + Supabase)
If you're building an AI app (image generation, transcription, an agent, anything that calls a model) you've probably realized a flat "$10/month" doesn't work. Every action costs you real money in GPU/API spend, so a single power user can torch your margins. The answer is usage credits : users buy a balance, each action spends some. Credits sound trivial. They are not. I've shipped about 10 small AI/SaaS apps, and the credit layer is where I got burned every single time. It took three patterns to fix it for good. Here they are, with copy-pasteable code for Next.js + Supabase + Stripe. Get these right and your billing won't oversell, double-charge, or strand a user's money. The three things everyone gets wrong Overdrawing. Two requests arrive at once, both read "balance = 1," both spend. Now the balance is negative and you gave away work for free. Double-granting. Stripe retries webhooks (it will ), and if you grant credits on every delivery, a $9 purchase becomes $18 of credits. Forgetting the refund. The AI job fails after you've already charged the credits. The user paid for nothing and emails you angry. Let's kill all three. Part 1. The atomic spend (overdraw becomes impossible) The mistake is doing the check in your app code: // DON'T: read-then-write has a race condition const { balance } = await getBalance ( userId ); if ( balance < cost ) throw new Error ( " insufficient " ); await setBalance ( userId , balance - cost ); // two concurrent requests both pass the check Do it in the database, in one statement, with the guard in the WHERE clause: -- balances: one row per user create table credit_balances ( user_id uuid primary key references auth . users ( id ) on delete cascade , balance integer not null default 0 check ( balance >= 0 ), updated_at timestamptz not null default now () ); -- append-only ledger = audit log + idempotency guard (see Part 2) create table credit_ledger ( id bigint generated always as identity primary key , user_id uuid not null referen
AI 资讯
Supabase doubles valuation to $10B in 8 months
Supabase, an example of an open source project becoming a fast-growing company, has greatly benefited from AI tools like Claude, Codex, and other vibe-coding platforms.
AI 资讯
I built PhysioFlow — clinic software for Indian physiotherapists, solo in a week
A physiotherapist asked me a simple question a couple of months ago: "Can you build something to run my whole clinic?" So I did — solo, in about a week. Here's the full 2.5-minute walkthrough 👇 What PhysioFlow does PhysioFlow runs an entire physiotherapy clinic from one screen — built for India (₹, GST, WhatsApp, +91): Dashboard — attendance, collections & pending bookings at a glance Patient files — recharge session packs, track usage, auto-generate GST-ready bills Attendance in seconds with a QR scan Online bookings that convert straight into a patient file Reports — daily ledger, revenue, CSV/PDF export Patient portal — patients see their own sessions & prescriptions The stack Next.js + Supabase + TypeScript — multi-tenant, with row-level security so no clinic's data ever leaks to another. Try it Live now with a 14-day free trial, no card needed → https://physioflow.devfrend.com I'm Amar, a full-stack & AI engineer. I design and build products like this end-to-end. I'm open to work — SaaS builds, MCP servers, LLM apps & automation. Reach me on LinkedIn .