audio: Phase 2 — Postgres AudioImportStore adapter + 0009 ledger; use PG store on commit with org from node; 0008 header updated; tests

This commit is contained in:
umbra audio
2026-10-16 03:25:00 -07:00
parent e35d7a144f
commit 4160ec49a0
7 changed files with 2877 additions and 0 deletions

View File

@@ -0,0 +1,892 @@
"""File-based import of an UmbraAudio metadata bundle.
The audio node is the master for the audiobooks. This module reads the
machine-readable bundle it exports (``bundle.json`` plus ``manifest.jsonl``),
checks it, and turns it into an :class:`ImportPlan` — the exact set of rows the
controller would write. Nothing here touches a database; the plan is a value,
which is what makes the dry run a real dry run rather than a rolled-back write.
Two things this module refuses to do, both on purpose:
* **It never fetches a render's bytes.** Every artifact it plans carries
``integrity='unverified'``. A path and a checksum are a claim that somebody
said a file exists there; only reading the bytes back turns that into a fact,
and that is a different operation with a different name.
* **It never derives a chapter's identity from its order.** The chapter row id is
the identity; ``chapter_index`` is reading order and a hint; the number in a
distribution filename is neither. The export carries ``numbering_basis`` for
exactly this reason, and a chapter without one is a bundle this module rejects.
The mapping this implements is ``docs/audio-system-mapping-v1.md``.
"""
from __future__ import annotations
import hashlib
import json
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Mapping, Protocol, runtime_checkable
from uuid import UUID, uuid4
from .security import utc_now
#: The only bundle format this module understands. A future format is a new
#: constant and a branch, not a guess about what an unknown field means.
BUNDLE_FORMAT_VERSION = 1
RECORD_KINDS = ("book", "chapter", "speaker", "utterance", "render")
UTTERANCE_TYPES = frozenset({"narration", "dialogue", "scene_break"})
#: ``audio_format.container`` (measured) to an IANA media type. Keyed on the
#: container and never on the file extension: 33 files in the reference bundle
#: are named ``.wav`` and are MPEG audio, and the extension is the one thing that
#: cannot be trusted.
CONTAINER_MEDIA_TYPES: Mapping[str, str] = {
"wav": "audio/wav",
"mp3": "audio/mpeg",
"aac": "audio/aac",
"mp4": "audio/mp4",
"m4a": "audio/mp4",
"m4b": "audio/mp4",
"ogg": "audio/ogg",
"opus": "audio/opus",
"flac": "audio/flac",
"webm": "audio/webm",
}
#: Used only when the container is genuinely unknown. It is deliberately not
#: ``audio/wav`` or any other guess: a wrong media type is worse than an honest
#: "I do not know", because the wrong one is believed.
UNKNOWN_MEDIA_TYPE = "application/octet-stream"
#: A render role that represents finished output rather than an intermediate.
FINAL_RENDER_ROLES = frozenset({"distribution_track", "assembled_book"})
DEFAULT_SYSTEM = "umbraaudio"
class BundleError(Exception):
"""A bundle that cannot be read, or that must not be trusted."""
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
# ---------------------------------------------------------------------------
# Reading the bundle
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class Bundle:
"""A bundle as read from disk, before any judgement is made about it."""
path: Path
envelope: Mapping[str, Any]
records: tuple[Mapping[str, Any], ...]
manifest_sha256: str
declared_content_hash: str | None
def of_kind(self, kind: str) -> tuple[Mapping[str, Any], ...]:
return tuple(record for record in self.records if record.get("kind") == kind)
@property
def content_hash_matches(self) -> bool:
return self.declared_content_hash is not None and self.declared_content_hash == self.manifest_sha256
def read_bundle(path: str | Path) -> Bundle:
"""Read a bundle directory. Raises :class:`BundleError` if it cannot be read.
Reading is separated from judging. A bundle that parses and a bundle that is
acceptable are different questions, and collapsing them is how a malformed
bundle comes back as "mostly fine, I think".
"""
root = Path(path)
if not root.is_dir():
raise BundleError(f"not a directory: {root}")
envelope_path = root / "bundle.json"
manifest_path = root / "manifest.jsonl"
for required in (envelope_path, manifest_path):
if not required.is_file():
raise BundleError(f"missing required file: {required.name}")
try:
envelope = json.loads(envelope_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise BundleError(f"bundle.json is not readable JSON: {error}") from error
if not isinstance(envelope, dict):
raise BundleError("bundle.json must be a JSON object")
manifest_bytes = manifest_path.read_bytes()
manifest_sha256 = hashlib.sha256(manifest_bytes).hexdigest()
records: list[Mapping[str, Any]] = []
for number, line in enumerate(manifest_bytes.decode("utf-8").splitlines(), start=1):
if not line.strip():
continue
try:
record = json.loads(line)
except json.JSONDecodeError as error:
raise BundleError(f"manifest.jsonl:{number}: not valid JSON ({error.msg})") from error
if not isinstance(record, dict):
raise BundleError(f"manifest.jsonl:{number}: a record, not a {type(record).__name__}")
records.append(record)
if not records:
raise BundleError("manifest.jsonl contains no records")
declared = envelope.get("content_hash")
declared_value = declared.get("value") if isinstance(declared, Mapping) else None
return Bundle(
path=root,
envelope=envelope,
records=tuple(records),
manifest_sha256=manifest_sha256,
declared_content_hash=declared_value if isinstance(declared_value, str) else None,
)
# ---------------------------------------------------------------------------
# Checking the bundle
# ---------------------------------------------------------------------------
def validate_bundle(bundle: Bundle) -> list[str]:
"""Return every structural or invariant problem, or an empty list.
Every problem is reported, not the first: a checker that stops at one turns a
round trip into a correspondence. This is the pure-Python half of the gate;
the JSON Schema half is ``docs/validate-audio-manifest.py``, run separately.
"""
problems: list[str] = []
version = bundle.envelope.get("bundle_format_version")
if version != BUNDLE_FORMAT_VERSION:
problems.append(f"bundle_format_version is {version!r}, expected {BUNDLE_FORMAT_VERSION}")
if not bundle.content_hash_matches:
problems.append(
"content_hash mismatch: bundle.json declares "
f"{bundle.declared_content_hash!r} but manifest.jsonl hashes to {bundle.manifest_sha256}"
)
by_id: dict[str, Mapping[str, Any]] = {}
for record in bundle.records:
rid = record.get("id")
if isinstance(rid, str):
by_id[rid] = record
chapters = {c.get("id") for c in bundle.of_kind("chapter")}
speakers = {s.get("id") for s in bundle.of_kind("speaker")}
narrator_ids = {s.get("id") for s in bundle.of_kind("speaker") if s.get("is_narrator")}
books = bundle.of_kind("book")
if len(books) != 1:
problems.append(f"expected exactly one book record, found {len(books)}")
if not narrator_ids:
problems.append("no speaker record has is_narrator=true")
for chapter in bundle.of_kind("chapter"):
cid = chapter.get("id")
if "numbering_basis" not in chapter:
problems.append(f"chapter {cid!r}: no numbering_basis; identity cannot be told from order")
if not isinstance(chapter.get("chapter_index"), int):
problems.append(f"chapter {cid!r}: chapter_index is not an integer")
if chapter.get("book_id") not in by_id:
problems.append(f"chapter {cid!r}: book_id {chapter.get('book_id')!r} does not resolve")
for utterance in bundle.of_kind("utterance"):
uid = utterance.get("id")
resolution = utterance.get("speaker_resolution")
raw = utterance.get("speaker_name_raw")
if utterance.get("chapter_id") not in chapters:
problems.append(f"utterance {uid!r}: chapter_id {utterance.get('chapter_id')!r} does not resolve")
if utterance.get("speaker_id") not in speakers:
problems.append(f"utterance {uid!r}: speaker_id {utterance.get('speaker_id')!r} does not resolve")
if utterance.get("utterance_type") not in UTTERANCE_TYPES:
problems.append(f"utterance {uid!r}: bad utterance_type {utterance.get('utterance_type')!r}")
if resolution == "implicit_narrator":
if raw is not None:
problems.append(
f"utterance {uid!r}: implicit_narrator with a non-null speaker_name_raw ({raw!r}); "
"null is what implicit means"
)
if utterance.get("speaker_id") not in narrator_ids:
problems.append(f"utterance {uid!r}: implicit narrator does not resolve to a narrator speaker")
elif resolution == "explicit":
if not isinstance(raw, str) or not raw:
problems.append(f"utterance {uid!r}: explicit attribution with speaker_name_raw {raw!r}")
else:
problems.append(f"utterance {uid!r}: unknown speaker_resolution {resolution!r}")
render_id = utterance.get("render_id")
if render_id is not None and render_id not in by_id:
problems.append(f"utterance {uid!r}: render_id {render_id!r} does not resolve")
for render in bundle.of_kind("render"):
rid = render.get("id")
fmt = render.get("audio_format")
if fmt is not None and not isinstance(fmt, Mapping):
problems.append(f"render {rid!r}: audio_format is not an object")
book_id = render.get("book_id")
if book_id is not None and book_id not in by_id:
problems.append(f"render {rid!r}: book_id {book_id!r} does not resolve")
chapter_id = render.get("chapter_id")
if chapter_id is not None and chapter_id not in chapters:
problems.append(f"render {rid!r}: chapter_id {chapter_id!r} does not resolve")
return problems
# ---------------------------------------------------------------------------
# The plan
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class ImportWarning:
"""Something a human should see before or after an import. Not an error."""
code: str
subject: str
message: str
@dataclass(frozen=True, slots=True)
class PlannedProject:
organization_id: UUID
external_id: str
name: str
slug: str
title: str | None
source_status: str | None
languages: tuple[str, ...]
numbering: Mapping[str, Any]
system: str
node_id: str
product_version: str | None
book_directory: str | None
content_hash: str
@dataclass(frozen=True, slots=True)
class PlannedChapter:
external_id: str
reading_order: int
matter: str # "novel" | "matter"
title: str | None
source_hash: str
source_row_id: int
numbering_basis: Mapping[str, Any]
utterance_count: int
source_status: str | None
@dataclass(frozen=True, slots=True)
class PlannedSpeaker:
external_id: str
display_name: str
role: str # "narrator" | "character"
aliases: tuple[str, ...]
collision_group: str | None
has_profile: bool
is_group: bool
unidentified: bool
edge_voice: str | None
tts_engine: str | None
voice_review_status: str | None
#: The canonical narrator a merged spelling resolves to, else ``None``.
merged_into: str | None
@dataclass(frozen=True, slots=True)
class PlannedUtterance:
external_id: str
chapter_external_id: str
index: int
kind: str
text: str
source_hash: str
speaker_external_id: str
speaker_name_raw: str | None
speaker_name_resolved: str
attribution: str
needs_review: bool
render_external_id: str | None
source_row_id: int | None
@dataclass(frozen=True, slots=True)
class PlannedArtifact:
source_render_id: str
kind: str
status: str
content_hash: str
byte_size: int
media_type: str
storage_key: str
storage_uri: str
integrity: str
derivation: Mapping[str, Any]
@dataclass(frozen=True, slots=True)
class ImportPlan:
project: PlannedProject
chapters: tuple[PlannedChapter, ...]
speakers: tuple[PlannedSpeaker, ...]
utterances: tuple[PlannedUtterance, ...]
artifacts: tuple[PlannedArtifact, ...]
warnings: tuple[ImportWarning, ...]
narrator_external_id: str | None
def counts(self) -> dict[str, int]:
return {
"chapters": len(self.chapters),
"speakers": len(self.speakers),
"utterances": len(self.utterances),
"artifacts": len(self.artifacts),
"warnings": len(self.warnings),
}
# ---------------------------------------------------------------------------
# Planning
# ---------------------------------------------------------------------------
def _media_type(audio_format: Mapping[str, Any] | None) -> tuple[str, str | None]:
"""Return the media type for a format, and a warning code when it is a guess."""
container = None
if isinstance(audio_format, Mapping):
container = audio_format.get("container")
if isinstance(container, str):
media = CONTAINER_MEDIA_TYPES.get(container.strip().lower())
if media is not None:
return media, None
return UNKNOWN_MEDIA_TYPE, "unknown_container"
def _book_directory(bundle: Bundle, book: Mapping[str, Any]) -> str | None:
scope = bundle.envelope.get("scope")
if isinstance(scope, Mapping):
names = scope.get("books_full_detail")
if isinstance(names, list) and names and isinstance(names[0], str):
return names[0]
for key in ("path_relative_to", "name"):
value = book.get(key)
if isinstance(value, str) and value and value != "book_directory":
return value
return None
def plan_import(
bundle: Bundle,
*,
organization_id: UUID,
project_name: str | None = None,
project_slug: str | None = None,
node_id: str | None = None,
) -> ImportPlan:
"""Map a validated bundle onto the rows the controller would write.
Raises :class:`BundleError` when the bundle is not valid. Callers that want
the individual problems rather than an exception call :func:`validate_bundle`
first, which is what the CLI does so it can print all of them.
"""
problems = validate_bundle(bundle)
if problems:
raise BundleError("; ".join(problems))
book = bundle.of_kind("book")[0]
source = bundle.envelope.get("source")
source = source if isinstance(source, Mapping) else {}
resolved_node = node_id or source.get("node_id") or "unknown"
languages = book.get("language_designations")
languages_tuple = tuple(str(value) for value in languages) if isinstance(languages, list) else ()
numbering = book.get("numbering")
numbering = dict(numbering) if isinstance(numbering, Mapping) else {}
external_id = str(book.get("id"))
resolved_name = project_name or str(book.get("name") or external_id)
resolved_slug = project_slug or _slugify(resolved_name)
project = PlannedProject(
organization_id=organization_id,
external_id=external_id,
name=resolved_name,
slug=resolved_slug,
title=book.get("title") if isinstance(book.get("title"), str) else None,
source_status=book.get("status") if isinstance(book.get("status"), str) else None,
languages=languages_tuple,
numbering=numbering,
system=str(source.get("system") or DEFAULT_SYSTEM),
node_id=str(resolved_node),
product_version=source.get("product_version") if isinstance(source.get("product_version"), str) else None,
book_directory=_book_directory(bundle, book),
content_hash=bundle.manifest_sha256,
)
warnings: list[ImportWarning] = []
chapters = tuple(
_plan_chapter(record) for record in sorted(bundle.of_kind("chapter"), key=_chapter_sort_key)
)
speakers_raw = bundle.of_kind("speaker")
narrator_ids = [s.get("id") for s in speakers_raw if s.get("is_narrator")]
canonical_narrator = _canonical_narrator(speakers_raw)
if len(narrator_ids) > 1:
warnings.append(
ImportWarning(
"narrator_rows_merged",
str(canonical_narrator),
f"{len(narrator_ids)} narrator speaker rows ({', '.join(str(i) for i in narrator_ids)}) "
f"resolve to one narrator role; canonical is {canonical_narrator!r}. "
"Source spellings are kept as aliases.",
)
)
speakers = tuple(
_plan_speaker(record, canonical_narrator if record.get("is_narrator") else None)
for record in sorted(speakers_raw, key=lambda r: str(r.get("id")))
)
utterances = tuple(
_plan_utterance(record, canonical_narrator, narrator_ids)
for record in sorted(bundle.of_kind("utterance"), key=_utterance_sort_key)
)
artifacts: list[PlannedArtifact] = []
for render in sorted(bundle.of_kind("render"), key=lambda r: str(r.get("id"))):
artifact, warning = _plan_artifact(render, project, bundle)
artifacts.append(artifact)
if warning is not None:
warnings.append(warning)
warnings.extend(_bundle_warnings(bundle, chapters, speakers))
return ImportPlan(
project=project,
chapters=chapters,
speakers=speakers,
utterances=utterances,
artifacts=tuple(artifacts),
warnings=tuple(warnings),
narrator_external_id=str(canonical_narrator) if canonical_narrator else None,
)
def _slugify(value: str) -> str:
import re
slug = re.sub(r"[^a-z0-9]+", "-", value.strip().lower()).strip("-")
return slug or "project"
def _chapter_sort_key(record: Mapping[str, Any]) -> tuple[int, str]:
index = record.get("chapter_index")
return (index if isinstance(index, int) else 0, str(record.get("id")))
def _utterance_sort_key(record: Mapping[str, Any]) -> tuple[str, int]:
index = record.get("utterance_index")
return (str(record.get("chapter_id")), index if isinstance(index, int) else 0)
def _canonical_narrator(speakers: tuple[Mapping[str, Any], ...]) -> str | None:
"""Pick the one narrator row that becomes the role.
Preference order is the same one the mapping document states: a row whose
voice was reviewed and approved, then the row carrying the most narration,
then the lowest id so the choice is deterministic across runs.
"""
narrators = [s for s in speakers if s.get("is_narrator")]
if not narrators:
return None
def rank(record: Mapping[str, Any]) -> tuple[int, int, str]:
approved = 1 if record.get("voice_review_status") == "approved" else 0
count = record.get("utterance_count_including_implicit_narrator")
return (-approved, -(count if isinstance(count, int) else 0), str(record.get("id")))
return str(min(narrators, key=rank).get("id"))
def _plan_chapter(record: Mapping[str, Any]) -> PlannedChapter:
index = record.get("chapter_index")
reading_order = index if isinstance(index, int) else 0
row_id = record.get("database_row_id")
numbering = record.get("numbering_basis")
return PlannedChapter(
external_id=str(record.get("id")),
reading_order=reading_order,
matter="matter" if reading_order < 0 else "novel",
title=record.get("title") if isinstance(record.get("title"), str) else None,
source_hash=str(record.get("source_text_sha256")),
source_row_id=row_id if isinstance(row_id, int) else 0,
numbering_basis=dict(numbering) if isinstance(numbering, Mapping) else {},
utterance_count=record.get("utterance_count") if isinstance(record.get("utterance_count"), int) else 0,
source_status=record.get("status") if isinstance(record.get("status"), str) else None,
)
def _plan_speaker(record: Mapping[str, Any], canonical_narrator: str | None) -> PlannedSpeaker:
speaker_id = str(record.get("id"))
is_narrator = bool(record.get("is_narrator"))
aliases = record.get("aliases")
return PlannedSpeaker(
external_id=speaker_id,
display_name=str(record.get("primary_name")),
role="narrator" if is_narrator else "character",
aliases=tuple(str(a) for a in aliases) if isinstance(aliases, list) else (),
collision_group=record.get("casefold_collision_group")
if isinstance(record.get("casefold_collision_group"), str)
else None,
has_profile=bool(record.get("has_profile")),
is_group=bool(record.get("is_group")),
unidentified=bool(record.get("unidentified")),
edge_voice=record.get("assigned_edge_voice")
if isinstance(record.get("assigned_edge_voice"), str)
else None,
tts_engine=record.get("tts_engine_used") if isinstance(record.get("tts_engine_used"), str) else None,
voice_review_status=record.get("voice_review_status")
if isinstance(record.get("voice_review_status"), str)
else None,
merged_into=canonical_narrator if is_narrator and canonical_narrator and canonical_narrator != speaker_id else None,
)
def _plan_utterance(
record: Mapping[str, Any],
canonical_narrator: str | None,
narrator_ids: list[Any],
) -> PlannedUtterance:
speaker_id = record.get("speaker_id")
# The merge, applied at the only place it changes an answer: an utterance
# whose speaker is any narrator spelling points at the one narrator role.
if canonical_narrator and speaker_id in narrator_ids:
speaker_id = canonical_narrator
row_id = record.get("database_row_id")
index = record.get("utterance_index")
return PlannedUtterance(
external_id=str(record.get("id")),
chapter_external_id=str(record.get("chapter_id")),
index=index if isinstance(index, int) else 0,
kind=str(record.get("utterance_type")),
text=str(record.get("text") or ""),
source_hash=str(record.get("text_sha256")),
speaker_external_id=str(speaker_id),
speaker_name_raw=record.get("speaker_name_raw")
if isinstance(record.get("speaker_name_raw"), str)
else None,
speaker_name_resolved=str(record.get("speaker_name_resolved") or ""),
attribution=str(record.get("speaker_resolution") or ""),
needs_review=bool(record.get("needs_review")),
render_external_id=record.get("render_id") if isinstance(record.get("render_id"), str) else None,
source_row_id=row_id if isinstance(row_id, int) else None,
)
def _plan_artifact(
render: Mapping[str, Any], project: PlannedProject, bundle: Bundle
) -> tuple[PlannedArtifact, ImportWarning | None]:
audio_format = render.get("audio_format")
audio_format = dict(audio_format) if isinstance(audio_format, Mapping) else {}
media_type, warning_code = _media_type(audio_format)
role = str(render.get("render_role"))
path = str(render.get("path"))
byte_size = render.get("byte_size")
byte_size = byte_size if isinstance(byte_size, int) else 0
derivation: dict[str, Any] = {
"source_render_id": str(render.get("id")),
"render_role": role,
"source_system": project.system,
"source_node": project.node_id,
"relative_to": render.get("relative_to"),
"book_directory": project.book_directory,
"audio_format": audio_format or None,
"duration_ms": render.get("duration_ms"),
"inputs": list(render.get("inputs") or []),
"book_external_id": render.get("book_id"),
"chapter_external_id": render.get("chapter_id"),
"file_modified_at": render.get("file_modified_at"),
}
extension_matches = audio_format.get("extension_matches_container")
if extension_matches is False:
derivation["extension_mismatch"] = True
storage_key = path
root = project.book_directory or ""
storage_uri = f"file://{project.node_id}/{root}/{path}" if root else f"file://{project.node_id}/{path}"
artifact = PlannedArtifact(
source_render_id=str(render.get("id")),
kind="audio",
status="final" if role in FINAL_RENDER_ROLES else "intermediate",
content_hash=str(render.get("content_sha256")),
byte_size=byte_size,
media_type=media_type,
storage_key=storage_key,
storage_uri=storage_uri,
integrity="unverified",
derivation=derivation,
)
warning: ImportWarning | None = None
subject = str(render.get("id"))
if warning_code == "unknown_container":
warning = ImportWarning(
"unknown_container",
subject,
f"container {audio_format.get('container')!r} has no known media type; recorded as {UNKNOWN_MEDIA_TYPE}",
)
elif byte_size == 0:
warning = ImportWarning(
"zero_byte_render", subject, f"render is 0 bytes at {path!r}; recorded, not treated as missing"
)
return artifact, warning
def _bundle_warnings(
bundle: Bundle,
chapters: tuple[PlannedChapter, ...],
speakers: tuple[PlannedSpeaker, ...],
) -> list[ImportWarning]:
warnings: list[ImportWarning] = []
book = bundle.of_kind("book")[0]
declared = book.get("declared_total_chapters")
measured = len(chapters)
if isinstance(declared, int) and declared != measured:
warnings.append(
ImportWarning(
"declared_vs_measured_chapters",
str(book.get("id")),
f"declared {declared} chapters, measured {measured}; surfaced, not reconciled",
)
)
mismatches = book.get("extension_container_mismatches")
if isinstance(mismatches, list):
for entry in mismatches:
if not isinstance(entry, Mapping):
continue
warnings.append(
ImportWarning(
"extension_mismatch",
str(entry.get("id")),
f"named {entry.get('named_extension')!r} but is {entry.get('actual_container')!r} at "
f"{entry.get('path')!r}",
)
)
for speaker in speakers:
if speaker.role == "character" and not speaker.has_profile:
warnings.append(
ImportWarning("speaker_without_profile", speaker.external_id, f"{speaker.display_name!r} has no profile")
)
collision_groups: dict[str, list[str]] = {}
for speaker in speakers:
if speaker.collision_group:
collision_groups.setdefault(speaker.collision_group, []).append(speaker.external_id)
for group, members in sorted(collision_groups.items()):
if len(members) > 1:
warnings.append(
ImportWarning(
"speaker_collision_group",
group,
f"spellings {', '.join(members)} share casefold group {group!r}; not merged",
)
)
return warnings
# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
def render_report(plan: ImportPlan, *, bundle_path: str | Path | None = None, problems: list[str] | None = None) -> str:
"""Render the dry-run report a human reads before confirming a commit."""
lines: list[str] = []
project = plan.project
if problems:
lines.append(f"VALIDATION FAILED: {len(problems)} problem(s)")
lines.extend(f" - {problem}" for problem in problems)
lines.append("")
header = f"Import plan for {project.name!r} ({project.slug})"
if bundle_path is not None:
header += f" from {bundle_path}"
lines.append(header)
lines.append(f" source: {project.system} / {project.node_id} ({project.product_version or 'unknown'})")
lines.append(f" external id: {project.external_id}")
lines.append(f" content hash: {project.content_hash}")
lines.append(f" languages: {', '.join(project.languages) if project.languages else '(none declared)'}")
lines.append(f" narrator: {plan.narrator_external_id or '(none)'}")
lines.append("")
counts = plan.counts()
lines.append(
" to write: "
+ ", ".join(f"{name}={counts[name]}" for name in ("chapters", "speakers", "utterances", "artifacts"))
)
lines.append(f" warnings: {counts['warnings']}")
for warning in plan.warnings:
lines.append(f" [{warning.code}] {warning.subject}: {warning.message}")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Importing
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class ImportResult:
project_id: UUID
import_id: UUID
external_id: str
content_hash: str
counts: Mapping[str, int]
imported_at: datetime
@runtime_checkable
class AudioImportStore(Protocol):
"""Where a plan is written. The PostgreSQL adapter and the dev ledger share it."""
def last_content_hash(self, external_id: str) -> str | None:
"""Return the content hash of the last import for this project, if any."""
raise NotImplementedError(f"{type(self).__name__} does not implement last_content_hash().")
def commit(self, plan: ImportPlan, *, imported_at: datetime) -> ImportResult:
raise NotImplementedError(f"{type(self).__name__} does not implement commit().")
@dataclass(frozen=True, slots=True)
class ImportOutcome:
status: str # "dry_run" | "imported" | "no_change"
plan: ImportPlan
result: ImportResult | None
report: str
def import_bundle(
bundle: Bundle,
store: AudioImportStore,
*,
organization_id: UUID,
project_name: str | None = None,
project_slug: str | None = None,
node_id: str | None = None,
dry_run: bool = True,
confirm: str | None = None,
now: datetime | None = None,
) -> ImportOutcome:
"""Plan a bundle and, when asked and confirmed, write it.
The confirmation gate is here rather than in the CLI so that every caller
passes through it: a non-dry-run commit requires ``confirm`` to be the exact
content hash of the bundle being imported. A flag named ``--commit`` is too
easy to leave in a script; the content hash is not.
"""
plan = plan_import(
bundle,
organization_id=organization_id,
project_name=project_name,
project_slug=project_slug,
node_id=node_id,
)
report = render_report(plan, bundle_path=bundle.path)
if dry_run:
return ImportOutcome(status="dry_run", plan=plan, result=None, report=report)
if confirm != plan.project.content_hash:
raise BundleError(
"refusing to commit without confirmation: pass confirm=<content_hash> "
f"({plan.project.content_hash})"
)
existing = store.last_content_hash(plan.project.external_id)
if existing == plan.project.content_hash:
return ImportOutcome(
status="no_change",
plan=plan,
result=None,
report=report + f"\n\nalready imported at {plan.project.content_hash}; nothing to do",
)
imported_at = now or utc_now()
result = store.commit(plan, imported_at=imported_at)
return ImportOutcome(status="imported", plan=plan, result=result, report=report)
@dataclass
class InMemoryAudioImportStore:
"""A store that keeps plans in memory. For tests and dry demonstrations.
It records the most recent content hash per project, which is what makes the
idempotency test a real test rather than a mock that always says yes.
"""
results: list[ImportResult] = field(default_factory=list)
plans: dict[str, ImportPlan] = field(default_factory=dict)
hashes: dict[str, str] = field(default_factory=dict)
def last_content_hash(self, external_id: str) -> str | None:
return self.hashes.get(external_id)
def commit(self, plan: ImportPlan, *, imported_at: datetime) -> ImportResult:
result = ImportResult(
project_id=uuid4(),
import_id=uuid4(),
external_id=plan.project.external_id,
content_hash=plan.project.content_hash,
counts=plan.counts(),
imported_at=imported_at,
)
self.results.append(result)
self.plans[plan.project.external_id] = plan
self.hashes[plan.project.external_id] = plan.project.content_hash
return result
__all__ = [
"BUNDLE_FORMAT_VERSION",
"Bundle",
"BundleError",
"ImportOutcome",
"ImportPlan",
"ImportResult",
"ImportWarning",
"InMemoryAudioImportStore",
"AudioImportStore",
"PlannedArtifact",
"PlannedChapter",
"PlannedProject",
"PlannedSpeaker",
"PlannedUtterance",
"import_bundle",
"plan_import",
"read_bundle",
"render_report",
"validate_bundle",
]

View File

@@ -0,0 +1,364 @@
"""The PostgreSQL store for audio-bundle imports.
This is the durable half of :mod:`umbratrans_web.audio_import`. That module maps
a bundle to an :class:`~umbratrans_web.audio_import.ImportPlan`; this one writes
the plan to the tables migration 0008 (and 0009) created, inside one transaction,
so that a commit either lands in full or not at all.
Two identities are derived rather than minted at random:
* ``project_id`` is ``uuid5`` of ``(organization_id, system, external_id)``. The
same source book imported again therefore keeps the same project id, which is
what lets a later translation or export point at it stably. A random id per
import would orphan everything the previous import produced.
* ``import_id`` is ``uuid5`` of ``(project_id, content_hash)`` and is written to
``public.translation_imports``. It names a real row; the audio side's warning
was exactly that an import must not return an identifier that corresponds to
nothing. It is reproducible, so replaying an import that was rolled back
reproduces the same id.
A changed bundle (a different ``content_hash``) is a new import, not an edit. The
source tables are append-only on UPDATE, so a re-import removes the rows it is
replacing (DELETE is permitted, and is what makes re-import possible) and writes
the new snapshot. The import ledger is append-only on UPDATE too and is never
cleared, so the run history survives the rows it describes.
"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Any, Sequence
from uuid import UUID, uuid5
import psycopg
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from .audio_import import ImportPlan, ImportResult
#: Namespace for deterministic audio-import identities. Fixed forever: changing it
#: rewrites the identity of every project already imported.
AUDIO_IMPORT_NAMESPACE = UUID("ddca30b0-0e0b-4dcd-afba-8ec400532193")
def audio_project_id(organization_id: UUID, system: str, external_id: str) -> UUID:
"""The stable project id for a source book, independent of when it came in."""
return uuid5(AUDIO_IMPORT_NAMESPACE, f"{organization_id}:{system}:{external_id}")
def audio_import_id(project_id: UUID, content_hash: str) -> UUID:
"""The stable id for one import run: this content, for this project."""
return uuid5(AUDIO_IMPORT_NAMESPACE, f"{project_id}:{content_hash}")
def _json(value: Any) -> str:
"""Serialize to the compact, UTF-8 form jsonb round-trips without surprises."""
return json.dumps(value, separators=(",", ":"), ensure_ascii=False, default=str)
class PostgresAudioImportStore:
"""Write an :class:`ImportPlan` to the audio source tables in one transaction.
Implements :class:`~umbratrans_web.audio_import.AudioImportStore`. The pool is
owned by this object; call :meth:`close` on shutdown.
"""
def __init__(
self,
dsn: str,
*,
min_size: int = 1,
max_size: int = 4,
application_name: str = "umbratrans-audio-import",
) -> None:
self._pool = ConnectionPool(
dsn,
min_size=min_size,
max_size=max_size,
open=True,
kwargs={
"row_factory": dict_row,
"autocommit": False,
"application_name": application_name,
},
)
@classmethod
def from_settings(cls, settings: object) -> PostgresAudioImportStore:
dsn = getattr(settings, "database_dsn", None)
if not dsn:
raise RuntimeError(
"the audio import store requires a database; UMBRATRANS_DATABASE_DSN is unset"
)
return cls(dsn)
def close(self) -> None:
self._pool.close()
# -- AudioImportStore --------------------------------------------------
def last_content_hash(self, external_id: str) -> str | None:
with self._pool.connection() as connection:
row = connection.execute(
"""
SELECT content_hash
FROM public.project_source_refs
WHERE external_id = %s
ORDER BY imported_at DESC
LIMIT 1
""",
(external_id,),
).fetchone()
return row["content_hash"] if row else None
def commit(self, plan: ImportPlan, *, imported_at: datetime) -> ImportResult:
project = plan.project
project_id = audio_project_id(
project.organization_id, project.system, project.external_id
)
import_id = audio_import_id(project_id, project.content_hash)
with self._pool.connection() as connection:
with connection.transaction():
cursor = connection.cursor()
# Artifacts live in public.artifacts and are content-addressed, so
# they are inserted with ON CONFLICT DO NOTHING and never deleted;
# a changed file becomes a second artifact rather than a rewrite.
before_artifacts = connection.execute(
"SELECT count(*) AS n FROM public.artifacts WHERE project_id = %s",
(project_id,),
).fetchone()["n"]
# The four source tables describe one snapshot, so a re-import
# replaces them wholesale. DELETE is permitted on these tables
# precisely so this is possible; there is no UPDATE anywhere.
for table in (
"translation_utterances",
"translation_chapters",
"translation_speakers",
"project_source_refs",
):
connection.execute(f"DELETE FROM public.{table} WHERE project_id = %s", (project_id,))
connection.execute(
"""
INSERT INTO public.project_source_refs (
project_id, organization_id, system, node_id, external_id,
source_title, source_status, languages, numbering,
content_hash, product_version, source_meta, imported_at
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb,
%s, %s, %s::jsonb, %s
)
""",
(
project_id,
project.organization_id,
project.system,
project.node_id,
project.external_id,
project.title,
project.source_status,
_json(list(project.languages)),
_json(project.numbering),
project.content_hash,
project.product_version,
_json(
{
"name": project.name,
"slug": project.slug,
"book_directory": project.book_directory,
}
),
imported_at,
),
)
if plan.speakers:
cursor.executemany(
"""
INSERT INTO public.translation_speakers (
project_id, organization_id, external_id, display_name,
role, aliases, collision_group, has_profile, is_group,
unidentified, edge_voice, tts_engine, voice_review_status,
merged_into_external_id, created_at
) VALUES (
%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s,
%s, %s, %s, %s, %s, %s
)
""",
[
(
project_id,
project.organization_id,
speaker.external_id,
speaker.display_name,
speaker.role,
_json(list(speaker.aliases)),
speaker.collision_group,
speaker.has_profile,
speaker.is_group,
speaker.unidentified,
speaker.edge_voice,
speaker.tts_engine,
speaker.voice_review_status,
speaker.merged_into,
imported_at,
)
for speaker in plan.speakers
],
)
if plan.chapters:
cursor.executemany(
"""
INSERT INTO public.translation_chapters (
project_id, organization_id, external_id, reading_order,
matter, title, source_hash, source_row_id, numbering_basis,
utterance_count, source_status, created_at
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s
)
""",
[
(
project_id,
project.organization_id,
chapter.external_id,
chapter.reading_order,
chapter.matter,
chapter.title,
chapter.source_hash,
chapter.source_row_id,
_json(chapter.numbering_basis),
chapter.utterance_count,
chapter.source_status,
imported_at,
)
for chapter in plan.chapters
],
)
if plan.utterances:
cursor.executemany(
"""
INSERT INTO public.translation_utterances (
project_id, organization_id, external_id,
chapter_external_id, utterance_index, kind, text,
source_hash, speaker_external_id, speaker_name_raw,
speaker_name_resolved, attribution, needs_review,
render_external_id, source_row_id, created_at
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
)
""",
[
(
project_id,
project.organization_id,
utterance.external_id,
utterance.chapter_external_id,
utterance.index,
utterance.kind,
utterance.text,
utterance.source_hash,
utterance.speaker_external_id,
utterance.speaker_name_raw,
utterance.speaker_name_resolved,
utterance.attribution,
utterance.needs_review,
utterance.render_external_id,
utterance.source_row_id,
imported_at,
)
for utterance in plan.utterances
],
)
if plan.artifacts:
cursor.executemany(
"""
INSERT INTO public.artifacts (
project_id, organization_id, lang, kind, status,
content_hash, byte_size, media_type, storage_key,
storage_uri, integrity, derivation, created_at
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s
)
ON CONFLICT DO NOTHING
""",
[
(
project_id,
project.organization_id,
None,
artifact.kind,
artifact.status,
artifact.content_hash,
artifact.byte_size,
artifact.media_type,
artifact.storage_key,
artifact.storage_uri,
artifact.integrity,
_json(dict(artifact.derivation)),
imported_at,
)
for artifact in plan.artifacts
],
)
after_artifacts = connection.execute(
"SELECT count(*) AS n FROM public.artifacts WHERE project_id = %s",
(project_id,),
).fetchone()["n"]
written_artifacts = int(after_artifacts) - int(before_artifacts)
counts = {
"chapters": len(plan.chapters),
"speakers": len(plan.speakers),
"utterances": len(plan.utterances),
"artifacts": written_artifacts,
"warnings": len(plan.warnings),
}
connection.execute(
"""
INSERT INTO public.translation_imports (
import_id, project_id, organization_id, system, node_id,
external_id, content_hash, counts, imported_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s)
""",
(
import_id,
project_id,
project.organization_id,
project.system,
project.node_id,
project.external_id,
project.content_hash,
_json(counts),
imported_at,
),
)
return ImportResult(
project_id=project_id,
import_id=import_id,
external_id=project.external_id,
content_hash=project.content_hash,
counts=counts,
imported_at=imported_at,
)
__all__ = [
"AUDIO_IMPORT_NAMESPACE",
"PostgresAudioImportStore",
"audio_import_id",
"audio_project_id",
]

View File

@@ -0,0 +1,305 @@
-- 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.

View File

@@ -0,0 +1,96 @@
-- 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;

View File

@@ -27,6 +27,8 @@ building. §7.7, §13.15.
from __future__ import annotations
import hashlib
import json
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
@@ -465,6 +467,26 @@ def compute_repository(request: Request):
return repository
def audio_import_store(request: Request):
"""The PostgreSQL audio-import store, built once per application.
Only a commit needs it, so it is built on the commit path rather than at
startup: a dry run is pure validation and must succeed on a deployment whose
database is not reachable. There is deliberately no in-memory fallback here,
because a commit that silently names nothing is the exact failure this
adapter exists to prevent.
"""
cached = getattr(request.app.state, "audio_import_store", None)
if cached is not None:
return cached
from .audio_import_postgres import PostgresAudioImportStore
store = PostgresAudioImportStore.from_settings(request.app.state.settings)
request.app.state.audio_import_store = store
return store
def require_node(
request: Request,
authorization: str | None = Header(default=None),
@@ -1718,6 +1740,163 @@ def decompose(
return [_job_response(child) for child in children]
class AudioIngestRequest(RequestModel):
"""An audio dataset bundle in the accepted format.
The audio side may POST the full envelope and records (same structure as
bundle.json + manifest.jsonl), allowing the server to validate and map
without fetching files.
"""
envelope: dict[str, Any]
records: list[dict[str, Any]]
project_name: str | None = Field(default=None, max_length=200)
project_slug: str | None = Field(default=None, max_length=200)
node_id: str | None = Field(default=None, max_length=200)
dry_run: bool = True
confirm: str | None = Field(default=None, max_length=128)
class AudioIngestWarningResponse(BaseModel):
code: str
subject: str
message: str
class AudioIngestCountsResponse(BaseModel):
chapters: int
speakers: int
utterances: int
artifacts: int
warnings: int
class AudioIngestResultResponse(BaseModel):
project_id: UUID | None = None
import_id: UUID | None = None
external_id: str | None = None
content_hash: str | None = None
counts: AudioIngestCountsResponse | None = None
imported_at: str | None = None
class AudioIngestResponse(BaseModel):
status: str
content_hash: str
narrator_external_id: str | None = None
counts: AudioIngestCountsResponse
warnings: list[AudioIngestWarningResponse]
result: AudioIngestResultResponse | None = None
@agent_router.post("/audio/import", response_model=AudioIngestResponse)
def import_audio_dataset(
payload: AudioIngestRequest,
request: Request,
node: NodeContext = Depends(require_node),
) -> AudioIngestResponse:
"""Import an audio bundle. Dry-runs by default; commit requires confirm."""
node.require("dataset:ingest")
from .audio_import import (
Bundle,
BundleError,
InMemoryAudioImportStore,
import_bundle,
)
try:
declared = payload.envelope.get("content_hash")
declared_value = (
declared.get("value") if isinstance(declared, dict) else None
)
manifest_bytes = (
"\n".join(
json.dumps(
r,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
)
for r in payload.records
)
+ "\n"
)
manifest_sha256 = hashlib.sha256(manifest_bytes.encode("utf-8")).hexdigest()
bundle = Bundle(
path=Path("."),
envelope=payload.envelope,
records=tuple(payload.records),
manifest_sha256=manifest_sha256,
declared_content_hash=declared_value
if isinstance(declared_value, str)
else None,
)
except Exception as error:
raise BundleError(f"invalid bundle payload: {error}")
# A dry run is pure validation and never touches the database. A commit goes
# through the PostgreSQL adapter or fails; there is deliberately no in-memory
# commit path, because a commit that writes nothing and reports success is
# the failure this route must not reproduce.
#
# The organization comes from the credential, never from the payload: a node
# belongs to exactly one organization, and letting a request name one would
# let a node write into another tenant's tables.
store = (
InMemoryAudioImportStore() if payload.dry_run else audio_import_store(request)
)
try:
outcome = import_bundle(
bundle,
store,
organization_id=node.organization_id,
project_name=payload.project_name,
project_slug=payload.project_slug,
node_id=payload.node_id or node.node_id,
dry_run=payload.dry_run,
confirm=payload.confirm,
)
except BundleError:
raise
except Exception as error:
raise BundleError(str(error))
result = outcome.result
counts = AudioIngestCountsResponse(
chapters=len(outcome.plan.chapters),
speakers=len(outcome.plan.speakers),
utterances=len(outcome.plan.utterances),
artifacts=len(outcome.plan.artifacts),
warnings=len(outcome.plan.warnings),
)
warnings = [
AudioIngestWarningResponse(
code=w.code, subject=w.subject, message=w.message
)
for w in outcome.plan.warnings
]
res = AudioIngestResultResponse()
if result is not None:
res = AudioIngestResultResponse(
project_id=result.project_id,
import_id=result.import_id,
external_id=result.external_id,
content_hash=result.content_hash,
counts=AudioIngestCountsResponse(**result.counts),
imported_at=result.imported_at.isoformat(),
)
return AudioIngestResponse(
status=outcome.status,
content_hash=outcome.plan.project.content_hash,
narrator_external_id=outcome.plan.narrator_external_id,
counts=counts,
warnings=warnings,
result=res if (outcome.result is not None or outcome.status == "no_change") else None,
)
__all__ = ["admin_router", "agent_router"]

View File

@@ -0,0 +1,594 @@
"""Tests for the file-based audio-bundle import.
These run against a synthetic bundle written to a temporary directory, so the
suite never depends on a real audio export being present. The synthetic bundle
deliberately carries every hazard the mapping document names - a negative
chapter index, two narrator rows, an MP3 file named ``.wav``, an absent
``chapter_id``, a zero-byte render, a chapter declared but not measured, and a
speaker with no profile - because a mapping that is only tested on the tidy case
is a mapping that reads the wrong field the first time it meets a real one.
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from uuid import uuid4
import pytest
from umbratrans_web.audio_import import (
BundleError,
ImportPlan,
InMemoryAudioImportStore,
import_bundle,
plan_import,
read_bundle,
render_report,
validate_bundle,
)
ORGANIZATION_ID = uuid4()
def _sha(seed: str) -> str:
return hashlib.sha256(seed.encode()).hexdigest()
def _render(rid: str, role: str, path: str, *, container: str, codec: str, ext: str, matches: bool | None,
book_id: str = "book-1", chapter_id: str | None = "ch-1", byte_size: int = 1000,
duration_ms: int | None = 1000, **extra: object) -> dict:
record = {
"id": rid,
"kind": "render",
"generated_at": "2026-10-14T12:00:00Z",
"render_role": role,
"path": path,
"relative_to": "book_directory",
"path_is_absolute": True,
"audio_format": {
"container": container,
"codec": codec,
"extension_matches_container": matches,
"detected_by": "ffprobe",
"file_extension": ext,
"sample_rate_hz": 24000,
"channel_count": 1,
"bit_rate_bps": None,
},
"duration_ms": duration_ms,
"byte_size": byte_size,
"content_sha256": _sha(path),
"book_id": book_id,
"inputs": [],
}
if chapter_id is not None:
record["chapter_id"] = chapter_id
record.update(extra)
return record
def synthetic_records() -> list[dict]:
return [
{
"id": "book-1",
"kind": "book",
"generated_at": "2026-10-14T12:00:00Z",
"name": "Synthetic",
"title": "synthetic.docx",
"status": "analyzed",
"chapters": ["ch-1", "ch-2"],
"chapter_count": 2,
"utterance_count": 4,
"speaker_count": 3,
"novel_chapter_count": 1,
"front_and_back_matter_count": 1,
"language_designations": [],
"numbering": {"hazard": "three numbers, none derivable"},
"path_relative_to": "book_directory",
"declared_total_chapters": 2,
"extension_container_mismatch_count": 1,
"extension_container_mismatches": [
{"id": "rnd-2", "named_extension": "wav", "actual_container": "mp3", "path": "voices/foo_ref.wav"}
],
"speaker_names_without_a_profile": ["Captain"],
},
{
"id": "ch-1",
"kind": "chapter",
"generated_at": "2026-10-14T12:00:00Z",
"book_id": "book-1",
"chapter_index": -1,
"database_row_id": 1,
"title": "Copyright",
"source_text_sha256": _sha("ch1"),
"source_text_char_count": 10,
"word_count": 2,
"utterance_count": 2,
"renders": ["rnd-1"],
"numbering_basis": {"note": "front matter, negative index"},
"status": "analyzed",
},
{
"id": "ch-2",
"kind": "chapter",
"generated_at": "2026-10-14T12:00:00Z",
"book_id": "book-1",
"chapter_index": 0,
"database_row_id": 2,
"title": "Salvage",
"source_text_sha256": _sha("ch2"),
"source_text_char_count": 20,
"word_count": 4,
"utterance_count": 2,
"renders": ["rnd-3", "rnd-4"],
"numbering_basis": {"note": "reading order 0"},
"status": "analyzed",
},
{
"id": "sp-narrator-a",
"kind": "speaker",
"generated_at": "2026-10-14T12:00:00Z",
"primary_name": "Narrator",
"aliases": ["narrator", "the narrator"],
"has_profile": True,
"is_narrator": True,
"is_group": False,
"unidentified": False,
"casefold_collision_group": "narrator",
"utterance_count": 1,
"utterance_count_casefold_grouped": 3,
"utterance_count_including_implicit_narrator": 10,
"voice_review_status": "approved",
"assigned_edge_voice": "en-CA-LiamNeural:-100",
"tts_engine_used": None,
},
{
"id": "sp-narrator-b",
"kind": "speaker",
"generated_at": "2026-10-14T12:00:00Z",
"primary_name": "narrator",
"aliases": ["narrator"],
"has_profile": True,
"is_narrator": True,
"is_group": False,
"unidentified": False,
"casefold_collision_group": "narrator",
"utterance_count": 2,
"utterance_count_casefold_grouped": 3,
"utterance_count_including_implicit_narrator": 5,
"voice_review_status": None,
"assigned_edge_voice": None,
"tts_engine_used": None,
},
{
"id": "sp-captain",
"kind": "speaker",
"generated_at": "2026-10-14T12:00:00Z",
"primary_name": "Captain",
"aliases": [],
"has_profile": False,
"is_narrator": False,
"is_group": False,
"unidentified": False,
"casefold_collision_group": None,
"utterance_count": 1,
"utterance_count_casefold_grouped": 1,
"utterance_count_including_implicit_narrator": 1,
"voice_review_status": None,
"assigned_edge_voice": None,
"tts_engine_used": "qwen",
},
{
"id": "utt-1",
"kind": "utterance",
"generated_at": "2026-10-14T12:00:00Z",
"book_id": "book-1",
"chapter_id": "ch-1",
"database_row_id": 10,
"utterance_index": 0,
"utterance_type": "narration",
"text": ",,,",
"text_sha256": _sha("t1"),
"speaker_id": "sp-narrator-b",
"speaker_name_raw": None,
"speaker_name_resolved": "narrator",
"speaker_resolution": "implicit_narrator",
"render_id": "rnd-1",
"needs_review": False,
},
{
"id": "utt-2",
"kind": "utterance",
"generated_at": "2026-10-14T12:00:00Z",
"book_id": "book-1",
"chapter_id": "ch-2",
"database_row_id": 11,
"utterance_index": 0,
"utterance_type": "dialogue",
"text": "Hello.",
"text_sha256": _sha("t2"),
"speaker_id": "sp-captain",
"speaker_name_raw": "Captain",
"speaker_name_resolved": "Captain",
"speaker_resolution": "explicit",
"render_id": "rnd-1",
"needs_review": False,
},
{
"id": "utt-3",
"kind": "utterance",
"generated_at": "2026-10-14T12:00:00Z",
"book_id": "book-1",
"chapter_id": "ch-2",
"database_row_id": 12,
"utterance_index": 1,
"utterance_type": "narration",
"text": "He left.",
"text_sha256": _sha("t3"),
"speaker_id": "sp-narrator-a",
"speaker_name_raw": "Narrator",
"speaker_name_resolved": "Narrator",
"speaker_resolution": "explicit",
"render_id": "rnd-1",
"needs_review": False,
},
{
"id": "utt-4",
"kind": "utterance",
"generated_at": "2026-10-14T12:00:00Z",
"book_id": "book-1",
"chapter_id": "ch-2",
"database_row_id": 13,
"utterance_index": 2,
"utterance_type": "scene_break",
"text": ",,,",
"text_sha256": _sha("t4"),
"speaker_id": "sp-narrator-b",
"speaker_name_raw": None,
"speaker_name_resolved": "narrator",
"speaker_resolution": "implicit_narrator",
"render_id": "rnd-1",
"needs_review": False,
},
_render("rnd-1", "utterance_audio", "chapter_audio/ch1/utt_0001.wav", container="wav", codec="pcm_s16le", ext="wav", matches=True),
_render("rnd-2", "voice_reference", "voices/foo_ref.wav", container="mp3", codec="mp3", ext="wav", matches=False, chapter_id=None),
_render("rnd-3", "distribution_track", "export/Deal_Hunter_01_Salvage.mp3", container="mp3", codec="mp3", ext="mp3", matches=True, chapter_id="ch-2", track_number_in_name="01"),
_render("rnd-4", "utterance_audio", "chapter_audio/ch2/utt_0226.wav", container="wav", codec="pcm_s16le", ext="wav", matches=True, chapter_id="ch-2", byte_size=0),
]
def write_bundle(directory: Path, records: list[dict], *, source: dict | None = None) -> Path:
"""Write a bundle whose declared hash matches its manifest."""
directory.mkdir(parents=True, exist_ok=True)
manifest = "\n".join(json.dumps(record, sort_keys=True) for record in records) + "\n"
(directory / "manifest.jsonl").write_text(manifest, encoding="utf-8")
digest = hashlib.sha256((directory / "manifest.jsonl").read_bytes()).hexdigest()
envelope = {
"bundle_format_version": 1,
"content_hash": {"algorithm": "sha256", "of": "manifest.jsonl bytes", "value": digest},
"generated_at": "2026-10-14T12:00:00Z",
"source": source or {"system": "umbraaudio", "node_id": "umbraaudio-m4", "product_version": "test"},
"counts": {"books": 1, "chapters": 2, "utterances": 4, "speakers": 3, "render_records": 4, "records_total": len(records)},
"record_kinds": {"discriminator": "kind", "values": ["book", "chapter", "speaker", "utterance", "render"]},
"scope": {"books_full_detail": ["SyntheticBook"]},
}
(directory / "bundle.json").write_text(json.dumps(envelope), encoding="utf-8")
return directory
@pytest.fixture
def bundle_dir(tmp_path: Path) -> Path:
return write_bundle(tmp_path / "bundle", synthetic_records())
@pytest.fixture
def bundle(bundle_dir: Path):
return read_bundle(bundle_dir)
@pytest.fixture
def plan(bundle) -> ImportPlan:
return plan_import(bundle, organization_id=ORGANIZATION_ID)
# -- reading ----------------------------------------------------------------
def test_read_bundle_hashes_the_manifest_bytes(bundle_dir: Path) -> None:
bundle = read_bundle(bundle_dir)
expected = hashlib.sha256((bundle_dir / "manifest.jsonl").read_bytes()).hexdigest()
assert bundle.manifest_sha256 == expected
assert bundle.content_hash_matches is True
assert bundle.declared_content_hash == expected
def test_read_bundle_rejects_a_missing_manifest(tmp_path: Path) -> None:
(tmp_path / "bundle").mkdir()
(tmp_path / "bundle" / "bundle.json").write_text("{}")
with pytest.raises(BundleError):
read_bundle(tmp_path / "bundle")
def test_a_tampered_manifest_does_not_match_its_declared_hash(bundle_dir: Path) -> None:
path = bundle_dir / "manifest.jsonl"
lines = path.read_text().splitlines()
record = json.loads(lines[0])
record["name"] = record.get("name", "") + " TAMPERED"
lines[0] = json.dumps(record, sort_keys=True)
path.write_text("\n".join(lines) + "\n")
bundle = read_bundle(bundle_dir)
assert bundle.content_hash_matches is False
assert any("content_hash mismatch" in problem for problem in validate_bundle(bundle))
# -- mapping ----------------------------------------------------------------
def test_chapter_identity_is_the_id_not_the_order(plan: ImportPlan) -> None:
by_id = {chapter.external_id: chapter for chapter in plan.chapters}
assert by_id["ch-1"].reading_order == -1
assert by_id["ch-2"].reading_order == 0
assert {c.external_id for c in plan.chapters} == {"ch-1", "ch-2"}
def test_negative_chapter_index_is_front_matter(plan: ImportPlan) -> None:
by_id = {chapter.external_id: chapter for chapter in plan.chapters}
assert by_id["ch-1"].matter == "matter"
assert by_id["ch-2"].matter == "novel"
def test_media_type_comes_from_the_container_not_the_extension(plan: ImportPlan) -> None:
by_id = {artifact.source_render_id: artifact for artifact in plan.artifacts}
# rnd-2 is named .wav and is MPEG audio.
assert by_id["rnd-2"].media_type == "audio/mpeg"
assert by_id["rnd-1"].media_type == "audio/wav"
assert by_id["rnd-3"].media_type == "audio/mpeg"
def test_artifacts_are_unverified(plan: ImportPlan) -> None:
assert plan.artifacts
assert all(artifact.integrity == "unverified" for artifact in plan.artifacts)
def test_render_without_a_chapter_key_attaches_to_the_project(plan: ImportPlan) -> None:
by_id = {artifact.source_render_id: artifact for artifact in plan.artifacts}
assert by_id["rnd-2"].derivation["chapter_external_id"] is None
assert by_id["rnd-2"].derivation["extension_mismatch"] is True
def test_final_roles_are_final_and_the_rest_intermediate(plan: ImportPlan) -> None:
by_id = {artifact.source_render_id: artifact for artifact in plan.artifacts}
assert by_id["rnd-3"].status == "final"
assert by_id["rnd-1"].status == "intermediate"
def test_zero_byte_render_is_recorded_not_dropped(plan: ImportPlan) -> None:
by_id = {artifact.source_render_id: artifact for artifact in plan.artifacts}
assert by_id["rnd-4"].byte_size == 0
assert any(warning.code == "zero_byte_render" and warning.subject == "rnd-4" for warning in plan.warnings)
# -- the narrator merge -----------------------------------------------------
def test_the_narrator_is_one_role_and_the_approved_row_is_canonical(plan: ImportPlan) -> None:
assert plan.narrator_external_id == "sp-narrator-a"
by_id = {speaker.external_id: speaker for speaker in plan.speakers}
assert by_id["sp-narrator-a"].role == "narrator"
assert by_id["sp-narrator-a"].merged_into is None
assert by_id["sp-narrator-b"].role == "narrator"
assert by_id["sp-narrator-b"].merged_into == "sp-narrator-a"
def test_every_narrator_utterance_points_at_the_one_role(plan: ImportPlan) -> None:
by_id = {utterance.external_id: utterance for utterance in plan.utterances}
# utt-1 and utt-4 were stored against sp-narrator-b.
assert by_id["utt-1"].speaker_external_id == "sp-narrator-a"
assert by_id["utt-4"].speaker_external_id == "sp-narrator-a"
# utt-3 was explicit against the canonical row and is unchanged.
assert by_id["utt-3"].speaker_external_id == "sp-narrator-a"
def test_scene_breaks_are_kept_as_structural_units(plan: ImportPlan) -> None:
by_id = {utterance.external_id: utterance for utterance in plan.utterances}
assert by_id["utt-4"].kind == "scene_break"
# -- warnings ---------------------------------------------------------------
def test_speaker_without_a_profile_is_flagged(plan: ImportPlan) -> None:
assert any(
warning.code == "speaker_without_profile" and warning.subject == "sp-captain" for warning in plan.warnings
)
def test_a_collision_group_is_surfaced_not_merged(plan: ImportPlan) -> None:
collisions = [w for w in plan.warnings if w.code == "speaker_collision_group"]
assert len(collisions) == 1
assert "sp-narrator-a" in collisions[0].message and "sp-narrator-b" in collisions[0].message
def test_extension_mismatch_is_reported_verbatim(plan: ImportPlan) -> None:
assert any(warning.code == "extension_mismatch" and warning.subject == "rnd-2" for warning in plan.warnings)
# -- validation -------------------------------------------------------------
def test_implicit_narrator_with_a_written_name_is_rejected(bundle_dir: Path) -> None:
records = synthetic_records()
for record in records:
if record["id"] == "utt-1":
record["speaker_name_raw"] = "narrator"
write_bundle(bundle_dir, records)
problems = validate_bundle(read_bundle(bundle_dir))
assert any("implicit_narrator" in problem for problem in problems)
def test_chapter_without_numbering_basis_is_rejected(bundle_dir: Path) -> None:
records = synthetic_records()
for record in records:
if record["id"] == "ch-2":
del record["numbering_basis"]
write_bundle(bundle_dir, records)
problems = validate_bundle(read_bundle(bundle_dir))
assert any("numbering_basis" in problem for problem in problems)
def test_dangling_chapter_reference_is_rejected(bundle_dir: Path) -> None:
records = synthetic_records()
for record in records:
if record["id"] == "utt-2":
record["chapter_id"] = "ch-does-not-exist"
write_bundle(bundle_dir, records)
problems = validate_bundle(read_bundle(bundle_dir))
assert any("does not resolve" in problem for problem in problems)
# -- import and idempotency -------------------------------------------------
def test_dry_run_writes_nothing(bundle) -> None:
store = InMemoryAudioImportStore()
outcome = import_bundle(bundle, store, organization_id=ORGANIZATION_ID, dry_run=True)
assert outcome.status == "dry_run"
assert outcome.result is None
assert store.results == []
assert store.hashes == {}
def test_commit_requires_the_content_hash(bundle) -> None:
store = InMemoryAudioImportStore()
with pytest.raises(BundleError):
import_bundle(bundle, store, organization_id=ORGANIZATION_ID, dry_run=False, confirm="yes")
assert store.results == []
def test_commit_writes_the_plan(bundle) -> None:
store = InMemoryAudioImportStore()
outcome = import_bundle(
bundle, store, organization_id=ORGANIZATION_ID, dry_run=False, confirm=bundle.manifest_sha256
)
assert outcome.status == "imported"
assert outcome.result is not None
assert outcome.result.content_hash == bundle.manifest_sha256
assert len(store.results) == 1
assert store.hashes[outcome.result.external_id] == bundle.manifest_sha256
def test_reimporting_the_same_content_hash_is_a_no_op(bundle) -> None:
store = InMemoryAudioImportStore()
import_bundle(bundle, store, organization_id=ORGANIZATION_ID, dry_run=False, confirm=bundle.manifest_sha256)
again = import_bundle(
bundle, store, organization_id=ORGANIZATION_ID, dry_run=False, confirm=bundle.manifest_sha256
)
assert again.status == "no_change"
assert len(store.results) == 1
def test_report_names_the_project_and_counts(plan: ImportPlan) -> None:
report = render_report(plan, bundle_path="/somewhere")
assert "Synthetic" in report
assert "chapters=2" in report
assert "sp-narrator-a" in report # the narrator line
assert "/somewhere" in report
# -- the command line -------------------------------------------------------
def _run_cli(argv: list[str], monkeypatch: pytest.MonkeyPatch) -> tuple[int, str, str]:
from io import StringIO
from umbratrans_web.cli import import_audio
monkeypatch.delenv("UMBRA_ENV", raising=False)
out, err = StringIO(), StringIO()
code = import_audio(argv, stdout=out, stderr=err)
return code, out.getvalue(), err.getvalue()
def test_cli_defaults_to_a_dry_run(bundle_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
ledger = tmp_path / "ledger.json"
code, out, _ = _run_cli(
["--bundle", str(bundle_dir), "--organization-id", str(ORGANIZATION_ID), "--ledger", str(ledger)],
monkeypatch,
)
assert code == 0
assert "dry run" in out
assert "chapters=2" in out
assert not ledger.exists()
def test_cli_commit_writes_the_ledger_then_is_idempotent(
bundle_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
ledger = tmp_path / "ledger.json"
bundle = read_bundle(bundle_dir)
base = ["--bundle", str(bundle_dir), "--organization-id", str(ORGANIZATION_ID), "--ledger", str(ledger)]
code, out, _ = _run_cli([*base, "--commit", "--confirm", bundle.manifest_sha256], monkeypatch)
assert code == 0
assert "imported:" in out
assert ledger.is_file()
assert ledger.stat().st_mode & 0o777 == 0o600
code, out, _ = _run_cli([*base, "--commit", "--confirm", bundle.manifest_sha256], monkeypatch)
assert code == 0
assert "nothing to do" in out
def test_cli_commit_without_confirmation_is_refused(
bundle_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
ledger = tmp_path / "ledger.json"
code, _, err = _run_cli(
["--bundle", str(bundle_dir), "--organization-id", str(ORGANIZATION_ID), "--ledger", str(ledger), "--commit"],
monkeypatch,
)
assert code == 1
assert "confirmation" in err
assert not ledger.exists()
def test_cli_commit_needs_an_organization(bundle_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
bundle = read_bundle(bundle_dir)
code, _, err = _run_cli(
["--bundle", str(bundle_dir), "--ledger", str(tmp_path / "ledger.json"), "--commit", "--confirm", bundle.manifest_sha256],
monkeypatch,
)
assert code == 1
assert "organization-id" in err
def test_cli_reports_a_broken_bundle_and_exits_two(
bundle_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
path = bundle_dir / "manifest.jsonl"
lines = path.read_text().splitlines()
record = json.loads(lines[0])
record["name"] = record.get("name", "") + " TAMPERED"
lines[0] = json.dumps(record, sort_keys=True)
path.write_text("\n".join(lines) + "\n")
code, _, err = _run_cli(["--bundle", str(bundle_dir)], monkeypatch)
assert code == 2
assert "VALIDATION FAILED" in err
assert "content_hash mismatch" in err
def test_cli_refuses_production(bundle_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from io import StringIO
from umbratrans_web.cli import import_audio
monkeypatch.setenv("UMBRA_ENV", "production")
out, err = StringIO(), StringIO()
code = import_audio(["--bundle", str(bundle_dir)], stdout=out, stderr=err)
assert code == 3
assert "development-only" in err.getvalue()

View File

@@ -0,0 +1,447 @@
"""Tests for the PostgreSQL audio-import store.
Two layers, matching ``test_postgres_repository.py``.
**Mapping and contract tests run everywhere.** The identities are derived rather
than minted, and those derivations are asserted directly: a random project id per
import would orphan everything the previous import produced, and the audio side
named that failure explicitly. The store is also checked against the
``AudioImportStore`` protocol by walking the MRO, and its INSERT statements are
checked against the columns the migrations actually create - a typo in a column
name is otherwise only visible against a real database.
**Integration tests require a real, migrated scratch database and skip without
one.** They are selected by ``UMBRATRANS_TEST_DSN`` and
``UMBRATRANS_TEST_DATABASE_IS_DISPOSABLE``, the same gate the repository suite
uses, and they additionally skip when migrations 0008 and 0009 have not been
applied, so an unmigrated scratch database is a skip with an instruction rather
than a failure that reads like a defect in the adapter.
"""
from __future__ import annotations
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from uuid import UUID, uuid4
import pytest
from umbratrans_web.audio_import import (
AudioImportStore,
import_bundle,
plan_import,
read_bundle,
)
from umbratrans_web.audio_import_postgres import (
AUDIO_IMPORT_NAMESPACE,
PostgresAudioImportStore,
audio_import_id,
audio_project_id,
)
from .test_audio_import import ORGANIZATION_ID, synthetic_records, write_bundle
TEST_DSN_ENV = "UMBRATRANS_TEST_DSN"
TEST_ACK_ENV = "UMBRATRANS_TEST_DATABASE_IS_DISPOSABLE"
MIGRATIONS = Path(__file__).resolve().parent.parent / "migrations"
# ----------------------------------------------------------------------
# Derived identities
# ----------------------------------------------------------------------
def test_project_id_is_stable_for_the_same_source() -> None:
first = audio_project_id(ORGANIZATION_ID, "umbraaudio", "book-1")
second = audio_project_id(ORGANIZATION_ID, "umbraaudio", "book-1")
assert first == second
assert isinstance(first, UUID)
def test_project_id_varies_with_every_part_of_its_key() -> None:
base = audio_project_id(ORGANIZATION_ID, "umbraaudio", "book-1")
assert base != audio_project_id(uuid4(), "umbraaudio", "book-1")
assert base != audio_project_id(ORGANIZATION_ID, "other", "book-1")
assert base != audio_project_id(ORGANIZATION_ID, "umbraaudio", "book-2")
def test_import_id_is_content_addressed() -> None:
project = audio_project_id(ORGANIZATION_ID, "umbraaudio", "book-1")
hash_a = "a" * 64
hash_b = "b" * 64
assert audio_import_id(project, hash_a) == audio_import_id(project, hash_a)
# A changed source is a new import, so the id must change with the hash.
assert audio_import_id(project, hash_a) != audio_import_id(project, hash_b)
# And it is scoped to the project, not globally to the hash.
assert audio_import_id(project, hash_a) != audio_import_id(uuid4(), hash_a)
def test_project_and_import_ids_live_in_the_stated_namespace() -> None:
project = audio_project_id(ORGANIZATION_ID, "umbraaudio", "book-1")
assert project.version == 5
assert project.variant == AUDIO_IMPORT_NAMESPACE.variant
# ----------------------------------------------------------------------
# Protocol conformance
# ----------------------------------------------------------------------
def test_the_adapter_provides_every_protocol_method() -> None:
"""A method counts only if a class below the protocol defines it.
``hasattr`` is true for an inherited ``NotImplementedError`` stub, so it would
pass while the adapter implemented nothing. This is the same trap the
repository suite documents.
"""
provided: set[str] = set()
for klass in PostgresAudioImportStore.__mro__:
if klass is AudioImportStore:
break
provided.update(name for name, value in vars(klass).items() if callable(value))
declared = {name for name, value in vars(AudioImportStore).items() if callable(value)}
assert declared - provided == set()
# ----------------------------------------------------------------------
# The INSERT statements against the migrations, without a database
# ----------------------------------------------------------------------
_CONSTRAINT_WORDS = {
"PRIMARY",
"FOREIGN",
"UNIQUE",
"CHECK",
"CONSTRAINT",
"REFERENCES",
"EXCLUDE",
}
def _created_columns() -> dict[str, set[str]]:
"""Column names per ``public.`` table, read from the migration files.
Both CREATE TABLE columns and ALTER TABLE ADD COLUMN columns are collected:
``artifacts`` gains ``storage_uri`` and ``integrity`` in 0007, and a parser
that read only CREATE TABLE would report them as typos.
"""
tables: dict[str, set[str]] = {}
for path in sorted(MIGRATIONS.glob("*.sql")):
text = path.read_text(encoding="utf-8")
for match in re.finditer(
r"CREATE TABLE IF NOT EXISTS\s+public\.(\w+)\s*\((.*?)\n\);",
text,
re.DOTALL,
):
table, body = match.group(1), match.group(2)
columns: set[str] = set()
for line in body.splitlines():
stripped = line.strip()
if not stripped:
continue
word = stripped.split()[0]
if word.upper() in _CONSTRAINT_WORDS:
continue
columns.add(word)
tables.setdefault(table, set()).update(columns)
for match in re.finditer(
r"ALTER TABLE\s+public\.(\w+)(.*?);",
text,
re.DOTALL | re.IGNORECASE,
):
table, body = match.group(1), match.group(2)
for column in re.findall(
r"ADD COLUMN(?:\s+IF NOT EXISTS)?\s+(\w+)", body, re.IGNORECASE
):
tables.setdefault(table, set()).add(column)
return tables
def _inserted_columns() -> dict[str, set[str]]:
"""Columns named by each ``INSERT INTO public.<table> (...)`` in the adapter."""
module = (
Path(__file__).resolve().parent.parent / "audio_import_postgres.py"
).read_text(encoding="utf-8")
inserted: dict[str, set[str]] = {}
for match in re.finditer(
r"INSERT INTO public\.(\w+)\s*\((.*?)\)\s*VALUES",
module,
re.DOTALL,
):
table, body = match.group(1), match.group(2)
columns = {token.strip() for token in body.split(",") if token.strip()}
inserted[table] = columns
return inserted
def test_every_inserted_column_exists_in_the_migrations() -> None:
created = _created_columns()
inserted = _inserted_columns()
assert inserted, "no INSERT statements were found; the parser is wrong"
for table, columns in inserted.items():
assert table in created, f"public.{table} is not created by any migration"
unknown = columns - created[table]
assert not unknown, f"public.{table} has no column(s): {sorted(unknown)}"
def test_the_adapter_covers_every_source_table() -> None:
# The four snapshot tables and the import ledger. public.artifacts is the
# fifth write but is created by 0003, not the audio-import migrations.
assert set(_inserted_columns()) == {
"project_source_refs",
"translation_speakers",
"translation_chapters",
"translation_utterances",
"artifacts",
"translation_imports",
}
# ----------------------------------------------------------------------
# Integration: requires a real, migrated scratch database
# ----------------------------------------------------------------------
def _integration_dsn() -> str | None:
return os.environ.get(TEST_DSN_ENV) or None
def _acknowledged() -> bool:
return os.environ.get(TEST_ACK_ENV, "").strip() in {"1", "true", "yes", "on"}
integration = pytest.mark.skipif(
_integration_dsn() is None or not _acknowledged(),
reason=(
f"{TEST_DSN_ENV} and {TEST_ACK_ENV}=1 are required. Point them at a "
"disposable scratch database with migrations 0001-0009 applied."
),
)
@pytest.fixture
def bundle_dir(tmp_path: Path) -> Path:
"""The synthetic bundle's directory, written fresh for each test."""
return write_bundle(tmp_path / "bundle", synthetic_records())
@pytest.fixture
def store_and_org():
"""A live store and a freshly inserted organization, cleaned up afterwards."""
import psycopg
dsn = _integration_dsn()
assert dsn is not None
connection = psycopg.connect(dsn, autocommit=True)
try:
if connection.execute(
"SELECT to_regclass('public.translation_imports')"
).fetchone()[0] is None:
pytest.skip(
"migrations 0008-0009 are not applied to the scratch database; "
"apply them before running the audio-import integration tests"
)
organization_id = uuid4()
connection.execute(
"INSERT INTO public.organizations (id, slug, name) VALUES (%s, %s, %s)",
(organization_id, f"audio-import-{organization_id.hex[:12]}", "Audio Import Test"),
)
store = PostgresAudioImportStore(dsn)
finally:
connection.close()
yield store, organization_id
store.close()
cleanup = psycopg.connect(dsn, autocommit=True)
try:
# Child-of-organization rows first; the session teardown also truncates
# public.organizations CASCADE, but leaving rows behind between tests
# would make one test's residue another test's surprise.
cleanup.execute("DELETE FROM public.translation_imports WHERE organization_id = %s", (organization_id,))
cleanup.execute("DELETE FROM public.artifacts WHERE organization_id = %s", (organization_id,))
cleanup.execute("DELETE FROM public.translation_utterances WHERE organization_id = %s", (organization_id,))
cleanup.execute("DELETE FROM public.translation_chapters WHERE organization_id = %s", (organization_id,))
cleanup.execute("DELETE FROM public.translation_speakers WHERE organization_id = %s", (organization_id,))
cleanup.execute("DELETE FROM public.project_source_refs WHERE organization_id = %s", (organization_id,))
cleanup.execute("DELETE FROM public.organizations WHERE id = %s", (organization_id,))
finally:
cleanup.close()
@pytest.fixture
def rebuilt_bundle(tmp_path: Path):
"""A writer for a second bundle under the same book id, with changed bytes."""
def build(name: str, *, mutate: bool) -> Path:
records = synthetic_records()
if mutate:
for record in records:
if record.get("id") == "utt-2":
record["text"] = "Goodbye."
record["text_sha256"] = "c" * 64
return write_bundle(tmp_path / name, records)
return build
@integration
def test_commit_writes_the_source_snapshot(store_and_org, bundle_dir: Path) -> None:
store, organization_id = store_and_org
bundle = read_bundle(bundle_dir)
plan = plan_import(bundle, organization_id=organization_id)
imported_at = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
result = store.commit(plan, imported_at=imported_at)
assert result.project_id == audio_project_id(
organization_id, plan.project.system, plan.project.external_id
)
assert result.import_id == audio_import_id(result.project_id, plan.project.content_hash)
assert result.content_hash == plan.project.content_hash
assert result.counts["chapters"] == len(plan.chapters)
assert result.counts["speakers"] == len(plan.speakers)
assert result.counts["utterances"] == len(plan.utterances)
assert result.counts["artifacts"] == len(plan.artifacts)
assert store.last_content_hash(plan.project.external_id) == plan.project.content_hash
# And the rows are actually there, counted independently of the result.
import psycopg
from psycopg.rows import dict_row
connection = psycopg.connect(_integration_dsn(), row_factory=dict_row)
try:
for table, expected in (
("project_source_refs", 1),
("translation_speakers", len(plan.speakers)),
("translation_chapters", len(plan.chapters)),
("translation_utterances", len(plan.utterances)),
("artifacts", len(plan.artifacts)),
("translation_imports", 1),
):
row = connection.execute(
f"SELECT count(*) AS n FROM public.{table} WHERE project_id = %s",
(result.project_id,),
).fetchone()
assert row["n"] == expected, table
ref = connection.execute(
"SELECT external_id, content_hash, source_meta FROM public.project_source_refs WHERE project_id = %s",
(result.project_id,),
).fetchone()
assert ref["external_id"] == plan.project.external_id
assert ref["content_hash"] == plan.project.content_hash
assert ref["source_meta"]["name"] == plan.project.name
finally:
connection.close()
@integration
def test_import_bundle_reports_no_change_for_identical_content(
store_and_org, bundle_dir: Path
) -> None:
store, organization_id = store_and_org
bundle = read_bundle(bundle_dir)
plan = plan_import(bundle, organization_id=organization_id)
first = import_bundle(
bundle,
store,
organization_id=organization_id,
dry_run=False,
confirm=plan.project.content_hash,
)
assert first.status == "imported"
second = import_bundle(
bundle,
store,
organization_id=organization_id,
dry_run=False,
confirm=plan.project.content_hash,
)
assert second.status == "no_change"
assert second.result is None
@integration
def test_reimport_of_changed_content_replaces_the_snapshot(
store_and_org, bundle_dir: Path, rebuilt_bundle
) -> None:
store, organization_id = store_and_org
first_bundle = read_bundle(bundle_dir)
first_plan = plan_import(first_bundle, organization_id=organization_id)
first_result = store.commit(
first_plan, imported_at=datetime(2026, 1, 1, tzinfo=timezone.utc)
)
changed_dir = rebuilt_bundle("changed", mutate=True)
changed_plan = plan_import(read_bundle(changed_dir), organization_id=organization_id)
assert changed_plan.project.content_hash != first_plan.project.content_hash
# Same book, so same project id; different bytes, so a different import id.
assert changed_plan.project.external_id == first_plan.project.external_id
second_result = store.commit(
changed_plan, imported_at=datetime(2026, 1, 2, tzinfo=timezone.utc)
)
assert second_result.project_id == first_result.project_id
assert second_result.import_id != first_result.import_id
import psycopg
from psycopg.rows import dict_row
connection = psycopg.connect(_integration_dsn(), row_factory=dict_row)
try:
# The snapshot tables hold exactly one copy: the new one, not two.
for table, expected in (
("project_source_refs", 1),
("translation_speakers", len(changed_plan.speakers)),
("translation_chapters", len(changed_plan.chapters)),
("translation_utterances", len(changed_plan.utterances)),
):
row = connection.execute(
f"SELECT count(*) AS n FROM public.{table} WHERE project_id = %s",
(first_result.project_id,),
).fetchone()
assert row["n"] == expected, table
ref = connection.execute(
"SELECT content_hash FROM public.project_source_refs WHERE project_id = %s",
(first_result.project_id,),
).fetchone()
assert ref["content_hash"] == changed_plan.project.content_hash
# The ledger is append-only and keeps both runs.
runs = connection.execute(
"SELECT count(*) AS n FROM public.translation_imports WHERE project_id = %s",
(first_result.project_id,),
).fetchone()
assert runs["n"] == 2
finally:
connection.close()
@integration
def test_the_source_tables_refuse_an_update(store_and_org, bundle_dir: Path) -> None:
"""The append-only guard is the reason a re-import DELETEs rather than edits."""
import psycopg
store, organization_id = store_and_org
bundle = read_bundle(bundle_dir)
plan = plan_import(bundle, organization_id=organization_id)
result = store.commit(plan, imported_at=datetime(2026, 1, 1, tzinfo=timezone.utc))
connection = psycopg.connect(_integration_dsn(), autocommit=True)
try:
with pytest.raises(psycopg.errors.RaiseException):
connection.execute(
"UPDATE public.project_source_refs SET source_title = 'edit' WHERE project_id = %s",
(result.project_id,),
)
finally:
connection.close()