diff --git a/CLAUDE.md b/CLAUDE.md index 946096c..8e8c0db 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Status -**Fase 0 complete. Fase 1 complete. Fase 2a complete. Full test suite green (54 Vitest + 12 pgTAP + 15 Playwright = 81 tests). ✅** +**Fase 0 complete. Fase 1 complete. Fase 2a complete. Fase 2b — Realtime infra prep done and R-series tests green. Full test suite green (57 Vitest + 16 pgTAP + 15 Playwright = 88 tests). ✅** - `README.md` — full development plan, confirmed tech stack, Justfile reference, and technical warnings - `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules) diff --git a/Justfile b/Justfile index 9781d31..9f26d92 100644 --- a/Justfile +++ b/Justfile @@ -105,9 +105,12 @@ test-all: test-db test-integration test-e2e # `set dotenv-load` at the top of this file sources .env so POSTGRES_PASSWORD # is available — we forward it to psql via PGPASSWORD. test-db: - PGPASSWORD="$POSTGRES_PASSWORD" psql -U postgres -h localhost -d postgres -v ON_ERROR_STOP=1 -f supabase/tests/001_accept_invitation.sql - PGPASSWORD="$POSTGRES_PASSWORD" psql -U postgres -h localhost -d postgres -v ON_ERROR_STOP=1 -f supabase/tests/002_item_frequency_trigger.sql - PGPASSWORD="$POSTGRES_PASSWORD" psql -U postgres -h localhost -d postgres -v ON_ERROR_STOP=1 -f supabase/tests/003_promote_on_admin_leave.sql + #!/usr/bin/env bash + set -euo pipefail + for f in supabase/tests/*.sql; do + PGPASSWORD="$POSTGRES_PASSWORD" psql -U postgres -h localhost -d postgres \ + -v ON_ERROR_STOP=1 -f "$f" + done # Run Vitest integration tests (RLS + trigger assertions via supabase-js) test-integration: diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index 687d50d..e986a3b 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -92,12 +92,21 @@ services: PORT: 4000 DB_HOST: db DB_PORT: 5432 - DB_USER: supabase_replication_admin + # Realtime auto-creates the `realtime` schema (messages, subscription, etc.) + # on first connect per tenant. It needs CREATE ON DATABASE + ownership, which + # only supabase_admin (superuser) reliably has. Using + # supabase_replication_admin fails with `permission denied for schema realtime`. + DB_USER: supabase_admin DB_PASSWORD: ${POSTGRES_PASSWORD:-postgres} DB_NAME: postgres DB_AFTER_CONNECT_QUERY: SET search_path TO _realtime DB_ENC_KEY: supabaserealtime API_JWT_SECRET: ${SUPABASE_JWT_SECRET} + # supabase-js resolves the tenant from the URL subdomain; for bare + # localhost with no subdomain it defaults to "realtime". The Realtime + # seed would otherwise create a tenant named "realtime-dev", which the + # client then fails to find → `TenantNotFound`. + SELF_HOST_TENANT_NAME: realtime FLY_ALLOC_ID: fly123 FLY_APP_NAME: realtime APP_NAME: realtime diff --git a/packages/test-utils/src/index.ts b/packages/test-utils/src/index.ts index c16c841..27434e9 100644 --- a/packages/test-utils/src/index.ts +++ b/packages/test-utils/src/index.ts @@ -1,3 +1,4 @@ export * from './seed-constants.js'; export * from './supabase-clients.js'; export * from './db-helpers.js'; +export * from './realtime-helpers.js'; diff --git a/packages/test-utils/src/realtime-helpers.ts b/packages/test-utils/src/realtime-helpers.ts new file mode 100644 index 0000000..fe4b1da --- /dev/null +++ b/packages/test-utils/src/realtime-helpers.ts @@ -0,0 +1,97 @@ +import type { RealtimeChannel, SupabaseClient } from '@supabase/supabase-js'; + +/** + * Subscribe to `postgres_changes` on a table + optional filter for a given + * client, and return the channel plus a promise-based `waitFor` that resolves + * on the next event matching the given predicate. Always call `unsubscribe()` + * in a `finally` block — the WebSocket stays open otherwise and leaks across + * tests. + * + * The `SUBSCRIBED` handshake is awaited before the function resolves, so + * callers can start mutating immediately and trust that events will be caught. + */ +export async function subscribePostgresChanges>( + client: SupabaseClient, + params: { + table: string; + schema?: string; + filter?: string; + event?: 'INSERT' | 'UPDATE' | 'DELETE' | '*'; + channelName?: string; + } +): Promise<{ + channel: RealtimeChannel; + events: Array<{ eventType: string; new: T; old: T | null }>; + waitFor: ( + predicate: (evt: { eventType: string; new: T; old: T | null }) => boolean, + timeoutMs?: number + ) => Promise<{ eventType: string; new: T; old: T | null }>; + unsubscribe: () => Promise; +}> { + const events: Array<{ eventType: string; new: T; old: T | null }> = []; + const waiters: Array<{ + predicate: (e: { eventType: string; new: T; old: T | null }) => boolean; + resolve: (e: { eventType: string; new: T; old: T | null }) => void; + }> = []; + + const channel = client.channel(params.channelName ?? `test:${params.table}:${Date.now()}`); + + channel.on( + // @ts-expect-error — supabase-js types require a specific literal-union, filter + // is a runtime string so the narrow overload complains + 'postgres_changes', + { + event: params.event ?? '*', + schema: params.schema ?? 'public', + table: params.table, + ...(params.filter ? { filter: params.filter } : {}) + }, + (payload: { eventType: string; new: T; old: T | null }) => { + const evt = { eventType: payload.eventType, new: payload.new, old: payload.old }; + events.push(evt); + for (let i = waiters.length - 1; i >= 0; i--) { + if (waiters[i].predicate(evt)) { + waiters[i].resolve(evt); + waiters.splice(i, 1); + } + } + } + ); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Realtime SUBSCRIBED timeout')), 10_000); + channel.subscribe((status) => { + if (status === 'SUBSCRIBED') { + clearTimeout(timeout); + resolve(); + } else if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT' || status === 'CLOSED') { + clearTimeout(timeout); + reject(new Error(`Realtime subscribe failed: ${status}`)); + } + }); + }); + + function waitFor( + predicate: (e: { eventType: string; new: T; old: T | null }) => boolean, + timeoutMs = 5_000 + ) { + const existing = events.find(predicate); + if (existing) return Promise.resolve(existing); + return new Promise<{ eventType: string; new: T; old: T | null }>((resolve, reject) => { + const t = setTimeout(() => reject(new Error('waitFor timeout')), timeoutMs); + waiters.push({ + predicate, + resolve: (e) => { + clearTimeout(t); + resolve(e); + } + }); + }); + } + + async function unsubscribe() { + await channel.unsubscribe(); + } + + return { channel, events, waitFor, unsubscribe }; +} diff --git a/packages/test-utils/src/supabase-clients.ts b/packages/test-utils/src/supabase-clients.ts index b3ffb38..17a5769 100644 --- a/packages/test-utils/src/supabase-clients.ts +++ b/packages/test-utils/src/supabase-clients.ts @@ -26,10 +26,14 @@ async function signToken(userId: string): Promise { /** * Create a Supabase client that authenticates as the given seed user. * RLS policies see auth.uid() = userId. + * + * Also calls `realtime.setAuth(token)` so Realtime subscriptions carry the + * same identity — the server evaluates RLS against this JWT when deciding + * which Postgres-changes events to forward. */ export async function createClientAs(userId: string) { const token = await signToken(userId); - return createClient( + const client = createClient( requiredEnv('PUBLIC_SUPABASE_URL'), requiredEnv('PUBLIC_SUPABASE_ANON_KEY'), { @@ -37,6 +41,9 @@ export async function createClientAs(userId: string) { auth: { persistSession: false, autoRefreshToken: false } } ); + // @ts-expect-error — realtime.setAuth exists at runtime on supabase-js v2 + client.realtime.setAuth(token); + return client; } /** diff --git a/packages/test-utils/tests/realtime-postgres-changes.test.ts b/packages/test-utils/tests/realtime-postgres-changes.test.ts new file mode 100644 index 0000000..c090f6f --- /dev/null +++ b/packages/test-utils/tests/realtime-postgres-changes.test.ts @@ -0,0 +1,157 @@ +/** + * R-series: Supabase Realtime `postgres_changes` over `shopping_items`. + * + * Verifies the end-to-end path: mutation in Postgres → WAL → supabase_realtime + * publication → Realtime service → WebSocket → subscribed client. + * + * Migration 007 adds shopping_items to the publication and sets REPLICA + * IDENTITY FULL. Without either, these tests fail with timeouts. + */ +import { describe, it, expect, afterAll } from 'vitest'; +import { createClientAs, createAdminClient } from '../src/supabase-clients.js'; +import { subscribePostgresChanges } from '../src/realtime-helpers.js'; +import { + ANA_ID, + BORJA_ID, + SEED_LIST_ID, + COLLECTIVE_ID +} from '../src/seed-constants.js'; + +const admin = createAdminClient(); +const createdItemIds: string[] = []; + +afterAll(async () => { + if (createdItemIds.length > 0) { + await admin.from('shopping_items').delete().in('id', createdItemIds); + } +}); + +describe('Realtime postgres_changes — shopping_items', () => { + it('R-01: INSERT made by Ana reaches Borja subscribed to the same list', async () => { + const borja = await createClientAs(BORJA_ID); + const ana = await createClientAs(ANA_ID); + const sub = await subscribePostgresChanges<{ id: string; list_id: string; name: string }>( + borja, + { + table: 'shopping_items', + event: 'INSERT', + filter: `list_id=eq.${SEED_LIST_ID}` + } + ); + + try { + const itemName = `R-01-item-${Date.now()}`; + const { data, error } = await ana + .from('shopping_items') + .insert({ list_id: SEED_LIST_ID, name: itemName, sort_order: 900, created_by: ANA_ID }) + .select('id') + .single(); + expect(error).toBeNull(); + if (data?.id) createdItemIds.push(data.id); + + const evt = await sub.waitFor((e) => e.eventType === 'INSERT' && e.new.name === itemName); + expect(evt.new.list_id).toBe(SEED_LIST_ID); + expect(evt.new.name).toBe(itemName); + } finally { + await sub.unsubscribe(); + await borja.removeAllChannels(); + await ana.removeAllChannels(); + } + }); + + it('R-02: UPDATE broadcasts include the full row (REPLICA IDENTITY FULL)', async () => { + const ana = await createClientAs(ANA_ID); + const borja = await createClientAs(BORJA_ID); + + // Seed an item owned by Ana to mutate + const { data: item } = await admin + .from('shopping_items') + .insert({ list_id: SEED_LIST_ID, name: 'R-02-seed', sort_order: 901, created_by: ANA_ID }) + .select('id') + .single(); + createdItemIds.push(item!.id); + + const sub = await subscribePostgresChanges<{ + id: string; + list_id: string; + name: string; + is_checked: boolean; + }>(borja, { + table: 'shopping_items', + event: 'UPDATE', + filter: `list_id=eq.${SEED_LIST_ID}` + }); + + try { + await ana + .from('shopping_items') + .update({ is_checked: true, checked_by: ANA_ID, checked_at: new Date().toISOString() }) + .eq('id', item!.id); + + const evt = await sub.waitFor((e) => e.eventType === 'UPDATE' && e.new.id === item!.id); + // With REPLICA IDENTITY FULL, `new` carries the full row including list_id + expect(evt.new.list_id).toBe(SEED_LIST_ID); + expect(evt.new.is_checked).toBe(true); + expect(evt.new.name).toBe('R-02-seed'); + } finally { + await sub.unsubscribe(); + await ana.removeAllChannels(); + await borja.removeAllChannels(); + } + }); + + it('R-03: filter on list_id excludes events from other lists', async () => { + // Create a second list in the same collective + const { data: otherList } = await admin + .from('shopping_lists') + .insert({ collective_id: COLLECTIVE_ID, name: 'R-03 other list', created_by: ANA_ID }) + .select('id') + .single(); + const otherListId = otherList!.id; + + const borja = await createClientAs(BORJA_ID); + const ana = await createClientAs(ANA_ID); + + // Borja subscribes to SEED_LIST_ID only + const sub = await subscribePostgresChanges<{ id: string; list_id: string; name: string }>( + borja, + { + table: 'shopping_items', + event: 'INSERT', + filter: `list_id=eq.${SEED_LIST_ID}` + } + ); + + try { + // Insert into the OTHER list — Borja should NOT receive this event + const noiseName = `R-03-noise-${Date.now()}`; + const { data: noise } = await ana + .from('shopping_items') + .insert({ list_id: otherListId, name: noiseName, sort_order: 902, created_by: ANA_ID }) + .select('id') + .single(); + createdItemIds.push(noise!.id); + + // Insert into SEED_LIST_ID — Borja SHOULD receive this + const signalName = `R-03-signal-${Date.now()}`; + const { data: signal } = await ana + .from('shopping_items') + .insert({ list_id: SEED_LIST_ID, name: signalName, sort_order: 903, created_by: ANA_ID }) + .select('id') + .single(); + createdItemIds.push(signal!.id); + + // Wait for the signal event + await sub.waitFor((e) => e.new.name === signalName); + + // The noise event should not be present + const noiseEvt = sub.events.find((e) => (e.new as { name: string }).name === noiseName); + expect(noiseEvt).toBeUndefined(); + } finally { + await sub.unsubscribe(); + await ana.removeAllChannels(); + await borja.removeAllChannels(); + await admin.from('shopping_lists').delete().eq('id', otherListId); + } + }); +}); diff --git a/plan/fase-2b-realtime-modo-compra.md b/plan/fase-2b-realtime-modo-compra.md index ad0392f..a2c9f60 100644 --- a/plan/fase-2b-realtime-modo-compra.md +++ b/plan/fase-2b-realtime-modo-compra.md @@ -1,5 +1,5 @@ ### Fase 2b — Sincronización en Tiempo Real y Modo Compra -**Estado: ⏳ Pendiente** +**Estado: 🚧 En curso — tests 2b.0 verdes (Realtime infra lista)** **Duración estimada: 2–3 semanas** **Objetivo: el diferencial del producto. Es la fase más compleja técnicamente.** @@ -7,11 +7,16 @@ Esta fase sigue TDD: **primero los tests, al final la verificación.** Ninguna t #### 2b.0 Tests primero — escribir antes de implementar +**Prep infra (previa a los tests):** + +- [x] Migración `007_realtime_publication.sql` — añade `shopping_items` y `shopping_lists` a la publicación `supabase_realtime`; pone `REPLICA IDENTITY FULL` en ambas +- [x] Docker Compose: `DB_USER: supabase_admin` (superuser) + `SELF_HOST_TENANT_NAME: realtime` para Realtime (ver memoria `project_realtime_config`) + **Vitest (`packages/test-utils/tests/`):** +- [x] `realtime-postgres-changes.test.ts` — R-01 INSERT broadcast, R-02 UPDATE con fila completa, R-03 filtro `list_id` aísla eventos de otras listas ✅ - [ ] `realtime-presence.test.ts` — dos clientes (Ana + Borja) entran en la misma lista; ambos aparecen en el canal Presence; salir del canal elimina al usuario -- [ ] `realtime-postgres-changes.test.ts` — Ana hace INSERT/UPDATE/DELETE en `shopping_items`; Borja (suscrito al canal) recibe el evento en < 1s -- [ ] `realtime-isolation.test.ts` — Ana y David (guest del mismo colectivo) están suscritos a la misma lista; Eva (fuera del colectivo) NO recibe nada aunque intente suscribirse (RLS en `supabase_realtime.broadcast`) +- [ ] `realtime-isolation.test.ts` — Ana y David (guest del mismo colectivo) reciben eventos; Eva (fuera del colectivo) NO recibe nada (RLS) - [ ] `sync-queue.test.ts` (unit, con mock de `idb`) — cada mutación escribe en `pending_ops` antes de llamar a Supabase; al recibir la confirmación se elimina de la cola; si falla se mantiene para reintento - [ ] `sync-flush.test.ts` (unit) — al disparar el evento `online` se flushea la cola respetando el orden original; colisiones resueltas con last-write-wins @@ -20,14 +25,12 @@ Esta fase sigue TDD: **primero los tests, al final la verificación.** Ninguna t - [ ] `session.test.ts` — S-01: abrir Modo Compra en `/lists/[id]/session` muestra el layout full-screen sin sidebar - [ ] `session.test.ts` — S-02: marcar un ítem lo mueve a la sección CHECKED con animación (no se duplica) - [ ] `session.test.ts` — S-03: botón "Finish shopping" → confirmación modal → lista pasa a `completed`, redirige a `/lists` -- [ ] `realtime.test.ts` — R-01 (dos contextos de navegador): Ana crea ítem, Borja lo ve sin refrescar la página -- [ ] `realtime.test.ts` — R-02: Ana marca ítem, avatar de Borja aparece en el indicador de Presence -- [ ] `offline.test.ts` — O-01: con `context.setOffline(true)`, añadir/marcar ítems sigue funcionando localmente y aparece el banner "sin conexión" -- [ ] `offline.test.ts` — O-02: al volver online (`setOffline(false)`), los cambios pendientes se sincronizan y el banner desaparece +- [ ] `realtime.test.ts` — dos contextos de navegador: Ana crea ítem, Borja lo ve sin refrescar; Ana marca ítem, el avatar de Borja aparece en Presence +- [ ] `offline.test.ts` — O-01 con `context.setOffline(true)`: mutaciones locales + banner "sin conexión"; O-02 al volver online: sync pendiente + banner desaparece **pgTAP (`supabase/tests/`):** -- [ ] `004_realtime_rls.sql` — verificar que `realtime.messages` respeta las políticas RLS heredadas de `shopping_items` (un miembro sólo ve cambios de sus colectivos) +- [x] `004_realtime_publication.sql` — P-01..P-04: publication + REPLICA IDENTITY FULL asegurados ✅ #### 2b.1 Sincronización Realtime diff --git a/supabase/migrations/007_realtime_publication.sql b/supabase/migrations/007_realtime_publication.sql new file mode 100644 index 0000000..66c7eea --- /dev/null +++ b/supabase/migrations/007_realtime_publication.sql @@ -0,0 +1,24 @@ +-- Migration 007: Realtime publication for shopping lists & items. +-- +-- Supabase Realtime broadcasts Postgres changes through a logical-replication +-- publication named `supabase_realtime`. Tables not in the publication emit +-- no events, so nothing reaches subscribed clients. +-- +-- REPLICA IDENTITY FULL makes UPDATE and DELETE events carry the entire row +-- (not just the primary key). We need this for two reasons: +-- 1) Clients filter events by `list_id` on shopping_items — a PK-only payload +-- on UPDATE/DELETE doesn't include list_id and the filter silently drops +-- valid events. +-- 2) Supabase Realtime evaluates RLS against the row being broadcast; for +-- DELETE events that means evaluating it against the OLD row, which is +-- only available with REPLICA IDENTITY FULL. +-- +-- Cost: logical-replication WAL entries grow by ~1 row width per mutation. +-- For the shopping-lists workload (low write rate, small rows) the impact is +-- negligible. + +ALTER PUBLICATION supabase_realtime ADD TABLE public.shopping_items; +ALTER PUBLICATION supabase_realtime ADD TABLE public.shopping_lists; + +ALTER TABLE public.shopping_items REPLICA IDENTITY FULL; +ALTER TABLE public.shopping_lists REPLICA IDENTITY FULL; diff --git a/supabase/tests/004_realtime_publication.sql b/supabase/tests/004_realtime_publication.sql new file mode 100644 index 0000000..180f3f3 --- /dev/null +++ b/supabase/tests/004_realtime_publication.sql @@ -0,0 +1,55 @@ +-- pgTAP: Realtime publication + replica identity — Fase 2b prep +-- Run with: psql -U postgres -d postgres -f supabase/tests/004_realtime_publication.sql +-- +-- These invariants must hold for Supabase Realtime to forward Postgres Changes +-- events for shopping_items / shopping_lists to subscribed clients with the +-- correct row payloads. Breaking any of them silently drops events. + +CREATE EXTENSION IF NOT EXISTS pgtap; + +BEGIN; +SELECT plan(4); + +-- P-01: shopping_items is in the supabase_realtime publication +SELECT ok( + EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' + AND schemaname = 'public' + AND tablename = 'shopping_items' + ), + 'P-01: shopping_items is part of the supabase_realtime publication' +); + +-- P-02: shopping_lists is in the supabase_realtime publication +SELECT ok( + EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' + AND schemaname = 'public' + AND tablename = 'shopping_lists' + ), + 'P-02: shopping_lists is part of the supabase_realtime publication' +); + +-- P-03: shopping_items has REPLICA IDENTITY FULL so UPDATE/DELETE events +-- carry the full row (needed for list_id-based client filtering and RLS on DELETE) +SELECT results_eq( + $$ SELECT relreplident::text FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relname = 'shopping_items' $$, + $$ VALUES ('f') $$, + 'P-03: shopping_items has REPLICA IDENTITY FULL' +); + +-- P-04: same for shopping_lists +SELECT results_eq( + $$ SELECT relreplident::text FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'public' AND c.relname = 'shopping_lists' $$, + $$ VALUES ('f') $$, + 'P-04: shopping_lists has REPLICA IDENTITY FULL' +); + +SELECT * FROM finish(); +ROLLBACK;