Fase 2b closes the critical-path product differentiator. Two sessions on the
same list see each other's changes in real time, mutations survive network
drops and sync when reconnected, and a dedicated full-screen shopping view
optimises the in-store experience.
2b.1 Realtime
- `$lib/stores/realtimeSync.ts` wraps `postgres_changes` with an `applyItemEvent`
reducer (INSERT/UPDATE/DELETE → next items[]) and a `subscribeToList` helper
that awaits the SUBSCRIBED handshake before returning
- `/lists/[id]` subscribes in onMount, unsubscribes in onDestroy. Uses a
`pendingTempIds` set to deduplicate own-mutation echoes against optimistic
rows (matches by name+created_by+sort_order)
- Client-generated UUIDs for new inserts make the path idempotent: if the
server response is lost and we retry, the second POST hits PK-conflict
which the queue treats as success
- CHECKED section div now carries role="listitem" so Playwright locators
follow the item across sections
2b.2 Offline queue
- `$lib/sync/queue.ts` — SyncQueue class backed by IndexedDB (idb), FIFO flush,
MAX_ATTEMPTS=5 retry budget, PK-conflict-as-success short-circuit
- `$lib/sync/index.ts` — app-wide singleton, window.online listener flushes
automatically, hydrateSyncState() at page load restores pendingOpsCount
- `$lib/stores/syncStatus.ts` — derived store (offline | syncing | synced)
tracking navigator.onLine + queue depth
- SyncBanner component renders the offline/syncing indicator in the detail
and session views
- handleAdd enqueues on failure instead of reverting, so an offline mutation
keeps its optimistic row and syncs on reconnect
2b.3 Modo Compra
- `/lists/[id]/session/+page.svelte` — full-screen overlay (fixed inset-0
z-50) that covers the sidebar while keeping the normal auth routing.
56×56 toggles, flip animation shuffling items between TO BUY / CHECKED,
Finish Shopping confirmation modal → completeList → goto('/lists')
- Link from `/lists/[id]` header (data-testid=start-session) with the
new `list_start_session` message
Testing infra
- apps/web gets its own Vitest config (jsdom + fake-indexeddb/auto) and a
new `pnpm test:unit` script. `just test-all` now chains pgTAP → integration
→ unit → e2e so a single command is the gate.
- packages/test-utils/tests/sync-queue.test.ts (placeholder scaffold) removed —
replaced by `apps/web/src/lib/sync/queue.test.ts` co-located with the module
Totals: 103 tests green
16 pgTAP
59 Vitest integration (in packages/test-utils)
6 Vitest unit (in apps/web — the SyncQueue)
22 Playwright E2E (5 auth + 4 lists + 6 items + 2 realtime + 2 offline + 3 session)
2 skipped (realtime-presence — upstream bug, unchanged)
Documented in plan/fase-2b and CLAUDE.md.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
300 lines
24 KiB
Markdown
300 lines
24 KiB
Markdown
# CLAUDE.md
|
||
|
||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||
|
||
## Code Language
|
||
|
||
**All code must be written in English.** This applies to variable names, function names, type names, database column names, SQL identifiers, comments, and any other code artifact. The UI may display text in any language, but the code layer is always English.
|
||
|
||
## Project Status
|
||
|
||
**Fase 0 complete. Fase 1 complete. Fase 2a complete. Fase 2b complete (Realtime + offline queue + Modo Compra). 103 tests green: 16 pgTAP + 59 Vitest integration + 6 Vitest unit + 22 Playwright. 2 skipped (Realtime presence — upstream bug in `supabase/realtime` `handle_out/3`, reactivate when fixed). ✅**
|
||
|
||
- `README.md` — full development plan, confirmed tech stack, Justfile reference, and technical warnings
|
||
- `analysis/analisis-funcional.md` — complete functional specification (domain model, use cases, data models, business rules)
|
||
- `plan/fase-*.md` — phase-by-phase implementation plans (Fase 0 through Fase 4)
|
||
|
||
### Fase 1 — what has been built
|
||
|
||
- `supabase/migrations/001_users.sql` — `users` table, `handle_new_user` trigger
|
||
- `supabase/migrations/002_collectives.sql` — `collectives`, `collective_members`, `collective_invitations`; auto-promote-oldest-admin trigger
|
||
- `supabase/migrations/003_rls.sql` — full RLS policies, `is_active_member`/`is_member`/`is_admin` helpers, `accept_invitation()` function
|
||
- `supabase/migrations/004_storage.sql` — `avatars` bucket (private, signed URLs)
|
||
- `apps/web/src/lib/supabase.ts` — Supabase singleton with PKCE flow
|
||
- `apps/web/src/lib/auth.ts` — `login()` / `logout()` via `supabase.auth.signInWithOAuth`
|
||
- `apps/web/src/lib/stores/auth.ts` — `currentUser`, `authLoading`, `isAuthenticated`, `displayName`
|
||
- `apps/web/src/lib/stores/collective.ts` — `currentCollective`, `userCollectives`, `collectiveMembers`
|
||
- `apps/web/src/lib/components/Avatar.svelte` — initials / emoji / upload with deterministic colour hash
|
||
- `apps/web/src/lib/components/ImageCropper.svelte` — lazy-loaded cropperjs, 1:1, 256×256 WebP output
|
||
- `apps/web/src/routes/+layout.svelte` — auth state listener, collective bootstrap
|
||
- `apps/web/src/routes/(app)/+layout.ts` — SSR disabled (`ssr: false`); no auth check here (see gotcha below)
|
||
- `apps/web/src/routes/(app)/+layout.svelte` — sidebar with collective switcher; auth redirect via `$effect`
|
||
- `apps/web/src/routes/auth/callback/+page.svelte` — PKCE code exchange
|
||
- `apps/web/src/routes/onboarding/+page.svelte` — create collective or join via link
|
||
- `apps/web/src/routes/(app)/collective/manage/+page.svelte` — members, roles, invite link generator
|
||
- `apps/web/src/routes/invitation/[token]/+page.svelte` — accept invitation (auth-aware)
|
||
- `apps/web/src/routes/(app)/settings/+page.svelte` — display name, avatar, language, sign out
|
||
- `apps/web/messages/en.json` + `es.json` — all Fase 1 + 2a strings
|
||
- `packages/types/src/database.ts` — manually seeded from migrations (update with `just db-types` once CLI is wired)
|
||
- `setup.sh` — idempotent local dev setup (prerequisites, .env, Docker, migrations, seed, Paraglide)
|
||
- `infra/kong-start.sh` — Kong container entrypoint; substitutes `${VAR}` placeholders in `kong.yml` before Kong starts (Kong 2.8 does not do this natively)
|
||
|
||
### Test suite — what has been built
|
||
|
||
- `packages/test-utils/` — shared test infrastructure (Vitest integration tests)
|
||
- `package.json` — `@colectivo/test-utils`; depends on `@supabase/supabase-js`, `jose`, `pg`
|
||
- `vitest.config.ts` — loads root `.env` via `loadEnv`; single-fork mode (no parallelism races)
|
||
- `src/seed-constants.ts` — fixed UUIDs for all 5 seed users + collective + seed list
|
||
- `src/supabase-clients.ts` — `createClientAs(userId)` signs HS256 JWT with `SUPABASE_JWT_SECRET`; `createAdminClient()` uses service role key
|
||
- `src/db-helpers.ts` — `sql()` via raw `pg` Pool for direct DB manipulation (bypasses RLS); used to set `deleted_at = 8 days ago` etc.
|
||
- `tests/rls-isolation.test.ts` — F-series: Eva (non-member) sees zero rows, all writes blocked
|
||
- `tests/rls-collective.test.ts` — B-series: collective read/update/invite by role
|
||
- `tests/rls-lists.test.ts` — C-series: list CRUD, soft-delete, trash window, cross-collective isolation
|
||
- `tests/rls-items.test.ts` — D-series: item CRUD by role, cross-collective item isolation
|
||
- `tests/rls-frequency.test.ts` — E-series: frequency read-only + trigger populates on INSERT
|
||
- `supabase/tests/*.sql` each start with `CREATE EXTENSION IF NOT EXISTS pgtap;` so they're idempotent on a fresh DB
|
||
- `001_accept_invitation.sql` — `accept_invitation()` happy path + not_found / expired / already_used; uses `set_config('request.jwt.claim.sub', …)` to set `auth.uid()` for the test session, and looks up by `token` (not `id`)
|
||
- `002_item_frequency_trigger.sql` — trigger normalizes name with `lower(trim(...))` and increments `use_count` on repeat INSERTs. RLS write-blocks are covered by the Vitest suite instead (postgres superuser bypasses RLS here)
|
||
- `003_promote_on_admin_leave.sql` — deletes a synthetic admin from `public.users` to fire the `BEFORE DELETE` trigger and asserts the oldest remaining member is promoted
|
||
- `apps/web/playwright.config.ts` — Playwright config; `globalSetup` is a Keycloak health check (not a session cache), `reuseExistingServer: true`
|
||
- `apps/web/tests/fixtures/users.ts` — seed user constants (Ana, Borja, Carmen, David, Eva)
|
||
- `apps/web/tests/fixtures/login.ts` — `loginAs(page, user)` runs the real OAuth flow (Keycloak → GoTrue PKCE) and waits for the session token to land in localStorage. Each test that needs auth calls this in `beforeEach`; cached `storageState` is NOT used (see gotcha #12)
|
||
- `apps/web/tests/e2e/auth.test.ts` — A-series: unauthenticated redirect, full login, collective visible in sidebar, sign-out, guest login
|
||
- `apps/web/tests/e2e/lists.test.ts` — C-series: create (with reload-persistence check), soft-delete → appears in trash drawer, archive, guest read-only
|
||
- `apps/web/tests/e2e/items.test.ts` — D-series: seed list renders, add item (with reload-persistence check), toggle check, desktop-hover delete, frequency suggestions, guest read-only
|
||
|
||
**Test commands:**
|
||
|
||
```bash
|
||
just test-all # run every suite (pgTAP + Vitest + Playwright) — requires just dev running
|
||
just test-integration # Vitest RLS tests (requires just dev stack running)
|
||
just test-db # pgTAP SQL tests (requires just dev stack running)
|
||
just test-e2e # Playwright E2E tests (requires just dev running)
|
||
just test-e2e-headed # Playwright with visible browser (debugging)
|
||
```
|
||
|
||
### Fase 2b — what has been built
|
||
|
||
- `supabase/migrations/007_realtime_publication.sql` — adds shopping_items + shopping_lists to `supabase_realtime` publication with `REPLICA IDENTITY FULL`
|
||
- `apps/web/src/lib/stores/realtimeSync.ts` — `subscribeToList(listId, onEvent)` postgres_changes subscription helper; `applyItemEvent` reducer for INSERT/UPDATE/DELETE
|
||
- `/lists/[id]/+page.svelte` — wired to realtimeSync with pendingTempIds echo-deduplication. Mutations use client-generated UUIDs so offline retries are idempotent (PK-conflict = success)
|
||
- `apps/web/src/lib/sync/queue.ts` — `SyncQueue` class (IDB-backed pending_ops, FIFO flush, MAX_ATTEMPTS retry, PK-conflict-as-success)
|
||
- `apps/web/src/lib/sync/index.ts` — app-wide SyncQueue singleton; `enqueueOp`, `hydrateSyncState`, auto-flush on `window.online`
|
||
- `apps/web/src/lib/stores/syncStatus.ts` — derived store: `offline | syncing | synced` from `navigator.onLine` + `pendingOpsCount`
|
||
- `apps/web/src/lib/components/SyncBanner.svelte` — renders the offline/syncing indicator; mounted in list detail + session views
|
||
- `apps/web/src/routes/(app)/lists/[id]/session/+page.svelte` — Modo Compra full-screen view (fixed positioning overlays the sidebar), 56px toggles, flip animation, confirm modal, completeList → redirect
|
||
- `apps/web/messages/*.json` — `sync_offline`, `sync_syncing`, `list_start_session`
|
||
- `packages/test-utils/src/realtime-helpers.ts` — `subscribePostgresChanges(client, opts)` with `waitFor(predicate, ms)` — awaits SUBSCRIBED handshake, dedups events, leaks nothing between tests
|
||
- Vitest unit harness in `apps/web/` — `vitest.config.ts` (jsdom env) + `vitest.setup.ts` (fake-indexeddb/auto) + `pnpm --filter @colectivo/web test:unit`
|
||
- Realtime config gotchas documented in `project_realtime_config` memory: tenant name, DB_USER=supabase_admin, `SEED_SELF_HOST: "true"`, pre-created `realtime` schema, publication + REPLICA IDENTITY FULL
|
||
|
||
### Fase 2a — what has been built
|
||
|
||
- `supabase/migrations/005_shopping.sql` — `shopping_lists`, `shopping_items`, RLS, `list_collective_id()` helper, pg_cron jobs (archive completed > 7d, purge deleted > 7d)
|
||
- `supabase/migrations/006_item_frequency.sql` — `item_frequency` table, `fn_record_item_frequency` trigger (SECURITY DEFINER, fires on every `shopping_items` INSERT)
|
||
- `apps/web/src/lib/stores/lists.ts` — shopping lists store (optimistic CRUD) + item operations + suggestion fetching
|
||
- `apps/web/src/lib/components/ItemSuggestions.svelte` — frequency chips with active-prefix highlighting
|
||
- `apps/web/src/routes/(app)/lists/+page.svelte` — overview: featured card + 2-col grid + completed section + trash view + sticky create input
|
||
- `apps/web/src/routes/(app)/lists/[id]/+page.svelte` — list detail: items with checkbox, qty stepper (−/N/+), inline edit, swipe-to-reveal-delete (touch) + hover delete (desktop), drag & drop reorder via `svelte-dnd-action`, sticky add form with `ItemSuggestions`
|
||
|
||
## Local Dev: Required /etc/hosts Entry
|
||
|
||
GoTrue (Supabase Auth) and the browser both must resolve `keycloak` to reach the Keycloak container. Add this line to `/etc/hosts` on the development machine:
|
||
|
||
```
|
||
127.0.0.1 keycloak
|
||
```
|
||
|
||
Without this, the OAuth redirect URL (`http://keycloak:8080/...`) built by Keycloak's discovery document is unreachable from the browser.
|
||
|
||
## Planned Stack
|
||
|
||
| Layer | Technology |
|
||
|---|---|
|
||
| Frontend / PWA | SvelteKit 2 + `@vite-pwa/sveltekit` + Workbox |
|
||
| Auth / IdP | Keycloak self-hosted (OIDC/OAuth2) |
|
||
| Backend / BaaS | Supabase self-hosted (GoTrue + Realtime + Storage + Kong) |
|
||
| Database | PostgreSQL 15 (via Supabase) |
|
||
| Offline storage | IndexedDB via `idb` |
|
||
| Monorepo | Turborepo + pnpm workspaces |
|
||
| Infra | Docker Compose (dev + prod) + nginx (prod, managed externally) |
|
||
| CI/CD | GitHub Actions |
|
||
|
||
## Planned Repository Layout
|
||
|
||
```
|
||
apps/web/src/lib/
|
||
supabase.ts # Supabase singleton client (PKCE, GoTrue auth)
|
||
auth.ts # login() / logout() via supabase.auth.signInWithOAuth (Keycloak provider)
|
||
stores/ # Svelte global stores
|
||
sync/ # offline queue + conflict resolution
|
||
components/ # reusable UI components
|
||
apps/web/src/routes/ # SvelteKit file-based routes
|
||
packages/types/src/
|
||
database.ts # types generated from Supabase schema (never edit manually)
|
||
domain.ts # domain types (Collective, ShoppingList, Item, etc.)
|
||
supabase/
|
||
migrations/ # versioned SQL migrations
|
||
seed.sql # dev test data (5 Keycloak users + sample collective)
|
||
functions/ # Edge Functions (invitations, etc.)
|
||
config.toml # Supabase CLI config + Keycloak OIDC
|
||
keycloak/
|
||
realm-export.json # "colectivo" realm — imported on dev startup
|
||
infra/
|
||
docker-compose.dev.yml
|
||
docker-compose.prod.yml
|
||
scripts/backup.sh / backup-cron.sh / restore.sh / deploy.sh
|
||
```
|
||
|
||
## Development Commands (Justfile)
|
||
|
||
```bash
|
||
just setup # first-time (and idempotent) local environment setup
|
||
just dev # start docker-compose.dev.yml + SvelteKit dev server
|
||
just serve # build + start prod Node server at :3000 against dev Docker stack
|
||
just stop # stop all services (Docker stack + Vite dev server)
|
||
just dev-stop # stop Docker stack only
|
||
just dev-clean # stop Docker stack and remove all volumes (full reset)
|
||
just db-reset # drop + migrate + seed (dev)
|
||
just db-migrate # apply pending migrations (dev)
|
||
just db-seed # apply supabase/seed.sql to the running dev db
|
||
just db-types # regenerate TS types from Supabase schema → packages/types
|
||
just kc-export # export Keycloak realm → keycloak/realm-export.json
|
||
just kc-open # open Keycloak Admin Console (localhost:8080)
|
||
just deploy # run infra/scripts/deploy.sh (prod)
|
||
just backup supabase # immediate manual backup
|
||
just restore supabase <file> # restore a specific dump
|
||
just logs # docker compose logs -f (prod)
|
||
```
|
||
|
||
## Local Dev Endpoints and Credentials
|
||
|
||
| Service | URL | Credentials (dev only) |
|
||
|---|---|---|
|
||
| SvelteKit app | http://localhost:5173 | — |
|
||
| Supabase Studio | http://localhost:54323 | — |
|
||
| Supabase API (Kong) | http://localhost:8001 | apikey: `PUBLIC_SUPABASE_ANON_KEY` from `.env` |
|
||
| Keycloak Admin | http://localhost:8080/admin | `admin` / `admin` |
|
||
| PostgreSQL | localhost:5432 | `postgres` / `postgres` |
|
||
|
||
**Dev secrets (set in `.env`, never commit to prod):**
|
||
|
||
| Variable | Dev value |
|
||
|---|---|
|
||
| `POSTGRES_PASSWORD` | `postgres` |
|
||
| `SUPABASE_JWT_SECRET` | `super-secret-jwt-token-with-at-least-32-characters-long` |
|
||
| `KEYCLOAK_ADMIN` / `KEYCLOAK_ADMIN_PASSWORD` | `admin` / `admin` |
|
||
| `KEYCLOAK_CLIENT_SECRET` | `gotrue-dev-secret` (client secret for `colectivo-web` in GoTrue) |
|
||
|
||
## Domain Model
|
||
|
||
The central organizing unit is the **Collective** (household group), not the individual user. All content belongs to the Collective.
|
||
|
||
**Roles:** `admin` (full CRUD + member management) | `member` (full CRUD on content) | `guest` (read-only)
|
||
|
||
**Core entities:** `Collective` → `CollectiveMember`, `ShoppingList` → `ShoppingItem`, `TaskList` → `Task`, `Note`, `CollectiveInvitation`
|
||
|
||
**Key field names (English):** `collective_id`, `list_id`, `is_checked`, `checked_by`, `checked_at`, `created_by`, `created_at`, `completed_at`, `sort_order`, `display_name`, `avatar_type`, `avatar_emoji`, `avatar_url`, `language`
|
||
|
||
**Key business rules:**
|
||
- Only one active shopping session per list at a time.
|
||
- Completing a list keeps items as history; it can be "reset" (uncheck all → back to active).
|
||
- Trash retains deleted items/notes for 7 days before permanent deletion.
|
||
- If the sole admin deletes their account, the system auto-promotes the oldest member before allowing deletion.
|
||
|
||
## Auth Architecture (Keycloak → GoTrue → Supabase)
|
||
|
||
**Strategy: Option B — GoTrue as OIDC proxy.**
|
||
The app authenticates with Keycloak (PKCE), then exchanges the Keycloak token with GoTrue for a Supabase JWT. Supabase RLS uses `auth.uid()` which resolves to the GoTrue user UUID (mapped from Keycloak `sub`). GoTrue maintains `auth.users`.
|
||
|
||
Key decisions:
|
||
- **Self-registration enabled** in Keycloak — users can create accounts on the Keycloak login screen.
|
||
- **Invitations are link-only** — no email sent. Admin generates a link and shares it manually.
|
||
- **Multiple collectives per user** — a user can belong to several collectives and switch between them in the sidebar.
|
||
|
||
Logout is a single call — `supabase.auth.signOut()` — which clears the GoTrue session. Keycloak's own SSO session persists (user won't be prompted for credentials on next login until the Keycloak session expires), which is acceptable for this app.
|
||
|
||
**Non-obvious integration requirements (all implemented; do not remove):**
|
||
|
||
1. **`colectivo-web` is a confidential client** — GoTrue needs a client secret to exchange codes with Keycloak. `GOTRUE_EXTERNAL_KEYCLOAK_SECRET` must match Keycloak's client secret (`gotrue-dev-secret` in dev).
|
||
|
||
2. **Custom `openid` client scope in Keycloak** — GoTrue v2.158.1 sends `scope=profile email` (no `openid`) to Keycloak, ignoring `GOTRUE_EXTERNAL_KEYCLOAK_SCOPES`. Without `openid` in the token scope, Keycloak's userinfo endpoint returns 401. Fix: a custom client scope named `openid` with `include.in.token.scope=true` is set as a default scope on `colectivo-web`, so Keycloak injects `openid` even when not requested.
|
||
|
||
3. **`GOTRUE_DISABLE_SIGNUP: "false"`** — GoTrue's signup flag blocks OIDC-based user creation too. Must be `false`; Keycloak is the gatekeeper for who can register.
|
||
|
||
4. **`auth.users` pre-seeded with Keycloak UUIDs** — GoTrue generates its own UUIDs on first login. `seed.sql` pre-inserts both `auth.users` (with `instance_id='00000000-0000-0000-0000-000000000000'` and empty-string token fields) and `auth.identities` (provider=keycloak, provider_id=Keycloak sub). This ensures `auth.uid()` = seed UUID = FK in all public tables.
|
||
|
||
5. **Kong env var substitution via `kong-start.sh`** — Kong 2.8 does not interpolate `${VAR}` in declarative config files. `infra/kong-start.sh` runs `perl -pe 's/\$\{(\w+)\}/$ENV{$1}/ge'` on `kong.yml` before starting Kong. Without this, Kong loads the literal string `${SUPABASE_ANON_KEY}` as the API key, rejecting all requests.
|
||
|
||
**SvelteKit + Supabase auth gotchas (do not remove these patterns):**
|
||
|
||
6. **Do not call `getSession()` in a SvelteKit `load` function to gate auth.** Supabase JS v2 initialises its session from localStorage asynchronously (`_recoverAndRefresh`). In a `load` function that runs immediately on mount, `getSession()` can return `null` for a valid session before initialisation completes — causing `login()` to fire and redirect to Keycloak unnecessarily. The correct pattern: `(app)/+layout.ts` only sets `ssr: false`, and `(app)/+layout.svelte` uses a `$effect` that redirects when `!$authLoading && !$isAuthenticated`. This waits for `onAuthStateChange` to fire (which is authoritative).
|
||
|
||
7. **`onAuthStateChange` fires `INITIAL_SESSION` on page load, not `SIGNED_IN`.** `SIGNED_IN` only fires immediately after a login flow. Load collective data whenever the session is present, regardless of event type — otherwise collectives are never loaded on page refresh. Pattern in root `+layout.svelte`: `if (session) { await loadUserCollectives(...) }` covering `INITIAL_SESSION`, `SIGNED_IN`, and `TOKEN_REFRESHED`.
|
||
|
||
9. **CSS custom properties use RGB triplets — always use `rgb()`, never `hsl()`.** All design tokens in `app.css` store values as space-separated RGB triplets (e.g. `51 65 85`). Using `hsl(var(--token))` interprets the first value as a hue degree — `hsl(51 65% 85%)` renders as light yellow, not slate-700. The Tailwind config and any `background-color`/`color` declarations in `app.css` must use `rgb(var(--token) / <alpha>)`.
|
||
|
||
11. **Supabase JS v2 + SvelteKit: `loadUserCollectives` (or any PostgREST query) inside `onAuthStateChange` must be deferred with `setTimeout(..., 0)`.** GoTrue's default `navigator.locks`-based auth lock is held while the `onAuthStateChange` callback runs. Any `supabase.from(...).select(...)` called inside that callback calls `getAccessToken() → getSession() → initializePromise → _acquireLock`, which is the same lock that's already held by the emit logic — the promise never resolves, the query hangs forever. Fix: schedule the query off the callback's microtask chain with `setTimeout(async () => { await loadUserCollectives(session.user.id); … }, 0)`. As a defensive extra, `$lib/supabase` passes a pass-through `auth.lock` option so the whole lock path is a no-op. Both are needed: without the defer, some browsers still deadlock; without the pass-through, others do. Removing either one breaks the Playwright E2E suite.
|
||
|
||
12. **Playwright auth: do not cache Supabase sessions via `storageState`. Do a live Keycloak login per test.** `browser.newContext({ storageState })` restores localStorage + cookies, but Supabase's `_recoverAndRefresh` does not reliably re-fire `INITIAL_SESSION` from a restored context — the page hydrates without triggering `onAuthStateChange`, so `currentUser` / `currentCollective` stay empty. The working pattern is `await loginAs(page, USERS.ana)` at the top of each test (see `tests/fixtures/login.ts`), which exercises the real OAuth flow and waits for the session token to land in localStorage before returning. Adds ~1s per test but is deterministic.
|
||
|
||
10. **PWA service worker: do not use `injectManifest` with a file named `service-worker.ts`.** SvelteKit intercepts any file at `src/service-worker.[jt]s` and enforces a hard restriction: only `$service-worker` and `$env/static/public` may be imported — Workbox modules are blocked. With `injectManifest`, `@vite-pwa/sveltekit` tries to find the compiled SW output at `.svelte-kit/output/client/service-worker.js`, but SvelteKit never produces it (compilation fails on the blocked imports). Fix for Fase 1–2a: use `generateSW` strategy (plugin auto-generates the SW, no custom source needed). Fix for Fase 2b when a custom SW is needed: name the file `src/sw.ts` — SvelteKit does not recognise it as a service worker — and set `strategies: 'injectManifest'`, `filename: 'sw.ts'` in `vite.config.ts`.
|
||
|
||
## Sync Strategy
|
||
|
||
| Content | Mechanism | Target latency |
|
||
|---|---|---|
|
||
| Items in active shopping session | WebSocket (Supabase Realtime) | < 1 second |
|
||
| Lists, tasks (outside session) | SSE / polling | < 5 seconds |
|
||
| Notes | Polling | < 30 seconds |
|
||
|
||
**Offline-first:** all operations execute locally first (IndexedDB), then sync in background. Conflict resolution is last-write-wins. Conflicts are logged to a `sync_conflicts` table for debugging — no CRDT in MVP.
|
||
|
||
## Critical Platform Gotchas
|
||
|
||
**iOS Safari / PWA:**
|
||
- The app uses Supabase OAuth PKCE flow (`signInWithOAuth({ provider: 'keycloak' })`). No keycloak-js, no iframe-based silent refresh — Safari-compatible by design.
|
||
- GoTrue session is persisted in `localStorage` and auto-refreshed by the Supabase client. No manual `updateToken()` needed.
|
||
- Background Sync API is not supported in Safari. Implement a manual fallback using the `online` event.
|
||
- Push notifications require iOS 16.4+ and the PWA must be installed to the home screen.
|
||
- Test the shopping session mode on a real iPhone during development, not only in DevTools.
|
||
|
||
**nginx (prod) — WebSocket for Realtime:**
|
||
- The `/realtime/` location block **must come before** the `/` block.
|
||
- `proxy_read_timeout 3600s` is required on the Realtime block — without it, nginx closes WebSocket connections after 60 seconds, forcing continuous reconnects during active shopping sessions.
|
||
- CSP `connect-src` must include both `https://` and `wss://` for the API domain.
|
||
|
||
**Keycloak behind nginx:**
|
||
- `X-Forwarded-Host` and `X-Forwarded-Port` headers are mandatory. Without them, Keycloak builds redirect URIs with the internal port (8080) instead of 443, breaking the OIDC flow.
|
||
|
||
**Supabase Realtime self-hosted:**
|
||
- Check `MAX_REPLICATION_SLOTS` in PostgreSQL and `REALTIME_MAX_CONNECTIONS` before going to production.
|
||
- Presence subscriptions are more resource-intensive than Postgres Changes — limit them to lists with an active session.
|
||
|
||
## Dev Test Users
|
||
|
||
Defined in `keycloak/realm-export.json` and `supabase/seed.sql`. All use password `test1234`.
|
||
|
||
| User | Email | Role in test collective |
|
||
|---|---|---|
|
||
| `ana` | ana@dev.local | Admin |
|
||
| `borja` | borja@dev.local | Member |
|
||
| `carmen` | carmen@dev.local | Member |
|
||
| `david` | david@dev.local | Guest |
|
||
| `eva` | eva@dev.local | (no collective — for onboarding testing) |
|
||
|
||
## Internationalisation
|
||
|
||
Library: **Paraglide JS** (`@inlang/paraglide-sveltekit`). Compile-time, zero runtime overhead, tree-shaken per language.
|
||
|
||
- Message files: `messages/en.json` (default) and `messages/es.json`
|
||
- Language detection order: `users.language` (if logged in) → `Accept-Language` header → `en`
|
||
- Language switching: update `users.language` in Supabase + call `setLanguageTag()` — no page reload
|
||
- **Never hardcode visible user-facing strings in components.** All UI text goes through Paraglide messages.
|
||
|
||
Supported languages: `en`, `es`.
|
||
|
||
## TypeScript Types
|
||
|
||
`packages/types/src/database.ts` is **generated** by `just db-types` (`supabase gen types typescript`). Never edit it manually. Domain-level types go in `packages/types/src/domain.ts`.
|