Skip to content

Store design (SQL vs memory)

The public block reads through a store abstraction with two interchangeable implementations and one shared shaping layer. The route handlers never touch SQLAlchemy directly; they call store methods that return ready-built response models.

The dependency seam

cms_api.public.store.get_public_store is the single dependency that route handlers inject with Depends(get_public_store):

PublicStore = Union[MemoryPublicStore, "SqlPublicStore"]

def get_public_store() -> PublicStore:
    """Return the production SQL-backed public store (override in tests)."""
    from .sql import SqlPublicStore
    return SqlPublicStore()

Two details matter:

  • PublicStore is a union type, so handler signatures and shared helpers can accept either implementation.
  • SqlPublicStore is imported lazily inside the function, not at module top. This keeps import cms_api.public.store from pulling in SQLAlchemy (and the admin store / session_scope), so a pure in-memory test run never imports the SQL stack.

Why two stores

SqlPublicStore MemoryPublicStore
Module cms_api.public.store.sql cms_api.public.store.memory
Backing session_scope() + SQLAlchemy queries over hq.sql_models plain Python dicts (sites, content, authors, settings, ...)
Used in production unit tests
Imports SQLAlchemy yes no

Tests override the dependency with the memory store via app.dependency_overrides[get_public_store]. The memory store fills its dicts with the same admin schema models (SiteOut, ContentOut, AuthorOut, ...) that the SQL store reads from the DB, so a test can build a fixture in memory and exercise the exact route handlers used in production -- no database required.

The shared shapers: v2_common

The reason memory-backed tests can validate the SQL store's contract is that both stores fetch raw rows their own way, then call the same pure shaper functions in cms_api.public.store.v2_common to build the response envelopes. Keeping the shaping logic in one place guarantees the two stores return byte-identical shapes.

flowchart TD
    R[v2 route handler] --> G{get_public_store}
    G -->|prod| SQL[SqlPublicStore]
    G -->|tests override| MEM[MemoryPublicStore]
    SQL -->|raw ORM rows| VC[v2_common shapers]
    MEM -->|raw dict rows| VC
    VC --> M[Pydantic v2 models<br/>extra=forbid]
    M --> R

v2_common owns, among others:

  • Single sources of truth -- ARTICLE_CONTENT_TYPES (which content_types the /pages endpoint serves as articles), CACHE_HINTS (per-endpoint s-maxage/swr), SETTINGS_FIELDS (the merged site_settings columns), and the versioned-read merge policy (HISTORY_IDENTITY_FIELDS vs HISTORY_BUSINESS_FIELDS).
  • Envelope builders -- build_settings_blob, build_article_content, build_article_seo, build_author_seo, build_breadcrumbs, list_item_from_fields, author_slice, author_stats, related_item, sitemap_entry, meta_envelope / cache_hint.
  • Pure helpers -- parse_since, compose_loc, navigation_urls, static_sitemap_entries, featured_image_variants.

Because these helpers are data-access agnostic (they take row-like objects and return models), the same call from SqlPublicStore and MemoryPublicStore produces the same SettingsBundle, ArticlePage, AuthorProfile, ListResponse, or SitemapResponse. The extra="forbid" config on those models then catches any field-level drift in either store.

Media URL absolutization

Both stores route media columns (featured_image, og_image, author image, the settings favicon/og-image/icon) through absolutize_media_url (cms_api.public.media), which prefixes relative paths with the site's cdn_base_url and leaves already-absolute (http(s)://, //) URLs untouched. Centralizing this in the shaper keeps the two stores consistent here too.

See the autodoc in API reference / Public store for the full method and helper signatures.