Generated: 2026-06-11 · Updated: 2026-09-30 (full rescan / deep — competitions agenda, audience counters, inbox rate limits, storage hardening) · Source of truth:
scripts/wakeref_schema.sql(reference dump) andscripts/wakeref_post_restore.sql(executable: views, functions, RLS, grants, triggers, bucket). One-off changes applied to the live DB are traced inscripts/migrations/0001…0026.
WakeRef stores all data in a single Supabase PostgreSQL database (public schema). There is no ORM and no migration framework — schema changes are applied by hand in the Supabase SQL Editor, recorded as a numbered file under scripts/migrations/, and mirrored into the two SQL files above. The frontend talks to the database directly through PostgREST (the @supabase/supabase-js client), so the RLS policies, grants, the two views and the RPC functions are the API contract (see api-contracts.md).
The live DB is the source of truth, not the SQL files. Content seeds (tips, descriptions, aliases, contexts — migrations 0009, 0011–0015) were one-off operations; the files are their trace.
Three families: the trick reference, the competitions agenda, and the judging tools — plus inboxes and audience counters that serve all three.
| Table | Family | Purpose | anon read |
anon write |
|---|---|---|---|---|
categories |
reference | 14 fixed trick categories with color + sort order | ✅ all rows | ❌ |
figures |
reference | Trick entries (core entity) | ✅ published = true |
❌ |
prerequisites |
reference | Many-to-many self-join on figures |
✅ all rows | ❌ |
videos |
reference | Video references (Storage uploads or external links) | ✅ takedown_requested = false |
❌ |
competitions |
agenda | Public agenda of cable competitions | ✅ published = true |
❌ |
competition_videos |
agenda | Ordered video links of a competition | ✅ if parent competition is published | ❌ |
video_submissions |
inbox | Public "suggest a video" box | ❌ | ✅ insert (column-scoped, rate-limited) |
takedown_requests |
inbox | Copyright removal requests | ❌ | ✅ insert (column-scoped, rate-limited) |
competition_submissions |
inbox | Public "a competition is missing" box | ❌ | ✅ insert (column-scoped, rate-limited) |
compositions |
engagement | Saved runs from the Compo page | ❌ (one row by id via RPC) | ✅ insert (rate-limited) |
figure_views |
audience | Per-figure, per-day view counter | ❌ | via track_figure_view only |
page_routes |
audience | Whitelist of counted non-trick page patterns | ✅ all rows | ❌ |
page_views |
audience | Per-route-pattern, per-day view counter | ❌ | via track_page_view only |
competition_views |
audience | Per-competition, per-day view counter | ❌ | via track_competition_view only |
judge_runs |
judging | Reference runs + official solution for /entrainement-juge |
❌ (2 RPCs) | ❌ |
parcours |
judging | Competition courses shared with judges by short code | ❌ (get_parcours) |
❌ |
All admin mutations run as the authenticated role (a single account).
sport_type—wakeboard|wakeskate|seated(figures.sport,videos.sport,judge_runs.discipline).seated= wakeboard assis / handiwake; a first-class discipline, never a label bolted onto a name.video_source—videos.source_type,judge_runs.source_type; defaultupload. The front end detects YouTube/Instagram from the URL first (src/lib/videoSource.js) and only falls back to this column when the URL won't parse.
| Column | Type | Notes |
|---|---|---|
id |
integer PK |
sequence-backed |
name |
text |
UNIQUE |
slug |
text |
UNIQUE — used in /figures?cat=<slug> |
color |
text |
hex; no longer rendered — the "papier" design removed category colors (ink + magenta only) |
sort_order |
integer |
default 0 |
Mirrored client-side in src/data/categories.js (adds icons). Keep the two in sync when adding a category.
| Column | Type | Notes |
|---|---|---|
id |
integer PK |
|
slug |
text UNIQUE |
/figures/:slug; also referenced by value in SCORING_SLUGS (lib/compoGrids.js) |
name |
text |
|
category_id |
integer FK → categories |
|
sport |
sport_type |
native discipline, default wakeboard |
sports |
text[] NOT NULL |
multi-discipline membership, always ⊇ {sport} |
difficulty |
smallint |
CHECK 1–5 |
description, description_en |
text |
bilingual |
tips, tips_en |
text[] |
bilingual |
tips_seated, tips_seated_en, tips_wakeskate, tips_wakeskate_en |
text[] |
per-discipline override; empty ⇒ falls back to tips |
is_switch, switch_of |
boolean, FK → figures |
switch variant → base trick |
built_on_id |
FK → figures |
"built on" parent; must target the base of a switch group, never a switch; kept acyclic by a trigger |
published |
boolean |
default true; RLS hides unpublished rows from anon |
contexts |
text[] |
kicker · feature · flat · air_trick (src/data/contexts.js); corrected in bulk by migration 0012 |
approach |
text[] |
entry stance — hs/ts standing, regular/fakie seated (no CHECK) |
rotation |
text[] |
fs / bs |
inverted, rewind |
boolean |
|
spin, inverts |
smallint |
degrees / flip count (default 0) |
rewind_degs |
smallint[] |
per-rewind amplitudes |
rotation_type |
text[] |
CHECK ⊆ {ole, handle_pass} |
aliases |
text[] NOT NULL default {} |
spoken/written aliases for the voice matcher (scripts/gen-aliases.mjs) |
created_at, updated_at |
timestamptz |
updated_at via trigger |
Switch groups (coalesce(switch_of, id)) share videos and prerequisites. Built-on tree: the view exposes parent, direct children and recursive root, all resolved from the base of the switch group. Approach axis has no DB CHECK — the valid set lives in admin/FigureForm.jsx and FigureDetail.jsx; add any new value in both. Seated naming: default implicit (FS 180 = regular), variant prefixed (Fakie FS 180), never "Seated …".
Composite PK (figure_id, requires_id), both FK → figures. "To land figure_id, first know requires_id."
| Column | Type | Notes |
|---|---|---|
id |
integer PK |
|
figure_id |
FK → figures, NOT NULL |
(why competitions got their own table) |
title, caption |
text |
|
file_path |
text |
path in the videos bucket for uploads |
source_type |
video_source |
fallback only — see above |
source_url |
text |
external link |
creator_name, creator_url |
text |
attribution |
sport |
sport_type |
discipline shown in the clip (migration 0003) |
performer_gender |
text |
CHECK ∈ {man, woman, other} — drives the quiz's gender parity (migration 0003) |
takedown_requested |
boolean |
true hides the row everywhere |
takedown_email, takedown_at |
set when a takedown is processed | |
sort_order |
integer |
ordering within a figure (surfaced by figures_full) |
uploaded_at |
timestamptz |
drives recent_video_figures |
Thumbnails live by convention at thumbnails/<shortcode>.jpg in the bucket. For Instagram (no public thumbnail) the admin drops one through VideoThumbField, shrunk in the browser by lib/compressImage.js; the path is shared by figure and competition videos.
Design frozen on 2026-09-06 (_bmad-output/brainstorming/brainstorming-session-2026-09-06-19-13.md). Unrelated to the judging tools.
| Column | Type | Notes |
|---|---|---|
id |
bigint identity PK |
what idFromParam reads from /competitions/:idSlug — the slug is decorative |
name |
text NOT NULL |
1–160 chars |
date_start |
date NOT NULL |
|
date_end |
date |
null = one-day event; CHECK date_end >= date_start |
date_precision |
text |
day | year. For year, date_start holds Dec 31st (CHECK competitions_year_is_dec31, date_end null) — a storage artefact that must never reach the screen |
wakepark |
text |
≤ 160 — proper noun, no _en |
affiliation |
text |
federal | independent (default) |
tour_name |
text |
≤ 160 — free text; a tour is not an entity. Its logo (competitions/tours/<slug>.png) and page (/competitions/circuit/<slug>) are derived from slugify(tour_name) |
tour_url, info_url, entry_url, live_video_url, live_scoring_url, organiser_instagram_url, wakepark_url |
text |
≤ 500 each; CHECK competitions_urls_http forces ^https?:// (blocks javascript:, which type="url" lets through) |
poster_path, logo_path |
text |
Storage paths competitions/<uuid>-affiche.<ext> or …-logo.<ext> (UUID names since 2026-09-13, so a draft's file can't be guessed) |
cancelled |
boolean |
the single editorial flag |
published |
boolean |
default true; anon sees published rows only |
created_at, updated_at |
timestamptz |
trigger competitions_updated_at |
Deliberately absent: no status column (past / live / upcoming are derived from dates by competitionState()), no discipline (implicit default = all disciplines), no translatable prose, hence no _en columns. Index competitions_date_start_idx (date_start desc).
| Column | Type | Notes |
|---|---|---|
id |
bigint identity PK |
|
competition_id |
FK → competitions on delete cascade |
|
url |
text NOT NULL |
1–500, ^https?:// |
title |
text |
≤ 160 |
sort_order |
integer |
index (competition_id, sort_order) |
created_at |
timestamptz |
A dedicated table (not JSONB) because rows are ordered and individually edited. CompetitionForm saves them by wholesale delete-then-insert without a transaction — it validates first, but a network cut between the two statements leaves the list empty (known, accepted while a single admin edits).
| Column | Type | Notes |
|---|---|---|
id |
bigint identity PK |
|
name |
text NOT NULL |
2–160 |
date_text |
text NOT NULL |
2–80, free text ("en juin", "été 2027") |
url |
text |
≤ 500, ^https?:// |
status |
text |
pending (default) | handled | rejected |
created_at |
timestamptz |
indexed desc |
Deliberately not a draft competitions row: a submission is raw input, a competition is editorial content. Anon may insert only name, date_text, url. Each insert fires the notify-competition-submission Edge Function via a DB webhook.
figure_id (FK, NOT NULL), source_url (NOT NULL), title, creator_name, creator_url, caption, status (pending | approved | rejected), submitted_at (indexed desc). Anon INSERT is granted per column (the six content fields), so a client can neither back-date a row nor insert it pre-approved. Each insert fires notify-video-submission.
video_id (FK → videos), name, email (NOT NULL), message, handled (default false), created_at (indexed desc). Anon INSERT granted on video_id, name, email, message only.
| Column | Type | Notes |
|---|---|---|
id |
text PK |
short id in /composition/:id |
name |
text |
≤ 80 |
data |
jsonb NOT NULL |
run snapshot incl. gridKey; ≤ 50 KB |
score |
integer |
denormalized, normalized to /20 |
created_at |
timestamptz |
| Table | Trigger | Cap | Shape |
|---|---|---|---|
compositions |
compositions_rate_limit |
20 / min | BEFORE INSERT, per row |
competition_submissions |
competition_submissions_rate_limit |
10 / min and 60 / day | AFTER INSERT FOR EACH STATEMENT (a row trigger doesn't see rows of the same batch insert) |
video_submissions |
video_submissions_rate_limit |
10 / min and 60 / day | idem (migration 0026) |
takedown_requests |
takedown_requests_rate_limit |
10 / min and 60 / day | idem (migration 0026) |
All security definer, raising errcode = 'PT429' with a French message the forms display. The cap is global, not per submitter — Postgres cannot see the IP — so it protects the admin's inbox, not the form's availability.
Three home-made counters, no identifier, no IP, no cookie (nothing to consent to): they count views, not visitors, and include JS-executing bots. Dedup is client-side, once per browser per day (src/lib/vuDuJour.js, one localStorage key per counter). Never purged (2026-06-16 decision).
Composite PK (figure_id, day), views integer. FK on delete cascade. Written by track_figure_view (ignores unknown ids), read by most_viewed_figures (home) and the four admin stats RPCs. Index on day.
page_routes(path PK, label)— the whitelist of counted route patterns, seeded inpost_restore(17 rows). It stores patterns, not URLs (/composition/:id, never/composition/a1b2c3).track_page_viewis open toanon; without the whitelist any string could be posted and the table would grow unbounded.page_views(path FK → page_routes on delete cascade, day, views)— PK(path, day), index onday.- Deliberately absent:
/figures/:slugand/competitions/:id(they have their own per-id counters), chromeless judging surfaces, admin, 404./competitions/circuit/:slugis aggregated (a tour has no id). - The pattern list lives twice — JS in
src/lib/pageViews.js, SQL in the seed.node scripts/test-page-routes.mjsfails when they drift. Adding a counted page = one line in each + one row on the live DB.
PK (competition_id, day), FK on delete cascade, index on day. Written by track_competition_view for published competitions only, counted by id so a rename keeps its history.
The 2026-08-14 → 2026-09-11 rows of
page_viewsandcompetition_viewsare a reconstruction, imported from a Vercel "Top Pages" export: totals exact, daily split estimated. Don't read the shape of those four weeks as data.
| Column | Type | Notes |
|---|---|---|
id |
bigint identity PK |
|
name |
text NOT NULL |
≤ 120 |
discipline |
sport_type NOT NULL |
|
grid_key |
text NOT NULL |
wakeboard · wakeskate · seated_mp1 · seated_mp5 |
difficulty |
text NOT NULL |
CHECK ∈ {easy, medium, hard} |
category |
text |
free grouping label |
source_type, video_path, video_url |
upload (bucket) vs external | |
solution |
jsonb NOT NULL |
the official run (Compo snapshot); ≤ 50 KB |
published |
boolean default false |
|
created_at, updated_at |
timestamptz |
trigger judge_runs_updated_at |
No anon policy. Public access only through list_judge_runs() (metadata, no solution) and get_judge_run_solution(id).
| Column | Type | Notes |
|---|---|---|
id |
text PK |
8-char base36 share code — what the judge types at /juge |
name |
text NOT NULL UNIQUE |
1–80 |
data |
jsonb NOT NULL |
{cableSpin, nbPoulies, poulieStart, parcours[]}; ≤ 50 KB |
created_at, updated_at |
timestamptz |
trigger parcours_touch |
PK and name are both unique, so an insert can raise 23505 for two reasons; src/lib/judge/api.js disambiguates (DuplicateNameError vs regenerate-and-retry, up to 5 attempts). Defined in scripts/competition_parcours.sql and mirrored in post_restore. Heats scored against a course are never stored server-side.
Both are security_invoker, and neither carries a published filter — the option is the protection. It does not survive a create view on its own: every create view must be followed by its alter view … set (security_invoker = true) in the same breath (it drifted once on figures_card, fixed by migration 0021).
id, slug, name, sport, difficulty, contexts, category_name, category_slug, sports, aliases. Backs /figures, the home rows (most_viewed_figures and recent_video_figures return setof figures_card) and — cached as wakeref_voice_figures — the offline catalogue of the voice matcher.
The figures columns plus:
category_name,category_slug,category_colorswitch_of_figure—{id, name, slug}of the base trickswitch_versions— switch variants of this figureprerequisites—{id, name, slug}[], taken from the base of the switch group (a switch inherits them)videos— non-takedown videos shared across the switch group, ordered bysort_order, each withperformer_genderbuilt_on_figure— immediate parent ({id, name, slug, switch_names}), anchored on the base; omitted if the parent is itself a switchbuilt_on_children— direct children of the base (the "+1" level), switches excluded, ordered by rotation / inverted / namebase_figure— root of the built-on chain (recursive CTE)- decomposition columns (
spin,inverts,rewind_degs,rotation_type) and discipline columns (tips_seated*,tips_wakeskate*,sports), appended at the end socreate or replace viewstays compatible
| Trigger | Table | Timing | Action |
|---|---|---|---|
figures_updated_at |
figures |
BEFORE UPDATE | set_updated_at() |
figures_built_on_acyclic |
figures |
BEFORE INSERT/UPDATE OF built_on_id |
rejects cycles and self-references |
compositions_rate_limit |
compositions |
BEFORE INSERT (row) | 20/min |
competition_submissions_rate_limit |
competition_submissions |
AFTER INSERT (statement) | 10/min, 60/day |
video_submissions_rate_limit |
video_submissions |
AFTER INSERT (statement) | 10/min, 60/day |
takedown_requests_rate_limit |
takedown_requests |
AFTER INSERT (statement) | 10/min, 60/day |
parcours_touch |
parcours |
BEFORE UPDATE | updated_at = now() |
judge_runs_updated_at |
judge_runs |
BEFORE UPDATE | set_updated_at() |
competitions_updated_at |
competitions |
BEFORE UPDATE | set_updated_at() |
| Index | Target | Purpose |
|---|---|---|
figures_search_idx |
GIN on to_tsvector('french', immutable_unaccent(name‖description)) |
French full-text search |
videos_figure_id_idx |
videos(figure_id) WHERE takedown_requested = false |
video aggregation |
figures_switch_of_idx, figures_built_on_id_idx, figures_category_id_idx |
figures |
switch groups, tree, category join |
prerequisites_requires_id_idx |
prerequisites(requires_id) |
prerequisite sub-query |
figure_views_day_idx, page_views_day_idx, competition_views_day_idx |
(day) |
rolling windows |
competitions_date_start_idx |
competitions(date_start desc) |
agenda feed |
competition_videos_competition_idx |
(competition_id, sort_order) |
ordered links of a comp |
competition_submissions_created_idx, video_submissions_submitted_idx, takedown_requests_created_idx |
(created_at / submitted_at desc) |
admin inbox order + rate-limit windows |
unaccent (in the extensions schema), wrapped by public.immutable_unaccent(text) so it can sit in the GIN index expression.
RLS is enabled on all 16 tables.
- Public SELECT:
categories,prerequisites,page_routes(all rows);figures(published);videos(takedown_requested = false);competitions(published);competition_videos(existsa published parent — without it, links of an unpublished comp would leak). - Admin full access (
for all using ((select auth.role()) = 'authenticated')— the sub-select makes it an initplan, evaluated once per query):figures,categories,prerequisites,videos,judge_runs,competitions,competition_videos;parcoursvia four per-command policies. - Public INSERT only:
takedown_requests,video_submissions,compositions,competition_submissions. Admin gets SELECT (+ UPDATE on the two submission boxes, DELETE on compositions). - Admin SELECT only:
figure_views,page_views,competition_views. - No
anonpolicy at all:judge_runs,parcours— public reads only throughsecurity definerRPCs.
A policy is not a grant. RLS decides which rows a role may see; grant select decides whether it may read the table at all — both are required. figure_views shipped with a policy but its grant only in post_restore, and stayed unreadable on the live DB for months (fixed by migration 0022). Every migration that creates a table must carry its own grants.
file_size_limit= 25 MB (the wider of the two app caps — a bucket can't tell a poster from a clip). The browser-siderefusSiTropLourd()check (25 MB video / 5 MB image) exists for a usable message, not for security.- SELECT on
storage.objectsis admin-only (migration 0025). A public bucket serves/object/public/…without any policy; the SELECT policy only governs listing, and it used to letanonenumerate every file name, drafts included. - INSERT / DELETE admin-only. No UPDATE policy — replacing a file means
removethenupload, neverupsert. - Layout:
<figure_id>/<timestamp>.<ext>(figure uploads),thumbnails/<shortcode>.jpg,runs/<timestamp>.<ext>(judge-run videos),competitions/<uuid>-affiche.<ext>or…-logo.<ext>(posters/logos),competitions/tours/<slug>.png,competitions/ffsnw.png.
- Write
scripts/migrations/NNNN-<topic>.sqland apply it in the Supabase SQL Editor. Include the grants of any table it creates. - Mirror it into
scripts/wakeref_post_restore.sql(executable) andscripts/wakeref_schema.sql(reference dump, not executed). - After any
create view, re-applysecurity_invoker = true. - Keep
src/data/categories.js/contexts.jsin sync, andpage_routes↔src/lib/pageViews.js(node scripts/test-page-routes.mjs).
Backups: .github/workflows/backup.yml runs pg_dump daily at 03:00 UTC into the private backups bucket (30-day retention).