feat(realtime): Fase 2b.0 infra + first R-series tests green

Enable postgres_changes broadcasts on shopping_items / shopping_lists and prove
the path end-to-end with Vitest Realtime tests and a pgTAP configuration check.

Infra changes that were silently blocking events
- Migration 007: shopping_items + shopping_lists added to supabase_realtime
  publication with REPLICA IDENTITY FULL (UPDATE/DELETE payloads need the full
  row so list_id-based filters work and RLS can evaluate DELETE against OLD)
- Realtime service: DB_USER=supabase_admin (superuser; supabase_replication_admin
  lacks CREATE on the `realtime` schema that the service auto-creates per tenant)
- Realtime service: SELF_HOST_TENANT_NAME=realtime so the seed tenant name
  matches the default supabase-js resolves for localhost URLs (was realtime-dev
  → TenantNotFound)

Tests
- pgTAP 004_realtime_publication.sql — P-01..P-04: publication membership and
  REPLICA IDENTITY FULL for both shopping tables
- Vitest realtime-postgres-changes.test.ts — R-01 INSERT broadcast,
  R-02 UPDATE carries full row, R-03 list_id filter isolates events
- test-utils realtime-helpers.ts — subscribePostgresChanges() returns a
  waitFor(predicate, timeout) helper that captures the SUBSCRIBED handshake
  before returning so mutations issued right after are not lost
- createClientAs now calls realtime.setAuth(token) so the server evaluates
  RLS against the test user's JWT
- Justfile test-db now globs supabase/tests/*.sql so new pgTAP files are picked
  up automatically

Totals: 54→57 Vitest, 12→16 pgTAP, 15 Playwright = 88 tests green.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-13 02:33:40 +02:00
parent f396897cb5
commit 4c913cf495
10 changed files with 370 additions and 14 deletions

View File

@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Status ## 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 - `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) - `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules)

View File

@@ -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 # `set dotenv-load` at the top of this file sources .env so POSTGRES_PASSWORD
# is available — we forward it to psql via PGPASSWORD. # is available — we forward it to psql via PGPASSWORD.
test-db: test-db:
PGPASSWORD="$POSTGRES_PASSWORD" psql -U postgres -h localhost -d postgres -v ON_ERROR_STOP=1 -f supabase/tests/001_accept_invitation.sql #!/usr/bin/env bash
PGPASSWORD="$POSTGRES_PASSWORD" psql -U postgres -h localhost -d postgres -v ON_ERROR_STOP=1 -f supabase/tests/002_item_frequency_trigger.sql set -euo pipefail
PGPASSWORD="$POSTGRES_PASSWORD" psql -U postgres -h localhost -d postgres -v ON_ERROR_STOP=1 -f supabase/tests/003_promote_on_admin_leave.sql 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) # Run Vitest integration tests (RLS + trigger assertions via supabase-js)
test-integration: test-integration:

View File

@@ -92,12 +92,21 @@ services:
PORT: 4000 PORT: 4000
DB_HOST: db DB_HOST: db
DB_PORT: 5432 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_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
DB_NAME: postgres DB_NAME: postgres
DB_AFTER_CONNECT_QUERY: SET search_path TO _realtime DB_AFTER_CONNECT_QUERY: SET search_path TO _realtime
DB_ENC_KEY: supabaserealtime DB_ENC_KEY: supabaserealtime
API_JWT_SECRET: ${SUPABASE_JWT_SECRET} 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_ALLOC_ID: fly123
FLY_APP_NAME: realtime FLY_APP_NAME: realtime
APP_NAME: realtime APP_NAME: realtime

View File

@@ -1,3 +1,4 @@
export * from './seed-constants.js'; export * from './seed-constants.js';
export * from './supabase-clients.js'; export * from './supabase-clients.js';
export * from './db-helpers.js'; export * from './db-helpers.js';
export * from './realtime-helpers.js';

View File

@@ -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<T = Record<string, unknown>>(
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<void>;
}> {
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<void>((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 };
}

View File

@@ -26,10 +26,14 @@ async function signToken(userId: string): Promise<string> {
/** /**
* Create a Supabase client that authenticates as the given seed user. * Create a Supabase client that authenticates as the given seed user.
* RLS policies see auth.uid() = userId. * 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) { export async function createClientAs(userId: string) {
const token = await signToken(userId); const token = await signToken(userId);
return createClient<Database>( const client = createClient<Database>(
requiredEnv('PUBLIC_SUPABASE_URL'), requiredEnv('PUBLIC_SUPABASE_URL'),
requiredEnv('PUBLIC_SUPABASE_ANON_KEY'), requiredEnv('PUBLIC_SUPABASE_ANON_KEY'),
{ {
@@ -37,6 +41,9 @@ export async function createClientAs(userId: string) {
auth: { persistSession: false, autoRefreshToken: false } auth: { persistSession: false, autoRefreshToken: false }
} }
); );
// @ts-expect-error — realtime.setAuth exists at runtime on supabase-js v2
client.realtime.setAuth(token);
return client;
} }
/** /**

View File

@@ -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);
}
});
});

View File

@@ -1,5 +1,5 @@
### Fase 2b — Sincronización en Tiempo Real y Modo Compra ### 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: 23 semanas** **Duración estimada: 23 semanas**
**Objetivo: el diferencial del producto. Es la fase más compleja técnicamente.** **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 #### 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/`):** **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-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) reciben eventos; Eva (fuera del colectivo) NO recibe nada (RLS)
- [ ] `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`)
- [ ] `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-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 - [ ] `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-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-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` - [ ] `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` — dos contextos de navegador: Ana crea ítem, Borja lo ve sin refrescar; Ana marca ítem, el avatar de Borja aparece en Presence
- [ ] `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)`: mutaciones locales + banner "sin conexión"; O-02 al volver online: sync pendiente + banner desaparece
- [ ] `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
**pgTAP (`supabase/tests/`):** **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 #### 2b.1 Sincronización Realtime

View File

@@ -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;

View File

@@ -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;