Skip to content

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:

columnmeaning
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_idnow 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 of createCourseMapFromPlan, which the program path always calls first, so it fronts every entry point.
  • View: a user reads the canonical directly (RLS + loadMapForUser allow 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 lists user_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 via admin-programs.ts does 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): resolveWriteTarget forks the canonical (services/map-fork.ts), the edit lands on the fork, and the response carries forkedTo: <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 own extraction_raw_json baseline.

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.