Files
UmbraTrans/umbratrans_web/migrations/0009_translation_import_runs.sql

97 lines
5.1 KiB
PL/PgSQL

-- 0009: the import run ledger — one durable row per audio source import.
--
-- Status: NOT YET APPLIED. Proposed alongside the PostgreSQL audio-import store
-- (`umbratrans_web/audio_import_postgres.py`). Apply with 0008 already present;
-- this file depends only on `public.organizations` and the append-only guard
-- function 0008 defines, and it is additive and idempotent.
--
-- Why a fifth table. 0008 stores *what was imported* — the source reference, the
-- speakers, chapters and utterances. It has no row for *the act of importing*, so
-- the importer had no durable identity to return for an import and would have
-- had to mint one from nothing. `project_source_refs.project_id` is one row per
-- project and identifies the project, not the run; two imports of the same book
-- at different content hashes are two runs and one project.
--
-- The audio side named this failure directly: an import that returns a
-- `project_id` and an `import_id` that correspond to no row is worse than an
-- error, because it looks like success. `translation_imports.import_id` is the
-- row that makes `import_id` name something, and `counts` records what that run
-- actually wrote so a later "the numbers do not add up" question has an answer.
--
-- The source hash is the idempotency key and stays on `project_source_refs`
-- (UNIQUE (system, external_id)). This table is an append-only history: it is
-- never updated, and a re-import appends a new run rather than editing the last.
BEGIN;
DO $preflight$
BEGIN
IF to_regclass('public.project_source_refs') IS NULL THEN
RAISE EXCEPTION 'public.project_source_refs is missing; apply 0008 before 0009';
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
WHERE n.nspname = 'app' AND p.proname = 'prevent_translation_source_mutation') THEN
RAISE EXCEPTION 'app.prevent_translation_source_mutation() is missing; 0008 must be applied first';
END IF;
END
$preflight$;
CREATE TABLE IF NOT EXISTS public.translation_imports (
import_id uuid PRIMARY KEY,
project_id uuid NOT NULL,
organization_id uuid NOT NULL REFERENCES public.organizations(id),
-- The exporting system and node, copied from the source reference at the
-- moment of import so the run stays legible even if a node row is removed.
system text NOT NULL CHECK (length(btrim(system)) BETWEEN 1 AND 64),
node_id text NOT NULL,
external_id text NOT NULL,
-- sha256 of the manifest bytes for this run. A run with a hash already
-- present for the project is a replay; the importer refuses it before here.
content_hash text NOT NULL CHECK (content_hash ~ '^[0-9a-f]{64}$'),
-- What this run actually wrote, not what it planned. Identical bytes dedupe
-- against rows already present, so the written counts can be lower than the
-- plan's; this is the written number and is the one an audit should read.
counts jsonb NOT NULL DEFAULT '{}'::jsonb,
imported_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS translation_imports_org_idx
ON public.translation_imports (organization_id);
CREATE INDEX IF NOT EXISTS translation_imports_project_idx
ON public.translation_imports (project_id, imported_at DESC);
-- Append-only on UPDATE, reusing the guard 0008 already defines. A run record is
-- a statement about a moment; editing it in place would break the link between
-- the row and the counts it reports. DELETE is permitted for the same reason as
-- the other four tables: retention and re-import need it.
DO $guard$
BEGIN
EXECUTE 'DROP TRIGGER IF EXISTS translation_imports_append_only ON public.translation_imports';
EXECUTE 'CREATE TRIGGER translation_imports_append_only BEFORE UPDATE ON public.translation_imports FOR EACH ROW EXECUTE FUNCTION app.prevent_translation_source_mutation()';
END
$guard$;
DO $rls$
BEGIN
EXECUTE 'ALTER TABLE public.translation_imports ENABLE ROW LEVEL SECURITY';
EXECUTE 'DROP POLICY IF EXISTS translation_imports_tenant ON public.translation_imports';
EXECUTE 'CREATE POLICY translation_imports_tenant ON public.translation_imports USING (app.is_platform_admin() OR app.is_organization_member(organization_id))';
END
$rls$;
COMMENT ON TABLE public.translation_imports IS
'One row per audio source import run: the durable identity an import returns, and what it actually wrote.';
COMMENT ON COLUMN public.translation_imports.counts IS
'Rows actually written by this run (after deduplication), keyed by chapters/speakers/utterances/artifacts/warnings.';
COMMENT ON COLUMN public.translation_imports.content_hash IS
'sha256 of the manifest bytes for this run. The idempotency key lives on project_source_refs; this is the run history.';
COMMIT;
-- ---------------------------------------------------------------------
-- Rollback, in reverse dependency order. Not applied automatically.
--
-- DROP POLICY IF EXISTS translation_imports_tenant ON public.translation_imports;
-- DROP TRIGGER IF EXISTS translation_imports_append_only ON public.translation_imports;
-- DROP TABLE IF EXISTS public.translation_imports;