store

package
v0.1.9 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 10, 2026 License: MIT Imports: 15 Imported by: 0

Documentation

Overview

Package store defines persistence interfaces + an in-memory test implementation for the CMS engine.

Per gocodealone-multisite SPEC.md:

V12: per-tenant data writes ! include tenant_id WHERE clause.
V15: CMS-rendered page → tenant_id from session; ⊥ from URL/header.
V22: page filtered by (tenant_id, subsite_label).

Production wiring: postgres-backed implementation lives in gocodealone-multisite (per the host's schema in migrations/0001). This package owns the interface and an in-memory store for tests + local dev.

Package store persists CMS records.

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrTenantNotFound  = errors.New("store: tenant not found")
	ErrTenantSlugTaken = errors.New("store: tenant slug taken")
	ErrDomainNotFound  = errors.New("store: domain not found")
	ErrDomainTaken     = errors.New("store: domain host taken")
)

Sentinel errors.

View Source
var ErrBatchInvalid = errors.New("page batch invalid")
View Source
var ErrHistoryQuery = errors.New("invalid page history query")
View Source
var ErrHistoryUnavailable = errors.New("page history unavailable; no write committed")
View Source
var ErrNotFound = errors.New("page not found")

ErrNotFound is returned when a page does not exist OR exists in a different tenant scope. Callers ! NOT distinguish the two cases — leaking tenant existence cross-tenant violates V12/V16.

View Source
var ErrPathConflict = errors.New("page path conflict")

ErrPathConflict is returned when a Create or Update would produce a duplicate (tenant_id, subsite, path) tuple.

View Source
var ErrVersionConflict = errors.New("page changed; reload before saving or deleting")

ErrVersionConflict refuses stale or missing write preconditions.

Functions

func ChangedPageStates added in v0.1.8

func ChangedPageStates(before, after []PageState) ([]PageState, []PageState)

ChangedPageStates includes version-only saves and excludes untouched pages.

func CheckPageBatch added in v0.1.8

func CheckPageBatch(state PageSet, batch PageBatch, allowRestore bool) error

CheckPageBatch compares the entire tenant baseline and checks the final paths before any mutation. Omitted pages remain untouched. restore is never accepted by ordinary apply; it is generated only by the guarded rollback implementation.

func WithPageWriteActor added in v0.1.8

func WithPageWriteActor(ctx context.Context, actor string) context.Context

WithPageWriteActor carries the host's authenticated stable identity into a store transaction. It grants no authority. Never populate it from a request body, arbitrary header, or unverified token claim.

Types

type Domain

type Domain struct {
	ID           int64
	TenantID     int64
	Host         string // exact, lowercase, e.g. "cool-band.com"
	SubsiteLabel string // empty = root subsite
	Kind         string // "vanity" | "preview" | "subdomain"
}

Domain is a vanity / preview / subdomain owned by a tenant.

Per SPEC V22: subsite scope is host-level (set on the Domain), not path-level — `cool-band.com` and `cool-band.com/tour` resolve to the same tenant but different subsites.

type MemoryPageStore

type MemoryPageStore struct {
	// contains filtered or unexported fields
}

MemoryPageStore is an in-memory PageStore for tests + local dev. Production uses a postgres-backed implementation that wires the same interface.

func NewMemoryPageStore

func NewMemoryPageStore() *MemoryPageStore

NewMemoryPageStore returns an empty in-memory store.

func (*MemoryPageStore) ApplyPageBatch added in v0.1.8

func (s *MemoryPageStore) ApplyPageBatch(ctx context.Context, tenantID int64, batch PageBatch) (PageBatchReceipt, error)

func (*MemoryPageStore) Create

func (s *MemoryPageStore) Create(ctx context.Context, tenantID int64, p *Page) error

Create inserts the page. Returns ErrPathConflict if (tenant, subsite, path) is already taken.

func (*MemoryPageStore) Delete

func (s *MemoryPageStore) Delete(ctx context.Context, tenantID, id int64, expectedVersion int) error

Delete removes the page. ErrNotFound on miss / wrong tenant.

func (*MemoryPageStore) Get

func (s *MemoryPageStore) Get(_ context.Context, tenantID, id int64) (*Page, error)

Get returns a copy of the page. tenant_id mismatch → ErrNotFound (no leak — V12/V16).

func (*MemoryPageStore) GetByPath

func (s *MemoryPageStore) GetByPath(_ context.Context, tenantID int64, subsite, path string) (*Page, error)

GetByPath looks up by (tenant, subsite, path).

func (*MemoryPageStore) List

func (s *MemoryPageStore) List(_ context.Context, tenantID int64, subsite string) ([]*Page, error)

List returns pages for the tenant, optionally filtered by subsite. Empty subsite arg = ALL pages for the tenant (including NULL-subsite + every subsite).

func (*MemoryPageStore) ReadPageHistory added in v0.1.8

func (s *MemoryPageStore) ReadPageHistory(_ context.Context, tenantID int64, q PageHistoryQuery) (PageHistory, error)

func (*MemoryPageStore) ReadPageState added in v0.1.8

func (s *MemoryPageStore) ReadPageState(_ context.Context, tenantID int64) (PageSet, error)

func (*MemoryPageStore) RollbackPageBatch added in v0.1.8

func (s *MemoryPageStore) RollbackPageBatch(ctx context.Context, tenantID int64, receipt PageBatchReceipt) (PageBatchReceipt, error)

func (*MemoryPageStore) Update

func (s *MemoryPageStore) Update(ctx context.Context, tenantID int64, p *Page) error

Update writes the page. Returns ErrNotFound if no record matches (tenant_id, id). Bumps Version + UpdatedAt.

type MemoryTenantAdminStore

type MemoryTenantAdminStore struct {
	// contains filtered or unexported fields
}

MemoryTenantAdminStore is the in-memory implementation used for tests and as the default until a persistent store is wired.

func NewMemoryTenantAdminStore

func NewMemoryTenantAdminStore() *MemoryTenantAdminStore

func (*MemoryTenantAdminStore) CreateDomain

func (s *MemoryTenantAdminStore) CreateDomain(_ context.Context, d *Domain) error

func (*MemoryTenantAdminStore) CreateTenant

func (s *MemoryTenantAdminStore) CreateTenant(_ context.Context, t *Tenant) error

func (*MemoryTenantAdminStore) DeleteDomain

func (s *MemoryTenantAdminStore) DeleteDomain(_ context.Context, tenantID, id int64) error

func (*MemoryTenantAdminStore) DeleteTenant

func (s *MemoryTenantAdminStore) DeleteTenant(_ context.Context, id int64) error

func (*MemoryTenantAdminStore) GetTenant

func (s *MemoryTenantAdminStore) GetTenant(_ context.Context, id int64) (*Tenant, error)

func (*MemoryTenantAdminStore) ListDomains

func (s *MemoryTenantAdminStore) ListDomains(_ context.Context, tenantID int64) ([]*Domain, error)

func (*MemoryTenantAdminStore) ListTenants

func (s *MemoryTenantAdminStore) ListTenants(_ context.Context) ([]*Tenant, error)

func (*MemoryTenantAdminStore) UpdateTenant

func (s *MemoryTenantAdminStore) UpdateTenant(_ context.Context, t *Tenant) error

type Page

type Page struct {
	ID          int64
	TenantID    int64
	Subsite     string
	Path        string
	Title       string
	BodyHTML    string
	BodyBlocks  json.RawMessage
	Status      PageStatus
	TemplateID  string
	PublishAt   *time.Time
	UnpublishAt *time.Time
	Version     int
	CreatedAt   time.Time
	UpdatedAt   time.Time
}

Page is a CMS-managed dynamic page for a tenant.

Field semantics:

  • TenantID: ! non-zero (V12 multi-tenancy guard)
  • Subsite: "" → applies to all subsites; "<label>" → subsite-scoped
  • Path: URL path (e.g. "/blog/welcome"); ! unique per (tenant, subsite)
  • BodyHTML: legacy/rendered HTML fallback for serve-time output
  • BodyBlocks: provider-specific block JSON (source of truth when present)
  • TemplateID: optional template shell identifier
  • PublishAt: optional first public-render timestamp
  • UnpublishAt: optional timestamp after which public rendering stops
  • Status: draft | published | scheduled | archived
  • Version: monotonic per-page edit counter

func (*Page) Validate

func (p *Page) Validate() error

Validate returns nil iff the page satisfies persistence invariants.

type PageBaseline added in v0.1.8

type PageBaseline struct {
	ID      int64  `json:"id"`
	Version int    `json:"version"`
	Digest  string `json:"digest"`
}

func Baseline added in v0.1.8

func Baseline(states []PageState) []PageBaseline

type PageBatch added in v0.1.8

type PageBatch struct {
	TargetScope      string         `json:"target_scope"`
	BaselineRevision int64          `json:"baseline_revision"`
	Baseline         []PageBaseline `json:"baseline"`
	Mutations        []PageMutation `json:"mutations"`
}

func BatchFor added in v0.1.8

func BatchFor(state PageSet, mutations []PageMutation) PageBatch

func RollbackBatch added in v0.1.8

func RollbackBatch(r PageBatchReceipt) (PageBatch, error)

type PageBatchReceipt added in v0.1.8

type PageBatchReceipt struct {
	TargetScope    string           `json:"target_scope"`
	BeforeRevision int64            `json:"before_revision"`
	AfterRevision  int64            `json:"after_revision"`
	Before         []PageState      `json:"before"`
	After          []PageState      `json:"after"`
	Mapping        map[string]int64 `json:"mapping"`
	Digest         string           `json:"digest"`
}

func (*PageBatchReceipt) Seal added in v0.1.8

func (r *PageBatchReceipt) Seal()

Seal detects archive corruption. It is not a signature or authorization.

type PageBatchStore added in v0.1.8

type PageBatchStore interface {
	ReadPageState(context.Context, int64) (PageSet, error)
	ApplyPageBatch(context.Context, int64, PageBatch) (PageBatchReceipt, error)
	RollbackPageBatch(context.Context, int64, PageBatchReceipt) (PageBatchReceipt, error)
}

PageBatchStore is internal persistence, not an authorized publication API. The host must provide approval, fencing, verified bundles and durable backups.

type PageContent added in v0.1.8

type PageContent struct {
	Subsite     string          `json:"subsite"`
	Path        string          `json:"path"`
	Title       string          `json:"title"`
	BodyHTML    string          `json:"body_html"`
	BodyBlocks  json.RawMessage `json:"body_blocks,omitempty"`
	Status      PageStatus      `json:"status"`
	TemplateID  string          `json:"template_id,omitempty"`
	PublishAt   *time.Time      `json:"publish_at,omitempty"`
	UnpublishAt *time.Time      `json:"unpublish_at,omitempty"`
}

PageContent is an explicit content allowlist; it carries no tenant authority.

func ContentOf added in v0.1.8

func ContentOf(p *Page) PageContent

func (PageContent) Digest added in v0.1.8

func (c PageContent) Digest() string

func (PageContent) Page added in v0.1.8

func (c PageContent) Page(tenantID, id int64, version int) *Page

Page reconstructs content in the caller's authorized tenant, not a payload tenant.

func (PageContent) Validate added in v0.1.8

func (c PageContent) Validate() error

type PageHistory added in v0.1.8

type PageHistory struct {
	Scope                string             `json:"scope"`
	HistoryStartRevision int64              `json:"history_start_revision"`
	CurrentRevision      int64              `json:"current_revision"`
	Entries              []PageHistoryEntry `json:"entries"`
	NextRevision         int64              `json:"next_revision"`
	HasMore              bool               `json:"has_more"`
}

HistoryStartRevision is the head at adoption. No entries before it are claimed or fabricated. CurrentRevision is read atomically with this page of history. NextRevision is an exclusive cursor; HasMore signals another page.

type PageHistoryEntry added in v0.1.8

type PageHistoryEntry struct {
	Scope       string      `json:"scope"`
	Revision    int64       `json:"revision"`
	CommittedAt time.Time   `json:"committed_at"`
	Actor       string      `json:"actor"`
	Operation   string      `json:"operation"`
	Before      []PageState `json:"before"`
	After       []PageState `json:"after"`
	Digest      string      `json:"digest"`
}

PageHistoryEntry records only pages touched by one committed write. Deleted pages remain in Before. Versions, blocks, schedules and templates are saved content; Scope and Revision identify the durable tenant content namespace. Digest detects corruption; it is not a signature or proof of authorization.

func NewPageHistoryEntry added in v0.1.8

func NewPageHistoryEntry(ctx context.Context, scope string, revision int64, operation string, before, after []PageState, now time.Time) PageHistoryEntry

func (PageHistoryEntry) ContentDigest added in v0.1.8

func (e PageHistoryEntry) ContentDigest() string

type PageHistoryQuery added in v0.1.8

type PageHistoryQuery struct {
	AfterRevision int64
	Limit         int
}

func (PageHistoryQuery) Validate added in v0.1.8

func (q PageHistoryQuery) Validate() error

type PageHistoryStore added in v0.1.8

type PageHistoryStore interface {
	ReadPageHistory(context.Context, int64, PageHistoryQuery) (PageHistory, error)
}

type PageMutation added in v0.1.8

type PageMutation struct {
	Key             string       `json:"key"`
	Kind            string       `json:"kind"` // create, update, delete; restore is rollback-only
	TargetID        int64        `json:"target_id"`
	ExpectedVersion int          `json:"expected_version"`
	Content         *PageContent `json:"content,omitempty"`
}

PageMutation never copies a source ID into a target ID implicitly.

type PageSet added in v0.1.8

type PageSet struct {
	Scope    string      `json:"scope"`
	Revision int64       `json:"revision"`
	Pages    []PageState `json:"pages"`
}

PageSet is read atomically in the invocation's authorized tenant. Scope is a durable content namespace, never a permission or copied tenant setting.

type PageState added in v0.1.8

type PageState struct {
	ID      int64       `json:"id"`
	Version int         `json:"version"`
	Content PageContent `json:"content"`
}

func States added in v0.1.8

func States(pages []*Page) []PageState

type PageStatus

type PageStatus string

PageStatus is the publish state of a CMS page.

const (
	StatusDraft     PageStatus = "draft"
	StatusPublished PageStatus = "published"
	StatusScheduled PageStatus = "scheduled"
	StatusArchived  PageStatus = "archived"
)

type PageStore

type PageStore interface {
	Create(ctx context.Context, tenantID int64, p *Page) error
	Get(ctx context.Context, tenantID int64, id int64) (*Page, error)
	GetByPath(ctx context.Context, tenantID int64, subsite, path string) (*Page, error)
	Update(ctx context.Context, tenantID int64, p *Page) error
	Delete(ctx context.Context, tenantID int64, id int64, expectedVersion int) error
	List(ctx context.Context, tenantID int64, subsite string) ([]*Page, error)
}

PageStore persists Page records scoped by tenant.

Every method takes tenantID as the FIRST arg AFTER ctx — making tenant-scoping impossible to forget at the call site (V12 / V15).

type Tenant

type Tenant struct {
	ID        int64
	Slug      string
	Label     string
	ThemeID   string
	CreatedAt time.Time
	UpdatedAt time.Time
}

Tenant is a multisite tenant.

type TenantAdminStore

type TenantAdminStore interface {
	// CreateTenant assigns an ID + timestamps. Slug must be unique;
	// returns ErrTenantSlugTaken otherwise.
	CreateTenant(ctx context.Context, t *Tenant) error
	GetTenant(ctx context.Context, id int64) (*Tenant, error)
	UpdateTenant(ctx context.Context, t *Tenant) error
	DeleteTenant(ctx context.Context, id int64) error
	ListTenants(ctx context.Context) ([]*Tenant, error)

	// CreateDomain attaches a Domain to an existing tenant. Host must be
	// globally unique across all tenants; returns ErrDomainTaken otherwise.
	CreateDomain(ctx context.Context, d *Domain) error
	DeleteDomain(ctx context.Context, tenantID, id int64) error
	ListDomains(ctx context.Context, tenantID int64) ([]*Domain, error)
}

TenantAdminStore is the CRUD surface for the multisite admin. It is separate from TenantStore (the resolver's read-only interface) so reads can be optimised independently from writes.

All operations are tenant-scoped via TenantID (no cross-tenant leak).

Directories

Path Synopsis
Package postgres provides a Postgres-backed CMS store.
Package postgres provides a Postgres-backed CMS store.
Package storetest provides the same mutation behavior contract for memory and real Postgres tests.
Package storetest provides the same mutation behavior contract for memory and real Postgres tests.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL