-- Export generation, unified.
--
-- ⚠ ROLLBACK RUNBOOK. This is the repo's first DESTRUCTIVE migration (it DROPs two columns). The
-- previous image will NOT boot against this schema: sqlx runs migrations before serving and errors
-- with VersionMissing on an unknown version, so you get a crash loop, not a degraded service.
-- To roll back to the previous release you must run 014_export_epoch.down.sql BY HAND FIRST, then
-- deploy the old image. The down migration re-derives the old ready flags from the epoch state, so
-- no keepsake is lost.
--
-- Before this migration, "which generation of the keepsake is current?" had no single answer.
-- It was assembled at runtime from three separately-written pieces of state across two tables:
--   * event.export_released_at   — a release marker with NO identity (you cannot ask *which* release)
--   * export_job.release_seq     — a per-row counter, in a different table, bumped in a different statement
--   * event.export_{zip,html}_ready — a CACHED COPY of the derivation of the other two
-- Keeping those three in agreement took ~10 hand-written guards, and every guard was a repair of the
-- same missing invariant. Three consecutive review rounds each found another path that slipped through.
--
-- This replaces all of it with ONE authority:
--
--   event.export_epoch — a monotonic counter bumped in the SAME UPDATE as any change to
--                        export_released_at (release and reopen are its only writers).
--   export_job.epoch   — a COPY of event.export_epoch taken when the job was enqueued.
--                        NEVER independently incremented.
--
-- The single invariant, which replaces the entire proof:
--
--   An export is downloadable IFF
--       event.export_released_at IS NOT NULL
--   AND export_job.epoch = event.export_epoch
--   AND export_job.status = 'done'
--
-- Readiness is therefore DERIVED, never stored — so it cannot drift, and no worker can set it.
-- A worker holding a dead epoch is INERT BY CONSTRUCTION: anything it writes is invisible to every
-- reader, with no guard involved. Losing a race can no longer corrupt state; it can only waste work.
--
-- Crucially, epoch equality is checked on the SAME ROW the worker updates (export_job), never via a
-- cross-table EXISTS. That matters: under READ COMMITTED, when an UPDATE blocks on a row lock and the
-- blocker commits, Postgres re-evaluates the WHERE against the *updated target row* but answers
-- subqueries on OTHER tables from the statement's ORIGINAL snapshot. The old cross-row guard
-- (`EXISTS (SELECT 1 FROM event WHERE ... export_released_at IS NOT NULL)`) was unsound for exactly
-- that reason — it could admit a claim against an already-reopened event while RETURNING the
-- post-bump seq. A same-row `epoch = $n` predicate is re-evaluated correctly by EPQ.

ALTER TABLE event ADD COLUMN export_epoch BIGINT NOT NULL DEFAULT 0;

-- A currently-released event's live generation becomes epoch 1.
UPDATE event SET export_epoch = 1 WHERE export_released_at IS NOT NULL;

-- The per-job counter becomes a plain copy of the event epoch it was enqueued for.
ALTER TABLE export_job RENAME COLUMN release_seq TO epoch;

-- Carry the OLD notion of "downloadable" across exactly: a job the old model considered ready
-- (released + done + its ready flag) adopts the event's live epoch. Everything else is retired to
-- -1, which can never equal a non-negative event.export_epoch, so it is inert forever.
UPDATE export_job j
   SET epoch = CASE
         WHEN e.export_released_at IS NOT NULL
          AND j.status = 'done'
          AND ((j.type = 'zip'  AND e.export_zip_ready)
            OR (j.type = 'html' AND e.export_html_ready))
         THEN e.export_epoch
         ELSE -1
       END
  FROM event e
 WHERE e.id = j.event_id;

-- The duplicated derivation. Gone — along with the whole class of bugs where a stale worker
-- resurrected it, or where it disagreed with the job row it was supposed to summarise.
ALTER TABLE event DROP COLUMN export_zip_ready;
ALTER TABLE event DROP COLUMN export_html_ready;

-- The ONE definition of "the current generation". Every reader goes through this, so readiness and
-- the file path are resolved in a SINGLE read — closing the download-path TOCTOU where the ready
-- flag was checked in one query and file_path fetched in another (a reopen between the two served a
-- keepsake that should have 404'd).
CREATE VIEW export_current AS
SELECT j.event_id,
       j.type,
       j.status,
       j.progress_pct,
       j.file_path,
       j.epoch
  FROM export_job j
  JOIN event e ON e.id = j.event_id
 WHERE e.export_released_at IS NOT NULL   -- implied by the epoch match; kept as an assertion
   AND j.epoch = e.export_epoch;
