Schema-Version Gating
Foundations: Schema-Version Gating & the Legacy Pipeline¶
Shared across every feature — see Foundations: Pipeline & Architecture for the rest.
Decided, per direct instruction
This ships as a permanent, explicit fork between two rendering pipelines selected by
schemaVersion, not a live migration. There is no automated transformer between the two for
this delivery. The reason is time, not principle: an automated migration system (NEO-1657,
currently backlog with no timeline) isn't built, and there isn't room to build and trust one
safely inside this delivery's own
window, so the safe default is two pipelines that never silently interact, not a half-trusted
auto-upgrade.
flowchart TB
Doc["Template document"]
Doc -->|"schemaVersion: 1"| Legacy["Legacy pipeline<br/>frozen snapshot of main<br/>unmodified, forever"]
Doc -->|"schemaVersion: 2"| V2["Resolver Engine pipeline<br/>resolve → measure → solve → animate → place"]
Legacy --> Out1["Rendered frame"]
V2 --> Out2["Rendered frame"]
schemaVersion: 1 documents render through the current, unmodified pipeline exactly as it exists
on main today, forever, until someone explicitly re-authors or migrates a specific document.
schemaVersion: 2 documents render through everything this plan builds — real pins, price,
background, group flow, and keyframes.
Not new plumbing — schemaVersion already exists
Checked directly: packages/retail-studio-schema/src/zod.ts already declares
schemaVersion: z.literal(1) in three places in the current, live template schema. Today it's
a bare literal, meaning 1 is the only value that has ever validated. The gate this needs is
exactly the "make illegal states unrepresentable" pattern already used elsewhere in this schema
(ADMITTED_LAYOUT_MODES's built-vs-designed distinction is the direct precedent): widen the
three literals to z.union([z.literal(1), z.literal(2)]), an explicit allow-list of exactly
two admitted values — a document claiming any other version fails validation outright, the same
"friendly message, not a raw union error" treatment validate.ts already gives a reserved-mode
layout.mode.
The naive fork doesn't work¶
Real, code-grounded risk
Dispatch once at the top and let two independent trees run underneath doesn't actually work cleanly, and this needs to be said plainly rather than assumed away.
Checked directly against real file diffs between main and feature/neo-1253-retail-studio-text-engine,
the tested worktree:
| File | Diff vs. main |
Consumers |
|---|---|---|
interpreter/layers/rich-text.tsx |
Real, substantial, load-bearing: the wrap-then-shrink solver (resolveWrappedLinesFittingHeight), an anchor-shift fix, box-text's own background-bleed handling. Not trivial. One piece is genuinely still WIP and self-declared as such: a temporary diagnostic useEffect explicitly commented "not consumed anywhere yet." |
18 files import it directly, not through one seam: LayerView.tsx, PointTextLayer.tsx, BoxTextContent.tsx, box-text-motion-blur-capture.tsx, point-text-motion-blur-capture.tsx, point-text-scale-raster.tsx, text-metrics.ts, plan/fonts.ts, plus every bespoke per-brand component: betta-price-box, betta-discount-box, betta-cashback-blocks, betta-cashback-callout, betta-payment-opt-bar, freshmart-price-box, freshmart-save-box. |
interpreter/layers/FitText.tsx |
Real but smaller — most of its current size reflects main's own independent overflow-enum rework (PR #1295/NEO-1932), not this plan's own padding-is-paint-only rework. |
Only 3 files import it directly: BoxTextContent.tsx, StickerLayer.tsx, FitText.test.ts. None of the bespoke per-brand components import FitText directly — they read through rich-text.tsx's PointTextBox/BoxTextContent instead. |
interpreter/layers/text-metrics.ts |
Real, substantial (word-advance measurement, cap-height, line-breaking), materially unaffected by PR #1295/NEO-1932's merge. | Consumed transitively through FitText.tsx and rich-text.tsx, same fan-out as those. |
interpreter/layers/point-text-scale-raster.tsx, interpreter/layers/text-raster.ts |
Identical to main, confirmed (the NEO-1612 continuous-scale-wobble fix, see Text Foundation → Risks — predates and is untouched by all of this). |
Not part of the fork question — shared as-is either way. |
packages/retail-studio-schema/src/zod.ts |
Real (the layout/content reshape itself, ADMITTED_LAYOUT_MODES, isLegacyLayoutValue compat, etc.). |
The schema every layer/document reads through. |
So three files, not one, diverged in real substance, and two of them (rich-text.tsx most heavily,
FitText.tsx more narrowly) are threaded into per-brand bespoke components, not called through one
shared render entry point. A dispatch that swaps a single top-level component based on
schemaVersion would still need every one of those leaf consumers to independently know which
version of each file to import — that isn't a clean fork, it's the same file tree with a
schemaVersion check smuggled into nearly two dozen places, and a correctness bug waiting to
happen the first time a new consumer is added without the check.
The approach this points to¶
Not yet built — needs real engineering investigation, not a rubber-stamped assumption
Fork at the package/module level, not the file level. Freeze a complete, self-contained
snapshot of packages/retail-studio-compositions as it exists on main today
(pre-any-of-this-plan's-changes) into its own legacy namespace, before any further v2 work lands
on top of the live package — a legacy/ subtree, a separate internal package, or an equivalent
isolation boundary (which of these is cheapest to build and safest to keep frozen is exactly the
open engineering question). The render entry point dispatches on document.schemaVersion to
one complete, independent module tree or the other, never mixing individual files between them.
The frozen tree includes the legacy schema, not just the renderer. A schemaVersion: 1
document validated by the reshaped zod.ts fails on the layout union before it ever reaches a
renderer, so dispatch reads schemaVersion off the raw document first and validates with the
matching schema second — the widened 1 | 2 literal is the one thing both trees agree on.
This keeps the fan-out problem contained to one dispatch decision instead of a dozen scattered checks, at the cost of a full, real duplication of the compositions package's current surface area, and an ongoing question of what a genuine must-fix bug in the frozen legacy path costs to patch without disturbing v2's own independent evolution.
Mixed-fleet risk: both trees loaded at once¶
Not yet designed against
A render worker handling a realistic mixed fleet (some schemaVersion: 1 jobs, some
schemaVersion: 2 jobs, interleaved) may need both the frozen legacy package and the live v2
package resident in the same JS runtime simultaneously, not one-or-the-other per process.
Two open questions that follow, neither answered here yet:
- Whether Remotion's own composition registry (compositions are typically registered by name) can hold both trees' registrations without a name collision if both register something like "Scene."
- Whether module-level singletons either tree relies on (canvas measurements are "cached per
(font, size, text)within a render") stay correctly scoped per-tree rather than accidentally shared if the bundler happens to deduplicate a dependency both trees import identically.
Needs real investigation once the isolation boundary above is chosen, since the answer likely
depends on which boundary gets picked (a separate package is far more likely to isolate cleanly
than a same-package legacy/ subtree).
What "legacy" actually needs to preserve¶
Concretely, given the diffs above: FitText.tsx, text-metrics.ts, and rich-text.tsx as they
exist on main today — all three confirmed to have real, substantial divergence.
point-text-scale-raster.tsx/text-raster.ts need no forking, confirmed identical on both sides.
Everything else the padding fix touched (roughly ten files across two rendering paths) hasn't been
individually checked against main yet — each needs the same real diff check the three files above
already got, rather than assuming trivial or assuming substantial either way.
Loose ends¶
- TemplateBuilder and any other authoring/tooling surface also needs to know which pipeline a document is on, since a v1 document's editing affordances (fields, controls, what's editable) are exactly what exists today, while a v2 document exposes the new schema surface.
- Retiring the legacy path is explicitly out of scope for this delivery, not solved here. Once
this ships,
schemaVersion: 1templates keep working, unmodified, indefinitely; deciding when and how to migrate them forward (by hand, or once an automated migration system exists) is a separate, later effort, not a countdown this plan starts. - The cost of patching a genuine bug in the frozen legacy pipeline, once it's frozen, isn't worked
through. The whole point of freezing it is to stop touching it, but a real, customer-affecting
bug in a still-live
schemaVersion: 1template needs some answer — not yet designed.