Files
LeoVasanko 91a16f56c5 Redesign translation overrides: keyed, structural, hash-anchored
Replace the old "patches" format (path:lang composite keys, ordered
hunk lists, text-anchored matching) with Data.overrides:
path -> lang -> LangEdits, keyed throughout so a save's database diff
touches only the edited chunks. The old "patches" key is ignored on
decode, discarding legacy data without a migration.

- Whole-paragraph additions/deletions are structural: a drop flag on
  the original chunk hash, and additions in their own dict anchored
  from the neighboring chunks' before/after (first live referrer wins),
  so they stay in place across retranslation and one-sided original
  edits.
- Within-paragraph edits (up to a full paragraph rewrite or split) are
  full-chunk replace patches applied by chunk hash alone: a
  retranslation is overridden wholesale, so user edits survive AI
  re-runs; editing the original changes the hash and orphans the patch.
  The old search-matching staleness gate is gone.
- Saving a translation on a page without original chunks is rejected
  (REST 400 / WS error); emptying the original afterwards renders the
  translation empty, with the orphaned overrides inert.
2026-09-21 18:14:48 +00:00

9.4 KiB

migrate_v3: content-addressed chunk storage

Status: implemented. migrate_v3 restructures how article text and translations are stored, motivated by the localization model in docs/localization.md (phase 2). Since it is a full migration, it is free to break the current Node.content: str | None layout.

Goals

  • Minimal change diffs. kanta persists change diffs; editing one paragraph of a long article must not rewrite the whole article string, and a translation refresh must touch only the re-translated chunks.
  • Fast, simple lookup. Everything heavy lives in flat dict[hash, content] stores; ordering lives in list[hash]. No large nested structures, no deep paths.
  • Path-independent text. Chunks and their translations are keyed by content hash, not by article path — the same paragraph (or menu title) appearing in several articles is stored and translated once. Moving or renaming an article touches nothing.

Design (chosen: global content-addressed stores)

Original articles are also stored as chunks; everything — originals and translations — lives in flat hash-keyed dicts. Costs accepted: rendering does one dict lookup per chunk (trivial), orphaned hashes need occasional garbage collection, and the editor save path re-chunks server-side (it already diffs). The rejected alternatives: per-article nested LangVersion structures (churn, duplication, whole-string originals) and a hybrid with whole originals plus global translations (keeps the worst change-diff property).

Target layout

class Node(msgspec.Struct, omit_defaults=True):
    ...
    #: Replaces `content: str | None`. None = pure category label;
    #: a list (possibly empty) = a page, as ordered chunk hashes.
    chunks: list[bytes] | None = None
    #: Primary language of the article (BCP-47 base tag). "" = inherit
    #: (nearest ancestor, front page last, site default "en" final).
    language: str = ""
    #: Chunk hashes the editor marked "do not translate" (always served
    #: from the original). Presence-keys, value always True.
    no_trans: dict[bytes, True] = {}
    #: Languages this article is available in (besides its primary
    #: language). Presence-keys, value always True — rendering, language
    #: selection and hreflang alternates read this set instead of probing
    #: the trans store chunk by chunk. Maintained by the writers (see
    #: "Language index maintenance" below).
    langs: dict[str, True] = {}


class Data(msgspec.Struct):
    ...
    #: API keys gating the translator service WebSocket (/_translate/{key}):
    #: key -> display name; the first is generated at bootstrap (state.py).
    translate_keys: dict[str, str] = {}
    #: Wanted target languages for the translator service (presence-keys);
    #: jobs are offered only in these ∩ a connection's capabilities.
    translate_langs: dict[str, True] = {}
    #: All original-language text, content-addressed: blake3(normalized)
    #: digest[:9] -> Markdown chunk. Shared by every article. Keys are
    #: bytes; kanta/msgspec base64-encode them at the JSON level.
    chunks: dict[bytes, str] = {}
    #: Machine translations: chunk hash -> lang -> translated Markdown
    #: (nested, not tuple keys: msgspec's JSON serializer rejects them).
    #: Also used for node titles (hash of the title text).
    trans: dict[bytes, dict[str, str]] = {}
    #: User override edits per article and language:
    #: path -> lang -> LangEdits (see localization.md) — keyed per original
    #: chunk hash throughout, so a save's change diff touches only the
    #: edited chunks. Replaced the old list-valued "patches" key (ignored
    #: on decode, discarding that data — no migration).
    overrides: dict[str, dict[str, LangEdits]] = {}

Notes:

  • Article paths never carry a leading slash in the DB or in lookup keys ("docs/setup", front page ""); the leading slash is added only when building hrefs. migrate_v3 audits existing stored paths (translation keys, analytics references, any path-valued fields) and normalizes them.
  • Titles are chunks too, by hash only: the nav renderer looks up trans.get(hash(node.title), {}).get(lang). No separate title storage; editing a title invalidates its translations automatically.
  • Per-hunk options live in two places: inherent options are derived at chunking time (code fences, HTML blocks and prose-free chunks are no-translate without storing anything — needs_translation, see docs/localization.md "Masking"); editor-set flags are node.no_trans (keyed by chunk hash, so a heavy edit silently drops the flag — acceptable and self-healing).
  • Override payloads stay inline in the LangEdits struct — overrides are small by construction (minimal server-computed diffs). If a pathological case shows up, they can be hash-stored later without schema pain.

Language index maintenance (node.langs)

node.langs is a denormalized index over the trans/overrides stores so that article rendering, select_language's availability check, and hreflang alternate links never enumerate chunks. It is written by whoever writes translation data, in the same transaction:

  • Translator service: the WebSocket API at /_translate/{key} (see docs/localization.md) offers pending fragments (titles + translatable chunks lacking an entry for the language) as single-item jobs — one at a time per connection, in Data.translate_langs ∩ the connection's announced capabilities — and receives the matching result; storing it writes the trans[h][lang] entry, sets node.langs[lang] = True on every article that gained one and invalidates the page cache — all in one transaction.
  • Translated-view save: recording the first override for a (path, lang) sets node.langs[lang] = True (overrides alone make the version exist).
  • Removals: deleting overrides or GC'ing translations re-derives the key: keep lang if any trans entry for the article's current chunks/title or any override remains, otherwise drop it. Stale langs keys are benign (an advertised language that renders as the original), so removal can lag.

Render / save pipeline (summary)

  • Render: text = "\n\n".join(chunks[h] for h in node.chunks) for the original; for language L (only ever attempted when L in node.langs), per chunk trans.get(h, {}).get(L) unless missing or h in node.no_trans, falling back to chunks[h]; then apply overrides[path][L] structurally in the article's own chunk order (drops, search/replace pairs, anchored additions — see docs/localization.md); then markdown.render as today. All of this assembles the Translation the phase-1 plumbing already consumes.
  • Availability: node.langs is the availability index; ?lang= handling uses exactly this set. (hreflang alternates are site-wide from translate_langs instead — see docs/localization.md.)
  • Save (primary language): server re-chunks the submitted Markdown, inserts new hashes into Data.chunks, replaces node.chunks. Unchanged chunks keep their hashes — only genuinely new text lands in the diff.
  • Save (translated view): diff against the served hybrid, record per-chunk overrides under overrides[path][lang]; node.chunks untouched.
  • Invalidate: any write to chunks / trans / overrides calls _invalidate_pages().

migrate_v3 steps

  1. Walk menu; for every node with a string content: chunks = chunk_markdown(content); write each into the new chunks store; replace the field with the hash list (None stays None).
  2. Initialize empty chunks / trans stores.
  3. language, no_trans and langs need nothing — struct defaults cover them (langs starts empty; the translator job fills it as translations land).

Chunking must be deterministic and shared with render/save, so chunk_markdown + chunk_key live in pagerite/i18n.py (or a small pagerite/chunks.py) and are imported by both migrations.py and views.py/state.py.

Implementation notes (deviations from the plan above)

  • Chunking lives in pagerite/chunks.py; hashing uses the blake3 package (already a dependency), truncated to a 9-byte bytes digest (kanta's JSON persistence base64-encodes bytes keys to 12-char strings).
  • trans is keyed hash -> lang -> text (nested dict), not by f"{hash}:{lang}" tuples: msgspec's JSON serializer only supports str-like/number-like dict keys, and kanta persists as JSON lines.
  • Translation.titles stayed keyed by node path (phase-1 shape, views untouched): get_translation builds it by walking the menu with the same per-title trans.get(chunk_key(node.title), {}).get(lang) lookups.
  • User overrides (record_override) diff with SequenceMatcher(autojunk=False) so overrides are deterministic (popular lines like blank separators never become junk).
  • The old list-valued patches store was later replaced by the keyed overrides store above; the rename itself discarded the old data (msgspec ignores the unknown key on decode), no migration.

Garbage collection (later, manual or idle-time)

Orphaned entries accumulate: chunks no longer referenced by any node.chunks/node.title, translations whose chunk hash is orphaned, overrides whose chunk hash is gone from the article (or whose search never matches). All are harmless (never read). A GC pass is a single tree walk collecting live hashes, then deleting the rest from chunks and trans; override entries for dead hashes get pruned. Not part of migrate_v3.