Appearance
Theme pipeline internals
Chapter 13. You've seen how a theme renders; this chapter is how it's stored and shipped — the content-addressed bundle, publish, and the base-rebase machinery.
How a theme is packed, stored, compiled, published, loaded, and rebased — the content-addressed bundle machinery. Code: packages/builder-core/src/theme/{bundle,assets,compile,theme-store,theme-compose,base-propagation}.ts and packages/data-objects.
Bundle format — bundle.ts
A theme's text files are ThemeFiles = Record<string, string> (path → UTF-8; binary bytes are not here, only their manifest entry). Packing is pure:
canonical(files)— a path-sorted array of[path, content]pairs, thenJSON.stringify. It's an array, not an object, so a file literally named__proto__round-trips (an object key would hit the prototype setter → silent drop → data loss + a wrong hash). Fixed order → identical file sets → identical bytes.bundleId(files)=sha256(canonical).hex— hashes contents, not the gzip bytes (codec-independent). This 64-hex string is the content address AND the CAS revision token.packBundle=gzipSync(canonical)(the single S3 blob);unpackBundle=gunzipSyncwith a 32 MB decompressed cap (zip-bomb guard — it runs on merchant-controlled blobs) andObject.definePropertyon rebuild (so__proto__becomes real data, not pollution).
Binary assets (assets.ts) ride a manifest at config/assets.json ({ "favicon.ico": { hash, contentType, size } }). The object key is assetHash(bytes) (sha256 hex → dedup + cache-forever). isAssetHash (/^[a-f0-9]{64}$/) is validated because the hash comes from the merchant-editable manifest and is interpolated into an S3 key. The manifest is written as canonical bytes by hand (sorted paths, fixed field order) — JSON.stringify would reorder keys → a spurious new bundle hash → a spurious new version. Allowed content types exclude svg (can script); everything is neutralized to application/octet-stream otherwise, enforced at upload and serve.
Draft save — optimistic concurrency — theme-store.ts
saveDraft(ref, files, { expectedRevision }):
- No
expectedRevision(base provisioning) → last-write-wins put. - With
expectedRevision→ CAS under a Postgres row lock:BEGIN; SET LOCAL lock_timeout='5s'; SELECT 1 FROM theme WHERE id=$1 FOR UPDATE; re-read the current draft, and ifbundleId(current) !== expectedRevision→DraftConflict(HTTP 409); else put; thenCOMMITis.catch(() => {})— the S3 put is the durable write and the txn changed no rows, so a COMMIT failure (lock release) must not be reported as an error (it would strand the client on a stale revision and spuriously 409 their retry).
saveOverrides(full) — the editor sends the full composed tree; this stores only diffFromBase(base, full), after removal guards (removing the layout / a required template / a protected structural file each throws) and managed-file guards (sw.js, manifest.json can't be in the merchant diff).
theme_file isn't populated here
The schema has a theme_file per-file index, but ThemeStore doesn't populate it ("lands with the editor"). The CAS revision the store actually uses is the content hash of the whole overrides bundle (bundleId), not theme_file.revision.
Compile & publish
compileThememinifies each*.csswith a string- and comment-awaretrimCss(not blind regex — preservesnav :hover,calc(1px + 2px), selector lists), catching per-file errors → fall back to original (one bad stylesheet must never fail a publish). Merchant CSS (customize.css,theme.css) is left byte-for-byte.freezeBundlesfreezes the immutable source bundle (= overrides, atsourceHash) and the compiled bundle (=composeTheme(base, overrides)compiled, atcompiledHash), andpromoteBaseCssstitches one sheet and points the manifest at it. An optional freeze-time CAS stops a concurrent merchant save being swept live during rebase.publishverifies the theme exists before freeze (a doomed publish writes no orphan bundles; content-addressed orphans are harmless anyway), then in a txn:- Content-addressed idempotency keyed on
compiled_hash(notsource_hash— a rebase leaves the overrides identical while the output changed): if the latest version'scompiled_hashmatches, reuse that version, skip the INSERT; elseversion = latest + 1. - makeLive under
SELECT … FROM tenants … FOR UPDATE(serializes vs concurrent activate/rollback). Modes:true(always flip),false(cut a version only),if-already-live(flip only if this theme is already live or none is). The purge is enqueued only when the pointer actually moves (an unchanged already-live republish must not spam the outbox).
- Content-addressed idempotency keyed on
setLive (activate/switch) and rollback move the pointer the same way, each under the tenant row lock, each enqueuing a purge. Lock ordering is theme-row first, then tenant-row everywhere, to avoid deadlock.
Load for render — loadLiveCompiled
One join selects the bundle from the live pointer; loadCompiled uses an in-memory LRU (default max 32, keyed by compiled_hash — always safe because content-addressed = immutable) and Object.freezes the files before caching (shared across requests; render is read-only, editors must clone). Returns the flattened, compiled ThemeFiles.
base ⊕ overrides & rebase — theme-compose.ts, base-propagation.ts
composeTheme(base, overrides)— copy base except deleted paths (a_deletescontrol key lists removed base paths), then apply overrides.diffFromBaseis the exact inverse (invariant:compose(base, diff(base, full)) === full); it recomputes_deletes, never trusting an incoming one.rebaseToBasere-anchors overrides onto a new base version: CAS thebase_versionpin; a dirty-draft guard (publishedsource_hashvsbundleId(draft)) refuses if unsaved;makeLiveonly if this theme is currently live (never hijack a store on a different theme); onmakeLiveit re-runs the full-document / required-template / section-ref validation; on any error it CASes the base pin back and restores the draft under CAS (a genuine restore failure is a loudAggregateError, never swallowed).- Fleet:
planBaseRebasepreviews (per-store shadowed files = overrides ∩ changed-base-files, and blocked reasons: dirty-draft / broken-layout / broken-refs / broken-home);applyBaseRebaseruns one store at a time, self-idempotent (a stale retry never republishes byte-identical versions), one failure never aborts the batch.
Object store — data-objects
A deliberately tiny ObjectStore seam (put/get/head/delete): immutable content-addressed objects (published versions
- assets) plus one mutable draft per theme (draft concurrency is the Postgres lock, not here).
CdnReadObjectStoreserves only content-hash/published/keys from the CDN and falls back to S3 on any non-2xx or throw — a pure accelerator. The S3 key layout:
stores/<tenantId>/themes/<themeId>/draft/source.gz # the one MUTABLE draft (overrides)
stores/<tenantId>/themes/<themeId>/published/source/<sourceHash>.gz # immutable frozen overrides
stores/<tenantId>/themes/<themeId>/published/compiled/<compiledHash>.gz # immutable render-ready
stores/<tenantId>/themes/<themeId>/assets/<assetHash> # raw immutable bytes (NOT gzipped)Gotchas
__proto__safety appears twice (bundle pairs + manifest) — object-keyed serialization would silently drop these paths and change the hash.- Idempotency keys on
compiled_hash, notsource_hash— a rebase leaves the overrides identical while the served output changes. - The manifest must be canonical bytes — any reorder → a new bundle hash → a spurious version.
saveDraftswallows COMMIT failure — the put is the durable write; the txn only holds a lock.- Bundle writes happen outside the publish txn — abort leaves harmless content-addressed orphans; the exists-check is before freeze.
loadBaseSourcefails loud (never?? {}) — a missing base blob would silently serve a blank store.- Compiled LRU values are frozen and shared — render must treat
ThemeFilesas read-only. - The 32 MB cap is on decompressed size (
unpackBundleruns on merchant blobs).