Canonical Course Maps & Per-User Forks
Why
Before this, every βgenerate a map for program Xβ inserted a fresh per-user
course_maps row β a fresh LLM generation each time, and the same degree could
render differently for different users. Now a programβs map is generated once
into a shared canonical, and each user gets their own editable fork the
first time they change anything.
Data model (migration 175)
course_maps gains:
| column | meaning |
|---|---|
is_canonical (bool) | the single shared per-program map; user_id IS NULL |
canonical_map_id (uuid) | for a fork, the canonical it was copied from |
user_id | now nullable (null only for canonicals) |
Invariants (DB CHECK constraints):
- A row is either a canonical (
is_canonical,user_id IS NULL) or owned (user_id IS NOT NULL), never both/neither. - A canonical never references another canonical (no chains).
Unique indexes:
- One canonical per
source_program_id. - One fork per
(user_id, canonical_map_id)β makes the lazy-fork race safe (a concurrent first-edit hits 23505 and the loser loads the winnerβs fork).
Three row kinds coexist: canonical (shared, generated), fork (a userβs
copy of a canonical), and legacy/owned (uploads + pre-migration per-user
maps β is_canonical = false, canonical_map_id = null). Legacy rows behave
exactly as before.
Lifecycle
- Generate (
createCourseMapFromPlan/createCourseMapFromProgram): if a canonical exists for the program, return it (no LLM call); else generate and insert it as the canonical. The cache-hit check lives at the top ofcreateCourseMapFromPlan, which the program path always calls first, so it fronts every entry point. - View: a user reads the canonical directly (RLS +
loadMapForUserallow it). No fork row is created β browse-only users never spawn rows. - Generate (user-facing wizard,
catalog-browse.ts): generating is an intentional βmake me this mapβ, so after seeding/reusing the canonical the route eagerly forks it for the generating user and returns the fork id. That puts the map in their dashboard immediately (which listsuser_id = me) and means their edits donβt need a first-edit fork. Idempotent β re-generating the same program returns the userβs existing fork. (Admin generation viaadmin-programs.tsdoes NOT fork β admins work on the canonical for review.) - First edit (any mutation: status, move, add, fill, remove, branding) of a
canonical a user reached WITHOUT generating (e.g. a shared/direct link):
resolveWriteTargetforks the canonical (services/map-fork.ts), the edit lands on the fork, and the response carriesforkedTo: <forkId>. The frontend (map/page.tsx applyFork) switches its active map id to the fork. - Admins edit the canonical in place (the review-queue workflow) β they do not fork.
- Revert: a fork resets to its canonicalβs current state
(
resetForkToCanonical); a canonical (admin) or legacy map reverts to its ownextraction_raw_jsonbaseline.
Forking policy: eager on generate, lazy on view
- Generate β eager fork: the generating user always gets their own fork, so the map appears in their dashboard right away (even if they never edit it).
- View β lazy fork: a user who only opens a canonical (shared link, direct URL) does not spawn a row until their first edit.
This gives both: no row spam for pure viewers, and generated maps are always saved to the generating userβs account.