Migration 018 adds public.dissolve_collective(uuid):
- admin-only; non-admin (member/guest/non-member) → errcode P0002
- single DELETE from public.collectives; all child tables cascade
(collective_members, collective_invitations, shopping_lists →
shopping_items, task_lists → tasks, notes, item_frequency,
sync_conflicts — verified by audit, no schema change needed)
/collective/manage gains a "Danger zone" card (admin-only) with a
Dissolve button. The confirmation modal loads live counts of lists,
tasks and notes, displays them in the warning, and requires the user
to type the collective name exactly before the confirm button is
enabled. On success, locals stores drop the collective and the user
is sent to /lists (next collective) or /onboarding (none left).
pgTAP 011 (8 assertions): RPC exists, member/guest/non-member all
rejected with P0002, admin succeeds, collective row gone, child
list cascade-deleted, members cascade-deleted. Playwright D-01..D-03
cover the happy path, the type-the-name guard, and member visibility.
Auxiliary collective fixture (NOT the seed) per test — the seed
collective must survive every run for downstream suites.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Migration 017 adds public.leave_collective(uuid) with three branches:
- normal member: remove membership row
- sole admin with other members: promote oldest-joined remaining member
to admin (inline; the existing user-delete trigger does not cover the
membership-delete path), then remove the row
- sole member: reject with errcode P0001 so the UI can direct the user
to the dissolve flow (CU-H08)
Settings page gains a "Your collectives" section listing the user's
memberships with a per-row Leave button; the confirmation modal calls
the RPC, drops the collective from local stores, and either switches
to the next collective or sends the user to /onboarding when none
remain. Also seeds the Danger zone scaffolding for Fase 10.5 and adds
all message keys consumed by 10.1, 10.3, 10.5 and 10.6.
pgTAP 010 (7 assertions): member-leave, sole-admin-leave + auto-promote,
sole-member rejected with P0001. Playwright L-01 walks Borja through
the UI flow + checks the seed collective survives (content stays).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes 9.3 in full: migration 016 + pgTAP test 010 + queue.ts
conflict-detection path + /settings debug panel + integration test.
- Migration 016 adds public.sync_conflicts (append-only audit log of
last-write-wins conflicts surfaced by the offline mutation queue).
RLS allows owners to SELECT + INSERT their own rows only; UPDATE
and DELETE have no policy and are silently denied. collective_id
is intentionally nullable so user-level conflicts (theme, language
in future) also fit. Indexed on (user_id, created_at desc) for the
"show me my last 20 conflicts" query.
- queue.ts gains a `QueueContext` (currentUserId + collectiveId) and
an optional `preImage` field on UpdateOp. Before sending an UPDATE
the queue fetches the remote row (limited to the patched fields),
proceeds with the UPDATE (last-write-wins), then fires a best-effort
INSERT into sync_conflicts when the remote diverged. Failure to log
never blocks the UPDATE or pollutes the pending_ops queue. Three
unit specs (SC-01..SC-03) lock the contract in.
- sync/index.ts exposes setSyncContext(), called from (app)/+layout's
$effect whenever currentUser / currentCollective change.
- New SyncConflictsPanel.svelte renders the last 20 conflict rows
for the current user with an "Export JSON" button. Gated on
import.meta.env.DEV (or a forceShow prop for a future secret
unlock) — never ships in the production bundle. Slotted into
/settings just above the Account section.
- Integration tests (packages/test-utils/tests/sync-conflicts.test.ts,
4 SC-INT specs) exercise the real PostgREST + RLS contract end-to-end
with the existing seed users (Ana, Borja). Requires fake-indexeddb in
the test-utils dev deps (queue's IDB shim).
- pgTAP test 010 covers structure, RLS reads/writes, ownership rejection,
and the append-only invariant (UPDATE/DELETE return 0 rows).
- packages/types/src/database.ts adds the sync_conflicts table type.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds `theme text not null default 'system' check (theme in
('light','dark','system'))` to public.users. New users get 'system' so
the UI follows the OS preference until they opt in; existing rows
backfill via the default.
Includes pgTAP test 009 (8 assertions covering column existence/type,
default value, valid transitions, and the check constraint rejecting
unknown values). Wires the theme field into the hand-curated
database.ts type so PostgREST queries stay typed.
Also hardens `just db-types` to bail out (instead of truncating
database.ts) when the supabase CLI is missing — the recipe was silently
wiping the file on this dev machine.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Clicking logout previously only called supabase.auth.signOut(): Keycloak's
SSO cookie was untouched, so the (app) layout's $effect immediately
re-fired login() and Keycloak silently re-authenticated — logout looked
broken. That same chain also surfaced "PKCE code verifier not found in
storage" on prod, since signOut()'s async storage clear could race
signInWithOAuth()'s verifier write, or the $effect could double-fire and
overwrite verifiers mid-flight.
- logout(): signOut() then redirect to Keycloak's end_session endpoint
(client_id + post_logout_redirect_uri=/logged-out). Keycloak shows a
one-click "Do you want to log out?" confirmation because GoTrue's proxy
flow does not expose the id_token, so we can't pass id_token_hint.
- isLoggingOut flag + guard in (app) layout $effect: prevents auto-relogin
during the logout navigation.
- New public /logged-out route, outside the (app) group.
- A-05 rewritten, new A-07 (fresh login must prompt for credentials after
RP-initiated logout).
- pgTAP 008 extended with 4 assertions for migration 014's UPDATE trigger.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GoTrue v2.158.1 emits an UPDATE right after inserting an OIDC-provisioned
user (pop ORM resends the full struct incl. the zero-value role=''),
which bypassed migration 013's BEFORE INSERT trigger and left prod users
with role='' — breaking every PostgREST call with `role "" does not exist`.
Add a matching BEFORE UPDATE trigger reusing the same guard function, and
re-backfill the rows already clobbered.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Found on ambrosio after the first real self-registration: GoTrue v2.158.1
persists OIDC-provisioned users with empty-string role and aud. PostgREST
then dies with `role "" does not exist` on every REST call because its
per-request `SET LOCAL role = $claim` can't resolve ''. Dev never caught
this because seed.sql hardcodes both columns.
- supabase/migrations/013_auth_users_role_default.sql: backfill existing
rows + BEFORE INSERT trigger on auth.users that fills empty/NULL role +
aud with 'authenticated'. Idempotent, preserves explicit values.
- supabase/tests/008_auth_user_role_default.sql: pgTAP coverage — function
+ trigger exist, backfill left nothing empty, empty-string and NULL
values get defaulted, explicit non-empty role is preserved.
- CLAUDE.md: status line updated to 255 tests (was 248), new gotcha #19
documenting the GoTrue quirk + that users in existing sessions must
re-login once for a fresh JWT after the fix lands on any given env.
Total: 41 pgTAP (was 34) + 140 integration + 15 unit + 58 Playwright + 1
gated rate-limit. Full `just test-all` green.
12 new Playwright tests closing the UI coverage gap in the collective
lifecycle (onboarding → invitation → admin manage). Writing them surfaced
three product-code bugs that have silently been present since Fase 1;
fixed as part of this phase.
Tests:
- tests/e2e/onboarding.test.ts O-01..O-03 (Eva auto-redirect, happy
path create, empty-name submit-disabled)
- tests/e2e/invitation.test.ts I-01..I-05 (token generation, accept
logged-out + logged-in, expired, used)
- tests/e2e/manage-collective.test.ts MC-02..MC-05 (promote, demote, remove
member, generate pending invite)
- tests/fixtures/db.ts resetEva, seedExpiredInvitation,
seedUsedInvitation, restoreSeedMembership,
countMembership — all via raw SQL so they
don't depend on SUPABASE_SERVICE_ROLE_KEY
Bugs found & fixed:
- supabase/migrations/012_create_collective_rpc.sql: atomic
`create_collective(name, emoji)` SECURITY DEFINER RPC. Fase 1 onboarding
used `.insert(...).select().single()` which triggers the SELECT policy on
the fresh row — creator isn't a member yet, so the INSERT was rejected with
"row-level security" even though the INSERT policy passed. onboarding now
calls the RPC which inserts both the collective AND the creator-as-admin
membership in one transaction, bypassing RLS. F-08 in rls-isolation.test.ts
has been silently masking this bug (only the spoof branch was asserted).
- routes/+layout.svelte: post-login redirect now reads sessionStorage
`pendingInvitationToken` and routes back to /invitation/<token> instead of
defaulting to /onboarding or /lists. The invitation page already stashed
the token before kicking off Keycloak, but nothing read it back.
- routes/(app)/collective/manage/+page.svelte: loadMembers now subscribes
to currentCollective so it re-runs when the store resolves after cold
navigation. onMount alone races with the auth listener's first emit and
the member list stayed empty on direct URL hits.
- routes/invitation/[token]/+page.svelte: awaits authLoading before deciding
whether to redirect to login. Previously raced with the auth listener and
triggered a redundant Keycloak round-trip for freshly-authenticated users.
Stable selectors added (Playwright):
- Onboarding: onboarding-create-tab, onboarding-join-tab,
collective-name-input, collective-submit
- Manage: member-row-<uid>, role-select-<uid>, remove-member-<uid>,
generate-invite, invite-link
- Invitation: invitation-accept, invitation-error (+ data-error-key attr)
Scope dropped from the plan — UI does not exist, would be feature work:
- O-04: "+ new collective" affordance in the sidebar.
- MC-01: rename-collective input in /collective/manage.
Both flagged as follow-ups in plan/fase-7-collective-flow-tests.md.
Test totals: 34 pgTAP + 140 Vitest integration + 15 Vitest unit + 58
Playwright + 1 gated rate-limit. 3 skipped (2 Realtime presence, 1 gated).
The unit field had no edit path under the redesign (only name + qty
remain in the row; the double-tap overlay is name-only). Users can encode
the unit inside the name when it matters — e.g. "Milk 1L", "Eggs 1 doz".
Changes
supabase/migrations/011_drop_shopping_items_unit.sql — DROP COLUMN
supabase/seed.sql — unit values folded into names where relevant
supabase/tests/002_item_frequency_trigger.sql — remove unit from INSERT
packages/types — remove unit from ShoppingItem Row/Insert/Update
apps/web/src/lib/stores/lists.ts — addItem/updateItem signatures
apps/web/src/routes/(app)/lists/[id]/+page.svelte — purge newUnit /
editUnit state + inputs + display
apps/web/src/routes/(app)/lists/[id]/session/+page.svelte — simplify
quantity-only render
apps/web/messages/{en,es}.json — remove list_unit_label
Verification
just test-db → 34 pgTAP green
just test-integration → 140 + 2 skipped
just test-unit → 11 green
just test-e2e → 39 + 2 skipped
The generated search tsvector on shopping_items.search already indexes
only `name`, so full-text search coverage is unchanged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Enable postgres_changes broadcasts on shopping_items / shopping_lists and prove
the path end-to-end with Vitest Realtime tests and a pgTAP configuration check.
Infra changes that were silently blocking events
- Migration 007: shopping_items + shopping_lists added to supabase_realtime
publication with REPLICA IDENTITY FULL (UPDATE/DELETE payloads need the full
row so list_id-based filters work and RLS can evaluate DELETE against OLD)
- Realtime service: DB_USER=supabase_admin (superuser; supabase_replication_admin
lacks CREATE on the `realtime` schema that the service auto-creates per tenant)
- Realtime service: SELF_HOST_TENANT_NAME=realtime so the seed tenant name
matches the default supabase-js resolves for localhost URLs (was realtime-dev
→ TenantNotFound)
Tests
- pgTAP 004_realtime_publication.sql — P-01..P-04: publication membership and
REPLICA IDENTITY FULL for both shopping tables
- Vitest realtime-postgres-changes.test.ts — R-01 INSERT broadcast,
R-02 UPDATE carries full row, R-03 list_id filter isolates events
- test-utils realtime-helpers.ts — subscribePostgresChanges() returns a
waitFor(predicate, timeout) helper that captures the SUBSCRIBED handshake
before returning so mutations issued right after are not lost
- createClientAs now calls realtime.setAuth(token) so the server evaluates
RLS against the test user's JWT
- Justfile test-db now globs supabase/tests/*.sql so new pgTAP files are picked
up automatically
Totals: 54→57 Vitest, 12→16 pgTAP, 15 Playwright = 88 tests green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a 4-layer test stack covering RLS, triggers, and UI flows for Fases 0–2a,
then restructure every plan file so future fases start with tests and end with
a verification gate.
Test suite
- packages/test-utils: Vitest integration tests signing HS256 JWTs via jose so
each test acts as a specific seed user (createClientAs + createAdminClient)
- supabase/tests: pgTAP for accept_invitation(), item_frequency trigger, and
promote-on-admin-leave; each file self-installs pgtap extension
- apps/web/tests: Playwright E2E with live Keycloak login per test (storageState
caching doesn't rehydrate Supabase's session state reliably)
- just test-all chains the three suites; test-db forwards POSTGRES_PASSWORD
as PGPASSWORD with ON_ERROR_STOP=1 so failures abort the chain
Supabase auth gotcha
- PostgREST queries inside onAuthStateChange deadlock on GoTrue's navigator.locks
auth lock (getAccessToken → getSession → initializePromise waits on the same
lock that's held during event dispatch). Fix is two defenses: a pass-through
lock in $lib/supabase, and a setTimeout(0) defer in root +layout.svelte to
push loadUserCollectives out of the callback's microtask chain. Either alone
is insufficient; both together unblock the Playwright suite.
Env key rotation
- apps/web/.env.development had a stale demo anon key signed with a different
secret than root .env; Vite inlined that into the browser bundle so Kong (which
uses the root .env value) rejected every request with 401. Aligned the two
files and added a memory entry to flag this for the next rotation.
Plan restructure (TDD)
- Every fase now opens with X.0 Tests primero and closes with X.Z Verificación
final. Completed fases (0, 1, 2a) show the pattern retroactively with the
tests that currently cover them; pending fases (2b, 3, 4) list the tests to
write before implementation.
Docs
- CLAUDE.md status line reports 54 + 12 + 15 = 81 green tests, adds gotchas
#11 (auth-lock deadlock) and #12 (no storageState caching)
- README.md adds a TDD methodology section and the test-all command
- .gitignore excludes Playwright's generated reports and auth state
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Auth: `getSession()` in a SvelteKit load function races with Supabase's async
localStorage init and can return null for a valid session, triggering a spurious
login() redirect. Moved auth redirect to (app)/+layout.svelte via $effect, which
correctly waits for onAuthStateChange to fire. Also fixed collective data never
loading on page refresh (INITIAL_SESSION event, not SIGNED_IN).
PWA: SvelteKit blocks Workbox imports in src/service-worker.ts, preventing
@vite-pwa/sveltekit from finding the compiled SW output. Switched to generateSW
strategy (plugin auto-generates the SW). Custom SW deferred to Fase 2b using
src/sw.ts (a filename SvelteKit does not intercept).
Docs: added gotchas 6–8 to CLAUDE.md covering both root causes so they are not
reintroduced. Updated plan/fase-2b with the sw.ts workaround note.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>