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.
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 inlist[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_v3audits 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 arenode.no_trans(keyed by chunk hash, so a heavy edit silently drops the flag — acceptable and self-healing). - Override payloads stay inline in the
LangEditsstruct — 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, inData.translate_langs∩ the connection's announced capabilities — and receives the matching result; storing it writes thetrans[h][lang]entry, setsnode.langs[lang] = Trueon 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)setsnode.langs[lang] = True(overrides alone make the version exist). - Removals: deleting overrides or GC'ing translations re-derives the key:
keep
langif anytransentry for the article's current chunks/title or any override remains, otherwise drop it. Stalelangskeys 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 languageL(only ever attempted whenL in node.langs), per chunktrans.get(h, {}).get(L)unless missing orh in node.no_trans, falling back tochunks[h]; then applyoverrides[path][L]structurally in the article's own chunk order (drops, search/replace pairs, anchored additions — see docs/localization.md); thenmarkdown.renderas today. All of this assembles theTranslationthe phase-1 plumbing already consumes. - Availability:
node.langsis the availability index;?lang=handling uses exactly this set. (hreflang alternates are site-wide fromtranslate_langsinstead — see docs/localization.md.) - Save (primary language): server re-chunks the submitted Markdown,
inserts new hashes into
Data.chunks, replacesnode.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.chunksuntouched. - Invalidate: any write to
chunks/trans/overridescalls_invalidate_pages().
migrate_v3 steps
- Walk
menu; for every node with a stringcontent:chunks = chunk_markdown(content); write each into the newchunksstore; replace the field with the hash list (NonestaysNone). - Initialize empty
chunks/transstores. language,no_transandlangsneed nothing — struct defaults cover them (langsstarts 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 theblake3package (already a dependency), truncated to a 9-bytebytesdigest (kanta's JSON persistence base64-encodes bytes keys to 12-char strings). transis keyedhash -> lang -> text(nested dict), not byf"{hash}:{lang}"tuples: msgspec's JSON serializer only supports str-like/number-like dict keys, and kanta persists as JSON lines.Translation.titlesstayed keyed by node path (phase-1 shape, views untouched):get_translationbuilds it by walking the menu with the same per-titletrans.get(chunk_key(node.title), {}).get(lang)lookups.- User overrides (
record_override) diff withSequenceMatcher(autojunk=False)so overrides are deterministic (popular lines like blank separators never become junk). - The old list-valued
patchesstore was later replaced by the keyedoverridesstore 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.