Files
2026-05-27 23:29:53 +01:00

4.7 KiB

CmsEngine

(no submodules)

Purpose

CmsEngine is the headless content-management core for @suaveplan/api-plugin-cms. It manages flexible content models, content entries with a draft → review → published → archived status lifecycle, slug uniqueness within a model, and a per-entry version trail. The engine is in-memory by design — three Maps back its state — so it is suitable for development, testing, and small-scale single-process deployments. Production-scale persistence is a downstream concern; the engine ships a stable surface that a future Surreal-backed storage port can wrap without modifying any caller code.

State changes flow through a typed EventBus<CmsEventMap> and an OpenTelemetry counter bundle so observability is free at the call site. Two abstractions are injectable for determinism: Clock drives every timestamp and IdGenerator drives every persisted identifier. Both default to the upstream Suaveplan main behaviour, so test fixtures captured against main remain byte-reproducible against this port.

Features

  • Content-model CRUD (createModel, getModel, listModels, deleteModel) with referential-integrity guard against orphaning entries.
  • Content-entry CRUD (createContent, getContent, queryContent, updateContent, deleteContent) with (modelId, slug) uniqueness and validateContentData enforcement on create and update.
  • queryContent supports filter by modelId, status, locale, case-insensitive search over title/slug, sort by an arbitrary field with asc/desc, and offset/limit paging.
  • Status transitions consult VALID_STATUS_TRANSITIONS and emit one of content.published / content.drafted / content.archived / fallback content.updated.
  • Version history retained per entry; getVersions returns the full trail, deletion drops it.
  • OTel counters fire from every mutating method.

Usage: Basic

import { CmsEngine } from "@suaveplan/api-plugin-cms";

const engine = new CmsEngine();
const model = engine.createModel({
    name: "Blog post",
    slug: "post",
    contentType: "post",
    fields: [
        { name: "title", type: "text", label: "Title", required: true },
        { name: "body", type: "richtext", label: "Body" },
    ],
});
const entry = engine.createContent({
    modelId: model.id,
    title: "Hello world",
    data: { title: "Hello world", body: "Welcome." },
});
engine.transitionStatus(entry.id, "published");
console.log(engine.queryContent({ status: "published" }));

Usage: Advanced

Inject a Clock and IdGenerator for deterministic test fixtures, and subscribe to the typed event bus to fan-out into downstream systems (audit log, search indexer, webhook dispatcher):

import { CmsEngine } from "@suaveplan/api-plugin-cms";
import { EventBus } from "@suaveplan/events";

let n = 0;
const engine = new CmsEngine({
    clock: () => 1_700_000_000_000,
    idGenerator: () => `ulid_${++n}`,
    events: new EventBus(),
});
engine.events.on("content.published", (entry) => {
    queueWebhook("content.published", entry);
});

API Reference

Member Description
new CmsEngine(options?) Construct the engine. Optional clock, idGenerator, events.
engine.events The underlying EventBus<CmsEventMap> — subscribe with .on(...).
createModel(input) Register a new model. Emits model.created.
getModel(id) Look up by id. Throws CMS.MODEL_NOT_FOUND on miss.
listModels() Snapshot of every registered model.
deleteModel(id) Remove a model. Throws CMS.MODEL_IN_USE if entries exist.
createContent(input) Validate + persist + version + emit content.created.
getContent(id) Look up entry. Throws CMS.CONTENT_NOT_FOUND on miss.
queryContent(query?) Filter / sort / page entries.
updateContent(id, input) Merge data; re-validate; version; emit content.updated.
transitionStatus(id, target) Status FSM step. Throws CMS.INVALID_TRANSITION on illegal edge.
deleteContent(id) Drop entry + versions; emit content.deleted.
getVersions(entryId) Full version trail.

Implementation Notes

The engine uses three Maps (models, entries, versions). The default IdGenerator produces cms_<n>_<clock()> strings — verbatim from upstream so byte-level test fixtures survive. The default Clock is () => Date.now(). EventBus<CmsEventMap> adapts to the single-payload suaveplan convention (Record<string, unknown>); upstream used tuple events, but the listener-side argument shape is identical. OTel counters are resolved lazily inside getCmsInstruments() so test harnesses installing a custom MeterProvider first capture every observation.