Files
UmbraDatastore/migrations/0008_audio_source_import.sql

306 lines
17 KiB
PL/PgSQL

-- 0008: the audio source import — the controller's copy of what the audio node
-- analyzed, and the tables the file-based importer writes.
--
-- Status: APPLIED (applied by the audio-controller to `umbratrans` as
-- described in the migration procedure; verified against scratch and then
-- applied). Proposed alongside `umbratrans_web/audio_import.py` and
-- `docs/audio-system-mapping-v1.md`.
--
-- Additive and idempotent: four new tables, one guard function, their policies.
-- Nothing existing is altered, and no existing table gains or loses a column.
--
-- Why these tables exist at all. The audio node owns the audiobooks and exports
-- a metadata bundle; the controller is the authoritative repository for the
-- translation pipeline and must hold a copy of that analysis rather than reach
-- across to the audio database whenever it needs a chapter or a speaker. The
-- copy is a snapshot identified by `content_hash`, and the fields are the ones
-- `audio_import.py` maps — the mapping document is the contract, this file is
-- where it lands.
--
-- Three decisions here are load-bearing and are commented where they are made:
--
-- 1. `project_id` carries no foreign key. `projects` lives in the per-tenant
-- schema (0005) and is created by `app.provision_tenant_schema`, so there is
-- no stable relation name to reference. `public.artifacts` reached the same
-- conclusion in 0003 and this follows it: `organization_id` is the FK that
-- matters for tenancy, and `project_id` is an opaque key scoped by it.
-- 2. Identity is the source row id, never the order. `source_row_id` and
-- `external_id` come from the audio database; `reading_order` is a snapshot
-- of one export and is deliberately not unique or indexed as an identity.
-- 3. The four tables are append-only on UPDATE. A source fact whose text is
-- rewritten underneath its `source_hash` is a row that no longer describes
-- anything; a changed source is a new import, not an edit. DELETE is
-- permitted, because re-import and retention both need it and the same
-- reasoning as `artifacts` applies.
--
-- `integrity` for an artifact is `unverified` on import by design: the importer
-- records a render's path and checksum as a claim. Reading the bytes back is a
-- different operation with a different name.
BEGIN;
-- Fail fast rather than half-create if the prerequisites are missing. Both are
-- phase-1 objects; if either is absent this is the wrong database.
DO $preflight$
BEGIN
IF to_regclass('public.organizations') IS NULL THEN
RAISE EXCEPTION 'public.organizations is missing; 0008 belongs on the control-plane schema';
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 = 'is_organization_member') THEN
RAISE EXCEPTION 'app.is_organization_member(uuid) is missing; the RLS policies this file writes would be inert';
END IF;
END
$preflight$;
-- ---------------------------------------------------------------------
-- 1. The source reference: one row per project, one source system
-- ---------------------------------------------------------------------
-- The book-level record: what this project came from, and the content hash of
-- the whole export. The hash is what makes an import idempotent — the same bytes
-- are the same import, and a different hash is a different one.
CREATE TABLE IF NOT EXISTS public.project_source_refs (
project_id uuid PRIMARY KEY,
organization_id uuid NOT NULL REFERENCES public.organizations(id),
-- The exporting system and the node that ran it. Text, not a FK to
-- compute_nodes: the source system is a fact about the data (it is
-- `umbraaudio` on `umbraaudio-m4`), and a node row can be removed (0006)
-- without the provenance of an already-imported book becoming unknown.
system text NOT NULL CHECK (length(btrim(system)) BETWEEN 1 AND 64),
node_id text NOT NULL,
external_id text NOT NULL,
source_title text,
source_status text,
-- Languages the audio side designated, if any. Empty is accepted as an
-- answer: target languages are a translation-side decision.
languages jsonb NOT NULL DEFAULT '[]'::jsonb,
-- The numbering schemes and the hazard that accompanies them, stored
-- verbatim because the note ("three numbers identify one chapter and none
-- derives from another") is the useful part.
numbering jsonb NOT NULL DEFAULT '{}'::jsonb,
content_hash text NOT NULL CHECK (content_hash ~ '^[0-9a-f]{64}$'),
product_version text,
source_meta jsonb NOT NULL DEFAULT '{}'::jsonb,
imported_at timestamptz NOT NULL DEFAULT now(),
-- One source system may only describe one project once. A second system (a
-- future cover pipeline, say) may add its own row about the same project, so
-- the uniqueness is on the pair and not on either alone.
CONSTRAINT project_source_refs_system_external_key UNIQUE (system, external_id)
);
CREATE INDEX IF NOT EXISTS project_source_refs_org_idx ON public.project_source_refs (organization_id);
-- ---------------------------------------------------------------------
-- 2. Speakers
-- ---------------------------------------------------------------------
-- One row per speaker *as the source recorded it*. The narrator is the one
-- merge the importer performs, and it is performed on the identity the pipeline
-- uses, not here: both narrator source rows are kept, and `merged_into` records
-- which spelling they resolve to. Nothing else is merged — a casefold collision
-- group is stored and surfaced to a human, never collapsed.
CREATE TABLE IF NOT EXISTS public.translation_speakers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL,
organization_id uuid NOT NULL REFERENCES public.organizations(id),
external_id text NOT NULL,
display_name text NOT NULL,
role text NOT NULL CHECK (role IN ('narrator', 'character')),
aliases jsonb NOT NULL DEFAULT '[]'::jsonb,
collision_group text,
has_profile boolean NOT NULL DEFAULT false,
is_group boolean NOT NULL DEFAULT false,
unidentified boolean NOT NULL DEFAULT false,
edge_voice text,
tts_engine text,
voice_review_status text,
-- The canonical narrator this row resolves to, when it is not itself the
-- canonical one. NULL for characters and for the canonical narrator.
merged_into_external_id text,
source_meta jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT translation_speakers_identity_key UNIQUE (project_id, external_id)
);
CREATE INDEX IF NOT EXISTS translation_speakers_org_idx ON public.translation_speakers (organization_id);
CREATE INDEX IF NOT EXISTS translation_speakers_project_idx ON public.translation_speakers (project_id);
-- ---------------------------------------------------------------------
-- 3. Chapters
-- ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.translation_chapters (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL,
organization_id uuid NOT NULL REFERENCES public.organizations(id),
external_id text NOT NULL,
-- A snapshot of the export's order. Useful, not an identity.
reading_order integer NOT NULL,
matter text NOT NULL DEFAULT 'novel' CHECK (matter IN ('novel', 'matter')),
title text,
source_hash text NOT NULL CHECK (source_hash ~ '^[0-9a-f]{64}$'),
source_row_id bigint NOT NULL,
numbering_basis jsonb NOT NULL DEFAULT '{}'::jsonb,
utterance_count integer NOT NULL DEFAULT 0 CHECK (utterance_count >= 0),
source_status text,
source_meta jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT translation_chapters_identity_key UNIQUE (project_id, external_id)
);
CREATE INDEX IF NOT EXISTS translation_chapters_org_idx ON public.translation_chapters (organization_id);
CREATE INDEX IF NOT EXISTS translation_chapters_project_idx ON public.translation_chapters (project_id, reading_order);
-- ---------------------------------------------------------------------
-- 4. Utterances
-- ---------------------------------------------------------------------
-- `chapter_external_id` is text and not a FK to `translation_chapters`, for the
-- same reason `project_id` has no FK: these rows arrive together in one import
-- and the importer has already resolved that every reference resolves. A FK
-- would add a constraint the import order has to satisfy without adding a
-- guarantee the importer does not already hold.
CREATE TABLE IF NOT EXISTS public.translation_utterances (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL,
organization_id uuid NOT NULL REFERENCES public.organizations(id),
external_id text NOT NULL,
chapter_external_id text NOT NULL,
utterance_index integer NOT NULL,
kind text NOT NULL CHECK (kind IN ('narration', 'dialogue', 'scene_break')),
text text NOT NULL,
source_hash text NOT NULL CHECK (source_hash ~ '^[0-9a-f]{64}$'),
speaker_external_id text NOT NULL,
-- NULL is the implicit narrator, and that is the whole meaning of NULL here.
-- An explicit attribution always carries the name the source wrote.
speaker_name_raw text,
speaker_name_resolved text NOT NULL DEFAULT '',
attribution text NOT NULL CHECK (attribution IN ('implicit_narrator', 'explicit')),
needs_review boolean NOT NULL DEFAULT false,
render_external_id text,
source_row_id bigint,
source_meta jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT translation_utterances_identity_key UNIQUE (project_id, external_id)
);
CREATE INDEX IF NOT EXISTS translation_utterances_org_idx ON public.translation_utterances (organization_id);
CREATE INDEX IF NOT EXISTS translation_utterances_chapter_idx
ON public.translation_utterances (project_id, chapter_external_id, utterance_index);
CREATE INDEX IF NOT EXISTS translation_utterances_speaker_idx
ON public.translation_utterances (project_id, speaker_external_id);
-- ---------------------------------------------------------------------
-- 5. Append-only on UPDATE
-- ---------------------------------------------------------------------
-- One guard for the four tables. UPDATE is refused because a source fact is a
-- statement about a document at a moment, and rewriting it in place breaks the
-- link between the row and its `source_hash`. DELETE is permitted, exactly as it
-- is for `artifacts`: re-importing a changed bundle and reclaiming storage both
-- need it, and the guarantee that matters (no silent edit) is not weakened by
-- allowing a row to be removed and written again.
DO $guard$
BEGIN
EXECUTE $func$
CREATE OR REPLACE FUNCTION app.prevent_translation_source_mutation()
RETURNS trigger LANGUAGE plpgsql AS $body$
BEGIN
RAISE EXCEPTION
'translation source rows are a snapshot and are append-only; a changed source is a new import, not an edit';
END;
$body$;
$func$;
EXECUTE 'DROP TRIGGER IF EXISTS project_source_refs_append_only ON public.project_source_refs';
EXECUTE 'CREATE TRIGGER project_source_refs_append_only BEFORE UPDATE ON public.project_source_refs FOR EACH ROW EXECUTE FUNCTION app.prevent_translation_source_mutation()';
EXECUTE 'DROP TRIGGER IF EXISTS translation_speakers_append_only ON public.translation_speakers';
EXECUTE 'CREATE TRIGGER translation_speakers_append_only BEFORE UPDATE ON public.translation_speakers FOR EACH ROW EXECUTE FUNCTION app.prevent_translation_source_mutation()';
EXECUTE 'DROP TRIGGER IF EXISTS translation_chapters_append_only ON public.translation_chapters';
EXECUTE 'CREATE TRIGGER translation_chapters_append_only BEFORE UPDATE ON public.translation_chapters FOR EACH ROW EXECUTE FUNCTION app.prevent_translation_source_mutation()';
EXECUTE 'DROP TRIGGER IF EXISTS translation_utterances_append_only ON public.translation_utterances';
EXECUTE 'CREATE TRIGGER translation_utterances_append_only BEFORE UPDATE ON public.translation_utterances FOR EACH ROW EXECUTE FUNCTION app.prevent_translation_source_mutation()';
EXECUTE 'REVOKE ALL ON FUNCTION app.prevent_translation_source_mutation() FROM PUBLIC';
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'umbracontrol_api') THEN
EXECUTE 'GRANT EXECUTE ON FUNCTION app.prevent_translation_source_mutation() TO umbracontrol_api';
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'umbracontrol_worker') THEN
EXECUTE 'GRANT EXECUTE ON FUNCTION app.prevent_translation_source_mutation() TO umbracontrol_worker';
END IF;
END
$guard$;
-- ---------------------------------------------------------------------
-- 6. Row-level security
-- ---------------------------------------------------------------------
-- Enabled but not forced, matching every other table in this schema: the
-- application owns these tables, so the policy is defence in depth for a
-- non-owner role and the adapter's own scoping is today's control. `FORCE` and a
-- non-owner application role arrive together at gate G2 and nowhere sooner.
--
-- `CREATE POLICY` has no `IF NOT EXISTS`, so a re-run would fail on the second
-- apply. Each is dropped first, which is what makes this file safe to apply
-- twice.
DO $rls$
DECLARE
target text;
BEGIN
FOREACH target IN ARRAY ARRAY[
'project_source_refs', 'translation_speakers', 'translation_chapters', 'translation_utterances'
] LOOP
EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', target);
EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', target || '_tenant', target);
END LOOP;
EXECUTE 'CREATE POLICY project_source_refs_tenant ON public.project_source_refs USING (app.is_platform_admin() OR app.is_organization_member(organization_id))';
EXECUTE 'CREATE POLICY translation_speakers_tenant ON public.translation_speakers USING (app.is_platform_admin() OR app.is_organization_member(organization_id))';
EXECUTE 'CREATE POLICY translation_chapters_tenant ON public.translation_chapters USING (app.is_platform_admin() OR app.is_organization_member(organization_id))';
EXECUTE 'CREATE POLICY translation_utterances_tenant ON public.translation_utterances USING (app.is_platform_admin() OR app.is_organization_member(organization_id))';
END
$rls$;
-- ---------------------------------------------------------------------
-- 7. Documentation for the schema itself
-- ---------------------------------------------------------------------
COMMENT ON TABLE public.project_source_refs IS
'One row per project per source system: what the project was imported from and the content hash of that import.';
COMMENT ON COLUMN public.project_source_refs.content_hash IS
'sha256 of the exported manifest bytes. The same bytes are the same import; a different hash is a different import.';
COMMENT ON TABLE public.translation_speakers IS
'Speakers as the source recorded them. The narrator merge is recorded in merged_into_external_id; no other row is merged.';
COMMENT ON TABLE public.translation_chapters IS
'Chapters. Identity is external_id (the source row id); reading_order is a snapshot and not an identity.';
COMMENT ON TABLE public.translation_utterances IS
'Utterances. speaker_name_raw NULL means the implicit narrator, which is the entire meaning of NULL in that column.';
COMMENT ON FUNCTION app.prevent_translation_source_mutation() IS
'Refuses UPDATE on the translation source tables. A changed source is a new import, not an edit.';
COMMIT;
-- ---------------------------------------------------------------------
-- Rollback, in reverse dependency order. Not applied automatically.
--
-- DROP POLICY IF EXISTS translation_utterances_tenant ON public.translation_utterances;
-- DROP POLICY IF EXISTS translation_chapters_tenant ON public.translation_chapters;
-- DROP POLICY IF EXISTS translation_speakers_tenant ON public.translation_speakers;
-- DROP POLICY IF EXISTS project_source_refs_tenant ON public.project_source_refs;
-- DROP TRIGGER IF EXISTS translation_utterances_append_only ON public.translation_utterances;
-- DROP TRIGGER IF EXISTS translation_chapters_append_only ON public.translation_chapters;
-- DROP TRIGGER IF EXISTS translation_speakers_append_only ON public.translation_speakers;
-- DROP TRIGGER IF EXISTS project_source_refs_append_only ON public.project_source_refs;
-- DROP FUNCTION IF EXISTS app.prevent_translation_source_mutation();
-- DROP TABLE IF EXISTS public.translation_utterances;
-- DROP TABLE IF EXISTS public.translation_chapters;
-- DROP TABLE IF EXISTS public.translation_speakers;
-- DROP TABLE IF EXISTS public.project_source_refs;
--
-- The rollback is destructive and drops the imported data with the tables. It is
-- safe only while nothing the translation pipeline produced points at a
-- translation row, which is true today because the pipeline has not run. Once a
-- translated chapter exists, the rollback must be replaced by a deprecation, not
-- run as written.