Storage & Versioning¶
Not new architecture — an extension of the bundled-versus-DB-native pattern Retail Studio templates already use — except Composer's own content is decided to be DB-native only, and full recursive versioning for every altitude from day one is decided, not phased.
What exists today¶
Retail Studio templates already come from two sources: bundled (compiled into the repo,
RETAIL_TEMPLATE_REGISTRY) and DB-native (a row in retail_template_versions, immutable per
version — a DB trigger blocks changing document / manifest in place — fetched when a template
"misses" the bundle via useResolvedCampaignTemplate's bundledMiss fallback). "Migrating a
template" is already defined as publishing a new version row, never an update, which is exactly the
freeze-at-commit behaviour the fork model relies on
(Fork, Rebase & Conflict Resolution).
Decided: Composer's content is DB-native only, no bundled path¶
Per direct instruction
Elements, Scenes, and Projects are DB-native only. The bundled path is not extended to any of Composer's three altitudes. This follows from what ownership, forking, and versioning already require: every Element, Scene, and Project needs a real identity row and a real version-lineage row (Identity, Versions & Schema Versioning) — for every one of them, not just some. A bundled Element would have to sidestep all of that, which makes it a fundamentally different kind of thing wearing the same name, not a storage optimisation. Maintaining two genuinely different code paths through every place that touches Composer content — the resolver, rebase, brand-scoping — for a case that gains nothing real is exactly the complexity the "one way to do a thing" principle argues against.
This doesn't change the existing template system's own bundled path; that stays exactly as it is for whatever it already covers. Primitives and components stay in the repo, as code.
The table families¶
Five identity/version pairs, each the same shape as retail_template_versions' immutable-per-version
pattern. The row shape — identity: id, name, ownership, sharing list, currentVersionId;
version: schemaVersion, parentVersionId, status, content, changes, brandStyleVersionId,
committedBy / committedAt — is specified on
Identity, Versions & Schema Versioning
and not repeated here.
| Pair | Holds | Ownership reference |
|---|---|---|
retail_elements / retail_elements_versions |
Elements | brand and/or organisation, plus a sharing list for the cross-brand case |
retail_scenes / retail_scenes_versions |
Scenes | brand and/or organisation (intersection-constrained, Brand Scoping & Brand Style) |
retail_projects / retail_projects_versions |
Projects | brand and/or organisation (intersection-constrained) |
retail_campaigns / retail_campaigns_versions |
Campaigns — a campaign is a placement fork of one Project version, and it needs a home of its own because a Project has no containing altitude for its forks to live in the way a Scene holds an Element instance's overrides | brand and/or organisation, inherited from the Project it forks; the identity row also carries the projectVersionId it is forked from |
retail_brand_style_profiles / retail_brand_style_profiles_versions |
The brand style profile | brand/organisation only — a profile belongs to exactly one brand, never shared the way an Element can be, and its version rows carry no brandStyleVersionId of their own, since the profile is the brand-style version |
The version row's committedBy / committedAt are the durable audit granularity incident response
depends on (Testing, Preview & Incident Response).
A campaign commit advances the campaign's pointer, never the Project's
This is why campaigns need their own pair rather than being retail_projects_versions rows: if
a campaign commit advanced the Project's currentVersionId, every other campaign on that
Project would go stale the moment one of them saved, and the staleness signal would become
noise. A campaign version's lineage runs through retail_campaigns_versions
(parentVersionId), while the Project version it forks from is a separate reference that moves
only through the fork-level rebase
(Fork, Rebase & Conflict Resolution). The published campaign
version is what renders, and what an incident revert reassigns.
Resolving a reference¶
The resolver folds one altitude's base plus Change[] and never recurses into a referenced version
(The Change Model). Joining an instance's overrides
against the content of the version it references is a storage-layer read: given a version id, return
that version's frozen content — upcast through the transformer chain if its schemaVersion is
behind current (Identity, Versions & Schema Versioning).
The one draft it ever computes on demand is the fork currently under edit, so an author sees their
own unsaved work; a reference never resolves to someone else's draft (see the commit rule). That read is the one place the upcast lives, so the resolver only
ever sees current-shape content. The renderer, the editor preview, and rebase all obtain referenced
content through this same read — one path, not one per consumer.
Assembling a render¶
The read above returns one version's content. Turning a Project into something renderable is a second, distinct step: walk the tree of references, and at each boundary apply the containing altitude's overrides and manifest bindings to the content the read handed back. A campaign value threads Project manifest field → Scene instance field → Scene manifest field → Element instance field → the Element layer property the binding targets, across three frozen documents.
This is deliberately not the resolver's job — the resolver stays one flat fold per altitude
(The Change Model), which is what keeps it pure and
what stops one altitude's Change[] reaching into another's. The assembly walk is recursive by
nature, reads only frozen (or upcast) content plus each fork's already-resolved overrides, and
produces the flat document the renderer consumes. Two consequences worth stating: the composition
depth a render walks is bounded by the altitude count, not by anything authored, and this is the
step whose output the visual-regression mechanism actually renders
(Testing, Preview & Incident Response). Its concrete
shape is not designed here; what's settled is that it exists, that it is one shared implementation,
and that it is not the resolver.
Two paths, depending on the undecided schema fork¶
Whether these tables extend the engine's schemaVersion: 2 format in place or exist as a wholly
separate schema is Blair's call (Identity, Versions & Schema Versioning):
| If Composer… | Then storage… |
|---|---|
| extends the engine's v2 format in place | makes the DB-native path primary and extends the identical shape two levels down, mirroring the existing immutable version-row pattern the current format already uses |
| exists as a separate format alongside it | can take whatever shape Composer's own schema needs, at the cost of not reusing a versioning mechanism already built and proven for the current format |
Decided: full recursive versioning from day one, not phased
The identity / version / instance schema and the fork/rebase model commit every altitude — Element, Scene, and Project — to a real frozen version from day one; nothing stays "live" until some later Template-level freeze.
Risks¶
- Every Composer resolution pays a live-query cost. Bundled templates are free at render time; DB-native ones are a live query, and Composer's content never uses the bundled path, so every Element, Scene, and Project resolution pays that cost with no fallback. Never measured against what the bundled path's near-zero cost would have offered. Matthew's own expectation is that this is unlikely to be significant in practice — tracked as worth a real cost analysis at some point, not a blocking risk.
Open questions¶
- Whether Overlays need a fifth pair or reuse
retail_sceneswith a kind discriminator. An Overlay is placed as a frozen version like anything else, so a row has to exist for its version id to point at; which shape it takes is open (Overlays). - The JSONB shape of
contentandchanges, and how the sharing list is represented — the column set is decided; these encodings aren't. - How wrapper Elements exist as rows — pre-generated per primitive type or on demand, and who owns them (The Change Model).
- How an Element's own assets (images, etc.) get stored and served. The existing bundled /
DB-native asset split (
compositionsServeUrl/ S3 for DB-native) should extend cleanly, but this hasn't been walked through concretely.