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:
@@ -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)
|
||||||
|
|||||||
9
Justfile
9
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
|
# `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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
97
packages/test-utils/src/realtime-helpers.ts
Normal file
97
packages/test-utils/src/realtime-helpers.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
157
packages/test-utils/tests/realtime-postgres-changes.test.ts
Normal file
157
packages/test-utils/tests/realtime-postgres-changes.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: 2–3 semanas**
|
**Duración estimada: 2–3 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
|
||||||
|
|
||||||
|
|||||||
24
supabase/migrations/007_realtime_publication.sql
Normal file
24
supabase/migrations/007_realtime_publication.sql
Normal 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;
|
||||||
55
supabase/tests/004_realtime_publication.sql
Normal file
55
supabase/tests/004_realtime_publication.sql
Normal 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;
|
||||||
Reference in New Issue
Block a user