Skip to content

feat(marketplace): serve versioned snapshots instead of live content - #8524

Open
LWS49 wants to merge 6 commits into
lws49/feat-marketplace-pr6d-auditfrom
lws49/feat-marketplace-pr7a-versioning-foundation
Open

feat(marketplace): serve versioned snapshots instead of live content#8524
LWS49 wants to merge 6 commits into
lws49/feat-marketplace-pr6d-auditfrom
lws49/feat-marketplace-pr7a-versioning-foundation

Conversation

@LWS49

@LWS49 LWS49 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Gives the Assessment Marketplace a versioned content model. Until now a listing served whatever its source assessment currently contained, so an adopter's copy and the marketplace's own preview both drifted the moment the publisher edited anything, and deleting the source course destroyed the listing along with every adopter's adoption record. This PR introduces Course::Assessment::Marketplace::ListingVersion: publishing snapshots the assessment into a single content-frozen container course, and the listing points at that snapshot rather than at the live source.

It also makes losing a source survivable. authoring_assessment_id becomes nullable with a nullifying foreign key, so deleting the origin orphans a listing instead of cascading through its version chain, and a background job rebuilds the authoring copy inside the container so the listing can be published from again.

Nothing here is user-visible. The surfaces that read this model land in the two PRs stacked on top.

The first three commits are pre-existing bug fixes that the versioning work happened to surface, kept separate so they stay individually cherry-pickable: a nil-course guard in the Cikgo destroy push, a toast that could not render a React node, and admin nav losing its selected tab on nested routes.

Design decisions

  • A version is identified by its publication datetime, with no ordinal column - an integer would name a series the system cannot navigate, since there is no rollback, and the stable internal referent is already the row's primary key. Recording v1's published_at as the listing's first-publication date is what removes the read-time special case for v1 entirely.
  • Snapshots live in one content-frozen container course keyed off a new Course#preview flag, and those same rows are what marketplace previews run against - a snapshot is the preview copy, so a preview can never show content that differs from what a duplicate would produce. One flag serves both the previewer sandbox and the snapshots' immutability guarantee.
  • Orphaning rebuilds the authoring copy automatically rather than waiting for an admin - the marketplace keeps serving the snapshot either way, so what orphaning actually costs is the ability to publish a new version, and an admin would otherwise learn that only by visiting the listings table. The manual rebuild action is then only ever a retry after a failed job.
  • Publishing never gates on whether content changed. Course::Assessment#updated_at does not move when a question, option, test case or rubric is edited, and walking the object graph misses edits below any fixed depth and misses deletions entirely, so the publisher decides when to cut a version.
  • Provenance records where a listing came from, not when its origin course ran. The source course's start/end dates are deliberately not captured: they are the wrong subject (a course's teaching period says nothing about whether the questions were revised), Course#set_defaults fabricates end_at from start_at so an untouched default is indistinguishable from a real range, and a value frozen at first publish would contradict the live course linked beside it. Version datetime plus last_published_at are the real staleness signals and both already exist.
  • Schema and data backfill sit in one migration rather than a pair per slice, because the backfill calls application code that always reflects the branch's final schema. Split across separate timestamps, a from-scratch db:migrate fails on PG::UndefinedColumn while db:schema:load masks it.

Regression prevention

Covers: version creation and the datetime uniqueness constraint per listing; first-publish versus deliberate re-cut, and that re-listing an already-versioned listing does not mint a version; provenance capture surviving origin-course deletion; the orphaned, unlisted, purgeable, marketplace-hosted and source-deleted predicates including the case where a rebuilt listing is simultaneously visible and origin-deleted; cross-tenant container resolution, which is the regression the tenant-free lookups exist for; the restore-authoring job and the purge service, including that snapshots are destroyed after their version rows so the foreign key holds; ability rules for the frozen container; and both backfills being idempotent.

Manually verified: publishing cuts a version and points the listing at the container snapshot; deleting the origin course orphans the listing and rebuilds its authoring copy in the container.

Also adds a regression test for the Cikgo destroy push, which previously raised when an assessment was destroyed as part of its whole course.

Backward compatible. The migration backfills every existing published listing to a v1 snapshot and stamps its adoptions, so no listing is left version-less; existing adopted copies are untouched.

@LWS49
LWS49 force-pushed the lws49/feat-marketplace-pr7a-versioning-foundation branch from b54c334 to b1449fb Compare July 29, 2026 13:32
@LWS49

LWS49 commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

Note on the db/schema.rb delta: the three dropped columns still physically exist in local dev/test DBs that already ran 20260728000000, because the migration was edited in place rather than followed by a drop migration. schema.rb was hand-edited and no db:migrate was run, so a re-dump cannot silently reinstate them. Reviewers pulling this branch onto a DB that already ran the old migration should expect the columns to linger locally until the DB is rebuilt; nothing reads them.

LWS49 added 2 commits July 30, 2026 10:02
A version IS its publication datetime, and the snapshot it serves is duplicated
into a `preview` container course so a published version can never change under
the courses that adopted it. Non-admins cannot edit container content.
Deleting the source assessment now orphans the listing instead of destroying it:
the marketplace keeps serving the snapshot, and the authoring copy is rebuilt in
the container automatically so a new version can still be published.
@LWS49
LWS49 force-pushed the lws49/feat-marketplace-pr7a-versioning-foundation branch from b1449fb to 0bbcfea Compare July 30, 2026 02:02
Nothing rolls back in this suite, so two unscoped `delete_all`s leaked into every
later spec file and failed them under some seeds: `User::Email.delete_all` stripped
the address off every earlier file's users (surfacing as `SMTP To address may not be
blank` in the mailer specs and a nil `user.email` in the external-assessment import
specs), and allow-list rows left behind granted `:access_marketplace` to users the
ability spec asserts cannot have it.

Scope the email cleanup to the domains that file hardcodes, and clear the allow-list
tables in the ability spec the way access_list_query_spec.rb already does.
@LWS49
LWS49 force-pushed the lws49/feat-marketplace-pr7a-versioning-foundation branch from 99f8c3c to 213421c Compare July 30, 2026 02:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant