304 lines
20 KiB
SQL
304 lines
20 KiB
SQL
-- +goose Up
|
|
|
|
-- The READING half of contract 0.3.0 (pack P7): the chapter tree with its pairs, the bank as the
|
|
-- client signs it, the frame history a stream is resumed from, and the vocabularies 0.3.0 renamed.
|
|
--
|
|
-- The rule this migration follows everywhere: a column nobody writes is a wire LIE, not a
|
|
-- placeholder. Three of them are removed below rather than left for a later pack, because each was
|
|
-- projected as a zero or as a word the contract now forbids.
|
|
|
|
-- ── Structure: which cut of the book the client is looking at ───────────────────────────────────
|
|
--
|
|
-- `revision` moves on every materialization; `structure_version` moves only when the book is cut
|
|
-- again, which is what a cursor, a pair anchor and a term's chapter window are bound to (canon
|
|
-- §StructureVersion). Binding those to the revision instead would restart pagination constantly.
|
|
--
|
|
-- `manifest_key` is how the move is DETECTED and it is the engine's own validity key
|
|
-- (pipeline/manifest.go): everything whose change moves a chapter boundary is hashed into it, so
|
|
-- comparing one string answers "is this still the same book, cut the same way" without the platform
|
|
-- re-deriving a rule that lives on the other side of the seam.
|
|
alter table books
|
|
add column structure_version integer not null default 0,
|
|
add column manifest_key text not null default '',
|
|
-- The frame counter of this book's event history. Separate from `revision` and it has to be:
|
|
-- one is a position in a stream a client resumes from, the other is the state a read is ordered
|
|
-- against, and a server that merged them would answer `Last-Event-ID` with a number that jumps
|
|
-- whenever anything materializes (canon §EventEnvelope).
|
|
add column event_position bigint not null default 0,
|
|
-- The revision at which each collection was last replaced WHOLESALE. A delta read whose
|
|
-- watermark predates one cannot be answered — a delta cannot express a deletion — and the
|
|
-- contract's answer to that is `400` with `cause.code: version_too_old` rather than a silently
|
|
-- short list (canon §AfterVersion).
|
|
add column structure_reset_revision bigint not null default 0,
|
|
add column bank_reset_revision bigint not null default 0;
|
|
|
|
-- ── `finalizing` leaves the vocabularies ────────────────────────────────────────────────────────
|
|
--
|
|
-- The engine has no such phase (`grep -ri finaliz backend/internal` is empty), the seam's event
|
|
-- vocabulary has no such value, and nothing on this side ever wrote one. It survived in three
|
|
-- checks and two Go switches because a status nobody writes costs nothing to keep — until a client
|
|
-- is generated from a contract that no longer has it (0.3.0 removed the value) and the DDL is the
|
|
-- only place still promising it.
|
|
alter table books drop constraint books_status_check;
|
|
alter table books add constraint books_status_check check (status in (
|
|
'uploading', 'parsing', 'not_started', 'translating', 'awaiting_bank',
|
|
'ready', 'stopped', 'rejected', 'failed', 'paused'));
|
|
|
|
alter table runs drop constraint runs_status_check;
|
|
alter table runs add constraint runs_status_check check (status in (
|
|
'translating', 'awaiting_bank', 'ready', 'stopped', 'failed', 'paused'));
|
|
|
|
-- ── The run: why it failed, and which segment its bar is measuring ──────────────────────────────
|
|
alter table runs
|
|
-- The contract's RunFailureReason, and the one state that IS an error carries it or nothing.
|
|
-- Three values, deliberately coarse: what a client decides from this is whether to offer a
|
|
-- retry, and a fourth value that answered that question the same way would only be a fourth
|
|
-- phrase to write.
|
|
add column failure_reason text
|
|
check (failure_reason is null or failure_reason in ('source_unreadable', 'service_error', 'interrupted')),
|
|
-- How many chapters the BOOK had already finished when this run started. It is the baseline the
|
|
-- run's own bar is measured from: `unit_resolutions` persist across runs (a resumed run re-walks
|
|
-- finished chapters at $0 and must not move them), so without it a second run over a half-done
|
|
-- book opens at `done` = everything the FIRST run did, against a `total` of what the second one
|
|
-- bought — a fraction that starts above zero and can exceed one (canon §Progress: "Chapters
|
|
-- finished in this segment", "the fraction always reaches one").
|
|
add column chapters_before integer not null default 0,
|
|
-- Whether this run's signing stop has been cleared. It is what tells the FIRST segment of the
|
|
-- work from the second (canon §Progress: a segment is the work between two stops, and the
|
|
-- counter starts again from zero when a stop is lifted). Phases stay inside the platform — the
|
|
-- wire carries one counter — and this is the one bit of that split the projection needs.
|
|
add column bank_released boolean not null default false;
|
|
|
|
-- ── Chapters ────────────────────────────────────────────────────────────────────────────────────
|
|
--
|
|
-- `heading` GOES. The engine's manifest carries one and it is a deterministic render of the pair's
|
|
-- heading rule («Глава N»), not a label out of the book's data — its own comment says a consumer
|
|
-- must not present it as one (pipeline/manifest.go), and the contract forbids a deployment to put a
|
|
-- rendered ordinal in that field (canon §Chapter.heading). A column that may only ever hold the
|
|
-- empty string is a trap for the next reader, so the projection answers `null` from nothing at all
|
|
-- until a producer of real labels exists (engine backlog row 160).
|
|
--
|
|
-- `note_count` goes for the same reason wearing the opposite clothes: nothing wrote it, so every
|
|
-- read answered zero — a number that is not "unknown" but "there are none", which is a different
|
|
-- fact and a false one. Notes are derived from `unit_resolutions` (see below), and so is their
|
|
-- count.
|
|
alter table chapters drop column heading, drop column note_count;
|
|
alter table books drop column note_count;
|
|
|
|
-- `books.genre` goes with them, and it is the third instance of the same rule rather than a fourth
|
|
-- decision: the field left the contract, the intake form and the render in 0.3.0 (Б-23, owner
|
|
-- 16.08), so nothing writes it any more. The engine keeps the key in its own `book.yaml` until its
|
|
-- removal lands (unified backlog row 184) — and that value is now the OPERATOR's, carried through
|
|
-- from their template untouched, which is why the platform has nowhere left to keep one.
|
|
alter table books drop column genre;
|
|
|
|
-- ── Pairs ───────────────────────────────────────────────────────────────────────────────────────
|
|
--
|
|
-- `units.ordinal` is the unit's LEADER chunk index, which is both its reading order inside the
|
|
-- chapter and the join key the engine publishes for it (`first_chunk_idx` in the manifest, and the
|
|
-- `unit` of every `unit_done` event). One column serving both is not thrift: a separate dense
|
|
-- ordinal would be a second numbering to keep in step with a cut that moves.
|
|
comment on column units.ordinal is
|
|
'The engine''s leader chunk index: reading order inside the chapter AND the join key of unit_resolutions.';
|
|
|
|
-- ── Notes ───────────────────────────────────────────────────────────────────────────────────────
|
|
--
|
|
-- The `notes` table goes, and this is the pack that would otherwise have given it a writer.
|
|
--
|
|
-- It cannot have one that is correct. A note is one flagged resolution of one pair, and the
|
|
-- durable record of that IS `unit_resolutions` (00015) — keyed by the ENGINE's ordinals, which is
|
|
-- what makes it writable while a run is going and before any chapter row exists. The `notes` table
|
|
-- is keyed by the platform's opaque chapter and unit ids, which exist only once a manifest has been
|
|
-- persisted, so a materializer writing both would either drop notes that arrive early or hold them
|
|
-- somewhere until the tree caught up. Two homes for one fact, and the second one lossy.
|
|
--
|
|
-- So notes are PROJECTED at read time from `unit_resolutions` joined onto the tree, exactly as
|
|
-- 00002 said the product phrase would be ("applied at read time from the contract's map"). The
|
|
-- identity a note needs on the wire is derived from the resolution's own key, so it survives a
|
|
-- re-read of the stream instead of being minted afresh by it.
|
|
drop table notes;
|
|
|
|
-- A resolution needs the one thing the read model orders everything else by: the book revision it
|
|
-- was materialized at. Without it a delta read of the notes has no watermark to compare against —
|
|
-- the row's own `at` is the ENGINE's clock, which is not the counter a client holds — and the
|
|
-- client would have to re-read every note of the book on every reconnect.
|
|
alter table unit_resolutions add column revision bigint not null default 0;
|
|
create index unit_resolutions_revision_idx on unit_resolutions (book_id, revision);
|
|
-- …which makes 00015's `(book_id)` a strict prefix duplicate. Dropped: the table takes one insert
|
|
-- per unit per wave, so a second index on the hottest write path buys nothing.
|
|
drop index unit_resolutions_book_idx;
|
|
|
|
-- ── Bank: the contract's vocabulary, not the engine's ───────────────────────────────────────────
|
|
--
|
|
-- 0.3.0 renamed both axes and the reason is the project's own invariant rather than taste: `ruby`
|
|
-- is Japanese furigana — pair-specific data in a shared layer — `mined` is the name of a pipeline
|
|
-- stage and `draft` the name of a wave, and none of the three may cross this seam (canon
|
|
-- §Boundaries). The mapping engine→contract is done where the sidecar is read; what is STORED is
|
|
-- what the client reads, so no path can project a word this deployment would have to translate
|
|
-- twice.
|
|
--
|
|
-- ⚠ The DATA is translated, not just the constraint, and the tables being empty on every deployment
|
|
-- today is NOT the reason to skip it: a migration that only holds while its tables are empty stops
|
|
-- being re-appliable the moment one row exists — including the re-apply after a rollback, which is
|
|
-- the ordinary shape of a failed release. Drop → translate → add, because the constraint standing at
|
|
-- each moment is the one being left behind.
|
|
alter table bank_terms drop constraint bank_terms_status_check;
|
|
alter table bank_terms drop constraint bank_terms_origin_check;
|
|
update bank_terms set status = case status when 'auto' then 'proposed'
|
|
when 'draft' then 'in_progress'
|
|
-- identity, not 'approved': an unexpected value must not
|
|
-- become "the user signed this" by falling through.
|
|
else status end,
|
|
origin = case origin when 'seed' then 'given'
|
|
when 'ruby' then 'annotated'
|
|
else 'found' end;
|
|
alter table bank_terms add constraint bank_terms_status_check
|
|
check (status in ('proposed', 'in_progress', 'approved'));
|
|
alter table bank_terms add constraint bank_terms_origin_check
|
|
check (origin in ('given', 'annotated', 'found'));
|
|
|
|
-- `since_chapter`/`until_chapter`: the engine's `0` sentinel becomes NULL, because `0` meant "no
|
|
-- boundary" in a column whose other values are chapter numbers starting at 1 — one sentinel with
|
|
-- two readings in two fields (canon §BankTerm).
|
|
alter table bank_terms
|
|
alter column since_chapter drop not null,
|
|
alter column since_chapter drop default,
|
|
alter column until_chapter drop not null,
|
|
alter column until_chapter drop default;
|
|
-- The sentinel is translated before the constraint that forbids it, for the same reason the
|
|
-- vocabularies above are: this migration has to be re-appliable over rows written under the old
|
|
-- shape, which is exactly what a rollback leaves behind.
|
|
update bank_terms set since_chapter = nullif(since_chapter, 0), until_chapter = nullif(until_chapter, 0);
|
|
alter table bank_terms
|
|
add constraint bank_terms_window_check
|
|
check ((since_chapter is null or since_chapter >= 1) and (until_chapter is null or until_chapter >= 1));
|
|
|
|
-- The uniqueness key holds with NULLs only under NULLS NOT DISTINCT: two rows of one surface
|
|
-- differing solely by an open boundary are the same term, and the default treatment would let both
|
|
-- in and hand the client two identical rows with different ids.
|
|
alter table bank_terms drop constraint bank_terms_book_id_src_sense_since_chapter_until_chapter_key;
|
|
alter table bank_terms add constraint bank_terms_key
|
|
unique nulls not distinct (book_id, src, sense, since_chapter, until_chapter);
|
|
|
|
-- `promote` → `approve`: one act had two words, and the one that survives is the one that produces
|
|
-- `approved` (canon §BankDecision).
|
|
alter table bank_decisions drop constraint bank_decisions_action_check;
|
|
alter table bank_decisions drop constraint bank_decisions_promote_has_dst;
|
|
update bank_decisions set action = 'approve' where action = 'promote';
|
|
alter table bank_decisions add constraint bank_decisions_action_check
|
|
check (action in ('approve', 'decline'));
|
|
alter table bank_decisions add constraint bank_decisions_approve_has_dst
|
|
check (action <> 'approve' or dst <> '');
|
|
|
|
-- ── The frame history a stream resumes from ─────────────────────────────────────────────────────
|
|
--
|
|
-- The reporting database is not an event store and this does not make it one: what is kept here is
|
|
-- a short LIVE BUFFER of the frames already emitted, pruned as it grows, so that two viewers of one
|
|
-- book see the same frame under the same id and a client that reconnects can be told from its
|
|
-- `Last-Event-ID` whether it can be continued at all (canon §streamBookEvents). Nothing reads it
|
|
-- back into a decision; the state is read from the tables the frames describe.
|
|
--
|
|
-- Only HISTORY frames live here. `hello`, `end` and `resync_required` belong to a connection rather
|
|
-- than to the book: they carry the id of the last history frame and consume none of their own, so
|
|
-- two simultaneous viewers cannot spend each other's numbers.
|
|
-- ⚠ `unique nulls not distinct` below and this table are PostgreSQL 15+, which the ratified floor of
|
|
-- 16 (`docs/STACK_DECISIONS.md`) covers. Unasserted — a server older than 15 fails on THIS migration
|
|
-- rather than later.
|
|
create table book_events (
|
|
book_id text not null references books (id) on delete cascade,
|
|
position bigint not null,
|
|
event text not null check (event in ('status', 'progress', 'chapter', 'note', 'bank')),
|
|
-- The frame's `data` exactly as it goes on the wire, built inside the transaction that caused
|
|
-- it: a frame is a snapshot of a moment, so re-deriving its payload at send time would answer a
|
|
-- later state under an earlier id.
|
|
data jsonb not null,
|
|
created_at timestamptz not null default now(),
|
|
primary key (book_id, position)
|
|
);
|
|
|
|
-- +goose Down
|
|
drop table book_events;
|
|
|
|
comment on column units.ordinal is null;
|
|
|
|
create index unit_resolutions_book_idx on unit_resolutions (book_id);
|
|
drop index unit_resolutions_revision_idx;
|
|
alter table unit_resolutions drop column revision;
|
|
|
|
-- ⚠ The DATA is translated back BEFORE the old vocabularies are restored, and this is not
|
|
-- symmetry for its own sake: a rollback runs in one transaction, so the first violated CHECK
|
|
-- aborts the WHOLE migration. Without these updates a deployment could not roll back at all the
|
|
-- moment one bank row existed — which is the same release that first writes one. (The nullability
|
|
-- restore below always had its update; these three did not, and a reviewer reproduced the abort.)
|
|
-- The ORDER is drop → translate → add, and it is forced: the constraint standing at each moment is
|
|
-- the one being left behind, so an update that runs before the drop violates the vocabulary it is
|
|
-- migrating away from.
|
|
alter table bank_decisions drop constraint bank_decisions_approve_has_dst;
|
|
alter table bank_decisions drop constraint bank_decisions_action_check;
|
|
update bank_decisions set action = 'promote' where action = 'approve';
|
|
alter table bank_decisions add constraint bank_decisions_promote_has_dst
|
|
check (action <> 'promote' or dst <> '');
|
|
alter table bank_decisions add constraint bank_decisions_action_check
|
|
check (action in ('promote', 'decline'));
|
|
|
|
alter table bank_terms drop constraint bank_terms_key;
|
|
alter table bank_terms drop constraint bank_terms_window_check;
|
|
update bank_terms set since_chapter = 0 where since_chapter is null;
|
|
update bank_terms set until_chapter = 0 where until_chapter is null;
|
|
alter table bank_terms
|
|
alter column since_chapter set default 0,
|
|
alter column since_chapter set not null,
|
|
alter column until_chapter set default 0,
|
|
alter column until_chapter set not null;
|
|
alter table bank_terms add constraint bank_terms_book_id_src_sense_since_chapter_until_chapter_key
|
|
unique (book_id, src, sense, since_chapter, until_chapter);
|
|
alter table bank_terms drop constraint bank_terms_origin_check;
|
|
alter table bank_terms drop constraint bank_terms_status_check;
|
|
update bank_terms set origin = case origin when 'given' then 'seed'
|
|
when 'annotated' then 'ruby'
|
|
else 'mined' end,
|
|
status = case status when 'proposed' then 'auto'
|
|
when 'in_progress' then 'draft'
|
|
else 'approved' end;
|
|
alter table bank_terms add constraint bank_terms_origin_check
|
|
check (origin in ('seed', 'ruby', 'mined'));
|
|
alter table bank_terms add constraint bank_terms_status_check
|
|
check (status in ('auto', 'draft', 'approved'));
|
|
|
|
create table notes (
|
|
id text primary key,
|
|
book_id text not null references books (id) on delete cascade,
|
|
chapter_id text references chapters (id) on delete cascade,
|
|
unit_id text references units (id) on delete cascade,
|
|
reason text not null,
|
|
created_at timestamptz not null default now(),
|
|
revision bigint not null default 0
|
|
);
|
|
create index notes_book_revision_idx on notes (book_id, revision);
|
|
create index notes_unit_idx on notes (unit_id);
|
|
-- The index 00004 added for the cascading foreign key, restored with the table it belongs to: the
|
|
-- down path of THAT migration drops it by name, so a rollback past this one would otherwise fail.
|
|
create index notes_chapter_idx on notes (chapter_id);
|
|
|
|
alter table books add column genre text not null default '';
|
|
alter table books add column note_count integer not null default 0;
|
|
alter table chapters
|
|
add column heading text not null default '',
|
|
add column note_count integer not null default 0;
|
|
|
|
alter table runs drop column bank_released, drop column chapters_before, drop column failure_reason;
|
|
|
|
alter table runs drop constraint runs_status_check;
|
|
alter table runs add constraint runs_status_check check (status in (
|
|
'translating', 'awaiting_bank', 'finalizing', 'ready', 'stopped', 'failed', 'paused'));
|
|
alter table books drop constraint books_status_check;
|
|
alter table books add constraint books_status_check check (status in (
|
|
'uploading', 'parsing', 'not_started', 'translating', 'awaiting_bank', 'finalizing',
|
|
'ready', 'stopped', 'rejected', 'failed', 'paused'));
|
|
|
|
alter table books
|
|
drop column bank_reset_revision,
|
|
drop column structure_reset_revision,
|
|
drop column event_position,
|
|
drop column manifest_key,
|
|
drop column structure_version;
|