Skip to content

Commit a9393b1

Browse files
perf: pool de conexiones + frontend optimista (arregla la lentitud) (#58)
* docs: diseño de rendimiento (pool de conexiones + frontend optimista) Backend: pool global (psycopg_pool) reusa conexiones → connect por request 1-8s → <0,3s; repos _connect→pool.connection() (mismo API); register_vector/dict_row al configure del pool; lifespan pre-calienta; añade psycopg[pool]. Frontend: OrgProvider usa activeOrgId de localStorage optimista (no bloquea la app en /v2/orgs) + staleTime largo. Multitenancy intacta (claims transaction-local). Aborda las capas 3 y 4 del análisis; 0€. Claude-Session: https://claude.ai/code/session_0198KfgRWvAM8BhiVz24uTok * docs: plan de rendimiento (pool + frontend optimista) — 4 tareas T1 src/db/pool.py (ConnectionPool singleton, configure=dict_row+register_vector) + psycopg[pool]; T2 los 7 repos + graph/gaps _connect→get_pool().connection() (quita register_vector/row_factory inline, mantiene claims); T3 asgi lifespan pre-calienta; T4 OrgProvider optimista (activeOrgId de localStorage sin bloquear + staleTime). Verificación local=CI. Claude-Session: https://claude.ai/code/session_0198KfgRWvAM8BhiVz24uTok * feat(db): pool de conexiones global (psycopg_pool) - requirements.txt: psycopg[binary,pool]==3.3.3 (añade pool extra) - src/db/pool.py: singleton ConnectionPool (min=2/max=10), lazy open, _configure centraliza dict_row + register_vector por conexión nueva - tests/test_db_pool.py: singleton + _configure (TDD RED→GREEN) - no-.env gate: 819 passed, rc=0 Claude-Session: https://claude.ai/code/session_0198KfgRWvAM8BhiVz24uTok * refactor(repos): _connect usa el pool global (reuso de conexión) - src/{orgs,certify,jira/integrations}_repository.py: _connect → get_pool().connection() - src/{graph/service,defects,knowledge,repo_ingest}/repository.py: mismo cambio; elimina register_vector + row_factory inline (ya los maneja configure del pool); quita import register_vector - src/graph/gaps.py: _connect(db_url) → get_pool().connection() (db_url queda por compat) - tests/test_graph_gaps.py: parcheaba psycopg.connect → ahora parchea get_pool con _fake_pool(conn_ctx) helper; sin debilitamiento de aserciones Gate: DATABASE_URL= pytest -m "not integration" → 819 passed, rc=0 Claude-Session: https://claude.ai/code/session_0198KfgRWvAM8BhiVz24uTok * perf(asgi): pre-calienta el pool en el arranque (lifespan) Claude-Session: https://claude.ai/code/session_0198KfgRWvAM8BhiVz24uTok * perf(frontend): OrgProvider optimista (no bloquea la app en /v2/orgs) Lee el activeOrgId de localStorage en el primer render (antes de que /v2/orgs resuelva) y lo valida/corrige cuando la lista llega. Agrega staleTime: 5 min para reducir refetch. Test O1 cubre la ruta optimista (pending → stored) y la corrección cuando el id guardado no pertenece a ninguna org (→ orgs[0]). Claude-Session: https://claude.ai/code/session_0198KfgRWvAM8BhiVz24uTok * fix(perf): migra actions/xray al pool + test de aislamiento de claims + log en lifespan - ActionRepository._connect y XrayConfig._connect migrados a get_pool().connection() (eliminado psycopg.connect directo por petición; row_factory/vector los gestiona el pool) - test_pooled_connection_does_not_leak_claims: test de integración con pool max_size=1 que verifica que set_config transaction-local no se filtra entre checkouts - asgi.py lifespan: except Exception:pass → logging.warning con el exc (no swallow silencioso) Claude-Session: https://claude.ai/code/session_0198KfgRWvAM8BhiVz24uTok
1 parent 9277c4d commit a9393b1

21 files changed

Lines changed: 546 additions & 513 deletions

File tree

.superpowers/sdd/task-1-report.md

Lines changed: 51 additions & 465 deletions
Large diffs are not rendered by default.

asgi.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,30 @@
22
33
Monta SOLO el API v2 (Autopilot, autenticado). El RAG v1 legacy (`api.py`,
44
endpoints /analyze,/sync,/history,/stats,/evaluate sin auth) queda deprecated y
5-
FUERA del arranque. El v2 usa getters perezosos, así que no necesita startup_event.
5+
FUERA del arranque.
6+
7+
El lifespan pre-calienta el pool de BD en el arranque para que las primeras
8+
peticiones no paguen el coste de conexión (~1-8 s). Si la BD no está disponible
9+
al arrancar, el error se absorbe (try/except) para no tumbar el contenedor.
610
"""
11+
from contextlib import asynccontextmanager
12+
713
from fastapi import FastAPI
814

915
from src.api_v2 import router as v2_router
16+
from src.db.pool import get_pool, close_pool
17+
18+
19+
@asynccontextmanager
20+
async def lifespan(app: FastAPI):
21+
try:
22+
get_pool() # pre-calienta el pool; si la BD no está, no tumbar el arranque
23+
except Exception as exc:
24+
import logging
25+
logging.getLogger(__name__).warning("pool pre-warm failed; lazy init on first request: %s", exc)
26+
yield
27+
close_pool()
28+
1029

11-
app = FastAPI(title="Mnemo Autopilot", version="2.0.0")
30+
app = FastAPI(title="Mnemo Autopilot", version="2.0.0", lifespan=lifespan)
1231
app.include_router(v2_router)
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
# Rendimiento · Pool de conexiones + frontend optimista — Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** Reusar conexiones a Postgres (pool) para eliminar el connect de 1–8 s por request, y que `/v2/orgs` no bloquee la carga de la app.
6+
7+
**Architecture:** T1 crea un pool global (`src/db/pool.py`). T2 migra los `_connect` de los repos a `pool.connection()`. T3 pre-calienta el pool en el arranque (lifespan). T4 hace el `OrgProvider` optimista.
8+
9+
**Tech Stack:** Python/FastAPI/psycopg 3.3 + psycopg_pool · Next.js/TS · vitest.
10+
11+
## Global Constraints
12+
13+
- **Pool lazy singleton**; `configure` centraliza `row_factory=dict_row` + `register_vector` (idempotente, para todas las conexiones aunque el repo no use pgvector).
14+
- **Mismo API de los repos**: `_connect()` sigue usándose como `with self._connect() as conn:` (ahora devuelve `pool.connection()`: commit al salir + return-to-pool).
15+
- **No tocar** `_set_claims`/`_set_user_claims` (son `set_config(..., is_local=true)` transaction-local; se limpian al commit → no fugan entre checkouts).
16+
- **Multitenancy intacta**: el membership app-layer y el bypass RLS del pooler no cambian.
17+
- `psycopg[binary,pool]==3.3.3` (misma versión, +extra pool). `register_vector` = `from pgvector.psycopg import register_vector`.
18+
- **Verificación local = CI por tarea:** backend pytest **sin `.env`** (`mv .env .env.bak; DATABASE_URL= python3 -m pytest -m "not integration" -q; mv .env.bak .env`); frontend `npm run lint:ci`+`test`+`build`. Si node_modules se corrompe (iCloud) → `npm --prefix frontend ci`. **No git worktree.** Commits con `Claude-Session: https://claude.ai/code/session_0198KfgRWvAM8BhiVz24uTok`.
19+
20+
---
21+
22+
## Task 1: Pool global (`src/db/pool.py`) + dependencia
23+
24+
**Files:** Create `src/db/__init__.py` (vacío si no existe), `src/db/pool.py`; Modify `requirements.txt`; Test `tests/test_db_pool.py`.
25+
26+
**Interfaces:** Produces — `get_pool() -> ConnectionPool`, `close_pool() -> None`. `get_pool().connection()` es un context manager que da una `psycopg.Connection` con `row_factory=dict_row` y vector registrado.
27+
28+
- [ ] **Step 1: Dependency** — en `requirements.txt`, cambiar `psycopg[binary]==3.3.3` por `psycopg[binary,pool]==3.3.3`. Instalar: `python3 -m pip install -q "psycopg[binary,pool]==3.3.3"`.
29+
30+
- [ ] **Step 2: Write the failing test** (`tests/test_db_pool.py`):
31+
```python
32+
import src.db.pool as poolmod
33+
34+
def test_get_pool_is_singleton(monkeypatch):
35+
created = []
36+
class FakePool:
37+
def __init__(self, *a, **k): created.append(k)
38+
def open(self, **k): pass
39+
def close(self): pass
40+
monkeypatch.setattr(poolmod, "ConnectionPool", FakePool)
41+
monkeypatch.setattr(poolmod, "_pool", None)
42+
p1 = poolmod.get_pool(); p2 = poolmod.get_pool()
43+
assert p1 is p2 # singleton
44+
assert len(created) == 1 # se crea una sola vez
45+
poolmod.close_pool()
46+
47+
def test_configure_sets_dict_row_and_vector(monkeypatch):
48+
calls = {"vector": 0}
49+
monkeypatch.setattr(poolmod, "register_vector", lambda conn: calls.__setitem__("vector", calls["vector"]+1))
50+
class Conn: row_factory = None
51+
c = Conn()
52+
poolmod._configure(c)
53+
from psycopg.rows import dict_row
54+
assert c.row_factory is dict_row
55+
assert calls["vector"] == 1
56+
```
57+
58+
- [ ] **Step 3: Run, expect FAIL.** `python3 -m pytest tests/test_db_pool.py -q`
59+
60+
- [ ] **Step 4: Implement** `src/db/pool.py`:
61+
```python
62+
from typing import Optional
63+
64+
from psycopg.rows import dict_row
65+
from psycopg_pool import ConnectionPool
66+
from pgvector.psycopg import register_vector
67+
68+
from src.config import DATABASE_URL
69+
70+
_pool: Optional[ConnectionPool] = None
71+
72+
73+
def _configure(conn) -> None:
74+
conn.row_factory = dict_row
75+
register_vector(conn)
76+
77+
78+
def get_pool() -> ConnectionPool:
79+
"""Pool global lazy. Pre-abre min_size conexiones (el connect lento se paga aquí)."""
80+
global _pool
81+
if _pool is None:
82+
pool = ConnectionPool(
83+
conninfo=DATABASE_URL,
84+
min_size=2,
85+
max_size=10,
86+
open=False,
87+
timeout=10,
88+
configure=_configure,
89+
kwargs={"row_factory": dict_row},
90+
)
91+
pool.open(wait=True, timeout=15)
92+
_pool = pool
93+
return _pool
94+
95+
96+
def close_pool() -> None:
97+
global _pool
98+
if _pool is not None:
99+
_pool.close()
100+
_pool = None
101+
```
102+
(Si `pgvector.psycopg.register_vector` requiere una conexión ya con el tipo disponible, el `configure` es el sitio correcto — corre una vez por conexión nueva del pool.)
103+
104+
- [ ] **Step 5: Run PASS** + backend-no-`.env` gate (rc=0, `.env` restaurado). **Commit** `feat(db): pool de conexiones global (psycopg_pool)` + trailer.
105+
106+
---
107+
108+
## Task 2: Migrar los repos al pool
109+
110+
**Files:** Modify `src/orgs/repository.py`, `src/certify/repository.py`, `src/jira/integrations_repository.py`, `src/graph/service.py`, `src/defects/repository.py`, `src/knowledge/repository.py`, `src/repo_ingest/repository.py`, `src/graph/gaps.py`; Tests: la suite existente de cada uno.
111+
112+
**Interfaces:** Consumes — `get_pool` de `src/db/pool.py` (Task 1).
113+
114+
- [ ] **Step 1** — En cada repo, importar `from src.db.pool import get_pool` y reemplazar el cuerpo de `_connect`:
115+
- Repos **sin** pgvector (`orgs`, `certify`, `jira/integrations_repository`):
116+
```python
117+
def _connect(self):
118+
return get_pool().connection()
119+
```
120+
(elimina `psycopg.connect(self.db_url, row_factory=dict_row)`).
121+
- Repos **con** pgvector (`graph/service`, `defects`, `knowledge`, `repo_ingest`): igual que arriba, y **eliminar** las 2 líneas inline `conn = psycopg.connect(..., row_factory=dict_row)` + `register_vector(conn)` (ahora en el `configure` del pool). Quitar el import `register_vector` si queda sin uso; **mantener** `from pgvector import Vector` (se usa en las queries).
122+
- `src/graph/gaps.py::_connect(db_url=DATABASE_URL)` (función de módulo):
123+
```python
124+
def _connect(db_url: str = DATABASE_URL):
125+
return get_pool().connection()
126+
```
127+
(el pool usa `DATABASE_URL` global; el parámetro `db_url` queda por compatibilidad de firma pero el pool lo ignora).
128+
129+
- [ ] **Step 2**`self.db_url` puede quedar sin uso en algunos repos; déjalo (lo usan otros métodos o el `__init__`) salvo que quede claramente muerto. No cambies las queries ni `_set_claims`.
130+
131+
- [ ] **Step 3: Run the backend suite** (sin `.env`): `mv .env .env.bak 2>/dev/null; DATABASE_URL= python3 -m pytest -m "not integration" -q; rc=$?; mv .env.bak .env 2>/dev/null; echo rc=$rc`. **Ajusta los tests que rompan**: los que parcheaban `psycopg.connect` a nivel de módulo del repo ahora deben parchear `<repo>.get_pool` (o el `_connect` del repo) para devolver un context manager fake que dé la conexión mock. Los que ya parchean `_connect` directamente siguen igual. Objetivo: rc=0.
132+
133+
- [ ] **Step 4: Commit** `refactor(repos): _connect usa el pool global (reuso de conexión)` + trailer.
134+
135+
---
136+
137+
## Task 3: Pre-calentar el pool en el arranque (`asgi.py`)
138+
139+
**Files:** Modify `asgi.py`.
140+
141+
- [ ] **Step 1: Implement** — añadir un `lifespan` y pasarlo a `FastAPI`:
142+
```python
143+
from contextlib import asynccontextmanager
144+
from fastapi import FastAPI
145+
146+
from src.api_v2 import router as v2_router
147+
from src.db.pool import get_pool, close_pool
148+
149+
150+
@asynccontextmanager
151+
async def lifespan(app: FastAPI):
152+
try:
153+
get_pool() # pre-calienta el pool; si la BD no está, no tumbar el arranque
154+
except Exception:
155+
pass
156+
yield
157+
close_pool()
158+
159+
160+
app = FastAPI(title="Mnemo Autopilot", version="2.0.0", lifespan=lifespan)
161+
app.include_router(v2_router)
162+
```
163+
164+
- [ ] **Step 2: Verify**`python3 -c "import asgi"` sin error; backend-no-`.env` gate rc=0 (el `try/except` evita que la ausencia de BD en el import rompa). **Commit** `perf(asgi): pre-calienta el pool en el arranque (lifespan)` + trailer.
165+
166+
---
167+
168+
## Task 4: `OrgProvider` optimista (frontend)
169+
170+
**Files:** Modify `frontend/src/components/providers/org-provider.tsx`; Test `frontend/src/components/providers/__tests__/org-provider.test.tsx`.
171+
172+
- [ ] **Step 1: Write the failing test** (vitest): mock `useAuth``{accessToken:"t"}` y `getOrganizations`. (a) con `localStorage["mnemo.activeOrgId"]="org-guardada"` y `getOrganizations` pendiente (promesa no resuelta), un componente que consume `useActiveOrg().activeOrgId` muestra `"org-guardada"` **antes** de que resuelva. (b) cuando `getOrganizations` resuelve con `[{id:"org-otra",...}]` (no contiene la guardada), `activeOrgId` pasa a `"org-otra"` (corrección).
173+
174+
- [ ] **Step 2: Implement** — en `org-provider.tsx`, leer el guardado y usarlo optimista + validar al cargar:
175+
```tsx
176+
const orgsQuery = useQuery({
177+
queryKey: ["organizations", accessToken],
178+
queryFn: () => getOrganizations(accessToken!),
179+
enabled: Boolean(accessToken),
180+
staleTime: 5 * 60_000,
181+
});
182+
const orgs = useMemo(() => orgsQuery.data ?? [], [orgsQuery.data]);
183+
184+
const storedOrgId = typeof window !== "undefined"
185+
? window.localStorage.getItem(STORAGE_KEY) ?? ""
186+
: "";
187+
188+
const activeOrgId = useMemo(() => {
189+
if (explicitOrgId) return explicitOrgId;
190+
if (orgs.length) {
191+
return orgs.some((o) => o.id === storedOrgId) ? storedOrgId : orgs[0].id; // validación
192+
}
193+
return storedOrgId; // optimista: aún no cargó la lista
194+
}, [explicitOrgId, orgs, storedOrgId]);
195+
```
196+
Mantener `setActiveOrgId` (escribe en localStorage) y `isLoading: orgsQuery.isLoading`.
197+
198+
- [ ] **Step 3: Verify**`npm run lint:ci` + `npm test` + `npx tsc --noEmit` + `npm run build`. **Commit** `perf(frontend): OrgProvider optimista (no bloquea la app en /v2/orgs)` + trailer.
199+
200+
---
201+
202+
## Notas de cierre
203+
- **Orden:** T1 (pool) → T2 (repos usan get_pool) → T3 (lifespan usa get_pool) → T4 (frontend, independiente).
204+
- **Riesgo principal (T2):** los tests que parchean `psycopg.connect`. La mitigación es parchear `get_pool`/`_connect` en su lugar; el gate sin `.env` lo caza.
205+
- **Reusa:** `register_vector`/`dict_row` (ahora en el pool), el patrón `with self._connect()`, `useQuery`/localStorage.
206+
- **Impacto esperado:** connect por request 1–8 s → ~0 (reuso) tras el arranque caliente; la app deja de esperar a `/v2/orgs`.
207+
- **Fuera de alcance:** cold start (pinger), hardware, mover de host.
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Rendimiento · Pool de conexiones + frontend optimista — diseño
2+
3+
**Fecha:** 2026-07-01 · **Base:** `main` 9277c4d (con `maxDuration=60`) · **Backend:** Python/FastAPI/psycopg · **Frontend:** Next.js/TS.
4+
5+
## Objetivo
6+
7+
Bajar la latencia por request y que `/v2/orgs` deje de bloquear toda la app. Del análisis de rendimiento: (3) el backend abre una conexión nueva a Supabase en **cada** request (`psycopg.connect` → SSL handshake al pooler de eu-central-1 → 1–8 s/request) y (4) el `OrgProvider` gatea la app entera esperando `/v2/orgs`. Este trabajo ataca ambos, **a 0 €** (sin cambiar de hosting). El cold start (pinger) y el hardware quedan fuera.
8+
9+
## Decisiones (confirmadas en el análisis)
10+
11+
- **Pool de conexiones** en el backend (reusar conexiones en vez de abrir una por request).
12+
- **Frontend optimista**: usar el `activeOrgId` guardado sin esperar a `/v2/orgs`.
13+
- **Sin romper la multitenancy**: el membership app-layer sigue; el pooler de Supabase ya bypassa RLS; los claims son `set_config(..., is_local=true)` (transaction-scoped → no contaminan una conexión reusada).
14+
- **Mismo API de los repos** (cambio mínimo, sin reescribir queries).
15+
16+
## Componentes
17+
18+
### 1. Pool global (`src/db/pool.py`, nuevo)
19+
Un `psycopg_pool.ConnectionPool` singleton, lazy:
20+
```python
21+
from psycopg.rows import dict_row
22+
from psycopg_pool import ConnectionPool
23+
from pgvector.psycopg import register_vector
24+
from src.config import DATABASE_URL
25+
26+
_pool: ConnectionPool | None = None
27+
28+
def _configure(conn):
29+
conn.row_factory = dict_row
30+
register_vector(conn) # pgvector, una vez por conexión del pool
31+
32+
def get_pool() -> ConnectionPool:
33+
global _pool
34+
if _pool is None:
35+
_pool = ConnectionPool(
36+
conninfo=DATABASE_URL, min_size=2, max_size=10,
37+
open=False, configure=_configure, timeout=10,
38+
kwargs={"row_factory": dict_row},
39+
)
40+
_pool.open(wait=True, timeout=15)
41+
return _pool
42+
43+
def close_pool():
44+
global _pool
45+
if _pool is not None:
46+
_pool.close(); _pool = None
47+
```
48+
- `min_size=2` pre-abre 2 conexiones (el connect lento se paga aquí, no por request). `max_size=10` para concurrencia.
49+
- `configure` corre por conexión NUEVA del pool → `row_factory` + `register_vector` centralizados.
50+
- Requiere `DATABASE_URL`; si falta, `get_pool()` lanza (igual que hoy sin BD).
51+
52+
### 2. Repos: `_connect` usa el pool
53+
Cada `_connect` pasa de abrir una conexión a **tomarla del pool**:
54+
```python
55+
def _connect(self):
56+
return get_pool().connection() # context manager: checkout → commit/rollback → return-to-pool
57+
```
58+
`with self._connect() as conn:` **no cambia** (mismo API; antes commit+close, ahora commit+return-to-pool). Se **elimina** el `register_vector(conn)` y el `row_factory=dict_row` inline de los repos (ya en el pool `configure`). `_set_claims`/`_set_user_claims` se mantienen igual (transaction-local). Afecta: `orgs`, `defects`, `knowledge`, `certify`, `jira/integrations_repository`, `graph/service`, `repo_ingest`; y `graph/gaps._connect` (función de módulo → misma sustitución).
59+
60+
### 3. Dependencia + arranque
61+
- `requirements.txt`: `psycopg[binary]==3.3.3``psycopg[binary,pool]==3.3.3` (añade `psycopg_pool`).
62+
- `asgi.py`: un **lifespan** que pre-abre el pool al startup y lo cierra al shutdown, para que el contenedor arranque con el pool caliente (no el primer request):
63+
```python
64+
from contextlib import asynccontextmanager
65+
from src.db.pool import get_pool, close_pool
66+
67+
@asynccontextmanager
68+
async def lifespan(app):
69+
try: get_pool() # pre-calienta; si la BD no está, degradar sin tumbar el arranque
70+
except Exception: pass
71+
yield
72+
close_pool()
73+
app = FastAPI(title="Mnemo Autopilot", version="2.0.0", lifespan=lifespan)
74+
```
75+
76+
### 4. Frontend: `OrgProvider` optimista (`org-provider.tsx`)
77+
- Leer el `activeOrgId` de `localStorage` en el **primer render** y usarlo aunque `/v2/orgs` aún no haya respondido → las páginas (con `enabled: accessToken && orgId`) arrancan sus queries de inmediato.
78+
- Cuando `orgsQuery` resuelve, **validar**: si el `activeOrgId` guardado no está en la lista, corregir a `orgs[0].id`.
79+
- `staleTime` largo para la query de orgs (`5 * 60_000`; cambian poco).
80+
- Fórmula: `activeOrgId = explicitOrgId || storedOrgId || (orgs[0]?.id ?? "")`, con la validación al cargar `orgs`.
81+
82+
## Garantías
83+
- **Multitenancy intacta**: el pool no cambia el membership app-layer ni los claims transaction-local; el pooler ya bypassa RLS. Sin estado compartido entre checkouts (claims local + `pool.connection()` hace rollback de transacción abierta al devolver).
84+
- **Mismo comportamiento**: `with self._connect() as conn:` sigue haciendo commit al salir; las queries no cambian.
85+
- **Degrada**: si el pool no abre (BD caída), los endpoints fallan igual que hoy; el lifespan no tumba el arranque.
86+
- **Impacto**: connect por request de 1–8 s → ~0 (reuso) → requests <0,3 s una vez caliente.
87+
88+
## Testing
89+
- **Backend** (`tests/test_db_pool.py`, nuevo): el pool devuelve conexiones con `dict_row`; reusa (el mismo pool en 2 `get_pool()`); `configure` aplica register_vector (mock/psycopg fake); dos checkouts consecutivos no comparten claims (el 2º no ve `request.jwt.claim.sub` del 1º). Los tests de repos existentes deben seguir verdes (el API de `_connect` no cambia; mockear `get_pool().connection()` donde haga falta). `pytest -m "not integration"` sin `.env`.
90+
- **Frontend** (vitest): `OrgProvider` expone el `activeOrgId` de localStorage antes de que `getOrganizations` resuelva; tras resolver con una lista que no contiene el guardado, corrige a `orgs[0]`.
91+
- **Verificación local = CI** por tarea (backend sin `.env`; frontend `lint:ci`+`test`+`build`).
92+
93+
## Fuera de alcance
94+
- Cold start (pinger), hardware persistente, mover de host.
95+
- Reescribir los repos a un ORM o cambiar las queries.
96+
- Pool en el proxy de Vercel (es serverless; no aplica).

frontend/src/components/providers/__tests__/org-provider.test.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,31 @@ describe("OrgProvider", () => {
7979
await waitFor(() => expect(screen.getByTestId("active-org").textContent).toBe(ORG_B.id));
8080
});
8181

82+
// Optimistic: stored id is shown BEFORE the query resolves
83+
it("O1 — muestra el id guardado en localStorage antes de que /v2/orgs resuelva", async () => {
84+
localStorage.setItem("mnemo.activeOrgId", "org-guardada");
85+
let resolveOrgs!: (value: typeof ORG_A[]) => void;
86+
const slowPromise = new Promise<typeof ORG_A[]>((res) => { resolveOrgs = res; });
87+
(getOrganizations as ReturnType<typeof vi.fn>).mockReturnValue(slowPromise);
88+
89+
renderWithClient(
90+
<OrgProvider>
91+
<OrgIdDisplay />
92+
</OrgProvider>,
93+
);
94+
95+
// Before the query resolves, the stored id must already be visible
96+
await waitFor(() =>
97+
expect(screen.getByTestId("active-org").textContent).toBe("org-guardada"),
98+
);
99+
100+
// After resolving with a list that does NOT contain the stored id, correct to orgs[0]
101+
resolveOrgs([ORG_A]); // ORG_A.id = "org-a", not "org-guardada"
102+
await waitFor(() =>
103+
expect(screen.getByTestId("active-org").textContent).toBe(ORG_A.id),
104+
);
105+
});
106+
82107
// I2: isLoading is exposed and transitions false→true→false as query resolves
83108
it("I2 — isLoading es true mientras la query está pendiente y false cuando resuelve", async () => {
84109
let resolveOrgs!: (value: typeof ORG_A[]) => void;

frontend/src/components/providers/org-provider.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,22 @@ export function OrgProvider({ children }: PropsWithChildren) {
2323
queryKey: ["organizations", accessToken],
2424
queryFn: () => getOrganizations(accessToken!),
2525
enabled: Boolean(accessToken),
26+
staleTime: 5 * 60_000,
2627
});
2728
const orgs = useMemo(() => orgsQuery.data ?? [], [orgsQuery.data]);
2829

30+
const storedOrgId =
31+
typeof window !== "undefined"
32+
? (window.localStorage.getItem(STORAGE_KEY) ?? "")
33+
: "";
34+
2935
const activeOrgId = useMemo(() => {
3036
if (explicitOrgId) return explicitOrgId;
31-
if (!orgs.length) return "";
32-
const stored = typeof window !== "undefined" ? window.localStorage.getItem(STORAGE_KEY) : null;
33-
return stored && orgs.some((o) => o.id === stored) ? stored : orgs[0].id;
34-
}, [explicitOrgId, orgs]);
37+
if (orgs.length) {
38+
return orgs.some((o) => o.id === storedOrgId) ? storedOrgId : orgs[0].id;
39+
}
40+
return storedOrgId; // optimistic: list not yet loaded
41+
}, [explicitOrgId, orgs, storedOrgId]);
3542

3643
function setActiveOrgId(id: string) {
3744
setExplicitOrgId(id);

0 commit comments

Comments
 (0)