contentstore

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Sep 14, 2026 License: MIT Imports: 21 Imported by: 0

README

Content Store

Content Store is a transport-independent Go library for managing schema-validated, localized content with separate draft and published representations.

It provides:

  • draft, publish, unpublish, archive, restore, and delete lifecycles;
  • complete published and preview snapshots;
  • optimistic concurrency and atomic field updates;
  • a schema registry with localized field validation;
  • a MongoDB-backed store and an injectable persistence interface;
  • checksummed export and atomic replacement operations.

Applications supply their own content schemas, locale configuration, ID generator, notification adapter, transport, authentication, and logging.

Installation

go get github.com/foomo/contentstore

Testing

go test ./...

The MongoDB integration tests use mongodb://localhost:27017 when available and skip automatically otherwise.

Documentation

Index

Constants

View Source
const (
	OpSaveDraft    = "saveDraft"
	OpPublish      = "publish"
	OpUnpublish    = "unpublish"
	OpArchive      = "archive"
	OpUnarchive    = "unarchive"
	OpDelete       = "delete"
	OpContentReset = "contentReset"
)

Content-change operations reported to a Notifier.

View Source
const (
	StateDraft     = "draft"
	StateChanged   = "changed"
	StatePublished = "published"
	StateArchived  = "archived"
)

Derived publishing states (never stored as an enum; see ContentItem.State).

View Source
const ContentExportFormatVersion = 1

Variables

View Source
var (
	// ErrNotFound is returned by a Store when a content item does not exist.
	ErrNotFound = errors.New("content item not found")
	// ErrConflict is returned when a conditional write precondition does not match.
	ErrConflict = errors.New("content item write conflict")
)

Functions

func OptionalInt

func OptionalInt(v int) *int

OptionalInt returns a pointer to v, for optional schema length hints.

Types

type ArchivedContentItem

type ArchivedContentItem struct {
	ID         string        `json:"id"`
	Key        string        `json:"key,omitempty"`
	Type       ContentType   `json:"type"`
	OwnerRef   *OwnerRef     `json:"ownerRef,omitempty"`
	Fields     ContentFields `json:"fields"`
	ArchivedAt time.Time     `json:"archivedAt"`
	UpdatedAt  time.Time     `json:"updatedAt"`
}

ArchivedContentItem is one item in an ArchivedSnapshot. It exposes the draft (last working) fields and ArchivedAt; it deliberately omits a derived State field, which would always be "archived".

type ArchivedSnapshot

type ArchivedSnapshot struct {
	Revision  int64                 `json:"revision"`
	CreatedAt time.Time             `json:"createdAt"`
	Items     []ArchivedContentItem `json:"items"`
}

ArchivedSnapshot is the bulk read of archived content, for editorial views that let editors find, restore (unarchive), or hard-delete archived items — which are otherwise excluded from both consumer snapshot views. It carries the working (draft) fields and when each item was archived.

type ArchivedSnapshotInput

type ArchivedSnapshotInput struct {
	Types []ContentType `json:"types,omitempty"`
}

ArchivedSnapshotInput narrows the archived snapshot to the given content types (all when empty).

type ContentChange

type ContentChange struct {
	Type     ContentType
	OwnerRef *OwnerRef
	Op       string
}

ContentChange describes a successful content write so consuming applications can invalidate the configured published or preview representation.

type ContentExport

type ContentExport struct {
	FormatVersion int            `json:"formatVersion"`
	CreatedAt     time.Time      `json:"createdAt"`
	ItemCount     int            `json:"itemCount"`
	Checksum      string         `json:"checksum"`
	Items         []*ContentItem `json:"items"`
}

ContentExport is a complete, transportable snapshot of Content Store data. It includes archived content and both draft and published representations.

type ContentFields

type ContentFields map[string]LocalizedFieldValues

ContentFields maps fieldID -> locale code -> value.

type ContentItem

type ContentItem struct {
	ID         string           `json:"id" bson:"_id"`
	Key        string           `json:"key,omitempty" bson:"key,omitempty"`
	Type       ContentType      `json:"type" bson:"type"`
	OwnerRef   *OwnerRef        `json:"ownerRef,omitempty" bson:"ownerRef,omitempty"`
	Draft      *ContentSnapshot `json:"draft,omitempty" bson:"draft,omitempty"`
	Published  *ContentSnapshot `json:"published,omitempty" bson:"published,omitempty"`
	ArchivedAt *time.Time       `json:"archivedAt,omitempty" bson:"archivedAt,omitempty"`
	CreatedAt  time.Time        `json:"createdAt" bson:"createdAt"`
	UpdatedAt  time.Time        `json:"updatedAt" bson:"updatedAt"`
}

ContentItem is one stored content piece, persisted as a single MongoDB document (ID is the Mongo _id). Draft and Published snapshots live in the same document.

func (*ContentItem) State

func (i *ContentItem) State() string

State derives the publishing state. Archived takes precedence; a live item always has a Draft, so Published == nil means draft-only.

type ContentItemMatch

type ContentItemMatch struct {
	ID  string `json:"id,omitempty"`
	Key string `json:"key,omitempty"`
}

ContentItemMatch selects an item by ID and/or Key.

type ContentSnapshot

type ContentSnapshot struct {
	Revision      int64         `json:"revision" bson:"revision"`
	SchemaVersion string        `json:"schemaVersion" bson:"schemaVersion"`
	SchemaHash    string        `json:"schemaHash" bson:"schemaHash"`
	Fields        ContentFields `json:"fields" bson:"fields"`
	CreatedAt     time.Time     `json:"createdAt" bson:"createdAt"`
	UpdatedAt     time.Time     `json:"updatedAt" bson:"updatedAt"`
}

ContentSnapshot is one revision of an item's fields (draft or published).

type ContentStore

type ContentStore interface {
	SaveDraft(ctx context.Context, input SaveDraftInput) (*ContentItem, error)
	SetFieldValue(ctx context.Context, input SetFieldValueInput) (*ContentItem, error)
	Publish(ctx context.Context, match ContentItemMatch) (*ContentItem, error)
	Unpublish(ctx context.Context, match ContentItemMatch) (*ContentItem, error)
	Archive(ctx context.Context, match ContentItemMatch) (*ContentItem, error)
	Unarchive(ctx context.Context, match ContentItemMatch) (*ContentItem, error)
	Delete(ctx context.Context, match ContentItemMatch) (bool, error)
	GetItem(ctx context.Context, match ContentItemMatch) (*ContentItem, error)
	GetSnapshot(ctx context.Context, view SnapshotView, input SnapshotInput) (*Snapshot, error)
	GetArchivedSnapshot(ctx context.Context, input ArchivedSnapshotInput) (*ArchivedSnapshot, error)
	GetSchemas(ctx context.Context) (*SchemasResult, error)
	ExportContent(ctx context.Context) (*ContentExport, error)
	ReplaceContent(ctx context.Context, export ContentExport) (*ReplaceContentResult, error)
}

ContentStore is the generic, transport-neutral interface for interacting with content. *Engine implements it; a transport binding (e.g. gotsrpc M2M) wraps it and maps the returned *Error to its own error model.

type ContentType

type ContentType string

ContentType identifies a content schema (e.g. "seoOverride").

type ContentTypeSchema

type ContentTypeSchema struct {
	Type    ContentType       `json:"type"`
	Version string            `json:"version"`
	Hash    string            `json:"hash"`
	Fields  []FieldDefinition `json:"fields"`

	// SuppressChangeEvents disables content-change notifications for this type
	// (writes never call the injected Notifier). Default
	// false = notify. It is a behavioural policy, not structural shape, and is
	// deliberately excluded from schemaHash so toggling it never perturbs snapshot
	// hashes. Use it for types with no downstream published-change consumer (e.g. an
	// internal, never-published draft type) so their deletes stay quiet.
	SuppressChangeEvents bool `json:"suppressChangeEvents,omitempty"`
}

ContentTypeSchema is the authoritative (Go-defined) schema for a content type.

func NewSchema

func NewSchema(t ContentType, version string, fields []FieldDefinition) ContentTypeSchema

NewSchema builds a schema and computes its structural hash. Consuming applications use it to define their content types before registering them.

type DeploymentRole

type DeploymentRole string

DeploymentRole is the immutable safety role of one Content Store instance.

const (
	DeploymentRoleCanonical DeploymentRole = "canonical"
	DeploymentRoleSandbox   DeploymentRole = "sandbox"
)

func ParseDeploymentRole

func ParseDeploymentRole(value string) (DeploymentRole, error)

ParseDeploymentRole validates a configured deployment role.

type Deps

type Deps struct {
	Store   Store
	Schemas *Registry
	Locales LocaleConfig
	NewID   func() string
	Notify  Notifier
	Role    DeploymentRole
}

Deps are the injected collaborators an Engine needs. Everything project- or platform-specific (persistence, schemas, locales, ID generation, change notification) is supplied here so the core stays dependency-free.

type Engine

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

Engine is the content store's business logic over an injected Store, schema Registry, and LocaleConfig. It returns typed *Error values (never a transport error type).

func New

func New(d Deps) *Engine

New builds an Engine from its dependencies.

func (*Engine) Archive

func (e *Engine) Archive(ctx context.Context, match ContentItemMatch) (*ContentItem, error)

Archive hides an item while keeping both snapshots.

func (*Engine) Delete

func (e *Engine) Delete(ctx context.Context, match ContentItemMatch) (bool, error)

Delete hard-deletes an item. Deleting a missing item is idempotent (returns false).

func (*Engine) ExportContent

func (e *Engine) ExportContent(ctx context.Context) (*ContentExport, error)

ExportContent returns every stored document, including archived items and both lifecycle snapshots, in a deterministic checksummed envelope.

func (*Engine) GetArchivedSnapshot

func (e *Engine) GetArchivedSnapshot(ctx context.Context, input ArchivedSnapshotInput) (*ArchivedSnapshot, error)

GetArchivedSnapshot returns all archived content, optionally narrowed by type — the counterpart to the consumer snapshot views, which both exclude archived items. It carries each item's working (draft) fields and ArchivedAt, for editorial views that restore or hard-delete archived content.

func (*Engine) GetItem

func (e *Engine) GetItem(ctx context.Context, match ContentItemMatch) (*ContentItem, error)

GetItem returns a single item by match.

func (*Engine) GetSchemas

func (e *Engine) GetSchemas(_ context.Context) (*SchemasResult, error)

GetSchemas exposes the registered content schemas for tooling and editors.

func (*Engine) GetSnapshot

func (e *Engine) GetSnapshot(ctx context.Context, view SnapshotView, input SnapshotInput) (*Snapshot, error)

GetSnapshot returns the compact consumer snapshot selected by view. Published reads never expose working drafts; preview reads expose the latest draft and derived lifecycle state.

func (*Engine) Publish

func (e *Engine) Publish(ctx context.Context, match ContentItemMatch) (*ContentItem, error)

Publish copies the current draft snapshot into the published snapshot.

func (*Engine) ReplaceContent

func (e *Engine) ReplaceContent(ctx context.Context, export ContentExport) (*ReplaceContentResult, error)

ReplaceContent atomically replaces all data on a sandbox instance. Canonical instances reject the operation before inspecting or writing the payload.

func (*Engine) SaveDraft

func (e *Engine) SaveDraft(ctx context.Context, input SaveDraftInput) (*ContentItem, error)

SaveDraft creates or updates an item's draft snapshot. It does not publish.

func (*Engine) SetFieldValue

func (e *Engine) SetFieldValue(ctx context.Context, input SetFieldValueInput) (*ContentItem, error)

SetFieldValue merges a single field/locale value into the item's draft, leaving other fields untouched. It creates the item when the match resolves to nothing and Type is given. A nil value clears that locale. This is the partial-write counterpart to SaveDraft (which replaces the whole field set) and backs per-field editing.

func (*Engine) Unarchive

func (e *Engine) Unarchive(ctx context.Context, match ContentItemMatch) (*ContentItem, error)

Unarchive restores an archived item to Draft. It drops the published snapshot: a previously-published item is NOT re-published automatically (symmetric to Contentful, where archived entries are never published). The working draft is preserved, so no content is lost; re-publish to make it live again.

func (*Engine) Unpublish

func (e *Engine) Unpublish(ctx context.Context, match ContentItemMatch) (*ContentItem, error)

Unpublish drops the published snapshot, returning the item to Draft while keeping the working draft. Unlike Unarchive it leaves ArchivedAt untouched, so it is the plain "take it off the live site" counterpart to Publish. An item that is not published is left alone (and emits nothing) rather than rejected, so bulk callers can apply it to a mixed selection.

type Error

type Error struct {
	Kind Kind
	Msg  string
	Err  error
}

Error is the typed error returned by the engine. Kind lets callers map it to their transport's error model; Msg is a caller-safe message; Err wraps an underlying cause (nil for validation/state errors).

func (*Error) Error

func (e *Error) Error() string

func (*Error) Unwrap

func (e *Error) Unwrap() error

type FieldDefinition

type FieldDefinition struct {
	ID            FieldID            `json:"id"`
	Type          FieldType          `json:"type"`
	EditingFormat FieldEditingFormat `json:"editingFormat,omitempty"`
	Localized     bool               `json:"localized"`
	Required      bool               `json:"required"`
	ReadOnly      bool               `json:"readOnly"`

	Label              string `json:"label,omitempty"`
	Description        string `json:"description,omitempty"`
	ContentHint        string `json:"contentHint,omitempty"`
	FormatHint         string `json:"formatHint,omitempty"`
	SuggestedMinLength *int   `json:"suggestedMinLength,omitempty"`
	SuggestedMaxLength *int   `json:"suggestedMaxLength,omitempty"`
}

FieldDefinition describes one field. ReadOnly marks system-generated fields editors may not author (defaults to editable).

type FieldEditingFormat

type FieldEditingFormat string

FieldEditingFormat describes the value representation expected by an editor. It is separate from FieldType so plain text and Markdown can both use text storage while remaining distinguishable to clients.

const (
	FieldEditingFormatPlainText          FieldEditingFormat = "plainText"
	FieldEditingFormatContentfulRichText FieldEditingFormat = "contentfulRichText"
	FieldEditingFormatMarkdown           FieldEditingFormat = "markdown"
)

type FieldID

type FieldID string

FieldID identifies a field within a schema (e.g. "metaTitle").

type FieldType

type FieldType string

FieldType is a supported field type (see schema.go).

const (
	FieldTypeSymbol   FieldType = "symbol"
	FieldTypeText     FieldType = "text"
	FieldTypeInteger  FieldType = "integer"
	FieldTypeRichText FieldType = "richtext"
	FieldTypeJSON     FieldType = "json"
)

Supported field types.

type FieldUpdate

type FieldUpdate struct {
	Match         ContentItemMatch
	Type          ContentType
	OwnerRef      *OwnerRef
	FieldID       string
	Locale        string
	Value         FieldValue
	SchemaVersion string
	SchemaHash    string
	Now           time.Time
	NewID         string
}

FieldUpdate is a single-field, single-locale draft write. It is applied as a targeted, atomic update (see Persistor.SetField) so concurrent writers editing different fields do not clobber each other and a first-time write creates the item exactly once.

type FieldValue

type FieldValue = any

FieldValue is a single field's value for one locale: a string for symbol/text, an integer-compatible number, or a JSON object for richtext/json.

type Kind

type Kind int

Kind classifies an engine error so a transport binding can map it to its own error model (e.g. an HTTP status or a gotsrpc ServiceError type) without the core depending on that model.

const (
	// KindInternal is an unexpected/technical failure (storage, serialization).
	KindInternal Kind = iota
	// KindBadRequest is a caller/validation error (bad input, unknown type/field).
	KindBadRequest
	// KindNotFound is a missing item addressed by an id/key match.
	KindNotFound
	// KindNotAcceptable is a state precondition failure (e.g. nothing to publish,
	// or a required field missing on publish).
	KindNotAcceptable
	// KindConflict is an optimistic-concurrency or create-only precondition failure.
	KindConflict
	// KindForbidden is an operation disallowed by the immutable deployment role.
	KindForbidden
)

type LocaleConfig

type LocaleConfig struct {
	Default   string
	Supported []string
}

LocaleConfig declares the locales the engine validates against. It is injected so the core does not depend on any project's locale package.

type LocalizedFieldValues

type LocalizedFieldValues map[string]FieldValue

LocalizedFieldValues maps locale code -> value. Non-localized fields carry only the default-locale key.

type Notifier

type Notifier func(ContentChange)

Notifier is a best-effort hook invoked on content changes. It must not fail the originating write; implementations handle their own errors. A nil Notifier disables emission.

type OwnerRef

type OwnerRef struct {
	Source string `json:"source" bson:"source"`
	Type   string `json:"type" bson:"type"`
	ID     string `json:"id" bson:"id"`
}

OwnerRef identifies the primary external entity this content is associated with. It is ownership/reconciliation metadata, not a generic reference system.

type Persistor

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

Persistor is the MongoDB-backed Store. It depends only on the mongo driver.

func NewMongoStore

func NewMongoStore(ctx context.Context, mongoURL string) (*Persistor, error)

NewMongoStore connects to MongoDB (database taken from the connection string) and ensures the content_items indexes, returning a ready Store.

func (*Persistor) Close

func (p *Persistor) Close() error

Close disconnects the underlying client.

func (*Persistor) Delete

func (p *Persistor) Delete(ctx context.Context, id string) error

Delete hard-deletes the item by ID. Deleting a missing item is not an error.

func (*Persistor) ExportAll

func (p *Persistor) ExportAll(ctx context.Context) ([]*ContentItem, error)

ExportAll reads every content document from one MongoDB snapshot transaction.

func (*Persistor) FindAll

func (p *Persistor) FindAll(ctx context.Context, types []ContentType, ownerRef *OwnerRef) ([]*ContentItem, error)

FindAll returns non-archived items (draft, changed, and published), optionally narrowed by type and exact owner. It backs the editing-oriented draft snapshot; archived items are excluded.

func (*Persistor) FindArchived

func (p *Persistor) FindArchived(ctx context.Context, types []ContentType) ([]*ContentItem, error)

FindArchived returns all archived items, optionally narrowed to the given types (all when empty). It is the counterpart to FindPublished/FindAll, which both exclude archived items.

func (*Persistor) FindPublished

func (p *Persistor) FindPublished(ctx context.Context, types []ContentType, ownerRef *OwnerRef) ([]*ContentItem, error)

FindPublished returns all published, non-archived items, optionally narrowed by type and exact owner.

func (*Persistor) GetByID

func (p *Persistor) GetByID(ctx context.Context, id string) (*ContentItem, error)

GetByID returns the item with the given ID, or ErrNotFound.

func (*Persistor) GetByKey

func (p *Persistor) GetByKey(ctx context.Context, key string) (*ContentItem, error)

GetByKey returns the item with the given key, or ErrNotFound.

func (*Persistor) ReplaceAll

func (p *Persistor) ReplaceAll(ctx context.Context, items []*ContentItem) error

ReplaceAll builds and verifies a temporary collection, then atomically renames it over content_items. Any failure before the rename leaves the live collection untouched.

func (*Persistor) SetField

func (p *Persistor) SetField(ctx context.Context, in FieldUpdate) (*ContentItem, error)

SetField atomically merges one field/locale into the item's draft, creating the item (keyed by Match.Key) when absent. Unlike Upsert it does not read-modify-write the whole document: the merge is a server-side $set on draft.fields.<fieldID>.<locale>, so a burst of concurrent per-field writes all land instead of clobbering each other. A nil value clears that locale.

func (*Persistor) Upsert

func (p *Persistor) Upsert(ctx context.Context, item *ContentItem, expectedDraftRevision *int64) error

Upsert inserts or replaces the item's document (keyed by _id). A nil expected revision preserves unconditional upsert behavior; zero is create-only; a positive value replaces only when the stored draft revision matches. It uses a full document replacement so fields cleared through omitempty are actually removed.

type Registry

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

Registry holds the authoritative Go-defined content schemas. It is populated by the consuming application at startup (no package-level global), so the core carries no project-specific content types.

func NewRegistry

func NewRegistry() *Registry

NewRegistry returns an empty schema registry.

func (*Registry) Register

func (r *Registry) Register(s ContentTypeSchema)

Register adds (or replaces) a schema. It computes the structural Hash when the schema does not already carry one.

type ReplaceContentResult

type ReplaceContentResult struct {
	ItemCount int    `json:"itemCount"`
	Checksum  string `json:"checksum"`
}

ReplaceContentResult identifies the snapshot installed in a sandbox.

type SaveDraftInput

type SaveDraftInput struct {
	Match            ContentItemMatch `json:"match"`
	Type             ContentType      `json:"type,omitempty"`
	OwnerRef         *OwnerRef        `json:"ownerRef,omitempty"`
	Fields           ContentFields    `json:"fields"`
	ExpectedRevision *int64           `json:"expectedRevision,omitempty"`
}

SaveDraftInput is the request for SaveDraft. Type is required only when creating. ExpectedRevision is optional: nil preserves unconditional save behavior, zero creates only when no item exists, and a positive value updates only that draft revision.

type SchemasResult

type SchemasResult struct {
	Schemas []ContentTypeSchema `json:"schemas"`
}

SchemasResult exposes the registered content schemas.

type SetFieldValueInput

type SetFieldValueInput struct {
	Match    ContentItemMatch `json:"match"`
	Type     ContentType      `json:"type,omitempty"`
	OwnerRef *OwnerRef        `json:"ownerRef,omitempty"`
	FieldID  string           `json:"fieldID"`
	Locale   string           `json:"locale"`
	Value    FieldValue       `json:"value"`
}

SetFieldValueInput sets a single field's value for one locale, merging into the item's draft (other fields are left untouched). Type/OwnerRef are used only when the match resolves to nothing and the item must be created. A nil Value clears that locale.

type Snapshot

type Snapshot struct {
	// Revision fingerprints the returned items using their latest timestamp. It
	// is not monotonic and can move backwards when the newest item is deleted;
	// consumers must not use it as a conditional-reload cursor.
	Revision  int64          `json:"revision"`
	CreatedAt time.Time      `json:"createdAt"`
	Items     []SnapshotItem `json:"items"`
}

Snapshot is the common bulk-read shape for published and preview consumers. The selected view changes visibility and fields, not the response contract.

type SnapshotInput

type SnapshotInput struct {
	Types    []ContentType `json:"types,omitempty"`
	OwnerRef *OwnerRef     `json:"ownerRef,omitempty"`
}

SnapshotInput narrows a consumer snapshot by content type and, optionally, exact owner. Empty filters return every item visible in the configured view.

type SnapshotItem

type SnapshotItem struct {
	ID        string        `json:"id"`
	Key       string        `json:"key,omitempty"`
	Type      ContentType   `json:"type"`
	OwnerRef  *OwnerRef     `json:"ownerRef,omitempty"`
	State     string        `json:"state"`
	Fields    ContentFields `json:"fields"`
	UpdatedAt time.Time     `json:"updatedAt"`
}

SnapshotItem is one compact consumer-facing item. UpdatedAt is the time at which the returned representation last changed: publish time for the published view and item update time for the preview view.

type SnapshotView

type SnapshotView string

SnapshotView selects which persisted representation is visible through the consumer snapshot API.

const (
	SnapshotViewPublished SnapshotView = "published"
	SnapshotViewPreview   SnapshotView = "preview"
)

func (SnapshotView) Valid

func (v SnapshotView) Valid() bool

Valid reports whether v identifies a supported consumer snapshot view.

type Store

type Store interface {
	GetByID(ctx context.Context, id string) (*ContentItem, error)
	GetByKey(ctx context.Context, key string) (*ContentItem, error)
	Upsert(ctx context.Context, item *ContentItem, expectedDraftRevision *int64) error
	SetField(ctx context.Context, in FieldUpdate) (*ContentItem, error)
	Delete(ctx context.Context, id string) error
	FindPublished(ctx context.Context, types []ContentType, ownerRef *OwnerRef) ([]*ContentItem, error)
	FindAll(ctx context.Context, types []ContentType, ownerRef *OwnerRef) ([]*ContentItem, error)
	FindArchived(ctx context.Context, types []ContentType) ([]*ContentItem, error)
	ExportAll(ctx context.Context) ([]*ContentItem, error)
	ReplaceAll(ctx context.Context, items []*ContentItem) error
}

Store is the persistence contract the engine depends on. *Persistor is the MongoDB-backed implementation; tests use a fake.

Jump to

Keyboard shortcuts

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