Page Resolution
Every read of a page — the storefront fetching a URL, the editor opening an entry, the AI agent resolving “the homepage” — asks the same question:
Given a path or id, an edition, a market, and published-or-draft, which page row do we serve?
That question used to be answered in several places, each with its own
hand-rolled SQL. They drifted, and the drift was visible to users (see
The bug this prevents). All exact path/id resolution
now flows through one module: apps/cms/src/server/utils/market-fallback.ts.
The model: override vs inherited
A page lives in an edition and belongs to a market. Markets are
edition-scoped: each edition has exactly one default market
(default-market-<editionId>), and every other market in that edition
inherits the default’s pages unless it has its own row at that path — its
override.
edition: "fall" default market ────────────── / (the base everyone inherits) /about market "germany" ──────────── / (override — germany's own row) (/about: no row → inherited from default)So resolving /about for germany yields the default row (inherited);
resolving / yields germany’s own row (override). The resolver returns
both the row and which branch it came from:
type ResolvedSource = "override" | "inherited";interface ResolvedEntry<TRow> { row: TRow; source: ResolvedSource;}The resolver
market-fallback.ts exports four functions. Everything else is a thin caller.
resolvePageByPath
The core. Active-market override first, then the inherited default.
const resolved = await resolvePageByPath<Row>(db, { spaceId, envId, marketId, path, extraWhere?: "status = 'published'", // applied to BOTH branches extraBinds?: [...], orderBy?: "CASE WHEN status = 'published' THEN 0 ELSE 1 END, updated_at DESC",});// → { row, source } | nullTwo queries, not one:
- Override branch —
market_id = marketId AND path = ?. Skipped entirely when the active market is the default (an override of the default is the default). If it hits,source: "override". - Default branch —
path = ? AND (market_id = defaultMarketId OR market_id IS NULL). Prefers the explicit default-market row over a legacyNULLrow (ORDER BY CASE WHEN market_id IS NULL THEN 1 ELSE 0 END), then the caller’sorderBy.sourceis"inherited"for a non-default caller,"override"for the default itself (it owns the base).
extraWhere is appended to both branches, so a caller’s filter can’t let an
excluded override shadow a valid default. Two callers use it:
| Caller | extraWhere | Effect |
|---|---|---|
entries.resolveEntryByPath | status != 'archived' | an archived override falls through to the default |
delivery (published) | status = 'published' | an unpublished override can’t shadow a published default |
orderBy is the tiebreak within a branch when more than one row survives
(preview’s orphan drafts, or a page+layout pair at one path). Preview passes
“published first, then most-recently-updated”; the published path needs none
(status is already pinned).
resolvePageById
By-id resolution is market-independent — ids are unique, so the market never
changes which row comes back. The resolver takes marketId only to classify
the row:
const rowMarket = row.market_id ?? defaultMarketId;const source = rowMarket === marketId ? "override" : "inherited";That classification is the whole fix for the override/inherited bug: the editor opens a page by id and the server authoritatively says whether that row is the active market’s own or inherited — the client no longer guesses.
listPagesWithMarketFallback
The entry-list view: every path the active market can see — its own rows plus
inherited-from-default rows for paths it hasn’t overridden — each tagged with
source so the UI can render an Inherited from default badge. The default
market sees only its own rows (all "override" from its perspective).
defaultMarketIdForEdition
default-market-${editionId}. Centralised so no caller hand-rolls the string.
Who routes through it
All four exact path/id resolution sites, plus the two entry endpoints:
| Site | Function | Notes |
|---|---|---|
entries.getEntry (GET /api/entries/:id) | resolvePageById | returns source + is_override to the editor |
entries.resolveEntryByPath (GET /api/entries/resolve-by-path) | resolvePageByPath | extraWhere: status != 'archived' |
entries list / dashboard | listPagesWithMarketFallback | own + inherited, deduped by path |
Delivery preview by-path (GET /api/v1/pages/by-path/*) | resolvePageByPath | orderBy = published-first, updated_at desc |
Delivery preview by-id (GET /api/v1/pages/:id) | resolvePageById | market-independent |
| Delivery published by-path | resolvePageByPath | extraWhere: status = 'published' |
| Delivery published by-id | resolvePageById | row kept only if status === 'published' |
How the request supplies edition + market
- Edition is token-pinned for delivery: it comes from the API key’s
target_edition_id(see Delivery API), not from a header. The management endpoints take it from context /X-Alokai-CMS-Edition. - Market comes from the optional
X-Alokai-CMS-Marketheader, defaulted by the auth middleware to the space’s default market. The resolver never reads headers — callers passmarketIdin.
Where the resolver sits relative to the cache
On the delivery hot path, KV is checked before the resolver. The resolver is the D1-miss path only; A/B bucketing, component-map signing, and design tokens all run after it, on both cache hit and miss. The cache key includes the market segment, so each market’s view is cached independently — see Caching Strategy.
What is intentionally not in the resolver
- Wildcard matching (
matchWildcardPath/scorePattern) — pattern scoring is a different algorithm from exact-path equality. It runs as a fallback after the resolver returnsnull. See Wildcard Routing. - Layout resolution — selecting the default
layoutpage is a separate entity lookup, not override/inherited resolution. See Layouts.
The bug this prevents
The editor used to re-derive “inherited vs override” on the client by
comparing the page’s market_id to a cached active-market id. After markets
became edition-scoped (same slug → a different id per edition), that cached id
went stale: an edition’s legitimate germany override was mislabeled
“inherited from default”, and trying to “create” the override that already
existed threw “This market already has an override at ’/’.”
The root cause was resolution drift — the client and server disagreeing about
the same question. Consolidating onto one resolver and having the server
return an authoritative source removes the client’s ability to guess wrong.
Tests
Behavior is pinned through public interfaces, not the resolver’s internals:
tests/entry-source.test.ts—getEntrysource classification;resolve-by-pathoverride → default fallback, archived-override skip.tests/delivery-preview-resolution.test.ts— preview by-path active-market override priority + default fallback.tests/store-inheritance.test.ts,tests/page-store-overrides.test.ts,tests/delivery-api.test.ts— the published-path parity net.
These stay green across any refactor of the queries behind the resolver.