Skip to content

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 } | null

Two queries, not one:

  1. Override branchmarket_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".
  2. Default branchpath = ? AND (market_id = defaultMarketId OR market_id IS NULL). Prefers the explicit default-market row over a legacy NULL row (ORDER BY CASE WHEN market_id IS NULL THEN 1 ELSE 0 END), then the caller’s orderBy. source is "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:

CallerextraWhereEffect
entries.resolveEntryByPathstatus != '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:

SiteFunctionNotes
entries.getEntry (GET /api/entries/:id)resolvePageByIdreturns source + is_override to the editor
entries.resolveEntryByPath (GET /api/entries/resolve-by-path)resolvePageByPathextraWhere: status != 'archived'
entries list / dashboardlistPagesWithMarketFallbackown + inherited, deduped by path
Delivery preview by-path (GET /api/v1/pages/by-path/*)resolvePageByPathorderBy = published-first, updated_at desc
Delivery preview by-id (GET /api/v1/pages/:id)resolvePageByIdmarket-independent
Delivery published by-pathresolvePageByPathextraWhere: status = 'published'
Delivery published by-idresolvePageByIdrow 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-Market header, defaulted by the auth middleware to the space’s default market. The resolver never reads headers — callers pass marketId in.

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 returns null. See Wildcard Routing.
  • Layout resolution — selecting the default layout page 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.tsgetEntry source classification; resolve-by-path override → 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.