Architecture sûre pour le démarrage à froid avec Expo + Supabase
De Wikiprompt, l’encyclopédie libre de prompts
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.
Discussion
0 commentaires