Adds JSONB `feature_flags` columns on public.users and public.collectives
(default '{}') plus two SQL helpers:
* public.known_sections() — IMMUTABLE, returns the canonical 4 sections
(lists, tasks, notes, search). Adding a section is a fresh migration.
* public.section_enabled(section, user, collective) — STABLE, returns
boolean. Precedence is COLLECTIVE > USER > default TRUE, evaluated as
a single COALESCE so Fase 13 can prepend a server layer as a one-line
diff. Unknown section keys default TRUE (forward-compat).
No new RLS policy: writes to feature_flags piggyback on users_update_own
and collectives_update (migration 003). 16 new pgTAP assertions verify
column shape, function signatures, volatility, and precedence semantics.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Migration 022 adds two new tables for collective-scoped item tags plus the
many-to-many bridge to shopping_items. Tags carry a colour from an 8-preset
palette enforced by CHECK. RLS uses is_member for SELECT (guests can see)
and is_active_member for writes (guests are read-only). A new STABLE helper
item_collective_id(uuid) resolves the parent collective in a single function
call so the shopping_item_tags policies do not chain two STABLE lookups.
Both tables join the supabase_realtime publication with REPLICA IDENTITY
FULL, matching the shape of 007 for shopping_items / shopping_lists.
pgTAP 014 covers schema invariants (publication, replica identity, unique
+ CHECK constraints, cascade behaviour for both tag delete and collective
delete). Per-role RLS semantics will be covered by the Vitest integration
suite in the next commit — pgTAP runs as postgres which bypasses RLS.
Types: ItemTag, ItemWithTags + ItemTagColor in domain.ts; item_tags,
shopping_item_tags, item_collective_id added by hand to the curated
database.ts (the supabase CLI is not installed locally; just db-types is
gated and skips with a notice).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Migration 019 normalizes every \`created_by\` / \`updated_by\` FK from
\`NOT NULL REFERENCES users(id) ON DELETE RESTRICT\` to
\`NULL REFERENCES users(id) ON DELETE SET NULL\`. Affected tables:
collectives, collective_invitations, shopping_lists, shopping_items,
task_lists, tasks, notes (created_by + updated_by). Without this,
delete_account() would be blocked the first time the user creates any
content. Spec §6.3: content survives the user; the row is orphaned with
the FK set to NULL (UI shows "Deleted user" — to be polished later).
Migration 021 adds delete_account():
- two-step delete: public.users (fires the existing promote-oldest-
admin trigger from migration 002, then CASCADEs collective_members
and SET-NULLs content FKs), then auth.users (CASCADEs sync_conflicts
+ drops the GoTrue identity row + token rows)
- guard: errcode P0003 when the caller is the sole admin of a
collective where every other member is a guest (nobody promotable);
the user must promote someone manually first
- Keycloak is NOT touched (documented limitation in the UI body); the
user can re-register with the same email and will receive a fresh
UUID-distinct profile
Settings UI (already shipped with 10.1) wires the modal: explicit body
listing what is and isn't deleted; confirm button only enables when the
user types DELETE exactly. Post-success calls logout() so the Keycloak
SSO cookie is ended too.
pgTAP 013 (8 assertions): RPC exists, regular user delete + cascade +
content-orphan, sole-admin-with-promotable promotes, sole-admin-with-
only-guest blocked with P0003. Playwright DEL-01 covers the
type-the-word UI guard; full "delete + re-login fails" is left to
pgTAP because seeding ephemeral Keycloak accounts in E2E would
contaminate every downstream suite.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Migration 020 (019 reserved per plan) introduces two SECURITY DEFINER
RPCs that back the trash drawer's per-row actions:
- restore_list(uuid): clears deleted_at; requires caller to be admin
or member of the owning collective; rejects if the list is not
currently in trash (defensive)
- hard_delete_list(uuid): permanently deletes a soft-deleted list;
same role guard; rejects if the list is still active
Both close the existing UI gap where guest could in principle issue
the equivalent UPDATE (the FROM-UPDATE policy is is_active_member,
which excludes guest, but the surface area is broader than these two
intents — the RPCs make the contract explicit).
The lists drawer already exposed the Restore + Delete-for-ever
buttons; only the store calls swap from chained PostgREST writes to
.rpc() — the UI itself is unchanged.
pgTAP 012 (8 assertions) covers existence, restore happy path,
non-member rejection, hard-delete happy path, active-list guard.
Integration T-10..T-14 (5 vitest) verifies the wire format the store
relies on (rpc errors, row state, active-listing visibility).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
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.
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>