diff --git a/AGENTS.md b/AGENTS.md index 2cdabb4..ad8690e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,9 @@ requires the exact Expo SDK 57 documentation before writing mobile code. - Deliver assigned work in coherent chunks, with one PR per chunk. - Give every small, meaningful change its own commit. Commit incrementally; do not wait until the end and put the entire task into one large commit. +- Prefer a higher number of focused commits per PR. Separate independently + reviewable behavior, UI, and documentation changes instead of bundling them; + keep regression tests with the behavior they verify. - Keep as many meaningful, atomic commits as the chunk naturally produces in its PR. There is no numeric maximum or minimum. Do not split a coherent change into broken fragments or make empty commits to inflate the count. diff --git a/README.md b/README.md index 4a5a9db..8df529b 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ link's build current automatically. - [Architecture](docs/ARCHITECTURE.md) — how the pieces fit, selection, reminders, security - [Roadmap](docs/ROADMAP.md) — shipped phases and what's next - [Backend](backend/README.md) — API endpoints, auth, running locally +- [Email templates](docs/EMAIL_TEMPLATES.md) — branded Supabase emails and installation - [Deployment](mobile/DEPLOYMENT.md) — EAS builds, OTA updates, release runbook ## Contributing diff --git a/backend/.env.example b/backend/.env.example index 75f8176..a27c6ed 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -13,6 +13,11 @@ DATABASE_URL=postgresql://postgres.PROJECT:PASSWORD@aws-0-REGION.pooler.supabase # Session-mode pooler (port 5432). Used for migrations and any DDL. DIRECT_URL=postgresql://postgres.PROJECT:PASSWORD@aws-0-REGION.pooler.supabase.com:5432/postgres +# Best-effort API connection warm-up. Zero interval disables it. +# The timeout covers pool checkout, reconnect, and the SELECT 1 probe. +DB_KEEPALIVE_INTERVAL_SECONDS=30 +DB_KEEPALIVE_TIMEOUT_SECONDS=5 + # Settings → API SUPABASE_URL=https://PROJECT.supabase.co # Public anon key (Settings → API). Safe to expose — it's already in the mobile @@ -34,11 +39,12 @@ GEMINI_MODEL=gemini-3.1-flash-lite GENERATION_ENABLED=false # The worker tops each topic up to this many published concepts. MIN_POOL_PER_TOPIC=25 -# Hard ceiling so a retry loop cannot burn the daily quota. +# Shared daily reservation ceiling across API prefetch and workers (Pacific day). +# Use the same cap on all services; zero blocks new generation calls. GENERATION_DAILY_CALL_CAP=200 -# Seconds between worker calls; the free tier allows ~10 requests a minute. +# Seconds between scheduled worker calls; provider limits vary by model/tier. GENERATION_PACE_SECONDS=6 -# Last-resort generation inside a request when a user's own pool is empty. +# Background refill when a user is running low; never waits on the HTTP path. GENERATION_ON_DEMAND=true # --- HTTP ----------------------------------------------------------------- diff --git a/backend/README.md b/backend/README.md index ea88e28..fd723d0 100644 --- a/backend/README.md +++ b/backend/README.md @@ -29,6 +29,7 @@ backend/ │ │ └── users.py profile bootstrap (safety net for the DB trigger) │ └── api/v1/ health, topics, daily ├── migrations/ # plain SQL, applied in filename order +├── email-templates/ # account email HTML installed manually in Supabase Auth ├── tests/ # 25 tests: token verification, selection, HTTP ├── Dockerfile # what Railway builds └── .env.example # copy to .env — never commit the filled copy @@ -52,7 +53,9 @@ python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt | GET | `/v1/topics` | yes | Active topics, concept counts, and whether you follow each. | | GET | `/v1/daily` | yes | Today's concept. Creates the assignment on first call, idempotent after. | | POST | `/v1/daily/complete` | yes | Mark today learned. Server sets the timestamp and the day it counts for. | -| GET | `/v1/me/state` | yes | Everything the app renders: follows, history, likes, saves, streaks. One query. | +| GET | `/v1/me/state?compact=true` | yes | Startup state with at most 50 learned/saved detail rows, full membership and totals, and today's lesson. | +| GET | `/v1/me/history` | yes | Completed concepts, newest assigned day first; cursor pagination. | +| GET | `/v1/me/saved` | yes | Saved metadata, newest save first; cursor pagination. | | GET | `/v1/me/stats` | yes | Streaks alone, for other consumers. | | PUT | `/v1/me/topics` | yes | Replace the followed set (whole-list semantics, so retries are safe). | | PATCH | `/v1/me` | yes | Display name and timezone. Unknown zones are rejected. | @@ -66,6 +69,33 @@ python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt been assigned every published concept — it never repeats one. Phase 6 hooks Gemini generation in at that point. +## Startup and collection pagination + +Updated mobile clients send `compact=true` on `GET /v1/me/state`, +`PUT /v1/me/topics`, and `PATCH /v1/me`. Each response embeds at most 50 learned +and 50 saved detail records. Full `likes` and `bookmarks` slug arrays, streaks, +and `stats.total_learned` remain authoritative. `learned_before_window` groups +older completions by topic name; add those counts to the recent learned rows +for category totals, including any optimistic offline completion. + +Continue from `history_next_cursor` or `saved_next_cursor` using the matching +collection endpoint. Each returns `{items, next_cursor}`; a null cursor means +there are no more rows. Both accept `limit` (default 50, range 1–100) and an +optional `cursor`. History uses the last assigned date; Saved uses an opaque +save-timestamp/UUID cursor so tied timestamps and deletions do not skip rows. +Keep cursors unchanged and URL-encode them. All queries use the verified user. +Pages are live reads: refresh startup state to see new saves/completions made +above an existing cursor while paging. + +The limit applies before per-concept like-count enrichment. Bare membership +arrays and aggregate calculations still grow with account activity. Older +clients that omit `compact=true` keep the full legacy detail response during +backend/OTA rollout; their startup cost is unchanged until updated. The mobile +History screen still shows the last ten lessons; its full-history UI is separate. +Saved loads older metadata only when opened, preserving search/category filters, +and caches it for offline use. Downloaded lesson bodies also supply missing +metadata offline, even if Saved was never opened before. + ## Authentication The project signs tokens with **ES256**, so the API verifies them against the @@ -77,6 +107,11 @@ confusion attacks, both of which are covered by tests. `user_id` is taken from the verified token's `sub` claim and from nowhere else. No endpoint accepts a user id as a parameter. +Supabase Auth sends signup, recovery, and enabled security notifications using +the project's configured sender. See [Email templates](../docs/EMAIL_TEMPLATES.md) +for the three branded HTML files and manual installation steps. An app deployment +does not publish these templates or change SMTP settings. + ## Content generation Gemini writes lessons. It does **not** choose subjects. @@ -86,6 +121,8 @@ curated backlog (150 titles) ↓ worker: is a topic below MIN_POOL_PER_TOPIC published concepts? ↓ +claim a title + reserve one shared daily call, then commit + ↓ Gemini writes {summary, example} for one backlogged title ↓ validate — length bounds, no boilerplate opener, no code fence, @@ -117,17 +154,60 @@ It is safe to run concurrently: backlog items are claimed with | Control | Effect | |---|---| | `GENERATION_ENABLED` | Master switch. Nothing calls Gemini when false. | -| `GENERATION_DAILY_CALL_CAP` | Hard ceiling per run, so a retry loop cannot burn the quota. | +| `GENERATION_DAILY_CALL_CAP` | Shared daily reservation ceiling across scheduled refill, on-demand prefetch, and catalog rewriting; zero stops new calls. | | `attempts < 3` | A title that keeps failing is retired rather than blocking the queue. | | Validation | Malformed output leaves the item pending; it never reaches a reader. | -| `GENERATION_ON_DEMAND` | Last-resort in-request generation when one user's pool is dry. | +| `GENERATION_ON_DEMAND` | Allows background refill when a user has few unread lessons. | + +### Shared generation budget + +`generation_daily_usage` stores committed call reservations, one row per Pacific +calendar day. PostgreSQL computes the day in `America/Los_Angeles`, matching +[Gemini's midnight Pacific RPD reset](https://ai.google.dev/gemini-api/docs/rate-limits), +including daylight saving time. User assignment/streak timezones are unchanged. +The atomic UPSERT prevents competing API/worker processes from spending the same +last slot. Restarts and repeated job runs retain usage; a new day gets a new row. + +`generate_one` claims the backlog title and reserves quota in one transaction, +then commits before calling Gemini. An exhausted budget rolls back the title +claim and attempt. Once committed, failed responses, rate limits, cancellation, +or a worker crash keep the reservation; uncertain provider calls must not be +refunded. Gemini throttling still refunds the separate **backlog retry attempt**. +Empty backlog does not consume quota. Database/reservation failures stop generation +before the provider call. The manual rewrite worker uses the same ledger and +respects `GENERATION_ENABLED`; unfinished lessons retain their old prompt version +for a later run. Daily reading remains independent of generation. + +Set the **same `GENERATION_DAILY_CALL_CAP` on the API and every worker** sharing +this database. Changing it does not erase existing usage. This is an application +budget, not a provider quota lookup: other applications using the same Gemini +project are outside this ledger, and provider rate/token limits still apply. + +**Before deploying this code:** apply +[`0010_generation_daily_usage.sql`](migrations/0010_generation_daily_usage.sql) +using the migration procedure in [RELEASING.md](../RELEASING.md). The production +ledger remains unchanged until actual application is verified. Earlier application +versions do not use this counter, and calls made before deployment cannot be +reconstructed from it. Pause old generators during rollout; enable the new code +at the next Pacific reset, or conservatively seed today's usage while generation +is paused, to avoid granting another allowance in the middle of the day. + +Inspect reservations without changing them: + +```sql +select budget_day, calls_used +from public.generation_daily_usage +order by budget_day desc +limit 7; +``` ### Fallback ladder in `/v1/daily` 1. An unseen concept in a followed topic. -2. Failing that, generate one in the user's least-recently-seen followed topic. -3. Failing that, widen to the whole catalog and flag `outside_followed_topics`. -4. Failing that, return `409 catalog_exhausted`. A concept is never repeated. +2. If that pool is dry, schedule a background refill and immediately widen to + the whole catalog, flagging `outside_followed_topics`. +3. If nothing unseen remains, return `409 catalog_exhausted`. A concept is never + repeated. A low unread watermark can also schedule refill before exhaustion. ## Latency and database region @@ -136,16 +216,48 @@ Measured against a Supabase project in `ap-northeast-1` from Europe, a single round trip is 160–1100 ms — so the code is written to minimise the *number* of statements rather than their complexity: -- `/v1/me/state` is **one query**. It returns follows, history, likes, saves, - today's assignment, and streaks together, and bootstrapping only runs when - that query finds no profile. +- The state aggregate is **one query**, returning follows, recent detail rows, + membership, assignment slug, and full streaks/totals. Bootstrapping only runs + when no profile exists. The `/v1/me/state` handler separately resolves today's + lesson, folding it into the same HTTP response. - Follow updates are one statement (a data-modifying CTE), not one per topic. - Connection pooling is on. Without it every request paid a fresh TCP + TLS + auth handshake to the database region, which cost seconds. -The remaining latency is geography. **Deploy the API in the same region as the -database** — on Railway, pick the region closest to your Supabase project — and -these round trips drop to single-digit milliseconds. +Geography and connection reuse both affect latency. **Deploy the API close to +the database**, then compare fresh, immediately reused, and post-idle requests. +Timing a slow query alone does not establish that a new connection was opened. + +The API runs a best-effort `SELECT 1` probe immediately on startup and then +every `DB_KEEPALIVE_INTERVAL_SECONDS` (default 30 seconds, measured after each +probe finishes). It borrows from the same pool as requests and promptly returns +the connection, rolling back the implicit transaction. The pool reuses its most +recently returned connection, so low traffic can use a warm slot while surplus +idle slots expire. `pool_pre_ping` remains enabled for dead connections. + +`DB_KEEPALIVE_TIMEOUT_SECONDS` (default 5 seconds) bounds checkout, reconnect, +and query together. Rollback/return has a separate budget of the same duration; +failed cleanup invalidates the connection. A failed attempt logs only the +exception type and retries after the interval; it does not block API startup. +Shutdown cancels the task before disposing the engine. Set the interval to `0` +to disable probes. Each API +process runs its own task; importing the engine in cron workers starts no task. +Pool size and overflow limits remain 5 each. A cold startup or a burst requiring +additional connections can still pay connection setup time. + +Reproduce idle expiry without production services using: + +```bash +DATABASE_URL=postgresql+asyncpg://postgres:postgres@127.0.0.1:55433/postgres \ +SUPABASE_URL=http://test.invalid SUPABASE_JWKS_URL=http://test.invalid/jwks \ +GENERATION_ENABLED=false GEMINI_API_KEY= \ + .venv/bin/python -m pytest tests/test_db_keepalive_postgres.py -q -s +``` + +The PostgreSQL 16 tests set short idle expiry only on their own sessions and +compare connection counts and request timings with warming disabled/enabled. +They also check transaction cleanup and recovery from a terminated connection. +These timings describe the local test environment, not deployed Supavisor latency. ## Connection strings diff --git a/backend/app/api/v1/me.py b/backend/app/api/v1/me.py index 683fe85..b6c7e42 100644 --- a/backend/app/api/v1/me.py +++ b/backend/app/api/v1/me.py @@ -1,14 +1,18 @@ -from datetime import time +from datetime import date, time -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import ARRAY, Time, bindparam, text from sqlalchemy.ext.asyncio import AsyncSession from app.db.session import get_db from app.deps import CurrentUser, get_current_user from app.schemas.daily import ConceptOut, DailyOut -from app.schemas.me import LearnedOut, ProfileIn, SavedConceptOut, StateOut, StreakOut, TopicsIn +from app.schemas.me import ( + HistoryPageOut, LearnedOut, ProfileIn, SavedConceptOut, SavedPageOut, StateOut, + StreakOut, TopicsIn, +) from app.schemas.notifications import NotificationPrefs, PushTokenIn +from app.services.collections import history_page, saved_page from app.services.interactions import set_followed_topics from app.services.selection import DailyResult, get_or_create_daily from app.services.state import load_state @@ -59,12 +63,16 @@ def _to_state_out(state) -> StateOut: for s in state.saved ], stats=StreakOut(**vars(state.stats)), + learned_before_window=state.learned_before_window, + history_next_cursor=state.history_next_cursor, + saved_next_cursor=state.saved_next_cursor, assignment_slug=state.assignment_slug, ) @router.get("/state", response_model=StateOut) async def get_state( + compact: bool = False, user: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> StateOut: @@ -73,11 +81,11 @@ async def get_state( Bootstrapping only runs when the state query finds no profile, so the common path costs a single round trip. """ - state = await load_state(db, user.id) + state = await load_state(db, user.id, compact=compact) if state is None: await ensure_bootstrapped(db, user.id, user.email) await db.commit() - state = await load_state(db, user.id) + state = await load_state(db, user.id, compact=compact) out = _to_state_out(state) # Fold today's concept in so the app needs one startup round trip (#102). # Same create-on-first-call behaviour as GET /v1/daily. @@ -85,6 +93,29 @@ async def get_state( return out +@router.get("/history", response_model=HistoryPageOut) +async def get_history( + cursor: date | None = None, + limit: int = Query(default=50, ge=1, le=100), + user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> HistoryPageOut: + return await history_page(db, user.id, cursor, limit) + + +@router.get("/saved", response_model=SavedPageOut) +async def get_saved( + cursor: str | None = Query(default=None, max_length=200), + limit: int = Query(default=50, ge=1, le=100), + user: CurrentUser = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> SavedPageOut: + try: + return await saved_page(db, user.id, cursor, limit) + except ValueError as exc: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Invalid saved cursor") from exc + + @router.get("/stats", response_model=StreakOut) async def get_stats( user: CurrentUser = Depends(get_current_user), @@ -96,11 +127,12 @@ async def get_stats( @router.put("/topics", response_model=StateOut) async def put_topics( body: TopicsIn, + compact: bool = False, user: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> StateOut: await set_followed_topics(db, user.id, body.topics) - return _to_state_out(await load_state(db, user.id)) + return _to_state_out(await load_state(db, user.id, compact=compact)) @router.post("/push-token", status_code=status.HTTP_204_NO_CONTENT) @@ -217,6 +249,7 @@ async def put_notifications( @router.patch("", response_model=StateOut) async def patch_profile( body: ProfileIn, + compact: bool = False, user: CurrentUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> StateOut: @@ -244,4 +277,4 @@ async def patch_profile( {"n": body.display_name, "uid": user.id}, ) await db.commit() - return _to_state_out(await load_state(db, user.id)) + return _to_state_out(await load_state(db, user.id, compact=compact)) diff --git a/backend/app/config.py b/backend/app/config.py index 46654c3..92fd5c0 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -1,5 +1,6 @@ from functools import lru_cache +from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -17,6 +18,10 @@ class Settings(BaseSettings): database_url: str direct_url: str | None = None + # Best-effort API pool warm-up; zero disables it. Workers do not start it. + db_keepalive_interval_seconds: float = Field(default=30, ge=0, allow_inf_nan=False) + db_keepalive_timeout_seconds: float = Field(default=5, gt=0, allow_inf_nan=False) + supabase_url: str supabase_jwks_url: str # Present for legacy HS256 projects; this project signs with ES256 via JWKS. @@ -32,10 +37,11 @@ class Settings(BaseSettings): gemini_model: str = "gemini-3.1-flash-lite" generation_enabled: bool = False min_pool_per_topic: int = 25 - generation_daily_call_cap: int = 200 + # Shared by all generation paths; zero prevents new reservations. + generation_daily_call_cap: int = Field(default=200, ge=0) # Seconds between worker calls; the free tier allows ~10 requests a minute. generation_pace_seconds: float = 6.0 - # Last-resort generation inside a request, when a user's pool is empty. + # Schedule background refill when a user's unread pool is low. generation_on_demand: bool = True allowed_origins: str = "http://localhost:8081" diff --git a/backend/app/db/keepalive.py b/backend/app/db/keepalive.py new file mode 100644 index 0000000..fc5927d --- /dev/null +++ b/backend/app/db/keepalive.py @@ -0,0 +1,55 @@ +"""Keep a reusable API connection warm without adding work to user requests.""" + +import asyncio +import logging +from time import perf_counter + +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine + +logger = logging.getLogger("uvicorn.error") + + +async def _release(connection: AsyncConnection, timeout: float) -> None: + try: + async with asyncio.timeout(timeout): + await connection.close() + except BaseException: + # A failed rollback must not leave a borrowed or broken pool slot. + await connection.invalidate() + raise + + +async def _probe(engine: AsyncEngine, timeout: float) -> None: + connection = None + try: + async with asyncio.timeout(timeout): + connection = await engine.connect() + await connection.exec_driver_sql("SELECT 1") + finally: + if connection is not None: + # SQLAlchemy's context exit shields close(), but cancellation can + # return before that close finishes. Retain and await cleanup so + # lifespan disposal cannot race a connection still being returned. + cleanup = asyncio.create_task(_release(connection, timeout)) + try: + await asyncio.shield(cleanup) + except asyncio.CancelledError: + await cleanup + raise + + +async def keep_database_warm(engine: AsyncEngine, interval: float, timeout: float) -> None: + """Probe immediately, then periodically; cancellation belongs to the lifespan.""" + while True: + started = perf_counter() + try: + await _probe(engine, timeout) + logger.debug("Database warm-up completed in %.1f ms", (perf_counter() - started) * 1000) + except Exception as error: + # Cleanup errors must not turn a shutdown cancellation into a retry. + if asyncio.current_task().cancelling(): + raise asyncio.CancelledError from error + # Outages must not stop the API or cause tight retry loops. Log only + # the exception type: driver messages can contain connection details. + logger.warning("Database warm-up failed (%s); retrying later", type(error).__name__) + await asyncio.sleep(interval) diff --git a/backend/app/db/models.py b/backend/app/db/models.py index ddde594..c83680e 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -12,6 +12,7 @@ Date, DateTime, ForeignKey, + Integer, SmallInteger, Text, Time, @@ -175,3 +176,12 @@ class ReminderLog(Base): local_date: Mapped[date] = mapped_column(Date, primary_key=True) slot: Mapped[time] = mapped_column(Time, primary_key=True) sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) + + +class GenerationDailyUsage(Base): + """Mirror of migrations/0010_generation_daily_usage.sql; backend-only quota.""" + + __tablename__ = "generation_daily_usage" + + budget_day: Mapped[date] = mapped_column(Date, primary_key=True) + calls_used: Mapped[int] = mapped_column(Integer, nullable=False, default=0) diff --git a/backend/app/db/session.py b/backend/app/db/session.py index 684f2c0..78d102f 100644 --- a/backend/app/db/session.py +++ b/backend/app/db/session.py @@ -1,8 +1,8 @@ from collections.abc import AsyncIterator -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine -from app.config import get_settings +from app.config import Settings, get_settings settings = get_settings() @@ -11,20 +11,24 @@ # is not only safe but necessary: without it every request pays a fresh # TCP + TLS + auth handshake to the database region, which dominates response # time when the database is far away. -_connect_args: dict = {} -if settings.uses_transaction_pooler: - _connect_args["statement_cache_size"] = 0 - -engine = create_async_engine( - settings.sqlalchemy_url, - echo=False, - pool_size=5, - max_overflow=5, - pool_recycle=1800, - pool_pre_ping=True, - connect_args=_connect_args, - execution_options={"compiled_cache": None} if settings.uses_transaction_pooler else {}, -) +def create_db_engine(config: Settings) -> AsyncEngine: + connect_args = {"statement_cache_size": 0} if config.uses_transaction_pooler else {} + return create_async_engine( + config.sqlalchemy_url, + echo=False, + pool_size=5, + max_overflow=5, + pool_recycle=1800, + pool_pre_ping=True, + # Keep the most recently used connection hot instead of cycling through + # every idle slot. Other slots still reconnect safely on demand. + pool_use_lifo=True, + connect_args=connect_args, + execution_options={"compiled_cache": None} if config.uses_transaction_pooler else {}, + ) + + +engine = create_db_engine(settings) SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) diff --git a/backend/app/main.py b/backend/app/main.py index 9cd6038..ecf18be 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,5 +1,6 @@ +import asyncio import logging -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, suppress from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -9,6 +10,7 @@ from app.api.v1.router import api_router from app.config import get_settings from app.core.security import JwksCache +from app.db.keepalive import keep_database_warm from app.db.session import engine @@ -22,8 +24,22 @@ async def lifespan(app: FastAPI): settings.environment, settings.uses_transaction_pooler, ) - yield - await engine.dispose() + warmup = None + if settings.db_keepalive_interval_seconds > 0: + warmup = asyncio.create_task( + keep_database_warm( + engine, settings.db_keepalive_interval_seconds, settings.db_keepalive_timeout_seconds + ), + name="database-keepalive", + ) + try: + yield + finally: + if warmup is not None: + warmup.cancel() + with suppress(asyncio.CancelledError): + await warmup + await engine.dispose() def create_app() -> FastAPI: diff --git a/backend/app/schemas/me.py b/backend/app/schemas/me.py index ecbbbbf..ab1553e 100644 --- a/backend/app/schemas/me.py +++ b/backend/app/schemas/me.py @@ -28,6 +28,16 @@ class LearnedOut(BaseModel): like_count: int = 0 +class HistoryPageOut(BaseModel): + items: list[LearnedOut] + next_cursor: str | None = None + + +class SavedPageOut(BaseModel): + items: list[SavedConceptOut] + next_cursor: str | None = None + + class StateOut(BaseModel): display_name: str | None = None timezone: str @@ -38,6 +48,11 @@ class StateOut(BaseModel): bookmarks: list[str] saved: list[SavedConceptOut] = Field(default_factory=list) stats: StreakOut + # Present for compact clients. Counts exclude the embedded recent window, + # so optimistic/offline completions can still be added by the client. + learned_before_window: dict[str, int] | None = None + history_next_cursor: str | None = None + saved_next_cursor: str | None = None assignment_slug: str | None = None # Today's concept, folded in so the app needs a single round trip at startup # (issue #102). Null when the catalog is exhausted for this user. This GET diff --git a/backend/app/services/collections.py b/backend/app/services/collections.py new file mode 100644 index 0000000..73fcb94 --- /dev/null +++ b/backend/app/services/collections.py @@ -0,0 +1,100 @@ +"""Bounded collection reads; cursor ordering survives deletions between pages.""" + +import base64 +import binascii +import json +import uuid +from datetime import date, datetime + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.schemas.me import HistoryPageOut, LearnedOut, SavedConceptOut, SavedPageOut + +STATE_WINDOW = 50 + + +def saved_cursor(saved_at: str, concept_id: str) -> str: + return base64.urlsafe_b64encode(json.dumps([saved_at, concept_id]).encode()).decode().rstrip("=") + + +def parse_saved_cursor(cursor: str | None) -> tuple[datetime | None, uuid.UUID | None]: + if cursor is None: + return None, None + try: + timestamp, concept_id = json.loads(base64.b64decode( + cursor + "=" * (-len(cursor) % 4), altchars=b"-_", validate=True, + )) + if not isinstance(timestamp, str) or not isinstance(concept_id, str): + raise ValueError("Cursor fields must be strings") + at = datetime.fromisoformat(timestamp) + if at.tzinfo is None: + raise ValueError("Timestamp must include a timezone") + return at, uuid.UUID(concept_id) + except (ValueError, TypeError, binascii.Error) as exc: + raise ValueError("Invalid saved cursor") from exc + + +_HISTORY_PAGE = text(""" + with page as materialized ( + select concept_id, assigned_for + from public.daily_assignments + where user_id = :uid and completed_at is not null + and (cast(:before as date) is null or assigned_for < :before) + order by assigned_for desc limit :take + ) + select c.slug as concept_slug, p.assigned_for as learned_on, + c.title, t.name as topic_name, + (select count(*) from public.concept_interactions ci + where ci.concept_id = p.concept_id and ci.liked_at is not null + and ci.user_id <> :uid)::int as like_count + from page p join public.concepts c on c.id = p.concept_id + join public.topics t on t.id = c.topic_id + order by p.assigned_for desc +""") + +_SAVED_PAGE = text(""" + with page as materialized ( + select concept_id, saved_at + from public.concept_interactions + where user_id = :uid and saved_at is not null + and (cast(:before as timestamptz) is null + or (saved_at, concept_id) < (:before, cast(:id as uuid))) + order by saved_at desc, concept_id desc limit :take + ) + select c.slug as concept_slug, c.title, t.name as topic_name, p.saved_at, p.concept_id, + (select count(*) from public.concept_interactions ci + where ci.concept_id = p.concept_id and ci.liked_at is not null + and ci.user_id <> :uid)::int as like_count + from page p join public.concepts c on c.id = p.concept_id + join public.topics t on t.id = c.topic_id + order by p.saved_at desc, p.concept_id desc +""") + + +async def history_page( + db: AsyncSession, user_id: uuid.UUID, before: date | None, limit: int, +) -> HistoryPageOut: + rows = (await db.execute(_HISTORY_PAGE, { + "uid": user_id, "before": before, "take": limit + 1, + })).mappings().all() + items = [LearnedOut(**r) for r in rows[:limit]] + return HistoryPageOut( + items=items, + next_cursor=items[-1].learned_on.isoformat() if len(rows) > limit else None, + ) + + +async def saved_page( + db: AsyncSession, user_id: uuid.UUID, cursor: str | None, limit: int, +) -> SavedPageOut: + before, concept_id = parse_saved_cursor(cursor) + rows = (await db.execute(_SAVED_PAGE, { + "uid": user_id, "before": before, "id": concept_id, "take": limit + 1, + })).mappings().all() + return SavedPageOut( + items=[SavedConceptOut(**r) for r in rows[:limit]], + next_cursor=(saved_cursor(rows[limit - 1]["saved_at"].isoformat(), + str(rows[limit - 1]["concept_id"])) + if len(rows) > limit else None), + ) diff --git a/backend/app/services/generation_budget.py b/backend/app/services/generation_budget.py new file mode 100644 index 0000000..cdebbad --- /dev/null +++ b/backend/app/services/generation_budget.py @@ -0,0 +1,38 @@ +"""Shared daily reservations, made in the caller's short database transaction. + +The caller must COMMIT before contacting Gemini. A failed/uncertain provider +request still costs its reservation; refunding it could exceed the daily cap. +""" + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + + +class GenerationBudgetExhausted(RuntimeError): + """Normal stop condition: no more generation calls may start today.""" + + +# Gemini RPD resets at midnight Pacific, including DST. Use the database clock +# for all workers and statement_timestamp rather than the transaction's start: +# a session may have read its work list before the day changed. +_RESERVE = text(""" + insert into public.generation_daily_usage (budget_day, calls_used) + select (statement_timestamp() at time zone 'America/Los_Angeles')::date, 1 + where :cap > 0 + on conflict (budget_day) do update + set calls_used = generation_daily_usage.calls_used + 1 + where generation_daily_usage.calls_used < :cap + returning calls_used +""") + + +async def reserve_generation_call(session: AsyncSession, call_cap: int) -> None: + """Atomically reserve one call; the caller commits or rolls back the claim. + + The UPSERT locks the shared day's row and checks the latest committed count, + so concurrent processes cannot each spend the same final slot. Database + errors propagate: generation must never proceed without a confirmed budget. + """ + used = (await session.execute(_RESERVE, {"cap": call_cap})).scalar_one_or_none() + if used is None: + raise GenerationBudgetExhausted("daily generation call cap reached") diff --git a/backend/app/services/pool.py b/backend/app/services/pool.py index 62631ca..9deea69 100644 --- a/backend/app/services/pool.py +++ b/backend/app/services/pool.py @@ -13,6 +13,8 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession +from app.config import get_settings +from app.services.generation_budget import GenerationBudgetExhausted, reserve_generation_call from app.services.generation import GenerationError, RateLimitedError, generate_concept log = logging.getLogger(__name__) @@ -130,11 +132,21 @@ class TopUpResult: async def generate_one( - session: AsyncSession, api_key: str, model: str, topic_id: uuid.UUID | None = None + session: AsyncSession, api_key: str, model: str, topic_id: uuid.UUID | None = None, + *, call_cap: int | None = None, ) -> uuid.UUID | None: - """Generate and publish a single backlog item. Returns the concept id.""" - claimed = (await session.execute(_CLAIM, {"topic_id": topic_id})).first() - await session.commit() + """Claim a title and daily budget together, then generate outside the transaction.""" + cap = get_settings().generation_daily_call_cap if call_cap is None else call_cap + try: + claimed = (await session.execute(_CLAIM, {"topic_id": topic_id})).first() + if claimed is not None: + await reserve_generation_call(session, cap) + await session.commit() + except BaseException: + # Quota denial/DB failure/cancellation must undo the claim and its attempt. + # Once committed, a call's quota reservation is never refunded. + await session.rollback() + raise if claimed is None: return None @@ -209,7 +221,7 @@ async def top_up( if reaped: log.warning("reclaimed %s stale 'generating' backlog rows", len(reaped)) - generated = failed = calls = 0 + generated = failed = 0 backoff = BACKOFF_START_SECONDS rate_limit_streak = 0 for topic in (await session.execute(_POOL_COUNTS)).all(): @@ -218,13 +230,11 @@ async def top_up( continue remaining = min(deficit, topic.pending) while remaining > 0: - if calls >= call_cap: - # A hard ceiling so a retry loop cannot burn the daily quota. - log.warning("stopping: hit the daily call cap of %s", call_cap) - return TopUpResult(generated, failed, "daily call cap reached") - calls += 1 try: - concept_id = await generate_one(session, api_key, model, topic.id) + concept_id = await generate_one(session, api_key, model, topic.id, call_cap=call_cap) + except GenerationBudgetExhausted: + log.info("stopping: shared daily call cap of %s reached", call_cap) + return TopUpResult(generated, failed, "daily call cap reached") except RateLimitedError as exc: rate_limit_streak += 1 if rate_limit_streak >= MAX_CONSECUTIVE_RATE_LIMITS: diff --git a/backend/app/services/prefetch.py b/backend/app/services/prefetch.py index 9b843e4..7615440 100644 --- a/backend/app/services/prefetch.py +++ b/backend/app/services/prefetch.py @@ -18,6 +18,7 @@ from app.config import get_settings from app.db.session import SessionLocal from app.services.generation import RateLimitedError +from app.services.generation_budget import GenerationBudgetExhausted from app.services.pool import generate_one log = logging.getLogger(__name__) @@ -85,8 +86,12 @@ async def _run(topic_id: uuid.UUID) -> None: break try: concept_id = await generate_one( - session, settings.gemini_api_key, settings.gemini_model, topic_id + session, settings.gemini_api_key, settings.gemini_model, topic_id, + call_cap=settings.generation_daily_call_cap, ) + except GenerationBudgetExhausted: + log.info("prefetch for topic %s stopped: daily call cap reached", topic_id) + break except RateLimitedError: log.info("prefetch for topic %s stopped: rate limited", topic_id) break diff --git a/backend/app/services/state.py b/backend/app/services/state.py index 675ee0b..3a6945b 100644 --- a/backend/app/services/state.py +++ b/backend/app/services/state.py @@ -13,6 +13,7 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession +from app.services.collections import STATE_WINDOW, saved_cursor from app.services.streaks import StreakStats @@ -46,6 +47,9 @@ class UserState: # user's 20 concepts). `bookmarks` stays as bare slugs for membership counts. saved: list[SavedConcept] stats: StreakStats + learned_before_window: dict[str, int] | None = None + history_next_cursor: str | None = None + saved_next_cursor: str | None = None assignment_slug: str | None = None display_name: str | None = None @@ -68,14 +72,22 @@ class UserState: join public.topics t on t.id = c.topic_id where a.user_id = :uid and a.completed_at is not null ), + recent_learned_rows as materialized ( + select * from learned_rows order by assigned_for desc limit :window_limit + ), + older_counts as ( + select topic_name, count(*)::int as n from learned_rows + where assigned_for < (select min(assigned_for) from recent_learned_rows) + group by topic_name + ), learned as ( select coalesce(json_agg(json_build_object( 'slug', slug, 'title', title, 'topic', topic_name, 'on', assigned_for, 'likes', (select count(*) from public.concept_interactions ci - where ci.concept_id = learned_rows.concept_id + where ci.concept_id = recent_learned_rows.concept_id and ci.liked_at is not null and ci.user_id <> :uid)::int) order by assigned_for desc), '[]'::json) as v - from learned_rows + from recent_learned_rows ), interactions as ( select @@ -85,18 +97,22 @@ class UserState: join public.concepts c on c.id = i.concept_id where i.user_id = :uid ), + saved_rows as materialized ( + select concept_id, saved_at from public.concept_interactions + where user_id = :uid and saved_at is not null + order by saved_at desc, concept_id desc limit :window_limit + ), saved as ( select coalesce(json_agg(json_build_object( 'slug', c.slug, 'title', c.title, 'topic', t.name, + 'at', i.saved_at, 'id', i.concept_id, 'likes', (select count(*) from public.concept_interactions ci where ci.concept_id = c.id and ci.liked_at is not null and ci.user_id <> :uid)::int) - order by i.saved_at desc) filter (where i.saved_at is not null), - '[]'::json) as v - from public.concept_interactions i + order by i.saved_at desc, i.concept_id desc), '[]'::json) as v + from saved_rows i join public.concepts c on c.id = i.concept_id join public.topics t on t.id = c.topic_id - where i.user_id = :uid ), assignment as ( select c.slug @@ -114,6 +130,8 @@ class UserState: prof.today, followed.v as followed_topics, learned.v as learned, + coalesce((select json_object_agg(topic_name, n) from older_counts), + '{}'::json) as learned_before_window, interactions.likes, interactions.saves, saved.v as saved, @@ -127,10 +145,12 @@ class UserState: """) -async def load_state(session: AsyncSession, user_id: uuid.UUID) -> UserState | None: +async def load_state(session: AsyncSession, user_id: uuid.UUID, *, compact: bool = False) -> UserState | None: """Returns None when the user has no profile row yet, so the caller can bootstrap and retry — keeping the common path to a single query.""" - row = (await session.execute(_STATE, {"uid": user_id})).first() + row = (await session.execute(_STATE, { + "uid": user_id, "window_limit": STATE_WINDOW if compact else None, + })).first() if row is None: return None @@ -165,5 +185,10 @@ async def load_state(session: AsyncSession, user_id: uuid.UUID) -> UserState | N longest=row.longest_streak, total_learned=row.total_learned, ), + learned_before_window=dict(row.learned_before_window) if compact else None, + history_next_cursor=(row.learned[-1]["on"] + if compact and row.total_learned > len(row.learned) else None), + saved_next_cursor=(saved_cursor(row.saved[-1]["at"], row.saved[-1]["id"]) + if compact and len(row.saves) > len(row.saved) else None), assignment_slug=row.assignment_slug, ) diff --git a/backend/app/workers/rewrite_catalog.py b/backend/app/workers/rewrite_catalog.py index fb673fb..0309afd 100644 --- a/backend/app/workers/rewrite_catalog.py +++ b/backend/app/workers/rewrite_catalog.py @@ -13,6 +13,7 @@ from app.config import get_settings from app.db.session import SessionLocal, engine +from app.services.generation_budget import GenerationBudgetExhausted, reserve_generation_call from app.services.generation import ( PROMPT_VERSION, GenerationError, @@ -45,54 +46,67 @@ async def main() -> None: logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") settings = get_settings() - async with SessionLocal() as session: - todo = (await session.execute(_TODO, {"pv": PROMPT_VERSION})).all() - # Close the read transaction before the paced generation loop begins: - # otherwise this initial SELECT's transaction stays open across every - # Gemini call, pace sleep, and rate-limit backoff below, pinning a server - # connection on the transaction pooler for the entire (long) run. - await session.commit() - log.info("%s lessons to rewrite with prompt %s", len(todo), PROMPT_VERSION) + try: + if not settings.generation_enabled: + log.info("generation disabled; catalog unchanged") + return + if not settings.gemini_api_key: + log.info("no API key configured; catalog unchanged") + return + async with SessionLocal() as session: + todo = (await session.execute(_TODO, {"pv": PROMPT_VERSION})).all() + # Close the read transaction before the paced generation loop begins: + # otherwise this initial SELECT's transaction stays open across every + # Gemini call, pace sleep, and rate-limit backoff below, pinning a server + # connection on the transaction pooler for the entire (long) run. + await session.commit() + log.info("%s lessons to rewrite with prompt %s", len(todo), PROMPT_VERSION) - rewritten = failed = 0 - backoff, streak = BACKOFF_START, 0 - for row in todo: - while True: - try: - result = await generate_concept( - title=row.title, topic_name=row.topic_name, angle=None, - api_key=settings.gemini_api_key, model=settings.gemini_model, - ) - except RateLimitedError as exc: - streak += 1 - if streak >= MAX_RATE_LIMIT_STREAK: - log.warning("giving up: %s consecutive rate limits", streak) - log.info("rewritten %s, failed %s (resume by re-running)", rewritten, failed) - await engine.dispose() + rewritten = failed = 0 + backoff, streak = BACKOFF_START, 0 + for row in todo: + while True: + try: + await reserve_generation_call(session, settings.generation_daily_call_cap) + await session.commit() + result = await generate_concept( + title=row.title, topic_name=row.topic_name, angle=None, + api_key=settings.gemini_api_key, model=settings.gemini_model, + ) + except GenerationBudgetExhausted: + await session.rollback() + log.info("daily call cap reached: rewritten %s, failed %s (resume on a later day)", rewritten, failed) return - delay = max(exc.retry_after or 0.0, backoff) - log.warning("rate limited; retrying %s in %.0fs", row.title, delay) - await asyncio.sleep(delay) - backoff = min(backoff * 2, BACKOFF_MAX) - continue - except GenerationError as exc: - # Leave it on the old prompt version; a later run retries it. - log.warning("skipping %s: %s", row.title, exc) - failed += 1 + except RateLimitedError as exc: + streak += 1 + if streak >= MAX_RATE_LIMIT_STREAK: + log.warning("giving up: %s consecutive rate limits", streak) + log.info("rewritten %s, failed %s (resume by re-running)", rewritten, failed) + return + delay = max(exc.retry_after or 0.0, backoff) + log.warning("rate limited; retrying %s in %.0fs", row.title, delay) + await asyncio.sleep(delay) + backoff = min(backoff * 2, BACKOFF_MAX) + continue + except GenerationError as exc: + # Leave it on the old prompt version; a later run retries it. + log.warning("skipping %s: %s", row.title, exc) + failed += 1 + break + streak, backoff = 0, BACKOFF_START + await session.execute(_UPDATE, { + "id": row.id, "summary": result.summary, "example": result.example, + "model": result.model, "pv": result.prompt_version, + }) + await session.commit() + rewritten += 1 + log.info("rewrote %s (%s/%s)", row.title, rewritten, len(todo)) break - streak, backoff = 0, BACKOFF_START - await session.execute(_UPDATE, { - "id": row.id, "summary": result.summary, "example": result.example, - "model": result.model, "pv": result.prompt_version, - }) - await session.commit() - rewritten += 1 - log.info("rewrote %s (%s/%s)", row.title, rewritten, len(todo)) - break - await asyncio.sleep(PACE_SECONDS) + await asyncio.sleep(PACE_SECONDS) - log.info("done: rewritten %s, failed %s", rewritten, failed) - await engine.dispose() + log.info("done: rewritten %s, failed %s", rewritten, failed) + finally: + await engine.dispose() if __name__ == "__main__": diff --git a/backend/email-templates/confirm-signup.html b/backend/email-templates/confirm-signup.html new file mode 100644 index 0000000..784094f --- /dev/null +++ b/backend/email-templates/confirm-signup.html @@ -0,0 +1,41 @@ + + + + + + Confirm your email — One Concept + + + +
One last step to finish setting up your account.
+ + +
+ + + + + +
+

One Concept.

+

by Coding Moves

+
+

Confirm your email.

+

Confirm this address to finish setting up your account.

+ + +
+ Confirm email +
+

Then return to the app and sign in.

+
+

If you didn’t sign up, you can ignore this email.

+

Button not working? Copy this link into your browser:

+

+ {{ .ConfirmationURL }} +

+
+ +
+ + diff --git a/backend/email-templates/password-changed.html b/backend/email-templates/password-changed.html new file mode 100644 index 0000000..46897df --- /dev/null +++ b/backend/email-templates/password-changed.html @@ -0,0 +1,34 @@ + + + + + + Your password was changed — One Concept + + + +
A security update for your One Concept account.
+ + +
+ + + + + +
+

One Concept.

+

by Coding Moves

+
+

Pass­word changed.

+

Your One Concept password was changed.

+

If you made this change, no further action is needed.

+
+

Didn’t make this change?

+

Open One Concept and use Forgot password on the sign-in screen to reset your password.

+

Contact support immediately.

+
+ +
+ + diff --git a/backend/email-templates/reset-password.html b/backend/email-templates/reset-password.html new file mode 100644 index 0000000..3367efd --- /dev/null +++ b/backend/email-templates/reset-password.html @@ -0,0 +1,41 @@ + + + + + + Reset your password — One Concept + + + +
Choose a new password for your One Concept account.
+ + +
+ + + + + +
+

One Concept.

+

by Coding Moves

+
+

Reset your pass­word.

+

A password reset was requested for your account. Choose a new password below.

+ + +
+ Reset password +
+

Once it’s saved, return to the app and sign in.

+
+

If you didn’t request this, ignore this email. Your password won’t change.

+

Button not working? Copy this link into your browser:

+

+ {{ .ConfirmationURL }} +

+
+ +
+ + diff --git a/backend/migrations/0010_generation_daily_usage.sql b/backend/migrations/0010_generation_daily_usage.sql new file mode 100644 index 0000000..8a1370d --- /dev/null +++ b/backend/migrations/0010_generation_daily_usage.sql @@ -0,0 +1,15 @@ +-- Shared reservation ledger for every application Gemini generation path. +-- Calls are reserved before network I/O and never refunded after commit. +-- A new Pacific calendar day gets a new row; no reset job is needed. +begin; + +create table public.generation_daily_usage ( + budget_day date primary key, + calls_used integer not null default 0 check (calls_used >= 0) +); + +-- Backend only. No policies means authenticated mobile users cannot access it, +-- even on installations whose default grants expose new public-schema tables. +alter table public.generation_daily_usage enable row level security; + +commit; diff --git a/backend/migrations/applied.txt b/backend/migrations/applied.txt index ae690ba..0ff1ffc 100644 --- a/backend/migrations/applied.txt +++ b/backend/migrations/applied.txt @@ -17,3 +17,4 @@ 0007_reminder_log.sql 0008_backlog_claimed_at.sql 0009_like_count_index.sql +0010_generation_daily_usage.sql diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 3f0e98c..eab4a5b 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -22,6 +22,7 @@ # the actual Gemini API instead of reporting exhaustion. os.environ["GENERATION_ENABLED"] = "false" os.environ["GEMINI_API_KEY"] = "" +os.environ["GENERATION_DAILY_CALL_CAP"] = "200" CONTAINER = "one-concept-test-db" PORT = 55433 @@ -114,3 +115,16 @@ async def user(session): ) await session.commit() return user_id + + +@pytest_asyncio.fixture +async def empty_generation_budget(session): + """Generation tests share a schema, but each starts with its own daily budget.""" + from sqlalchemy import text + + await session.execute(text("delete from public.generation_daily_usage")) + await session.commit() + yield + await session.rollback() + await session.execute(text("delete from public.generation_daily_usage")) + await session.commit() diff --git a/backend/tests/test_db_keepalive.py b/backend/tests/test_db_keepalive.py new file mode 100644 index 0000000..6e7259c --- /dev/null +++ b/backend/tests/test_db_keepalive.py @@ -0,0 +1,137 @@ +"""Pool warming must be bounded, release connections, and follow API lifetime.""" + +import asyncio +from contextlib import suppress +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from app.config import Settings +from app.db.keepalive import keep_database_warm + + +async def stop(task): + task.cancel() + with suppress(asyncio.CancelledError): + await task + + +@pytest.mark.parametrize("failure", ["query_error", "query_timeout", "checkout_timeout", "release_timeout"]) +async def test_probe_recovers_without_overlap_or_leaking_a_connection(failure, caplog): + entered = 0 + active = 0 + maximum_active = 0 + recovered = asyncio.Event() + + async def query(sql): + assert sql == "SELECT 1" + if entered == 1: + if failure == "query_error": + raise ConnectionError("private connection details must not be logged") + if failure != "release_timeout": + await asyncio.Event().wait() + else: + recovered.set() + + async def connect(): + nonlocal entered, active, maximum_active + entered += 1 + if entered == 1 and failure == "checkout_timeout": + await asyncio.Event().wait() + active += 1 + maximum_active = max(maximum_active, active) + released = False + + async def invalidate(): + nonlocal active, released + if released: + return + released = True + active -= 1 + + async def close(): + if entered == 1 and failure == "release_timeout": + await asyncio.Event().wait() + await invalidate() + + return SimpleNamespace(exec_driver_sql=query, close=close, invalidate=invalidate) + + task = asyncio.create_task(keep_database_warm(SimpleNamespace(connect=connect), 0.02, 0.02)) + try: + await asyncio.wait_for(recovered.wait(), 2) + finally: + await stop(task) + assert entered == 2 + assert maximum_active == 1 + assert active == 0 + assert "Database warm-up failed" in caplog.text + assert "private connection details" not in caplog.text + + +@pytest.mark.parametrize("fail_request", [False, True]) +@pytest.mark.parametrize("fail_cleanup", [False, True]) +async def test_lifespan_starts_without_waiting_and_cancels_probe_before_disposal(monkeypatch, fail_request, fail_cleanup): + from app import main + + config = Settings( + _env_file=None, database_url="postgresql://test:test@localhost/test", + supabase_url="http://test.invalid", supabase_jwks_url="http://test.invalid/jwks", + db_keepalive_interval_seconds=30, + ) + events = [] + probing = asyncio.Event() + + async def query(sql): + probing.set() + await asyncio.Event().wait() + + async def close(): + events.append("released") + if fail_cleanup: + raise ConnectionError("rollback failed during shutdown") + + connection = SimpleNamespace( + exec_driver_sql=query, + close=close, + invalidate=AsyncMock(), + ) + engine = SimpleNamespace( + connect=AsyncMock(return_value=connection), + dispose=AsyncMock(side_effect=lambda: events.append("disposed")), + ) + monkeypatch.setattr(main, "engine", engine) + monkeypatch.setattr(main, "get_settings", lambda: config) + + async def run(): + async with main.lifespan(main.app): + await asyncio.wait_for(probing.wait(), 2) + if fail_request: + raise RuntimeError("lifespan body failed") + + if fail_request: + with pytest.raises(RuntimeError, match="lifespan body failed"): + await run() + else: + await run() + assert events == ["released", "disposed"] + assert connection.invalidate.await_count == int(fail_cleanup) + + +async def test_zero_interval_disables_warming(monkeypatch): + from app import main + + config = Settings( + _env_file=None, database_url="postgresql://test:test@localhost/test", + supabase_url="http://test.invalid", supabase_jwks_url="http://test.invalid/jwks", + db_keepalive_interval_seconds=0, + ) + probe = AsyncMock() + engine = SimpleNamespace(dispose=AsyncMock()) + monkeypatch.setattr(main, "get_settings", lambda: config) + monkeypatch.setattr(main, "engine", engine) + monkeypatch.setattr(main, "keep_database_warm", probe) + async with main.lifespan(main.app): + await asyncio.sleep(0) + probe.assert_not_called() + engine.dispose.assert_awaited_once() diff --git a/backend/tests/test_db_keepalive_postgres.py b/backend/tests/test_db_keepalive_postgres.py new file mode 100644 index 0000000..5a262ea --- /dev/null +++ b/backend/tests/test_db_keepalive_postgres.py @@ -0,0 +1,105 @@ +"""Reproduce idle expiry with real PostgreSQL, never a configured live database.""" + +import asyncio +from contextlib import suppress +from time import perf_counter + +import pytest +from sqlalchemy import event, text + +from app.config import Settings +from app.db.session import create_db_engine + + +@pytest.mark.parametrize("warming", [False, True]) +async def test_idle_request_reconnects_only_when_warming_is_disabled(database, monkeypatch, warming): + from app import main + + config = Settings( + _env_file=None, database_url=database, + supabase_url="http://test.invalid", supabase_jwks_url="http://test.invalid/jwks", + db_keepalive_interval_seconds=0.2 if warming else 0, + ) + engine = create_db_engine(config) + connections = 0 + + @event.listens_for(engine.sync_engine, "connect") + def connected(connection, _): + nonlocal connections + connections += 1 + # Expire only this test's physical sessions, outside a transaction. + connection.run_async(lambda driver: driver.execute("SET idle_session_timeout = '800ms'")) + + monkeypatch.setattr(main, "engine", engine) + monkeypatch.setattr(main, "get_settings", lambda: config) + + async def sample(): + started = perf_counter() + async with engine.connect() as connection: + assert await connection.scalar(text("SELECT 1")) == 1 + return (perf_counter() - started) * 1000 + + # Start with two checked-in slots, as after a small concurrent traffic burst. + # Requests must reuse a warm slot even when the pool has more than one slot. + async with engine.connect(), engine.connect(): + pass + try: + async with main.lifespan(main.app): + before = connections + cold_or_reused = [] + for _ in range(3): + await asyncio.sleep(1.2) + # Measure outside a probe's checkout, so this is idle latency, + # not a request competing for a currently borrowed connection. + async with asyncio.timeout(5): + while engine.pool.checkedout(): + await asyncio.sleep(0.01) + cold_or_reused.append(round(await sample(), 2)) + assert connections - before == (0 if warming else 3) + print(f"warming={warming}, new_connections={connections-before}, request_ms={cold_or_reused}") + finally: + await engine.dispose() + + +async def test_warmup_releases_transactions_and_recovers_from_a_dead_connection(database): + from app.db.keepalive import keep_database_warm + + config = Settings( + _env_file=None, database_url=database, + supabase_url="http://test.invalid", supabase_jwks_url="http://test.invalid/jwks", + ) + engine = create_db_engine(config) + completed = asyncio.Event() + + @event.listens_for(engine.sync_engine, "after_cursor_execute") + def executed(*_): + completed.set() + + task = asyncio.create_task(keep_database_warm(engine, 0.05, 5)) + try: + await asyncio.wait_for(completed.wait(), 5) + # Cancel immediately after SELECT, potentially during rollback/return. + task.cancel() + with suppress(asyncio.CancelledError): + await task + assert engine.pool.checkedout() == 0 + async with engine.connect() as connection: + driver = (await connection.get_raw_connection()).driver_connection + assert not driver.is_in_transaction() + await connection.scalar(text("SELECT 1")) + pid = await connection.scalar(text("SELECT pg_backend_pid()")) + + # A pool slot can still die between probes; pre-ping must remain enabled. + killer = create_db_engine(config) + try: + async with killer.connect() as connection: + assert await connection.scalar(text("SELECT pg_terminate_backend(:pid)"), {"pid": pid}) + finally: + await killer.dispose() + async with engine.connect() as connection: + assert await connection.scalar(text("SELECT 1")) == 1 + finally: + task.cancel() + with suppress(asyncio.CancelledError): + await task + await engine.dispose() diff --git a/backend/tests/test_generation.py b/backend/tests/test_generation.py index 1342736..fe39ac7 100644 --- a/backend/tests/test_generation.py +++ b/backend/tests/test_generation.py @@ -137,7 +137,7 @@ async def test_unexpected_shape_is_rejected(patch_httpx): # ---------------------------------------------------------------- pool -async def test_generate_one_publishes_and_marks_the_backlog(session, patch_httpx): +async def test_generate_one_publishes_and_marks_the_backlog(empty_generation_budget, session, patch_httpx): patch_httpx(_stub_transport(_gemini_response(GOOD_SUMMARY, GOOD_EXAMPLE))) before = await session.scalar(text("select count(*) from public.concepts where source = 'gemini'")) @@ -158,7 +158,7 @@ async def test_generate_one_publishes_and_marks_the_backlog(session, patch_httpx assert row.backlog_status == "done" -async def test_failed_generation_leaves_the_item_retryable(session, patch_httpx): +async def test_failed_generation_leaves_the_item_retryable(empty_generation_budget, session, patch_httpx): patch_httpx(_stub_transport({}, status=500)) claimed_before = await session.scalar( text("select count(*) from public.concept_backlog where status = 'pending'")) @@ -178,7 +178,7 @@ async def test_failed_generation_leaves_the_item_retryable(session, patch_httpx) assert after == claimed_before, "the item returned to the queue" -async def test_repeated_failures_retire_the_item(session, patch_httpx): +async def test_repeated_failures_retire_the_item(empty_generation_budget, session, patch_httpx): patch_httpx(_stub_transport({}, status=500)) # An inactive topic of its own, so this cannot disturb the shared backlog # that the other tests draw from. @@ -202,7 +202,7 @@ async def test_repeated_failures_retire_the_item(session, patch_httpx): assert row.attempts == 3 -async def test_rate_limit_releases_the_claim_and_refunds_the_attempt(session, patch_httpx): +async def test_rate_limit_releases_the_claim_and_refunds_the_attempt(empty_generation_budget, session, patch_httpx): patch_httpx(_stub_transport({}, status=429)) pending_before = await session.scalar( text("select count(*) from public.concept_backlog where status = 'pending'")) @@ -225,7 +225,7 @@ async def test_retry_delay_is_read_from_the_response(): assert generation._retry_after_seconds(response) == 12.0 -async def test_stale_generating_rows_are_reclaimed(session): +async def test_stale_generating_rows_are_reclaimed(empty_generation_budget, session): """Issue #37: a row abandoned mid-generation by a crashed worker must not be stuck in 'generating' forever — a later run reclaims it to 'pending'.""" topic_id = (await session.execute(text(""" @@ -259,7 +259,7 @@ async def test_stale_generating_rows_are_reclaimed(session): assert rows["in-flight"] == "generating", "a fresh claim must be left alone" -async def test_slug_collision_does_not_mark_the_backlog_done(session, patch_httpx): +async def test_slug_collision_does_not_mark_the_backlog_done(empty_generation_budget, session, patch_httpx): """Issue #37: if the concept slug already exists the insert is a no-op, so the backlog row must be flagged failed — not marked done, which would retire the title having burned a Gemini call without publishing anything.""" @@ -295,7 +295,7 @@ async def test_slug_collision_does_not_mark_the_backlog_done(session, patch_http assert generation._retry_after_seconds(httpx.Response(429, text="{}")) is None -async def test_top_up_backs_off_and_retries_after_a_rate_limit(session, patch_httpx, monkeypatch): +async def test_top_up_backs_off_and_retries_after_a_rate_limit(empty_generation_budget, session, patch_httpx, monkeypatch): calls = {"n": 0} def handler(request: httpx.Request) -> httpx.Response: @@ -317,7 +317,7 @@ async def fake_sleep(seconds): assert sleeps and sleeps[0] >= pool.BACKOFF_START_SECONDS -async def test_top_up_gives_up_after_consecutive_rate_limits(session, patch_httpx, monkeypatch): +async def test_top_up_gives_up_after_consecutive_rate_limits(empty_generation_budget, session, patch_httpx, monkeypatch): patch_httpx(_stub_transport({}, status=429)) async def fake_sleep(seconds): @@ -330,20 +330,20 @@ async def fake_sleep(seconds): assert result.skipped_reason == "rate limited" -async def test_top_up_respects_the_kill_switch(session): +async def test_top_up_respects_the_kill_switch(empty_generation_budget, session): result = await top_up(session, api_key="k", model="m", enabled=False, minimum_per_topic=25, call_cap=200) assert result.generated == 0 assert result.skipped_reason == "generation disabled" -async def test_top_up_without_a_key_does_nothing(session): +async def test_top_up_without_a_key_does_nothing(empty_generation_budget, session): result = await top_up(session, api_key="", model="m", enabled=True, minimum_per_topic=25, call_cap=200) assert result.skipped_reason == "no API key configured" -async def test_top_up_stops_at_the_call_cap(session, patch_httpx): +async def test_top_up_stops_at_the_call_cap(empty_generation_budget, session, patch_httpx): patch_httpx(_stub_transport(_gemini_response(GOOD_SUMMARY, GOOD_EXAMPLE))) result = await top_up(session, api_key="k", model="gemini-2.0-flash", enabled=True, minimum_per_topic=25, call_cap=3) @@ -351,7 +351,7 @@ async def test_top_up_stops_at_the_call_cap(session, patch_httpx): assert result.skipped_reason == "daily call cap reached" -async def test_top_up_fills_only_topics_below_the_threshold(session, patch_httpx): +async def test_top_up_fills_only_topics_below_the_threshold(empty_generation_budget, session, patch_httpx): patch_httpx(_stub_transport(_gemini_response(GOOD_SUMMARY, GOOD_EXAMPLE))) threshold = 6 @@ -381,6 +381,7 @@ async def test_top_up_fills_only_topics_below_the_threshold(session, patch_httpx async def test_dry_followed_topic_schedules_prefetch_not_inline_generation( + empty_generation_budget, session, user, monkeypatch ): """Issue #43: the request never calls Gemini inline. When a followed topic diff --git a/backend/tests/test_generation_budget.py b/backend/tests/test_generation_budget.py new file mode 100644 index 0000000..633ba08 --- /dev/null +++ b/backend/tests/test_generation_budget.py @@ -0,0 +1,92 @@ +"""A real PostgreSQL ledger must serialize competing workers and survive reruns.""" + +import asyncio +from datetime import date, datetime + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError + +from app.services import generation_budget as budget +from app.services.generation_budget import GenerationBudgetExhausted, reserve_generation_call + +pytestmark = pytest.mark.usefixtures("empty_generation_budget") + + +async def usage(session): + return (await session.execute(text( + "select budget_day, calls_used from public.generation_daily_usage order by budget_day" + ))).all() + + +async def test_concurrent_sessions_cannot_spend_the_same_slots(sessionmaker_for_test, session): + async def reserve(): + async with sessionmaker_for_test() as worker: + try: + await reserve_generation_call(worker, 3) + await worker.commit() + return True + except GenerationBudgetExhausted: + await worker.rollback() + return False + + results = await asyncio.gather(*(reserve() for _ in range(20))) + assert sum(results) == 3 + assert [row.calls_used for row in await usage(session)] == [3] + # A later worker with a fresh session sees the spent budget too. + assert await reserve() is False + + +async def test_zero_cap_and_rollback_do_not_spend_budget(session): + with pytest.raises(GenerationBudgetExhausted): + await reserve_generation_call(session, 0) + await session.rollback() + assert await usage(session) == [] + await reserve_generation_call(session, 1) + await session.rollback() + assert await usage(session) == [] + await reserve_generation_call(session, 1) + await session.commit() + assert [row.calls_used for row in await usage(session)] == [1] + + +@pytest.mark.parametrize("before, after, first_day, second_day", [ + ("2026-01-12T07:59:59+00:00", "2026-01-12T08:00:00+00:00", date(2026, 1, 11), date(2026, 1, 12)), + ("2026-07-12T06:59:59+00:00", "2026-07-12T07:00:00+00:00", date(2026, 7, 11), date(2026, 7, 12)), +]) +async def test_budget_resets_at_pacific_midnight_in_winter_and_summer( + session, monkeypatch, before, after, first_day, second_day, +): + # Replace only the database clock, retaining the production timezone/UPSERT. + query = text(str(budget._RESERVE).replace("statement_timestamp()", "cast(:now as timestamptz)")) + for now in [before, before, after]: + monkeypatch.setattr(budget, "_RESERVE", query.bindparams(now=datetime.fromisoformat(now))) + await reserve_generation_call(session, 2) + await session.commit() + assert await usage(session) == [(first_day, 2), (second_day, 1)] + + +async def test_lowering_limit_does_not_erase_previous_usage(session): + for _ in range(3): + await reserve_generation_call(session, 3) + await session.commit() + with pytest.raises(GenerationBudgetExhausted): + await reserve_generation_call(session, 2) + await session.rollback() + assert [row.calls_used for row in await usage(session)] == [3] + await reserve_generation_call(session, 4) + await session.commit() + assert [row.calls_used for row in await usage(session)] == [4] + + +async def test_mobile_role_cannot_read_or_forge_usage(session): + await reserve_generation_call(session, 2) + # Simulate Supabase's broad public-schema grants; RLS must still deny access. + await session.execute(text("grant select, insert, update, delete on public.generation_daily_usage to authenticated")) + await session.commit() + await session.execute(text("set local role authenticated")) + assert await usage(session) == [] + with pytest.raises(DBAPIError, match="row-level security"): + await session.execute(text("insert into public.generation_daily_usage values ('2000-01-01', 0)")) + await session.rollback() + assert [row.calls_used for row in await usage(session)] == [1] diff --git a/backend/tests/test_generation_limits.py b/backend/tests/test_generation_limits.py new file mode 100644 index 0000000..2f5e65e --- /dev/null +++ b/backend/tests/test_generation_limits.py @@ -0,0 +1,302 @@ +"""Shared call limits across real backlog transactions and mocked generation.""" + +import asyncio +import uuid +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +import pytest_asyncio +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError + +from app.services import generation_budget as budget, pool, prefetch +from app.services.generation import GeneratedConcept, GenerationError, RateLimitedError +from app.services.generation_budget import GenerationBudgetExhausted +from app.workers import rewrite_catalog as rewrite + +pytestmark = pytest.mark.usefixtures("empty_generation_budget") + + +@pytest_asyncio.fixture +async def topic(session, monkeypatch): + tid = uuid.uuid4() + await session.execute(text(""" + insert into public.topics (id, slug, name, is_active) + values (:id, :slug, 'Budget fixture', false) + """), {"id": tid, "slug": f"budget-{tid}"}) + await session.execute(text(""" + insert into public.concept_backlog (topic_id, slug, title) + select :id, :prefix || n, 'Fixture ' || n from generate_series(1, 20) n + """), {"id": tid, "prefix": f"budget-{tid}-"}) + await session.commit() + # Restrict only fixture selection; retain the real counts/claim/publish SQL. + monkeypatch.setattr(pool, "_POOL_COUNTS", text(str(pool._POOL_COUNTS).replace( + "where t.is_active", "where t.id = :tid" + )).bindparams(tid=tid)) + yield tid + await session.rollback() + await session.execute(text("delete from public.concepts where topic_id = :id"), {"id": tid}) + await session.execute(text("delete from public.topics where id = :id"), {"id": tid}) + await session.commit() + + +@pytest.fixture +def generator(monkeypatch): + mock = AsyncMock(return_value=GeneratedConcept(summary="Fixture summary", example="Fixture example", model="test")) + monkeypatch.setattr(pool, "generate_concept", mock) + return mock + + +async def calls_used(session): + return await session.scalar(text("select coalesce(sum(calls_used), 0) from public.generation_daily_usage")) + + +async def top_up(session, cap): + return await pool.top_up(session, api_key="test", model="test", enabled=True, + minimum_per_topic=25, call_cap=cap) + + +async def test_scheduled_runs_share_daily_limit(topic, generator, session): + first = await top_up(session, 2) + second = await top_up(session, 2) + assert (first.generated, second.generated) == (2, 0) + assert second.skipped_reason == "daily call cap reached" + assert generator.await_count == await calls_used(session) == 2 + + +async def test_quota_denial_rolls_back_claim_and_attempt(topic, generator, session): + with pytest.raises(GenerationBudgetExhausted): + await pool.generate_one(session, "test", "test", topic, call_cap=0) + assert not session.in_transaction() + assert await calls_used(session) == 0 + statuses = (await session.execute(text( + "select distinct status, attempts, claimed_at from public.concept_backlog where topic_id = :id" + ), {"id": topic})).all() + assert statuses == [("pending", 0, None)] + generator.assert_not_awaited() + + +async def test_no_backlog_does_not_spend_budget(topic, generator, session): + await session.execute(text("delete from public.concept_backlog where topic_id = :id"), {"id": topic}) + await session.commit() + assert await pool.generate_one(session, "test", "test", topic, call_cap=2) is None + assert await calls_used(session) == 0 + generator.assert_not_awaited() + + +async def test_reservation_is_committed_before_provider_call(topic, generator, session, sessionmaker_for_test): + async def generate(**kwargs): + assert not session.in_transaction(), "no connection stays pinned during generation" + async with sessionmaker_for_test() as observer: + assert await calls_used(observer) == 1 + return GeneratedConcept(summary="Fixture", example="Fixture", model="test") + generator.side_effect = generate + assert await pool.generate_one(session, "test", "test", topic, call_cap=1) + + +@pytest.mark.parametrize("failure", [GenerationError("bad response"), RateLimitedError(), asyncio.CancelledError()]) +async def test_failed_throttled_or_cancelled_calls_keep_their_reservation(topic, generator, session, failure): + generator.side_effect = failure + if type(failure) is GenerationError: + assert await pool.generate_one(session, "test", "test", topic, call_cap=1) is None + else: + with pytest.raises(type(failure)): + await pool.generate_one(session, "test", "test", topic, call_cap=1) + with pytest.raises(GenerationBudgetExhausted): + await pool.generate_one(session, "test", "test", topic, call_cap=1) + assert generator.await_count == await calls_used(session) == 1 + attempts = await session.scalar(text( + "select sum(attempts) from public.concept_backlog where topic_id = :id" + ), {"id": topic}) + assert attempts == (0 if isinstance(failure, RateLimitedError) else 1) + + +async def test_parallel_generation_keeps_claims_and_budget_consistent(topic, generator, session, sessionmaker_for_test): + async def generate(): + async with sessionmaker_for_test() as worker: + try: + return await pool.generate_one(worker, "test", "test", topic, call_cap=3) + except GenerationBudgetExhausted: + return None + results = await asyncio.gather(*(generate() for _ in range(12))) + assert len({result for result in results if result}) == 3 + assert generator.await_count == await calls_used(session) == 3 + attempts = await session.scalar(text( + "select sum(attempts) from public.concept_backlog where topic_id = :id" + ), {"id": topic}) + assert attempts == 3 + + +async def test_budget_database_error_prevents_generation_and_releases_claim(topic, generator, session, monkeypatch): + monkeypatch.setattr(budget, "_RESERVE", text("select 1 / 0")) + with pytest.raises(DBAPIError): + await pool.generate_one(session, "test", "test", topic, call_cap=2) + assert not session.in_transaction() + generator.assert_not_awaited() + assert await calls_used(session) == 0 + assert await session.scalar(text( + "select sum(attempts) from public.concept_backlog where topic_id = :id" + ), {"id": topic}) == 0 + + +async def test_failed_budget_commit_never_calls_provider(topic, generator, session, monkeypatch): + with monkeypatch.context() as patch: + patch.setattr(session, "commit", AsyncMock(side_effect=RuntimeError("commit failed"))) + with pytest.raises(RuntimeError, match="commit failed"): + await pool.generate_one(session, "test", "test", topic, call_cap=2) + generator.assert_not_awaited() + assert await calls_used(session) == 0 + + + +def test_negative_daily_cap_is_rejected(): + from pydantic import ValidationError + from app.config import Settings + with pytest.raises(ValidationError, match="generation_daily_call_cap"): + Settings(_env_file=None, database_url="postgresql://test:test@localhost/test", + supabase_url="http://test.invalid", supabase_jwks_url="http://test.invalid/jwks", + generation_daily_call_cap=-1) + + +@pytest.fixture +def prefetch_config(monkeypatch, sessionmaker_for_test): + config = SimpleNamespace(generation_enabled=True, generation_on_demand=True, + gemini_api_key="test", gemini_model="test", generation_daily_call_cap=2) + monkeypatch.setattr(prefetch, "get_settings", lambda: config) + monkeypatch.setattr(prefetch, "SessionLocal", sessionmaker_for_test) + return config + + +async def run_prefetch(topic): + prefetch.request_prefetch(topic) + await asyncio.gather(*list(prefetch._tasks)) + assert topic not in prefetch._inflight + + +async def test_prefetch_zero_cap_makes_no_provider_call(topic, generator, session, prefetch_config, caplog): + prefetch_config.generation_daily_call_cap = 0 + with caplog.at_level("INFO"): + await run_prefetch(topic) + generator.assert_not_awaited() + assert await calls_used(session) == 0 + assert "daily call cap reached" in caplog.text + assert not any(record.levelname == "ERROR" for record in caplog.records) + + +@pytest.mark.parametrize("prefetch_first", [True, False]) +async def test_scheduled_and_prefetch_share_remaining_budget(topic, generator, session, prefetch_config, prefetch_first): + if prefetch_first: + await run_prefetch(topic) + assert (await top_up(session, 2)).generated == 0 + else: + assert (await top_up(session, 2)).generated == 2 + await run_prefetch(topic) + # Re-triggering a completed background job cannot restore its allowance. + await run_prefetch(topic) + assert generator.await_count == await calls_used(session) == 2 + + +async def test_prefetch_and_worker_compete_for_the_same_last_slots(topic, generator, session, prefetch_config): + await asyncio.gather(run_prefetch(topic), top_up(session, 2)) + assert generator.await_count == await calls_used(session) == 2 + + +@pytest.mark.parametrize("disabled", ["generation_enabled", "generation_on_demand", "gemini_api_key"]) +async def test_prefetch_preserves_generation_switches(topic, generator, session, prefetch_config, disabled): + setattr(prefetch_config, disabled, "" if disabled == "gemini_api_key" else False) + await run_prefetch(topic) + generator.assert_not_awaited() + assert await calls_used(session) == 0 + + +async def test_prefetch_rate_limit_spends_quota_but_refunds_backlog_attempt(topic, generator, session, prefetch_config): + generator.side_effect = RateLimitedError() + await run_prefetch(topic) + assert generator.await_count == await calls_used(session) == 1 + assert await session.scalar(text( + "select sum(attempts) from public.concept_backlog where topic_id = :id" + ), {"id": topic}) == 0 + + +@pytest_asyncio.fixture +async def rewrite_config(topic, session, generator, sessionmaker_for_test, monkeypatch): + config = SimpleNamespace(generation_enabled=True, gemini_api_key="test", gemini_model="test", + generation_daily_call_cap=2) + await session.execute(text(""" + insert into public.concepts (topic_id, slug, title, summary) + select :tid, :prefix || n, 'Original title ' || n, 'Original summary' + from generate_series(1, 3) n + """), {"tid": topic, "prefix": f"rewrite-{topic}-"}) + await session.commit() + monkeypatch.setattr(rewrite, "get_settings", lambda: config) + monkeypatch.setattr(rewrite, "SessionLocal", sessionmaker_for_test) + monkeypatch.setattr(rewrite, "generate_concept", generator) + monkeypatch.setattr(rewrite, "_TODO", text(str(rewrite._TODO).replace( + "where c.status = 'published'", "where c.status = 'published' and c.topic_id = :tid" + )).bindparams(tid=topic)) + monkeypatch.setattr(rewrite, "engine", SimpleNamespace(dispose=AsyncMock())) + monkeypatch.setattr(rewrite, "asyncio", SimpleNamespace(sleep=AsyncMock())) + return config + + +async def rewritten_count(session, topic): + return await session.scalar(text(""" + select count(*) from public.concepts where topic_id = :tid and prompt_version = :pv + """), {"tid": topic, "pv": rewrite.PROMPT_VERSION}) + + +async def test_rewrite_reruns_and_scheduled_jobs_share_budget(topic, generator, session, rewrite_config): + await rewrite.main() + await rewrite.main() + assert await rewritten_count(session, topic) == 2 + assert (await top_up(session, 2)).generated == 0 + assert generator.await_count == await calls_used(session) == 2 + assert rewrite.engine.dispose.await_count == 2 + + +async def test_scheduled_budget_denial_leaves_catalog_unchanged(topic, generator, session, rewrite_config): + assert (await top_up(session, 2)).generated == 2 + generator.reset_mock() + await rewrite.main() + generator.assert_not_awaited() + assert await session.scalar(text(""" + select count(*) from public.concepts where topic_id = :tid and summary = 'Original summary' + """), {"tid": topic}) == 3 + rewrite.engine.dispose.assert_awaited_once() + + +@pytest.mark.parametrize("disabled", ["generation_enabled", "gemini_api_key", "generation_daily_call_cap"]) +async def test_rewrite_preserves_switches_and_zero_budget(topic, generator, session, rewrite_config, disabled): + setattr(rewrite_config, disabled, "" if disabled == "gemini_api_key" else 0) + await rewrite.main() + generator.assert_not_awaited() + assert await calls_used(session) == 0 + assert await rewritten_count(session, topic) == 0 + rewrite.engine.dispose.assert_awaited_once() + + +async def test_rewrite_throttling_retry_spends_each_call(topic, generator, session, rewrite_config): + generator.side_effect = [RateLimitedError(), GeneratedConcept(summary="Fixture", example="Fixture", model="test")] + await rewrite.main() + assert generator.await_count == await calls_used(session) == 2 + assert await rewritten_count(session, topic) == 1 + assert rewrite.asyncio.sleep.await_count >= 1 + + +async def test_rewrite_database_failure_never_calls_provider(topic, generator, session, rewrite_config, monkeypatch): + monkeypatch.setattr(budget, "_RESERVE", text("select 1 / 0")) + with pytest.raises(DBAPIError): + await rewrite.main() + generator.assert_not_awaited() + assert await calls_used(session) == 0 + rewrite.engine.dispose.assert_awaited_once() + + +async def test_rewrite_cancelled_provider_keeps_budget_and_cleans_up(topic, generator, session, rewrite_config): + generator.side_effect = asyncio.CancelledError() + with pytest.raises(asyncio.CancelledError): + await rewrite.main() + assert await calls_used(session) == 1 + assert await rewritten_count(session, topic) == 0 + rewrite.engine.dispose.assert_awaited_once() diff --git a/backend/tests/test_state_pagination.py b/backend/tests/test_state_pagination.py new file mode 100644 index 0000000..eb63eed --- /dev/null +++ b/backend/tests/test_state_pagination.py @@ -0,0 +1,157 @@ +"""Large accounts retain totals and full collection access with compact startup.""" + +import uuid + +import pytest_asyncio +from httpx import ASGITransport, AsyncClient +from sqlalchemy import text + +from app.db.session import get_db +from app.deps import CurrentUser, get_current_user +from app.main import app + + +@pytest_asyncio.fixture +async def collection_client(session, sessionmaker_for_test, user): + prefix = f"collection-{user}-" + await session.execute(text(""" + insert into public.concepts (topic_id, slug, title, summary) + select (select id from public.topics where slug = 'computer-science'), + :prefix || n, 'Lesson ' || n, 'Fixture summary' + from generate_series(1, 365) n + """), {"prefix": prefix}) + await session.execute(text(""" + insert into public.daily_assignments (user_id, concept_id, assigned_for, completed_at) + select :uid, id, current_date - cast(substring(slug from length(:prefix) + 1) as int), now() + from public.concepts where starts_with(slug, :prefix) + """), {"prefix": prefix, "uid": user}) + await session.execute(text(""" + insert into public.concept_interactions (user_id, concept_id, liked_at, saved_at) + select :uid, id, now(), now() + from public.concepts where starts_with(slug, :prefix) + """), {"prefix": prefix, "uid": user}) + await session.commit() + + async def db(): + async with sessionmaker_for_test() as value: + yield value + + app.dependency_overrides[get_db] = db + app.dependency_overrides[get_current_user] = lambda: CurrentUser(id=user, email="fixture@example.invalid") + try: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + yield client + finally: + app.dependency_overrides.clear() + + +async def test_compact_state_bounds_details_without_losing_totals(collection_client): + legacy = await collection_client.get("/v1/me/state") + compact = await collection_client.get("/v1/me/state?compact=true") + assert legacy.status_code == compact.status_code == 200 + before, after = legacy.json(), compact.json() + print(f"state bytes: legacy={len(legacy.content)}, compact={len(compact.content)}; " + f"detail rows: learned={len(after['learned'])}, saved={len(after['saved'])}") + assert len(before["learned"]) == len(before["saved"]) == 365 + assert len(after["learned"]) == len(after["saved"]) == 50 + assert len(after["likes"]) == len(after["bookmarks"]) == 365 + assert after["stats"] == before["stats"] == {"current": 365, "longest": 365, "total_learned": 365} + assert after["learned_before_window"] == {"Computer Science": 315} + assert after["history_next_cursor"] and after["saved_next_cursor"] + assert len(compact.content) < len(legacy.content) / 2 + + +async def test_pages_cover_history_and_tied_saves(collection_client): + state = (await collection_client.get('/v1/me/state?compact=true')).json() + for path, field, cursor_field in ( + ('history', 'learned', 'history_next_cursor'), + ('saved', 'saved', 'saved_next_cursor'), + ): + items = state[field][:] + cursor = state[cursor_field] + while cursor: + response = await collection_client.get(f'/v1/me/{path}', params={'cursor': cursor, 'limit': 37}) + assert response.status_code == 200, response.text + page = response.json() + assert 0 < len(page['items']) <= 37 + items.extend(page['items']) + cursor = page['next_cursor'] + assert len(items) == len({item['concept_slug'] for item in items}) == 365 + assert all(item['like_count'] == 0 for item in items) # own likes excluded + full = (await collection_client.get('/v1/me/state')).json()[field] + assert items == full + + +async def test_state_mutations_support_compact_clients(collection_client): + for response in ( + await collection_client.put('/v1/me/topics?compact=true', json={'topics': ['computer-science']}), + await collection_client.patch('/v1/me?compact=true', json={'display_name': 'Updated'}), + ): + assert response.status_code == 200, response.text + assert len(response.json()['learned']) == len(response.json()['saved']) == 50 + assert response.json()['learned_before_window'] == {'Computer Science': 315} + legacy = await collection_client.patch('/v1/me', json={'display_name': 'Legacy'}) + assert len(legacy.json()['learned']) == len(legacy.json()['saved']) == 365 + + +async def test_empty_and_other_user_collections(collection_client, session, user): + from app.services.collections import saved_cursor + from datetime import datetime, timezone + + # A cursor contains ordering only; it cannot choose which user's rows to read. + other = uuid.uuid4() + app.dependency_overrides[get_current_user] = lambda: CurrentUser(id=other, email='other@example.invalid') + for path in ('saved', 'history'): + result = await collection_client.get(f'/v1/me/{path}') + assert result.json() == {'items': [], 'next_cursor': None} + cursor = saved_cursor(datetime.now(timezone.utc).isoformat(), str(user)) + assert (await collection_client.get('/v1/me/saved', params={'cursor': cursor})).json()['items'] == [] + + +async def test_cursor_validation_and_limits(collection_client): + import base64 + import json + + for cursor in ('not-a-cursor', base64.urlsafe_b64encode(json.dumps([ + '2026-01-01T00:00:00', str(uuid.uuid4()), + ]).encode()).decode(), base64.urlsafe_b64encode(b'[42, 42]').decode()): + result = await collection_client.get('/v1/me/saved', params={'cursor': cursor}) + assert result.status_code == 400 + assert (await collection_client.get('/v1/me/history?cursor=bad')).status_code == 422 + for path in ('saved', 'history'): + for limit in (0, 101): + assert (await collection_client.get(f'/v1/me/{path}?limit={limit}')).status_code == 422 + response = await collection_client.get(f'/v1/me/{path}?limit=100') + assert len(response.json()['items']) == 100 + + +async def test_saved_deletion_between_pages_does_not_skip_rows(collection_client, session, user): + first = (await collection_client.get('/v1/me/saved?limit=10')).json() + await session.execute(text(''' + update public.concept_interactions set saved_at = null + where user_id = :uid and concept_id = ( + select id from public.concepts where slug = :slug + ) + '''), {'uid': user, 'slug': first['items'][-1]['concept_slug']}) + await session.commit() + second = (await collection_client.get('/v1/me/saved', params={ + 'cursor': first['next_cursor'], 'limit': 100, + })).json() + all_remaining = (await collection_client.get('/v1/me/state')).json()['saved'] + assert second['items'] == all_remaining[9:109] + + +async def test_exact_window_has_no_cursor(collection_client, session, user): + await session.execute(text(''' + delete from public.daily_assignments where user_id = :uid + and assigned_for < current_date - 50 + '''), {'uid': user}) + await session.execute(text(''' + update public.concept_interactions set saved_at = null where user_id = :uid + and concept_id not in (select concept_id from public.daily_assignments where user_id = :uid) + '''), {'uid': user}) + await session.commit() + state = (await collection_client.get('/v1/me/state?compact=true')).json() + assert len(state['learned']) == len(state['saved']) == 50 + assert state['learned_before_window'] == {} + assert state['history_next_cursor'] is state['saved_next_cursor'] is None diff --git a/docs/CODEBASE_MAP.md b/docs/CODEBASE_MAP.md index d4505f8..ce4544c 100644 --- a/docs/CODEBASE_MAP.md +++ b/docs/CODEBASE_MAP.md @@ -18,6 +18,7 @@ learned history, streaks, likes, saved concepts, and push reminders. | Operations | `.github/workflows/`, `backend/railway.json`, `backend/Dockerfile`, `mobile/eas.json`, `mobile/app.config.js`. | | Documentation | Root `README.md`, `RELEASING.md`, `CONTRIBUTING.md`, `docs/ARCHITECTURE.md`, `docs/ROADMAP.md`, and the backend/mobile guides. | | Agent guidance | Root `AGENTS.md`; `mobile/AGENTS.md` adds Expo documentation requirements and `mobile/CLAUDE.md` references it. | +| Authentication email | `backend/email-templates/` contains branded signup, recovery, and password-changed HTML; `docs/EMAIL_TEMPLATES.md` covers manual Supabase installation and activation checks. Templates use the configured sender and are not installed by app deployment. | ## Mobile navigation and presentation @@ -33,9 +34,9 @@ inside a root stack, with a concept-detail modal above them. | `StatsScreen.tsx` | Streak and topic statistics. | | `ProfileScreen.tsx` | Account, reminder preferences, theme, sign-out, and links to profile subpages. | | `PersonalizationScreen.tsx` | Server topic catalog and follow controls through `useTopics`. | -| `SavedScreen.tsx` | Saved concepts and detail navigation. | -| `ConceptDetailScreen.tsx` | Full lesson fetched by slug, with bundled catalog fallback. | -| `AuthScreen.tsx` | Sign-in, sign-up, and password recovery. | +| `SavedScreen.tsx` | Recent/cached saved concepts, older metadata pagination, search/category filters, and detail navigation. | +| `ConceptDetailScreen.tsx` | Cached full lesson first, then online refresh by slug; bundled catalog fallback. | +| `AuthScreen.tsx` | Sign-in, sign-up, password recovery, and accessible show/hide password controls that reset on mode changes or submission. | | `AboutScreen.tsx` | Branding and app information. | All screens live in `mobile/src/screens/`. Reusable presentation in @@ -61,26 +62,46 @@ typography, shadows, and scaling; `ThemeContext` persists light/dark preference. `api/fetchWithTimeout.ts` bounds API and auth fetches to 15 seconds. - `ProgressContext.tsx` is the shared UI state owner. It loads cached state before revalidation, applies optimistic actions, serializes mutation requests, - and flushes queued work on foreground/connectivity events. + and flushes queued work on the same mutation chain. `services/syncLoop.ts` + retries while offline or actions remain, using 5–30 second backoff, including + when only some requests succeed. Daily refreshes preserve pending actions; + account/source changes invalidate them and clear the displayed state. Foreground + and browser reconnect events wake an idle loop immediately; backgrounding + pauses timers. + This remains compatible with the current APK and has no closed-app worker. Screen retries use its serialized `refresh`; topic and detail screens have their own retry paths. Failed loads do not substitute demo lessons or totals for an authenticated account. - `services/progressRepository.ts` defines the persistence interface. `remoteProgressRepository.ts` implements API state, account caching, optimistic - offline fallbacks, and replay. `localProgressRepository.ts` and `storage.ts` + offline fallbacks, and replay. It opts into compact startup metadata; Stats + uses full server totals plus older-topic counts through `progressTotals.ts`. + `pendingProgress.ts` retains unacknowledged + likes, saves, and same-day completions during server reconciliation. + `localProgressRepository.ts` and `storage.ts` retain local/demo support; this is not a separate visible guest navigation flow. -- `mutationQueue.ts` stores the latest intent per like/save/topic/completion key - in AsyncStorage. Replay discards stale-day completions, retains retryable - failures, and reconciles state. It does not backdate server completion. +- `mutationQueue.ts` wires AsyncStorage to `mutationOutbox.ts`, which serializes + disk writes and stores the latest intent per like/save/topic/completion key. + Replay discards stale-day completions, retains retryable failures, and + reconciles state. It does not backdate server completion. - `accountCaches.ts` centralizes account cache cleanup. The remote repository's - epoch guards prevent some late results from being persisted after a wipe. + epoch guards reject late mutation callbacks after a wipe; the API invalidates + requests still waiting for an old account's token during cleanup. Device theme/demo state is separate from account data. - `dailyApi.ts` maps server concepts to UI types and clears an old daily cache; - current daily data arrives in `/v1/me/state`. `conceptApi.ts` fetches full - concepts by slug. UI concept IDs are slugs, while the database also has UUIDs. -- `hooks/useTopics.ts` and `services/topicsApi.ts` load the dynamic server catalog - and replace follow sets. This path is separate from the progress repository's - queued topic mutations; inspect the caller before assuming offline support. + current daily data arrives in `/v1/me/state`. `conceptApi.ts` persists full + lessons by slug, including each cached daily lesson and missing saved lessons + downloaded with three workers. Offline reading requires a completed download. + `offlineCache.ts` provides per-entry storage and fences late writes on sign-out. + UI concept IDs are slugs, while the database also has UUIDs. +- `hooks/useSavedConcepts.ts` loads older Saved metadata in 50-record pages on + that screen, retaining full search/filter access. `services/savedApi.ts` owns + its account-keyed disk cache; `accountCaches.ts` clears it and invalidates late + writes. Missing offline metadata can be recovered from downloaded lesson bodies. +- `hooks/useTopics.ts`, `services/topicsApi.ts`, and `topicStore.ts` share the + cached dynamic topic catalog. Follow changes enter the same durable outbox as + other actions; queued choices override stale server responses until replay. + Both the catalog and full-concept cache participate in account cleanup. - `services/notifications.ts` handles permissions, Android channel setup, Expo tokens, timezone sync, preference caching, and deregistration before sign-out. - `data/concepts.ts`, `services/dailyConcept.ts`, `dates.ts`, `streak.ts`, and @@ -88,14 +109,20 @@ typography, shadows, and scaling; `ThemeContext` persists light/dark preference. - `data/whatsNew.ts`, `hooks/useWhatsNew.ts`, and `services/whatsNewStore.ts` control version announcements. Every release includes a matching one-time card focused on new features and user-visible improvements, per `RELEASING.md`. + `components/WhatsNewCard.tsx` scrolls long highlight lists independently of the + heading/dismissal controls so small screens can reach every item. - `src/types/index.ts` defines shared concept, progress, daily, history, and streak types. API payloads also have types near their service consumers. ## Backend request and service flow `main.py` configures CORS, routes, production documentation visibility, a shared -JWKS cache, and engine cleanup. `config.py` loads settings and normalizes pooler -URLs; `db/session.py` creates the async engine/session dependency. +JWKS cache, and engine cleanup. Its lifespan owns `db/keepalive.py`'s configurable +database probes; checkout/query and connection return are bounded, failures retry, +and cancellation awaits cleanup before engine disposal. `config.py` loads settings +and normalizes pooler URLs; `db/session.py` creates the async engine/session +dependency and reuses the most recently returned connection to keep a hot slot. +The existing pre-ping, transaction pooler mode, and pool limits remain in place. `deps.py` obtains identity from bearer tokens verified by `core/security.py` (ES256, issuer, audience, expiry, and subject). `core/errors.py` formats auth errors. @@ -104,7 +131,8 @@ URLs; `db/session.py` creates the async engine/session dependency. | `health.py`: `GET /health` | Liveness plus a database query. | | `topics.py`: `GET /v1/topics` | Active topics, published counts, follow state. | | `daily.py`: `GET /v1/daily`, `POST /v1/daily/complete` | Selection, completion, server-derived date and streaks. Exhaustion returns 409 with `catalog_exhausted`. | -| `me.py`: `GET /v1/me/state`, `/stats` | Aggregate app state, with today's lesson folded into the state response. | +| `me.py`: `GET /v1/me/state`, `/stats` | Optional compact state, exact totals, today's lesson. | +| `me.py`: `GET /v1/me/history`, `/saved` | Cursor pages through `services/collections.py`; default 50, maximum 100 items. | | `me.py`: `PUT /v1/me/topics`, `PATCH /v1/me` | Whole-set follows, profile name, PostgreSQL-validated timezone. | | `me.py`: `GET/PUT /v1/me/notifications`, `POST/DELETE /v1/me/push-token` | Reminder preferences and scoped device registration/removal. | | `concepts.py`: `GET /v1/concepts/{slug}`, `PUT/DELETE .../like`, `.../save` | Published lesson detail and independent interaction writes. | @@ -120,7 +148,10 @@ models live in `schemas/daily.py`, `me.py`, `notifications.py`, and `topics.py`. - `services/state.py` aggregates profile, follows, learned/saved metadata, likes, assignment slug, and derived streaks in one SQL statement. The `/me/state` handler then calls selection separately to add `daily`; one HTTP request does - not mean one database statement for the entire endpoint. + not mean one database statement for the entire endpoint. Compact clients get + at most 50 enriched learned/saved rows, older-topic counts and continuation + cursors; bare membership and aggregate streak/totals remain complete. Legacy + clients keep the full detail lists until upgraded. - `services/interactions.py` implements likes/saves, full-set follows, and idempotent completion of the most recent assignment from today or yesterday. Yesterday's grace applies when there is no newer assignment; older days cannot @@ -131,23 +162,30 @@ models live in `schemas/daily.py`, `me.py`, `notifications.py`, and `topics.py`. - `services/generation.py` builds versioned prompts, calls Gemini through httpx, validates output, and exposes rate-limit errors. `pool.py` claims backlog work with `FOR UPDATE SKIP LOCKED`, publishes validated rows, refunds throttled - attempts, reclaims stale work, and applies pacing/retry/call limits. + attempts and reclaims stale work. Claims and daily call reservations commit + together before contacting the provider; quota denial rolls back the claim. +- `services/generation_budget.py` atomically reserves from the shared + `generation_daily_usage` ledger using PostgreSQL's Pacific calendar day. All + API prefetch, scheduled refill, and manual rewrite calls share this budget. + Failed/uncertain calls retain their reservation; restarts do not reset it. - `services/prefetch.py` schedules bounded background top-ups, with a low unread watermark, a published-count target, and per-process in-flight topic tracking. + Exhausted shared budget is a normal stop condition. - `services/reminders.py` claims due user/day/time slots before sending Expo push batches, handles timezone and midnight windows, suppresses completed days, and drops unregistered device tokens. A claimed but failed send can miss a reminder. - `workers/pool_topup.py` and `workers/reminders.py` are cron entry points. `workers/rewrite_catalog.py` is a maintenance command that rewrites existing - lessons through Gemini; do not run it merely to inspect the project. + lessons through Gemini with the shared budget and generation kill switch; + do not run it merely to inspect the project. ## Schema and migrations `db/models.py` mirrors the SQL schema; migrations are the schema authority. -The ten tables cover profiles, topics, concepts, user topics, daily assignments, +The eleven tables cover profiles, topics, concepts, user topics, daily assignments, concept interactions, notification preferences, device tokens, the concept -backlog, and reminder logs. Unique constraints enforce one daily assignment and -no concept repeats per user. RLS adds isolation behind backend identity checks. +backlog, reminder logs, and shared daily generation usage. Unique constraints +enforce one daily assignment and no concept repeats per user. RLS adds isolation behind backend identity checks. | Migration | Purpose | | --- | --- | @@ -158,24 +196,29 @@ no concept repeats per user. RLS adds isolation behind backend identity checks. | `0007_reminder_log.sql` | Unique reminder claims. | | `0008_backlog_claimed_at.sql` | Timestamp for reclaiming abandoned generation. | | `0009_like_count_index.sql` | Index for public like counts. | +| `0010_generation_daily_usage.sql` | Backend-only daily Gemini call reservations shared by all generation paths. | -All nine filenames are recorded in `migrations/applied.txt` in this checkout. -That is repository evidence, not an independent check of production. Application -connections use the transaction pooler; migration DDL uses `DIRECT_URL` and the -session pooler. Applied migrations must not be rewritten. +All ten filenames are recorded in `migrations/applied.txt`; migration 0010 was +applied and independently verified in production during the 1.8.0 release +follow-up. The ledger is repository evidence, not a live check of production. +Application connections use the transaction pooler; migration DDL uses `DIRECT_URL` +and the session pooler. Applied migrations must not be rewritten. ## Builds, checks, and releases - Mobile dependencies/scripts are in `mobile/package.json` and `package-lock.json`; use npm. `npm run typecheck` runs `tsc --noEmit`; `npm test` uses Node 24's - built-in runner for session recovery, auth messages, and request timeouts. + built-in runner for session recovery, auth messages, request timeouts, offline + cache cleanup, outbox ordering, topic persistence, and sync scheduling. Expo provides Android/iOS/web development commands. Native project folders are not tracked. EAS profiles separate development, preview, production, and production APKs. - Backend dependencies are pinned in `requirements.txt`/`requirements-dev.txt`. From `backend/`, run `.venv/bin/python -m uvicorn app.main:app --reload --port 8000` for development and `.venv/bin/python -m pytest` for tests after configuration. -- Seven test modules cover HTTP contracts, token validation, daily selection, - writes/streaks, generation, reminders, and notification preferences. +- Nine test modules cover HTTP contracts, token validation, daily selection, + writes/streaks, generation, reminders, notification preferences, and connection + warm-up/cleanup. The pool integration checks compare real PostgreSQL idle expiry + with warming disabled/enabled and print timing plus physical-connection counts. `tests/conftest.py` supplies a disposable PostgreSQL 16 database through Podman on port 55433, applies every migration, and disables live generation. HTTP calls to Gemini/Expo are mocked. Database-dependent tests skip if Podman cannot start. @@ -191,7 +234,7 @@ session pooler. Applied migrations must not be rewritten. findings as issues. `cleanup.yml` manages stale issues; Dependabot schedules dependency updates with Expo-managed version restrictions. The checked-in workflows do not include a general PR pytest job. -- `mobile/app.config.js` currently has app version `1.7.1` and native runtime +- `mobile/app.config.js` currently has app version `1.8.0` and native runtime `1.3.0`; `package.json`'s `1.0.0` is not the release-version authority. ## Documentation drift to remember @@ -207,8 +250,8 @@ the implementation or older documentation: only. Both are used by the authenticated application today. - `mobile/DEPLOYMENT.md` describes an older OTA trigger and fewer workflows. Use current workflow YAML plus `RELEASING.md` to trace release behavior. -- The roadmap still lists offline reading as future work although cached state - and queued progress writes exist. This does not establish complete offline - coverage for every screen (topic personalization has its own request path). +- The roadmap's older offline milestones predate the current full-lesson cache, + cached personalization, and foreground queue synchronization. Closed-app OS + background scheduling remains outside the current APK's capabilities. - The backend README's test-count/phase notes are historical. See [WORK_LOG.md](WORK_LOG.md) for the actual local validation baseline. diff --git a/docs/EMAIL_TEMPLATES.md b/docs/EMAIL_TEMPLATES.md new file mode 100644 index 0000000..4e429d9 --- /dev/null +++ b/docs/EMAIL_TEMPLATES.md @@ -0,0 +1,68 @@ +# One Concept email templates + +These three templates customize emails sent by Supabase Auth. They work with +the project's configured sender; installing the HTML does not require Resend, +a purchased domain, or an app update. Provider setup remains separate in +[draft PR #187](https://github.com/Coding-Moves/one-concept/pull/187). + +**Merging or releasing the app does not install these files in Supabase.** +Paste the templates into the dashboard to change future emails. The repository +stores their reviewed source; it does not synchronize production settings. + +## Install + +1. Open the correct Supabase project, then **Authentication → Emails → Templates**. +2. Open each template listed below and set its subject exactly as shown. +3. Replace the body with the entire raw HTML file, starting with ``. + Copy the file's source, not its rendered browser preview, and save. +4. Under **Security → Password changed**, also enable the notification and save. + +| Supabase template | Subject | HTML source | +| --- | --- | --- | +| Confirm sign up | `Confirm your email — One Concept` | [confirm-signup.html](../backend/email-templates/confirm-signup.html) | +| Reset password | `Reset your password — One Concept` | [reset-password.html](../backend/email-templates/reset-password.html) | +| Password changed (Security) | `Your password was changed — One Concept` | [password-changed.html](../backend/email-templates/password-changed.html) | + +Keep every `{{ .ConfirmationURL }}` placeholder in signup and recovery. Supabase +fills in the appropriate verification URL; the button and copyable fallback +must both pass through that verification. Password changed reports a completed +change and intentionally has no token or reset-link placeholder. It directs +unrecognized changes to Forgot password in the app and the existing support email. + +Preserve the project's existing working redirect configuration. Signup uses the +configured Site URL for `/confirmed`; recovery requests `/reset-password` on the +app's API base URL. The corresponding HTTPS destinations must be allowed in +Supabase's URL Configuration. Do not replace verification links with those +landing-page URLs. See [Supabase email templates](https://supabase.com/docs/guides/auth/auth-email-templates). + +## Sending and activation + +Template customization and sending capacity are separate. Supabase's default +sender is for testing: it currently sends only to project-team addresses and +allows two emails per hour. Other recipients require custom SMTP, which can be +the owner's existing Gmail setup once configured and tested. These templates +do not remove sender limits or establish delivery. See +[Supabase SMTP documentation](https://supabase.com/docs/guides/auth/auth-smtp) +(checked 2026-09-12). + +Before announcing the new emails to users, test with accounts and inboxes you +control under the configured sender's limits: + +- Confirm signup, follow the email's button, and successfully sign in to the app. +- Request Forgot password, set a new password through the recovery email, and + sign in with it. Verify the separate Password changed notification arrives. +- Check the design in a real inbox on phone and desktop, the full fallback links, + and the Coding Moves organization and support destinations. Use a second inbox + provider and a non-team recipient when custom SMTP is available. + +Record the results before treating production activation as complete. Local +browser previews verify layout, not Gmail/Outlook rendering or live delivery. +No production settings or messages are changed by the template PR itself. + +## Design + +The dark masthead uses the owner's selected One Concept text branding and links +Coding Moves once to its GitHub organization. The app's current image assets are +Expo starter icons, so the emails do not use them as a logo. Inline styles, +presentation tables, system fonts, and text links need no external assets. +Subjects are separate dashboard fields; the HTML comments do not configure them. diff --git a/docs/WORK_LOG.md b/docs/WORK_LOG.md index 2594d07..74175b7 100644 --- a/docs/WORK_LOG.md +++ b/docs/WORK_LOG.md @@ -7,20 +7,417 @@ claims as completed work. ## Current status -- Assigned scope: add the explicitly requested one-time What's New card for - `1.7.1` to the open release PR. -- Branch: `codex/1-7-1-whats-new`, based on `develop` at `0596f6d`. -- Version preparation [#178](https://github.com/Coding-Moves/one-concept/pull/178) - is merged. Release [#179](https://github.com/Coding-Moves/one-concept/pull/179) - is open; its migration check and version-preparation preview OTA passed. -- Card and standing-policy follow-up: - [#180](https://github.com/Coding-Moves/one-concept/pull/180), targeting `develop` - so it is included in release #179. Its PR records the final merge/check status. -- [PR #177](https://github.com/Coding-Moves/one-concept/pull/177) is merged; its - [preview OTA](https://github.com/Coding-Moves/one-concept/actions/runs/34677521802) - passed. Its completed browser checks also covered retry after reconnecting, - dark/reduced-motion mode, and friendly offline sign-in errors. -- Production merge/publication remains the owner's next step after release review. +- [Release PR #191](https://github.com/Coding-Moves/one-concept/pull/191) is open + from **develop → main** for **1.8.0**, with the six-benefit one-time card and + runtime **1.3.0**. Feature/fix PRs #183–#186, #188, and #189 are included; + preparation #190 and release bookkeeping #192 are merged. +- The owner explicitly approved applying migration 0010 to production after + the earlier automatic-approval rejections. The exact migration is now applied + and its committed schema was independently verified. The ledger entry was + added only afterward, in `01a532e`. +- [Fix PR #193](https://github.com/Coding-Moves/one-concept/pull/193) brings the + verified ledger and handoff into `develop`, updating release #191. Confirm its + merge and the release's latest required check on GitHub before production merge. + The unchanged migration workflow passes locally for all ten migrations. +- No production app release was merged or deployed in this follow-up. The shared + generation-budget first-day rollout and consistent API/worker caps still need + the release-time handling documented in `backend/README.md`. +- Resend setup remains deferred in [draft PR #187](https://github.com/Coding-Moves/one-concept/pull/187). + The owner reports installing all three merged email templates and enabling the + password-change notification in Supabase; actual inbox delivery is unverified. + +## Release migration check follow-up — 2026-09-13 + +- **Problem:** both failed runs on #191 reported only + `0010_generation_daily_usage.sql`; the latest preview OTA passed. Read-only + production inspection confirmed the table was absent, so adding an unverified + ledger entry or weakening CI would not fix the deployment prerequisite. +- **Authorization:** automatic review initially rejected the production mutation. + The owner then explicitly approved the exact migration, verification, and PR + update. Previous blocked attempts did not run SQL. +- **Application:** executed the unchanged migration against the configured One + Concept production Supabase database using its validated session connection. + It creates only `public.generation_daily_usage` and enables RLS; existing tables + and rows are unchanged. The initial helper's post-commit assertion failed; + no retry of the migration was performed. A separate read-only connection + verified the committed schema with explicit text casts for constraint kinds. +- **Verification:** `budget_day` is a nonnullable date primary key; `calls_used` + is a nonnullable integer defaulting to zero with a nonnegative check. RLS is + enabled and no client policies exist. Recorded the migration only after all + these assertions passed. No test data was inserted into production. +- **Commits:** `df377b0` records scope; `402f520` records the previous authorization + blocker; `01a532e` records verified production application in the ledger; + `docs: record verified release migration and handoff` records the result and + updates the codebase map. Preserve all commits in #193. +- **Checks:** the unchanged CI shell check passes locally; all ten SQL filenames + are recorded with no unknown ledger entries. Migration SQL and workflow are + unchanged. Reviewed staged changes and checked whitespace/local documentation + paths. No application tests were rerun for this ledger/documentation follow-up; + the release's prior 145 backend tests used this same SQL on disposable + PostgreSQL 16 and passed without skips. +- **Handoff:** merge #193 into `develop`, confirm the required migration check on + #191, and update that release's checklist with the verified application. GitHub + records the resulting merge/check state. Release merging remains with the owner. + +## Version 1.8.0 preparation — 2026-09-13 + +- **Scope:** wind up merged work and open the release PR with its required + version-matched, one-time What's New card. Keep original focused commits and + leave production release merging to the owner. +- **Highlights:** downloaded saved lessons/examples offline; automatic action + synchronization on reconnect/reopen; persistent offline topic choices; lighter + startup and older Saved search/filter access; password visibility; clearer + account emails. The deferred SMTP-provider draft is excluded. +- **Card:** allow the highlight list to scroll while the heading and Got it stay + visible. Keep the existing device/version dismissal key and sign-in gating. + The browser regression checks the actual exported version, both themes at + 320×568, 390×844, and 844×390, the last highlight, and offline restart after + dismissal. No native dependency or runtime change is needed. +- **Commits:** `d087182` records scope; `e846760` makes long release cards readable + with regression coverage; `1e92332` sets version 1.8.0 and its six highlights. + `docs: record 1.8.0 release validation and migration prerequisite` records the + test instructions, map, and this handoff. +- **Validation:** all **145 backend tests passed, no skips**, using disposable + PostgreSQL 16 and dummy Auth configuration with generation disabled. TypeScript + and all **33 Node 24 tests passed, no skips**. Android and web production exports + passed with dummy public configuration. The new card browser scenario passed + all six viewport/theme combinations and dismissal across offline restart; + screenshots were inspected. An initial web export retained the old Expo config + version in Metro's cache; rebuilding with `--clear` resolved it. No application + code workaround was needed. Artifacts remain under `/tmp/one-concept-1-8-*`. +- **Limits:** physical-device/native accessibility checks and actual inbox + delivery were not exercised. This release reran backend/unit/card checks; + #189's documented password and full offline-collection acceptance checks remain + the coverage for those unchanged flows. +- **Database prerequisite at preparation:** production initially lacked the + usage table and automatic approval blocked applying it. Resolved after the + owner's explicit approval in the migration-check follow-up above; #193 records + verified production application. +- **Deployment handoff:** the shared generation-budget rollout in + [backend/README.md](../backend/README.md#shared-generation-budget) still + requires consistent caps across API/workers and pausing old generators during + rollout. Enable the new generators at the next Pacific reset, or seed today's + usage conservatively while paused. No live worker settings were changed. +- **PRs:** preparation [#190](https://github.com/Coding-Moves/one-concept/pull/190) + merged into `develop` as `fa2b7a7`, preserving all five commits. Release + [#191](https://github.com/Coding-Moves/one-concept/pull/191) is open from + `develop` to `main` at the owner's explicit request to open it now. The release + description initially marked migration 0010 and generation rollout as pending. + `docs: record 1.8.0 release PR handoff` records this outcome. +- **Next step:** require the latest migration check to pass after #193 lands + before production merge. Opening #191 did not itself apply the migration or + publish a production release. + +## Saved reading offline and password visibility (#182) — 2026-09-12 + +- Read the exact Expo SDK 57 and React Native 0.86 input/Pressable documentation. + Compared fresh `develop` with production `main` and inspected both issue images. + Production still fetches full lessons only from the API; `develop` already + persists/downloads saved bodies through the earlier #133/#150 changes. +- Reproduced the missing eye button in an unchanged web export: the new browser + test failed at Show password. The existing 365-item offline scenario passed + before editing, so no duplicate cache implementation or new native storage + dependency was needed. Lessons must download online once before offline use. +- Added an eye button with changing Show/Hide password accessibility labels and + a 48px minimum target. It preserves typed values/autofill hints, disables + correction, and masks again on sign-in/signup submission and mode changes. + The field/control disable during requests. Visibility is not persisted. +- **Validation:** TypeScript and all 33 Node 24 tests passed, with no skips. + Web and Android production exports succeeded with dummy configuration. + The password browser regression passed both themes, keyboard activation, + value/submission preservation, busy state, signup confirmation, remasking, + and 320/390/960px targets/overflow. An initial harness assertion expected an + explicit HTML text type; corrected it to check the input's effective type. +- Both final offline browser scenarios passed: large collections and timer-only + reconnect with a replay error/sign-out in flight. Verified all 365 explanations + and examples on disk after restart, unopened older/read saved bodies in the UI, + search/filter, retry, pending unsaves, offline save, and cache cleanup. No browser + runtime errors. Inspected light/dark auth and offline lesson screenshots. +- Artifacts stay under `/tmp/one-concept-182-*`. No live account, email, backend, + or production service was used. Native keyboard/autofill, screen readers, and + on-device storage were not exercised; preview checks remain in + `mobile/tests/README.md`. Backend tests were not run for this mobile-only change. +- Commits: `b76fecd` records scope; `2e41289` adds the UI and browser regression; + `18fb94b` strengthens offline acceptance; `eef0ea4` records the map, test + instructions, and handoff. `docs: link password and saved reading PR` records + [PR #189](https://github.com/Coding-Moves/one-concept/pull/189), non-draft into + `develop`, with five focused commits. Version/runtime are unchanged; this + chunk does not open or merge a release PR. + +## Separate branded email templates — 2026-09-12 + +- Owner requested the three templates in a separate non-draft PR, allowing + branding to merge while domain/provider setup stays deferred. Rechecked + Supabase documentation: customizing templates does not remove the built-in + sender's team-only/two-emails-per-hour restrictions. Existing configured + Gmail SMTP can be used separately once authenticated and tested. +- Preserved nine original template commits from #187, in order, covering signup, + recovery, copy/spacing improvements, the dark text masthead, the Coding Moves + organization link, and the password-changed notification. Sources are byte for + byte identical to the previously validated versions. The owner selected text + branding; no custom app logo was available. +- Added `docs/EMAIL_TEMPLATES.md` with exact subjects, manual installation, + confirmation placeholders, the security toggle, existing redirect contract, + and activation checks. The guide does not require domain purchase or Resend. +- **Validation:** the unchanged HTML previously passed 60 Chromium scenarios: + 48 for signup/recovery and 12 for password changed, across four widths and + normal/doubled/stripped styles, plus normal/long verification URLs. Those checks + covered links, fallback URLs, styled overflow, spacing, action sizes, and no + external resources; mobile/desktop/enlarged previews were inspected. For this + split, verified byte equality with `b0695ca`, inspected app redirect/support + source, checked local Markdown paths/anchors, and ran `git diff --check`. + Browser scenarios were not repeated for identical HTML. Backend/mobile and + live-email tests were not run for this HTML/documentation-only change. +- Commits: `245b7f6` records scope; `d88f432` through `50d065d` preserve the nine + template changes; `4ccba83` adds installation/navigation docs; this handoff is + `docs: record independent email template PR and validation`. +- **Handoff:** merge #188 independently of #187. Then install/test templates in + Supabase before announcing the changed emails. Merging/releasing the app does + not synchronize templates or enable security notifications. Delivery issues + #152/#171 stay open for the remaining operational work; no release PR opened. + +## Shared generation budget (#151) — 2026-09-12 + +- Confirmed both gaps before implementation against the same generation source + now on `develop`: with a zero configured cap, actual prefetch/pool control flow + reached the mocked generator five times; two scheduled runs capped at two each + made four calls total. Database/provider boundaries were mocked, with no live + services or keys. The existing counter resets per run; prefetch never reads it. +- Use one database reservation per attempted provider call, committed before the + network request. Budget denial must roll back the backlog claim without burning + an attempt. Once reserved, failed/throttled/uncertain calls still consume budget; + preserve the separate backlog retry refund for rate limits. +- Align the budget day with Gemini's documented midnight Pacific reset, computed + by PostgreSQL in `America/Los_Angeles`, independently of user progress timezones. + [Provider documentation](https://ai.google.dev/gemini-api/docs/rate-limits). +- Inspection also found the manual catalog rewriter calls the same provider; + include it in the shared budget rather than leaving another bypass. No catalog + rewrite will actually be run against production. +- Intended commits: scope/reproduction; counter migration/service/concurrency + tests; backlog/scheduled enforcement/tests; prefetch integration/tests; rewrite + enforcement/tests; operational documentation/validation; PR bookkeeping. + +- Added migration `0010_generation_daily_usage.sql`, an ORM mirror, and a backend- + only ledger with an atomic UPSERT. A new Pacific budget date gets a new row; + no reset job or process memory is involved. All services must use the same cap. +- Before pool integration, the new PostgreSQL regression reproduced two scheduled + runs each spending two calls under a cap of two (expected second run: zero). + After integration it passes. The prefetch zero-cap test now makes zero calls, + and simultaneous scheduled/prefetch runs share their remaining slots. +- Quota reservation and backlog claim commit together before the provider call, + releasing the connection. Denial or a failed commit rolls the claim back. Empty + backlog spends nothing; committed failed/throttled/cancelled attempts retain + quota. Separate backlog rate-limit refunds and retry retirement remain intact. +- Catalog rewrites use the same reservation gate, honor generation/key switches, + retain old content when stopped, and dispose their engine on every exit. The + maintenance command was exercised only with a disposable DB and mocked provider. +- **Validation:** all 145 backend tests passed against PostgreSQL 16 (no skips), + including 33 new cases across the counter and generation-path suites. Ruff + (`F,E9`), whitespace, and documentation link checks passed. Twenty concurrent + reservations with cap three admit exactly three; twelve concurrent backlog + generations also admit three unique publications without extra spent attempts. + Coverage includes independent sessions/reruns, Pacific winter/summer midnight, + cap changes/zero/negative values, RLS denial for client roles, empty backlog, + committed-before-provider checks, DB/commit failures, provider failures and + cancellation, switches, rate-limit refunds, and cross-path competition. +- **Deployment limits:** production DDL, migration-ledger updates, paid calls, + live provider quota checks, and deployment were not performed. Mobile tests + were not run because no mobile code changed. The ledger starts empty and cannot + reconstruct old calls: pause old generators during rollout, then enable at the + next budget reset or seed today's usage conservatively. Other applications' + Gemini usage is outside this application ledger; provider limits still apply. +- Commits: `92d537f` scope/reproduction; `91f70ba` ledger/migration/tests; + `b8eabdc` claim/scheduled enforcement/tests; `2b199da` prefetch integration/tests; + `e39df10` rewrite enforcement/tests; `2baec95` operational documentation and + validation. +- **PR:** [#186](https://github.com/Coding-Moves/one-concept/pull/186), open into + `develop` with individual commits and owner authorship. Publication bookkeeping: + `docs: link shared generation budget pull request`. Ready for review; merging, + production migration application, and deployment were not performed. + +## Bounded startup state (#150) — 2026-09-12 + +- Read the exact Expo SDK 57 documentation before mobile edits. +- Confirmed before implementation with disposable PostgreSQL 16 and 365 completed + and saved concepts: both the existing request and `?compact=true` returned all + 365 detail rows in each list, about 151 KB. The new regression failed at the + expected 50-row limit. No production service was used. +- Keep legacy state responses for older clients during backend/OTA rollout. The + updated client will request compact metadata, retain exact aggregate totals, + and load older Saved metadata in pages when needed. Full History UI is separate. + +- Delivered opt-in compact state on GET state, PUT topics, and PATCH profile; + updated mobile requests use it. Both cursor endpoints default to 50 and allow + 1–100 items. Like counts are enriched after limiting rows; full membership and + streak/aggregate work still grow with activity. Saved timestamp/UUID ordering + handles ties and deletion between pages, with all queries scoped to the JWT user. +- Stats combines older-topic aggregates with recent/optimistic learned rows. + Saved paints recent/cached rows, then loads older metadata in bounded pages; + previously downloaded bodies provide older titles offline even before the first + Saved visit. The new account-keyed cache clears at sign-out and fences late pages. +- Final review reproduced a manual Retry that sent no request after connectivity + returned. Fixed it in a follow-up commit and retained the failing-before/passing- + after browser scenario. Equivalent state refreshes do not spin failed page loads. +- **Validation:** all 112 backend tests passed against disposable PostgreSQL 16, + with no skips; 33 Node 24 tests, TypeScript, Ruff (`F,E9`), and whitespace checks + passed. Android/web production exports succeeded using dummy configuration. + The first restricted export stalled; it was stopped and completed with local + worker communication enabled. Documentation links/new source paths were checked. +- The 365-record PostgreSQL fixture returned 151,007 bytes through the legacy + contract and 55,676 bytes through compact state, about 63% smaller, with exactly + 50 learned and 50 saved details. Pagination recovered all 365 without duplicates. + Tests cover limits/invalid cursors, user isolation, exact window boundaries, + mutations, tied saves, deletion between pages, own-like exclusion, and totals. +- Browser coverage passed for the 365-item account: full Stats/category totals, + older Saved search/category filters and reading, offline restart before Saved + was ever opened, page failure/retry without a request loop, manual reconnect + retry, offline completion/unsave, and sign-out during a pending successful page. + Inspected the mobile-sized search screenshot. Existing timer-only sync, 503 + replay recovery, saved-body/topic persistence, midnight save, and partial- + connectivity recovery scenarios also passed without browser runtime errors. +- **Limits:** no physical-device run or production benchmark/deployment. Android + validation is a JS export, not a new APK. Old clients keep the unbounded legacy + response until OTA adoption; membership arrays remain complete. Saved still + downloads all older metadata when opened to preserve full search, and offline + full text requires its completed body download. Pagination is a live view; + refresh startup state for new rows above an existing cursor. +- Commits: `64afb6d` scope/reproduction; `09f4b22` future commit preference; + `ce38299` backend contract/endpoints/tests; `32cc70a` Stats compatibility/tests; + `b62ab19` Saved paging/cache/browser checks; `50b7f88` compact request activation; + `2d83b7a` manual reconnect retry. Documentation handoff: + `docs: document compact state rollout and validation` (`730a28d`). +- **PR:** [#185](https://github.com/Coding-Moves/one-concept/pull/185), open into + `develop` with individual commits and owner authorship. Publication bookkeeping: + `docs: link bounded startup pull request`. Ready for owner review; merging and + deployment were not performed. Disposable browser/database checks finished. + +## Database connections after idle (#149) — 2026-09-12 + +- Reproduced before implementation using the unchanged app engine/session and + a disposable PostgreSQL 16 container on loopback port 55434. Set the test + server's idle-session timeout to 1.5 seconds, then waited two seconds between + requests. SQLAlchemy connection events confirm all three post-idle requests + created a new physical connection: 260.23, 221.45, and 220.35 ms. Immediate + reuse took 10.91, 10.24, and 9.40 ms with zero new connections. +- This reproduces the request-time reconnect mechanism under controlled idle + expiry, not Supavisor's deployed timeout or the issue's production 600 ms figure. +- Intended fix: configurable, bounded probes in the FastAPI lifespan, reusing + the most recently returned connection. Keep `pool_pre_ping`, transaction-mode + configuration, and current connection limits. Cancel probes before pool disposal. +- Planned commits: reproduction/scope; warm connection lifecycle with regression + tests; measured validation, operational instructions, and PR handoff. +- Implemented an immediate then periodic API lifespan probe (30-second default, + zero to disable), reusing the most recently returned pool connection. Checkout/ + query and rollback/return each have a five-second default budget. Failures retry + after the interval; logs omit raw connection details. Shutdown waits for cleanup, + including when cleanup itself fails. The task does not start in cron workers. +- A real-PostgreSQL test caught cancellation returning before SQLAlchemy finished + connection cleanup. Fixed it before handoff and added failure/cancellation coverage. +- **Validation:** all 105 backend tests passed, with real PostgreSQL 16 integration + coverage and no skips. Ruff (`F,E9`) and whitespace checks passed. The final + controlled comparison (800 ms test-session idle timeout, 1.2-second gaps) was: + + | Warm-up | Three request times (ms) | New physical connections | + | --- | --- | --- | + | Disabled | 223.98, 209.61, 234.05 | 3 | + | Enabled, 200 ms test interval | 7.93, 8.18, 8.29 | 0 | + + Both cases use the same engine factory, query, and disposable database. Timings + are reported rather than asserted; connection counts are the regression check. + Covered query/checkout/cleanup timeouts, recovery, cancellation, transaction + release, terminated connections, disabled probes, and lifespan failures. +- **Limits:** no live Supabase/Railway measurement, production deployment, mobile + test, or native build. The issue's deployed timeout/600 ms figure remains + unverified. Cold startup and additional connections during bursts can still pay + setup cost. Each API process adds a periodic probe with the configured interval. +- Commits: `50d73ab` — reproduction and scope; `6bf0399` — implementation/tests; + `679163a` — validation and operational documentation. +- **PR:** [#184](https://github.com/Coding-Moves/one-concept/pull/184), open into + `develop` with all individual commits and the owner's configured identity. + Publication bookkeeping: `docs: link database warm-up pull request`. + Disposable reproduction/test containers were removed. Ready for owner review; + merging and deployment were not performed. + +## PR #183 review fixes — 2026-09-12 + +- Owner requested both findings fixed in the existing PR against `develop`. + Planned and delivered one fix/test commit per finding, followed by this + documentation handoff. Read the exact Expo SDK 57 documentation before edits. +- Reproduced both problems before editing: a save waiting behind a like across + midnight never persisted (the unchanged `develop` control succeeded); successful + state requests followed by failed topics requests caused rapid repeated fetches. + The new committed browser scenarios fail on the previous PR export. +- `d9a07f5` — `fix: preserve pending actions across midnight`: split account + invalidation from daily refresh, preserve pending counts, and prevent cached + previews from overwriting pending optimistic actions. Browser coverage checks + queued save persistence through date change, offline restart, and replay. +- `409b1af` — `fix: retain backoff after partial sync failures`: failed attempts + retain backoff even when their own requests report connectivity changes. + Tests cover the full 5/10/20/30-second progression, immediate reconnect while + waiting, coalesced successful wakeups, and automatic recovery in the browser. +- **Validation:** 31 Node 24 tests, TypeScript, Android/web production exports, + and whitespace checks passed. Both new browser scenarios and the full offline + flow passed, including timer-only reconnect, 503 recovery, offline save restart, + and sign-out with an in-flight request. No browser runtime errors. No backend + changes; backend tests, live services, and physical-device checks were not run. +- **Handoff:** both fixes stay in [PR #183](https://github.com/Coding-Moves/one-concept/pull/183) + on `codex/133-offline-reading-sync`. Native runtime, version, and dependencies + are unchanged. Documentation commit: `docs: record PR 183 review fixes`. + +## Offline reading and synchronization (#133) — 2026-09-12 + +- Before editing, exported the unchanged web app and exercised it in Chromium + with dummy authentication and intercepted API requests. No live account used. +- Confirmed: a custom saved concept opened online loses its full text after an + offline restart; a warmed topic catalog is unavailable after offline restart; + offline topic changes do not enter the persistent queue; restoring connectivity + alone leaves an offline like queued without sending a request. +- Passing controls: saved detail online, cached Today offline, and durable offline + like queuing. Preserve those existing behaviors and the offline banner. +- Planned atomic commits: persistent full-concept reading with account cleanup + and tests; cached topic catalog and queued follows with tests; automatic sync + triggers with tests; validation, codebase map, and PR handoff. +- Owner chose to keep the current APK: automatically retry while the app is open + or reopened. No OS background worker, native dependency, or version/runtime bump. +- Read the exact Expo SDK 57 documentation before mobile edits. Other issues, + including #182's password-visibility request, remain outside this PR. +- Additional replay checks reproduced two related failures before their fixes: + a 503 reverted a queued unlike in the UI; a request failing after sign-out + recreated the old action queue. Preserve pending choices during reconciliation + and fence late request callbacks/token resolution after account cleanup. +- Implemented full per-lesson storage and missing saved-lesson downloads; + shared cached topics with durable follows; serialized outbox writes; automatic + foreground retry with 5–30 second backoff, immediate browser/foreground wakeup, + and no idle polling once reachable with an empty queue. Saved reading requires + the lesson to have finished downloading during an online session. +- **Validation:** all 28 Node 24 regression tests, TypeScript, Android/web Expo + production exports, and whitespace checks passed. The mocked Chromium flow + passed offline restart, unopened saved-lesson downloads, cached sharing, follows, + likes/saves, timer-only and browser-event reconnect, 503 retention/recovery, + and sign-out with a request in flight. Inspected the offline detail screenshot. + No live backend, physical phone, native share sheet, or production deployment + was tested; no backend code changed and backend tests were not run. +- **Repeatability:** `mobile/tests/offline.browser.cjs` and its README retain the + before/after reproduction flow, dummy public configuration, and optional race + checks. New focused Node tests cover storage, outbox, topics, reconciliation, + and scheduler behavior without adding dependencies. + +| Change | Commit | +| --- | --- | +| Baseline reproduction and scope | `0e82f03` | +| Full offline lesson storage and saved downloads | `0085285` | +| Cached topics and queued follow choices | `41da1ab` | +| Serialized durable outbox | `f8d1ca6` | +| Automatic foreground synchronization | `1a82c2e` | +| Preserve pending choices during reconciliation | `cfa24ea` | +| Sign-out request fence and browser regression | `d19e1fe` | +| Validation and navigation guide | `docs: record issue 133 validation and handoff` | + +- **PR:** [#183](https://github.com/Coding-Moves/one-concept/pull/183), opened + into `develop` with all individual commits and the owner's configured identity. + Publication bookkeeping: `docs: link issue 133 pull request`. +- **Handoff:** ready for owner review. The PR remains open; merging and release + preparation are the owner's next steps, outside this task. ## Working agreement — 2026-09-11 diff --git a/mobile/app.config.js b/mobile/app.config.js index 254e010..ba62a98 100644 --- a/mobile/app.config.js +++ b/mobile/app.config.js @@ -18,7 +18,7 @@ module.exports = { name: 'One Concept', slug: 'one-concept', owner: 'coding-moves', - version: '1.7.1', + version: '1.8.0', orientation: 'portrait', icon: './assets/icon.png', userInterfaceStyle: 'automatic', diff --git a/mobile/src/api/client.ts b/mobile/src/api/client.ts index c90439e..8da63cd 100644 --- a/mobile/src/api/client.ts +++ b/mobile/src/api/client.ts @@ -26,6 +26,12 @@ export class ApiError extends Error { type TokenProvider = () => Promise; let getAccessToken: TokenProvider = async () => null; +let accountEpoch = 0; + +/** Cancel requests still waiting for a token when the account is cleared. */ +export function invalidateAccountRequests(): void { + accountEpoch += 1; +} /** Registered once by the auth layer in Phase 3. */ export function setTokenProvider(provider: TokenProvider): void { @@ -77,7 +83,9 @@ export async function apiRequest(path: string, options: RequestOptions = {}): throw new ApiError(0, 'EXPO_PUBLIC_API_BASE_URL is not set'); } + const epoch = accountEpoch; const token = await getAccessToken(); + if (epoch !== accountEpoch) throw new ApiError(401, 'Account changed'); const headers: Record = { Accept: 'application/json' }; if (options.body !== undefined) headers['Content-Type'] = 'application/json'; if (token) headers.Authorization = `Bearer ${token}`; diff --git a/mobile/src/components/WhatsNewCard.tsx b/mobile/src/components/WhatsNewCard.tsx index fd12a82..19a804c 100644 --- a/mobile/src/components/WhatsNewCard.tsx +++ b/mobile/src/components/WhatsNewCard.tsx @@ -5,6 +5,7 @@ import { Easing, Modal, Pressable, + ScrollView, StyleSheet, Text, View, @@ -71,7 +72,11 @@ export function WhatsNewCard({ entry, onDismiss }: Props) { What's new Version {entry.version} - + {entry.highlights.map((line, i) => ( - {line} + {line} ))} - + cardWrap: { width: '100%', maxWidth: 420, + maxHeight: '100%', }, card: { + flexShrink: 1, backgroundColor: colors.surface, borderRadius: radius.xl, borderWidth: 1, @@ -149,10 +156,11 @@ const createStyles = (colors: ThemeColors) => marginTop: 2, marginBottom: spacing.lg, }, - list: { - gap: spacing.md, + highlights: { + flexShrink: 1, marginBottom: spacing.lg, }, + list: { gap: spacing.md }, item: { flexDirection: 'row', alignItems: 'flex-start', diff --git a/mobile/src/context/ProgressContext.tsx b/mobile/src/context/ProgressContext.tsx index 8b806e9..783f2ce 100644 --- a/mobile/src/context/ProgressContext.tsx +++ b/mobile/src/context/ProgressContext.tsx @@ -8,8 +8,8 @@ import { useRef, useState, } from 'react'; -import { AppState } from 'react-native'; -import { subscribeConnectivity } from '../api/client'; +import { AppState, Platform } from 'react-native'; +import { getConnectivity, isApiConfigured, setConnectivity, subscribeConnectivity } from '../api/client'; import { CONCEPTS } from '../data/concepts'; import { Category, Concept, DailyOutcome, ProgressState } from '../types'; import { selectDailyConcept } from '../services/dailyConcept'; @@ -17,6 +17,9 @@ import { todayKey } from '../services/dates'; import { localProgressRepository } from '../services/localProgressRepository'; import { ProgressRepository } from '../services/progressRepository'; import { remoteProgressRepository } from '../services/remoteProgressRepository'; +import { pending as queuedMutations, subscribeQueue } from '../services/mutationQueue'; +import { createSyncLoop } from '../services/syncLoop'; +import { fetchTopics } from '../services/topicsApi'; import { useAuth } from './AuthContext'; import { EMPTY_PROGRESS } from '../services/storage'; import { computeStreaks, StreakStats } from '../services/streak'; @@ -73,22 +76,33 @@ export function ProgressProvider({ children, repository: override }: Props) { override ?? (session ? remoteProgressRepository : localProgressRepository); const today = todayKey(); + const userId = session?.user.id; + const chain = useRef>(Promise.resolve()); + const pending = useRef(0); + const accountEpoch = useRef(0); + const confirmed = useRef(null); + // Only an account/source change invalidates queued actions. A new calendar + // day refreshes the assignment without cancelling taps still in flight. useEffect(() => { - let cancelled = false; - (async () => { - setLoading(true); - // The repository just swapped (sign-in or sign-out). The old account's - // state must not stay on screen while the new source loads — wiping the - // caches below is not enough when the leak lives in React state. - setProgress(EMPTY_PROGRESS); + accountEpoch.current += 1; + pending.current = 0; + confirmed.current = null; + setLoading(true); + setProgress(EMPTY_PROGRESS); + return () => { accountEpoch.current += 1; }; + }, [repository, userId]); + useEffect(() => { + let cancelled = false; + chain.current = chain.current.then(async () => { + if (cancelled) return; // Paint from the last known state immediately — on a slow connection // the difference between this and waiting on the network is the whole // perceived speed of the app. The fresh load replaces it silently. const cached = await repository.loadCached?.(); if (cached && !cancelled) { - setProgress(cached); + if (pending.current === 0) setProgress(cached); setLoading(false); } @@ -101,48 +115,13 @@ export function ProgressProvider({ children, repository: override }: Props) { const next = picked ? await repository.setAssignment(picked.id, today) : stored; if (cancelled) return; - setProgress(next); + confirmed.current = next; + if (pending.current === 0) setProgress(next); setLoading(false); - })(); - return () => { - cancelled = true; - }; - }, [repository, today]); - - // Drain the offline mutation queue when connectivity returns or the app comes - // back to the foreground, then apply the server-reconciled state (issue #133). - // Serialised via `flushing` so overlapping triggers don't double-replay. - useEffect(() => { - if (!repository.flushQueue) return; - let active = true; - let flushing = false; - const flush = async () => { - if (flushing || !active) return; - flushing = true; - try { - const next = await repository.flushQueue?.(); - if (active && next) setProgress(next); - } catch { - // A flush failure just leaves items queued for the next trigger. - } finally { - flushing = false; - } - }; - - const unsubscribe = subscribeConnectivity((online) => { - if (online) flush(); - }); - const appState = AppState.addEventListener('change', (s) => { - if (s === 'active') flush(); - }); - flush(); // catch up on anything left from a previous session - - return () => { - active = false; - unsubscribe(); - appState.remove(); - }; - }, [repository]); + if (userId && getConnectivity()) void fetchTopics().catch(() => {}); + }).catch(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [repository, today, userId]); // The day's assignment is pinned once made, even if the concept's topic is // unfollowed later that day — topic changes apply from the next assignment. @@ -177,22 +156,25 @@ export function ProgressProvider({ children, repository: override }: Props) { // momentarily wipe a later tap's optimistic change (a flicker). On failure we // undo just this change functionally, on top of the latest state, so a // concurrent change is never lost (the clobber #39 originally fixed). - const chain = useRef>(Promise.resolve()); - const pending = useRef(0); const apply = useCallback( ( optimistic: ((prev: ProgressState) => ProgressState) | null, - run: () => Promise, + run: () => Promise, undo?: (prev: ProgressState) => ProgressState ) => { + const epoch = accountEpoch.current; if (optimistic) setProgress(optimistic); pending.current += 1; chain.current = chain.current.then(async () => { + if (epoch !== accountEpoch.current) return; try { const next = await run(); + if (epoch !== accountEpoch.current) return; pending.current -= 1; - if (pending.current === 0) setProgress(next); + if (next) confirmed.current = next; + if (pending.current === 0 && confirmed.current) setProgress(confirmed.current); } catch { + if (epoch !== accountEpoch.current) return; pending.current -= 1; if (undo) setProgress(undo); } @@ -202,6 +184,57 @@ export function ProgressProvider({ children, repository: override }: Props) { [] ); + // Queue replay shares the same chain as taps and initial loading. A later + // optimistic action cannot be overwritten by a reconnect's server snapshot. + useEffect(() => { + if (!repository.flushQueue || !isApiConfigured()) return; + let active = true; + const loop = createSyncLoop(async () => { + let retry = true; + await apply(null, async () => { + if (!active) return null; + const entries = await queuedMutations(); + let next: ProgressState | null = null; + if (entries.length) next = await repository.flushQueue!(); + else if (!getConnectivity()) next = await repository.load(); + if (!active) return null; + if (next && getConnectivity()) await fetchTopics().catch(() => {}); + retry = !getConnectivity() || (await queuedMutations()).length > 0; + return next; + }); + return retry; + }, AppState.currentState !== 'background' && AppState.currentState !== 'inactive'); + const unsubscribe = subscribeConnectivity(online => { + if (online) loop.wake(); + else loop.retry(); + }); + const unsubscribeQueue = subscribeQueue(() => { + if (getConnectivity()) loop.wake(); + else loop.retry(); + }); + const appState = AppState.addEventListener('change', state => loop.setActive(state === 'active')); + // Browsers have an immediate reconnect event. Native JS retries pending + // work with backoff because the current APK has no connectivity module. + const reconnect = () => loop.wake(); + const disconnect = () => setConnectivity(false); + if (Platform.OS === 'web') { + window.addEventListener('online', reconnect); + window.addEventListener('offline', disconnect); + } + loop.wake(); + return () => { + active = false; + loop.stop(); + unsubscribe(); + unsubscribeQueue(); + appState.remove(); + if (Platform.OS === 'web') { + window.removeEventListener('online', reconnect); + window.removeEventListener('offline', disconnect); + } + }; + }, [apply, repository, userId]); + // Retry shares the mutation chain, so a refresh cannot overwrite a later tap. const refresh = useCallback(() => apply(null, () => repository.load()), [apply, repository]); diff --git a/mobile/src/data/whatsNew.ts b/mobile/src/data/whatsNew.ts index b32e169..4be8f70 100644 --- a/mobile/src/data/whatsNew.ts +++ b/mobile/src/data/whatsNew.ts @@ -21,6 +21,17 @@ export interface WhatsNewEntry { } export const WHATS_NEW: WhatsNewEntry[] = [ + { + version: '1.8.0', + highlights: [ + 'Read saved lessons and examples offline after they download to your device.', + 'Likes, saves, and progress sync automatically when you reconnect or reopen the app.', + 'Choose topics offline and keep your selections between visits.', + 'Enjoy a lighter startup and find older saved lessons with search and topic filters.', + 'Show or hide your password when signing in or creating an account.', + 'Refreshed account emails make confirmation, password resets, and security updates clearer.', + ], + }, { version: '1.7.1', highlights: [ diff --git a/mobile/src/hooks/useSavedConcepts.ts b/mobile/src/hooks/useSavedConcepts.ts new file mode 100644 index 0000000..45506d1 --- /dev/null +++ b/mobile/src/hooks/useSavedConcepts.ts @@ -0,0 +1,95 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { getConnectivity } from '../api/client'; +import { useAuth } from '../context/AuthContext'; +import { conceptCache } from '../services/conceptApi'; +import { fetchSavedPage, savedCollectionCache } from '../services/savedApi'; +import { ProgressState, SavedConcept } from '../types'; + +/** Fetch older metadata only while Saved is open. Recent rows paint immediately; + * cached pages and already-downloaded lesson bodies preserve offline search. */ +export function useSavedConcepts(progress: ProgressState) { + const { session } = useAuth(); + const userId = session?.user.id ?? null; + const [attempt, setAttempt] = useState(0); + const retry = useCallback(() => setAttempt(n => n + 1), []); + const [loaded, setLoaded] = useState<{ + owner: string | null; items: SavedConcept[]; loading: boolean; failed: boolean; + }>({ owner: null, items: [], loading: false, failed: false }); + // Equivalent state refreshes must not restart a failed page fetch in a loop. + const key = JSON.stringify([progress.bookmarks, progress.savedConcepts, progress.savedNextCursor]); + const snapshot = useMemo(() => ({ + bookmarks: progress.bookmarks, recent: progress.savedConcepts ?? [], cursor: progress.savedNextCursor, + }), [key]); + + useEffect(() => { + if (!userId) return; + let cancelled = false; + const epoch = savedCollectionCache.epoch; + const contentEpoch = conceptCache.epoch; + const active = () => !cancelled && epoch === savedCollectionCache.epoch; + const membership = new Set(snapshot.bookmarks); + const rows = new Map(); + const recent = new Map(snapshot.recent.map(s => [s.conceptId, s])); + const items = () => [...new Map([...recent, ...[...rows].map(([id, row]) => + [id, recent.get(id) ?? row] as const)]).values()].filter(s => membership.has(s.conceptId)); + const publish = (loading: boolean, failed = false) => { + if (active()) setLoaded({ owner: userId, items: items(), loading, failed }); + }; + const persist = () => active() + ? savedCollectionCache.set(userId, items(), epoch).catch(() => {}) : Promise.resolve(); + + publish(true); + void (async () => { + const cached = await savedCollectionCache.get(userId, epoch); + if (!active()) return; + cached?.forEach(s => rows.set(s.conceptId, s)); + publish(!!snapshot.cursor); + let failed = false; + let cursor = snapshot.cursor; + const seen = new Set(); + try { + // An explicit retry probes the network even if the last request was + // offline; automatic loads can keep using downloaded metadata. + while (cursor && active() && (getConnectivity() || attempt > 0)) { + if (seen.has(cursor)) throw new Error('Repeated saved cursor'); + seen.add(cursor); + const page = await fetchSavedPage(cursor); + if (!active()) return; + page.items.forEach(s => rows.set(s.conceptId, s)); + cursor = page.nextCursor; + publish(!!cursor); + await persist(); + } + } catch { + failed = true; + } + if (!active()) return; + // Offline users may have downloaded all saved bodies without ever opening + // this screen. Recover their metadata even if the list cache is absent. + const missing = snapshot.bookmarks.filter(id => !rows.has(id) && !recent.has(id)); + for (let offset = 0; offset < missing.length && active(); offset += 20) { + const concepts = await Promise.all(missing.slice(offset, offset + 20) + .map(id => conceptCache.get(id, contentEpoch))); + if (!active()) return; + concepts.forEach(c => { + if (c) rows.set(c.id, { conceptId: c.id, title: c.title, topicName: c.category, likeCount: c.likeCount }); + }); + } + publish(false, failed || items().length < membership.size); + await persist(); + })(); + return () => { cancelled = true; }; + }, [userId, snapshot, attempt]); + + const membership = new Set(progress.bookmarks); + const rows = new Map((loaded.owner === userId ? loaded.items : []).map(s => [s.conceptId, s])); + // Apply optimistic saves/unsaves immediately, before any in-flight page ends. + progress.savedConcepts?.forEach(s => rows.set(s.conceptId, s)); + return { + savedConcepts: progress.savedConcepts === undefined ? undefined + : [...rows.values()].filter(s => membership.has(s.conceptId)), + loading: !!userId && loaded.owner === userId && loaded.loading, + failed: !!userId && loaded.owner === userId && loaded.failed, + retry, + }; +} diff --git a/mobile/src/hooks/useTopics.ts b/mobile/src/hooks/useTopics.ts index cc8d28f..3d5986c 100644 --- a/mobile/src/hooks/useTopics.ts +++ b/mobile/src/hooks/useTopics.ts @@ -1,6 +1,6 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'; import { useAuth } from '../context/AuthContext'; -import { fetchTopics, ServerTopic, setFollowedTopics } from '../services/topicsApi'; +import { fetchTopics, ServerTopic, topicStore } from '../services/topicsApi'; export interface Topics { loading: boolean; @@ -17,7 +17,7 @@ export function useTopics(): Topics { // Key on the user id, not the session object: supabase hands a fresh object // on every token refresh, which would otherwise refetch the list hourly. const userId = session?.user?.id ?? null; - const [topics, setTopics] = useState([]); + const topics = useSyncExternalStore(topicStore.subscribe, topicStore.getSnapshot); const [loading, setLoading] = useState(true); const [error, setError] = useState(false); const [attempt, setAttempt] = useState(0); @@ -25,7 +25,6 @@ export function useTopics(): Topics { useEffect(() => { if (!userId) { - setTopics([]); setLoading(false); return; } @@ -33,9 +32,6 @@ export function useTopics(): Topics { setLoading(true); setError(false); fetchTopics() - .then((list) => { - if (!cancelled) setTopics(list); - }) .catch(() => { if (!cancelled) setError(true); }) @@ -49,25 +45,10 @@ export function useTopics(): Topics { const toggle = useCallback( (slug: string) => { - // Compute the next list synchronously from current state — never read a - // value assigned inside a setState updater, which React may not have run - // yet (that would PUT an empty list and unfollow everything). - const next = topics.map((t) => - t.slug === slug ? { ...t, following: !t.following } : t - ); - setTopics(next); - - const followed = next.filter((t) => t.following).map((t) => t.slug); - // Fire-and-forget; on failure reload the server's truth so the pill can - // never lie about what was actually saved. - setFollowedTopics(followed).catch(() => { - fetchTopics() - .then(setTopics) - .catch(() => {}); - }); + topicStore.toggle(slug).catch(() => setError(true)); }, - [topics] + [] ); - return { loading, error, retry, topics, toggle }; + return { loading: loading && topics.length === 0, error, retry, topics, toggle }; } diff --git a/mobile/src/screens/AuthScreen.tsx b/mobile/src/screens/AuthScreen.tsx index 55088de..02799a6 100644 --- a/mobile/src/screens/AuthScreen.tsx +++ b/mobile/src/screens/AuthScreen.tsx @@ -29,6 +29,7 @@ export function AuthScreen() { const [mode, setMode] = useState('signIn'); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const [passwordVisible, setPasswordVisible] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); // A banner with a bold header + body. The reset variant carries the email so @@ -40,6 +41,7 @@ export function AuthScreen() { const canSubmit = email.trim().length > 3 && password.length >= 6 && !busy; const submit = async () => { + setPasswordVisible(false); setError(null); setNotice(null); setBusy(true); @@ -143,17 +145,35 @@ export function AuthScreen() { Password - + + + setPasswordVisible(visible => !visible)} + disabled={busy} + accessibilityRole="button" + accessibilityLabel={passwordVisible ? 'Hide password' : 'Show password'} + accessibilityState={{ disabled: busy }} + style={({ pressed }) => [styles.passwordToggle, pressed && { opacity: 0.6 }]} + > + + + {mode === 'signIn' ? ( { setMode(mode === 'signIn' ? 'signUp' : 'signIn'); + setPasswordVisible(false); setError(null); setNotice(null); }} @@ -269,6 +290,18 @@ const createStyles = (colors: ThemeColors) => color: colors.text, ...shadows.card, }, + passwordInput: { paddingRight: scaleIcon(20) + spacing.md * 2 + spacing.sm }, + passwordToggle: { + position: 'absolute', + right: spacing.xs, + top: 0, + bottom: 0, + minWidth: 48, + minHeight: 48, + paddingHorizontal: spacing.sm, + alignItems: 'center', + justifyContent: 'center', + }, banner: { flexDirection: 'row', alignItems: 'flex-start', diff --git a/mobile/src/screens/ConceptDetailScreen.tsx b/mobile/src/screens/ConceptDetailScreen.tsx index 1704044..352e461 100644 --- a/mobile/src/screens/ConceptDetailScreen.tsx +++ b/mobile/src/screens/ConceptDetailScreen.tsx @@ -31,7 +31,12 @@ export function ConceptDetailScreen() { useEffect(() => { let active = true; setStatus('loading'); - fetchConcept(conceptId) + fetchConcept(conceptId, (cached) => { + if (active) { + setConcept(cached); + setStatus('ready'); + } + }) .then((c) => { if (active) { setConcept(c); diff --git a/mobile/src/screens/SavedScreen.tsx b/mobile/src/screens/SavedScreen.tsx index fcbf9ce..086feb0 100644 --- a/mobile/src/screens/SavedScreen.tsx +++ b/mobile/src/screens/SavedScreen.tsx @@ -2,12 +2,13 @@ import { Ionicons } from '@expo/vector-icons'; import { CompositeNavigationProp, useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useMemo, useState } from 'react'; -import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import { ActivityIndicator, FlatList, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { CategoryChip } from '../components/CategoryChip'; import { LikeCount } from '../components/LikeCount'; import { UnavailableState } from '../components/UnavailableState'; import { useOnline } from '../context/ConnectivityContext'; import { useProgress } from '../context/ProgressContext'; +import { useSavedConcepts } from '../hooks/useSavedConcepts'; import { useTheme } from '../context/ThemeContext'; import { CONCEPTS_BY_ID } from '../data/concepts'; import { RootStackParamList } from '../navigation'; @@ -34,6 +35,7 @@ export function SavedScreen() { const navigation = useNavigation