Discussion

Architecture sûre pour le démarrage à froid avec Expo + Supabase

De Wikiprompt, l’encyclopédie libre de prompts

Tugrul Erdem
Contribué parTugrul ErdemSource

25 déc. 2025

Architecture sûre pour le démarrage à froid avec Expo + Supabase Une invite complète pour concevoir une architecture d'application mobile évolutive et sûre en cas de démarrage à froid, utilisant Expo, Supabase et un service worker séparé pour la génération IA.

Contenu du PromptEnregistrer

🌐
A) Architecture summary The architecture separates concerns to ensure cold-start safety and scalability: - **Expo client** handles UI, optimistic updates, and realtime subscriptions. - **Supabase Edge Functions** provide lightweight gating (auth, credits) and enqueue jobs; they never run heavy AI work. - **Supabase Postgres + Storage + Realtime** store job state, generation results, and stream updates. - **Worker service** (separate process) polls for queued jobs, runs AI generation, uploads to storage, and updates records with retry/idempotency. This keeps Edge Functions fast (cold-start friendly) and moves heavy compute off the critical path. --- B) Migrations (SQL) ```sql -- 1. Enable extensions create extension if not exists "pgcrypto"; -- 2. Entitlements table (credits / is_paid) create table public.entitlements ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) on delete cascade, credits_remaining integer not null default 0 check (credits_remaining >= 0), is_paid boolean not null default false, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); -- 3. Jobs table create table public.jobs ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) on delete cascade, status text not null default 'queued' check (status in ('queued', 'processing', 'completed', 'failed')), input_payload jsonb not null, error_message text, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); -- 4. Generations table (always created, even on failure) create table public.generations ( id uuid primary key default gen_random_uuid(), job_id uuid not null references public.jobs(id) on delete cascade, user_id uuid not null references auth.users(id) on delete cascade, status text not null default 'pending' check (status in ('pending', 'completed', 'failed')), original_input jsonb not null, storage_path text, failure_reason text, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); -- 5. Indexes create index idx_jobs_user_id on public.jobs(user_id); create index idx_jobs_status_created on public.jobs(status, created_at); create index idx_generations_user_id on public.generations(user_id); create index idx_generations_job_id on public.generations(job_id); -- 6. RLS notes -- Enable RLS on all tables alter table public.entitlements enable row level security; alter table public.jobs enable row level security; alter table public.generations enable row level security; -- Policies (simplified - adjust per app needs) create policy "Users read own entitlements" on public.entitlements for select using (auth.uid() = user_id); create policy "Users read own jobs" on public.jobs for select using (auth.uid() = user_id); create policy "Users insert own jobs" on public.jobs for insert with check (auth.uid() = user_id); create policy "Users read own generations" on public.generations for select using (auth.uid() = user_id); -- Note: Worker service uses service_role key (bypasses RLS) for writes. ``` --- C) Edge function file structure + key code blocks ``` supabase/functions/ ping/index.ts enqueue_generation/index.ts get_job_status/index.ts ``` **ping/index.ts** (HEAD/GET - minimal) ```ts import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; serve(async (req) => { if (req.method === "HEAD" || req.method === "GET") { return new Response("ok", { status: 200 }); } return new Response("Method not allowed", { status: 405 }); }); ``` **enqueue_generation/index.ts** (validate auth, check credits, create job) ```ts import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; const supabase = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! ); serve(async (req) => { if (req.method !== "POST") { return new Response("Method not allowed", { status: 405 }); } const authHeader = req.headers.get("Authorization"); if (!authHeader) { return new Response("Unauthorized", { status: 401 }); } // Verify JWT (minimal - use supabase-js auth) const { data: { user }, error: authError } = await supabase.auth.getUser(authHeader.replace("Bearer ", "")); if (authError || !user) { return new Response("Unauthorized", { status: 401 }); } // Check entitlements const { data: entitlement } = await supabase .from("entitlements") .select("credits_remaining, is_paid") .eq("user_id", user.id) .single(); if (!entitlement || (entitlement.credits_remaining <= 0 && !entitlement.is_paid)) { return new Response("Insufficient credits", { status: 403 }); } const body = await req.json(); const { input_payload } = body; // Create job const { data: job, error: jobError } = await supabase .from("jobs") .insert({ user_id: user.id, input_payload }) .select() .single(); if (jobError) { return new Response("Failed to create job", { status: 500 }); } // Decrement credits (if not paid) if (!entitlement.is_paid) { await supabase .from("entitlements") .update({ credits_remaining: entitlement.credits_remaining - 1 }) .eq("user_id", user.id); } return new Response(JSON.stringify({ jobId: job.id }), { headers: { "Content-Type": "application/json" }, status: 200, }); }); ``` **get_job_status/index.ts** (light read) ```ts import { serve } from "https://deno.land/std@0.177.0/http/server.ts"; import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; const supabase = createClient( Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! ); serve(async (req) => { const url = new URL(req.url); const jobId = url.searchParams.get("jobId"); if (!jobId) { return new Response("Missing jobId", { status: 400 }); } const { data: job } = await supabase .from("jobs") .select("id, status, error_message, updated_at") .eq("id", jobId) .single(); if (!job) { return new Response("Job not found", { status: 404 }); } return new Response(JSON.stringify(job), { headers: { "Content-Type": "application/json" }, status: 200, }); }); ``` --- D) Expo integration notes + key code blocks **Non-blocking warm ping on app start** ```tsx // app/_layout.tsx or App.tsx useEffect(() => { // Fire-and-forget ping - never blocks launch fetch("https://<project-ref>.functions.supabase.co/ping", { method: "HEAD", }).catch(() => { // Silently ignore - cold start will happen on first real call }); }, []); ``` **Generate button with optimistic UI** ```tsx const [jobs, setJobs] = useState<Job[]>([]); const [pendingJob, setPendingJob] = useState<Job | null>(null); const handleGenerate = async (input: string) => { // Optimistic placeholder const tempJob: Job = { id: `temp-${Date.now()}`, status: "queued", input_payload: { prompt: input }, created_at: new Date().toISOString(), }; setPendingJob(tempJob); setJobs((prev) => [tempJob, ...prev]); try { const { data } = await supabase.functions.invoke("enqueue_generation", { body: { input_payload: { prompt: input } }, }); // Replace temp with real job setJobs((prev) => prev.map((j) => (j.id === tempJob.id ? { ...j, id: data.jobId } : j)) ); setPendingJob({ ...tempJob, id: data.jobId }); } catch (error) { // Rollback optimistic UI setJobs((prev) => prev.filter((j) => j.id !== tempJob.id)); setPendingJob(null); Alert.alert("Error", "Failed to enqueue generation"); } }; ``` **Subscribe to job updates via Realtime (with polling fallback)** ```tsx useEffect(() => { if (!pendingJob) return; // Realtime subscription const channel = supabase .channel(`job-${pendingJob.id}`) .on( "postgres_changes", { event: "UPDATE", schema: "public", table: "jobs", filter: `id=eq.${pendingJob.id}`, }, (payload) => { const updatedJob = payload.new as Job; setJobs((prev) => prev.map((j) => (j.id === updatedJob.id ? updatedJob : j)) ); if (updatedJob.status === "completed" || updatedJob.status === "failed") { setPendingJob(null); // Fetch generation to replace placeholder fetchGeneration(updatedJob.id); } } ) .subscribe(); // Polling fallback (every 5s) const pollInterval = setInterval(async () => { const { data } = await supabase.functions.invoke("get_job_status", { query: { jobId: pendingJob.id }, }); if (data && data.status !== "queued" && data.status !== "processing") { setJobs((prev) => prev.map((j) => (j.id === data.id ? { ...j, ...data } : j)) ); setPendingJob(null); fetchGeneration(data.id); clearInterval(pollInterval); } }, 5000); return () => { supabase.removeChannel(channel); clearInterval(pollInterval); }; }, [pendingJob?.id]); ``` **Final generation replaces placeholder in gallery** ```tsx const fetchGeneration = async (jobId: string) => { const { data } = await supabase .from("generations") .select("*") .eq("job_id", jobId) .single(); if (data) { setJobs((prev) => prev.map((j) => j.id === jobId ? { ...j, generation: data, status: "completed" } : j ) ); } }; ``` --- E) Worker outline + pseudo-code **Worker service** (Node.js/TypeScript, separate process) ``` worker/ src/ index.ts // Main loop ai.ts // AI generation logic storage.ts // Supabase Storage upload db.ts // Supabase client (service_role) ``` **Pseudo-code** ```ts // index.ts - main loop const POLL_INTERVAL = 5000; // 5s const MAX_RETRIES = 3; async function main() { while (true) { try { // Fetch queued jobs (oldest first) const { data: jobs } = await supabase .from("jobs") .select("*") .eq("status", "queued") .order("created_at", { ascending: true }) .limit(10); for (const job of jobs || []) { await processJob(job); } } catch (error) { console.error("Worker loop error:", error); } await sleep(POLL_INTERVAL); } } async function processJob(job: Job) { // Mark as processing (idempotency check) const { data: updated } = await supabase .from("jobs") .update({ status: "processing", updated_at: new Date().toISOString() }) .eq("id", job.id) .eq("status", "queued") // Only if still queued .select() .single(); if (!updated) return; // Already processed by another worker let retries = 0; while (retries < MAX_RETRIES) { try { // 1. Run AI generation const result = await runAIGeneration(job.input_payload); // 2. Upload to storage const storagePath = `generations/${job.user_id}/${job.id}.png`; await uploadToStorage(storagePath, result.imageBuffer); // 3. Update job + insert generation await supabase.from("jobs").update({ status: "completed", updated_at: new Date().toISOString(), }).eq("id", job.id); await supabase.from("generations").insert({ job_id: job.id, user_id: job.user_id, status: "completed", original_input: job.input_payload, storage_path: storagePath, }); return; // Success } catch (error) { retries++; if (retries >= MAX_RETRIES) { // Final failure - still create generation record await supabase.from("jobs").update({ status: "failed", error_message: error.message, updated_at: new Date().toISOString(), }).eq("id", job.id); await supabase.from("generations").insert({ job_id: job.id, user_id: job.user_id, status: "failed", original_input: job.input_payload, failure_reason: error.message, }); } else { await sleep(1000 * retries); // Exponential backoff } } } } ``` **Key idempotency measures:** - Job status transitions use conditional updates (`WHERE status = 'queued'`) - Generation records are created exactly once per job (unique job_id) - Storage paths are deterministic (`user_id/job_id`) - Retry with exponential backoff (1s, 2s, 4s)

Connectez-vous pour voir le prompt complet

Continuer avec:

En vous connectant, vous acceptez nos Conditions et Confidentialité

Utilisation

Ce prompt est conçu pour être utilisé avec coding. Copiez le contenu ci-dessus et collez-le dans votre outil d’IA préféré.

Pour de meilleurs résultats, personnalisez les espaces réservés (indiqués par des crochets ou des majuscules) selon vos besoins.

Références

Catégories :coding| prompts.chat| expo| supabase

Discussion