MicroGym: the anatomy
A live-operations tool for small-group training, built with a Toronto gym. Trainers assemble workouts, schedule sessions with a roster, then run the session live on a tablet while an assessment engine adapts every exercise to every member in the room.
Built March 2025 to July 2026 for one gym. No tests and no CI, said plainly.
Stack Next.js, Supabase, Postgres with row-level security
- 502
- commits, all one author
- 81k
- lines across 344 files
- 34
- tables
- 1,507
- lines in the substitution engine
- 4
- phases of security hardening, each reversible
- 4 to 0
- rows a member could see that were not theirs
The signal path reads top to bottom. Every stage is a decision that was made in the code, with the problem it answered and what it cost.
01 Library: Trainers build a movement library, per gym, with a body area and defaults.
The problem
A new member has no history, so nothing downstream can suggest a target for them. Leaving the cell blank puts the typing on the trainer, in the room, eight times over.
The decision
Every movement carries a body area and default targets at three strength levels (reps, weight, duration). A member's intake strength level picks the default until their own history exists to replace it.
What it cost
Fifteen extra fields on every movement, most of them empty for movements that never need them. The trainer fills them once.
02 Workout: Sets by rounds, assembled from the library, saved as templates.
The problem
The same workout is run by eight people who are not the same. The template has to describe the intent, not the outcome.
The decision
The template is the plan. The per-member version is never stored as a workout: substitutions are written when the session is created, from the plan plus each member's assessment answers, and resolved per member when the tablet renders the grid.
What it cost
Two views of every workout, kept apart by structure rather than by a check: no session or substitution code path writes to the template's rows.
03 Assessment: Injuries and limitations per member, turned into substitution rules.
The problem
Two assessments can fire rules against the same movement and disagree. Somebody has to win, and the choice has to be the same every time or the trainer stops trusting it.
The decision
Newest assessment wins. A same-date tie keeps whichever rule was seen first, and the comment on that branch admits the order is arbitrary but consistent.
What it cost
Admitting the arbitrariness in writing, and one more thing the comment does not say: the rule query has no ordering clause, so the consistency rests on the database returning rows in the same order. Noted here so the next person does not trust it further than that.
04 Roster: A session is scheduled and members are placed on it.
The problem
A member with no history has no basis for a target weight. Pre-filling nothing means the trainer types everything; pre-filling a guess means being wrong in front of a client.
The decision
History first: a one-rep-max estimate from recent sets suggests the next target. No history: the movement's default for the member's strength level stands in. Either way the number is a suggestion the trainer overrides with one tap.
What it cost
The estimate is the Epley formula, which is known to drift at high repetitions, and the code does not say so where it is used. It should, and this page said it did until the source was re-read on 4 September 2026.
05 Live: The session runs on a tablet. Timers, per-member entry, notes.
The problem
Eight members, one grid, every cell a write, and the trainer's thumb is already moving to the next cell. Anything on the write path that stalls is felt in the room.
The decision
Milestone checks and other side effects fire without being awaited from the performance write. The write lands; everything that reacts to it happens after.
What it cost
A side effect can fail silently where a blocking call would have shown an error. Failures are logged rather than shown. The comment says the check must not block the write; that a failure is never surfaced is implemented, not written down.
06 History: Every set ever recorded, per member, feeding the suggestions.
The problem
Aggregating a member's history on every session start could get slow.
The decision
Aggregates are computed in application code, not SQL, and the comment next to it says: fine at this scale, a member has tens of rows; move to an RPC or view if a member ever accumulates tens of thousands of performances.
What it cost
A ceiling, named. Not a limitation hidden until it hurts.
07 Isolation: Every gym on the platform sees only itself. Enforced in the database.
The problem
Multi-tenant security in row-level policies cannot be unit-tested the way code can, and it recurses the moment two tables check each other. The first version was wrong.
The decision
A four-phase hardening, each phase with its own rollback file, the first verified by impersonating a member and counting the rows they could reach. Before: four rows that were not theirs. After: zero. Three tables were found with row-level security switched off and turned on.
What it cost
Publishing the number four. It is the strongest thing on this page: the person who audits his own system and writes down what he found.
-- RLS hardening Phase 4: enable RLS on 3 tables that had it OFF (anon could read/write freely).
CREATE OR REPLACE FUNCTION public.current_user_gym_ids() RETURNS SETOF uuid
LANGUAGE sql SECURITY DEFINER STABLE SET search_path = public, pg_temp AS $fn$
SELECT id FROM gyms WHERE admin_ids @> ARRAY[auth.uid()]
UNION SELECT gym_id FROM trainers WHERE auth_id = auth.uid()
UNION SELECT gym_id FROM users WHERE auth_id = auth.uid()
$fn$;
ALTER TABLE milestone_templates ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Gym members access their milestone templates" ON milestone_templates FOR ALL
USING (gym_id IN (SELECT public.current_user_gym_ids())) WITH CHECK (gym_id IN (SELECT public.current_user_gym_ids()));