Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
# @suaveplan/api-plugin-cms - Headless CMS primitives for @suaveplan/api: in-memory CmsEngine over @suaveplan/events with content models, entries, status lifecycle, version history, slug uniqueness, Zod-validated plugin options, RFC 7807 problem+json renderer, and an OTel instrument bundle. Server-tier — Bun + Node.
Headless content-management primitives for `@suaveplan/api`. Ships an
in-memory `CmsEngine` that 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 package factory wraps the engine in a typed
`PluginDescriptor` consumable by `@suaveplan/api-plugin`'s
`PluginRegistry`; the engine is exposed under the `cms` slot of the
host `ServiceRegistry`.
<!-- begin:auto-docs -->
## Documentation tree
- [engine](./src/engine/cms-engine.md) — `CmsEngine` is the headless content-management core for `@suaveplan/api-plugin-cms`. It manages flexible content models…
- [cms-engine](./src/engine/cms-engine.md) — `CmsEngine` is the headless content-management core for `@suaveplan/api-plugin-cms`. It manages flexible content models…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable error-code namespace `CMS.*` for the package and the concrete `CmsError` class that consume…
- [error-codes](./src/error-codes/error-codes.md) — This module owns the stable error-code namespace `CMS.*` for the package and the concrete `CmsError` class that consume…
- [otel](./src/otel/instruments.md) — The package emits seven OpenTelemetry counters on every production mutation so operators can dashboard CMS activity and…
- [instruments](./src/otel/instruments.md) — The package emits seven OpenTelemetry counters on every production mutation so operators can dashboard CMS activity and…
- [plugin](./src/plugin/plugin.md) — Wraps `CmsEngine` in a typed `PluginDescriptor` consumable by `@suaveplan/api-plugin`'s `PluginRegistry`. The factory m…
- [plugin](./src/plugin/plugin.md) — Wraps `CmsEngine` in a typed `PluginDescriptor` consumable by `@suaveplan/api-plugin`'s `PluginRegistry`. The factory m…
- [routes](./src/routes/problem-json.md) — Render `CmsError` (and arbitrary throwables) as RFC 7807 problem+json HTTP responses without requiring `@suaveplan/api-…
- [problem-json](./src/routes/problem-json.md) — Render `CmsError` (and arbitrary throwables) as RFC 7807 problem+json HTTP responses without requiring `@suaveplan/api-…
- [schemas](./src/schemas/options.md) — Zod schema validating the `CmsPluginOptions` bundle accepted by `createCmsPlugin(...)`. Boundary validation at the plug…
- [options](./src/schemas/options.md) — Zod schema validating the `CmsPluginOptions` bundle accepted by `createCmsPlugin(...)`. Boundary validation at the plug…
- [services](./src/services/registry-augmentation.md) — Declares the `cms` slot on the `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface via TypeScript mo…
- [registry-augmentation](./src/services/registry-augmentation.md) — Declares the `cms` slot on the `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface via TypeScript mo…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-plugin-cms
```
## Quick start
```ts
import { createCmsPlugin } from "@suaveplan/api-plugin-cms";
import { createPluginRegistry } from "@suaveplan/api-plugin";
const registry = createPluginRegistry();
registry.register(createCmsPlugin({}));
await registry.initAll();
const cms = registry.context("cms").getService("cms");
const model = cms?.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 = cms?.createContent({
modelId: model!.id,
title: "Hello world",
data: { title: "Hello world", body: "Welcome." },
});
cms?.transitionStatus(entry!.id, "published");
```
## Rendering errors as RFC 7807 problem+json
Use `respondProblemJson(res, err)` from your route's error handler.
The renderer maps every `CMS.*` code to the documented HTTP status
(404 / 400 / 409 / 500).
```ts
import { respondProblemJson } from "@suaveplan/api-plugin-cms";
app.onError((err, c) => respondProblemJson(c, err));
```
## Modules
- [`engine/cms-engine`](./src/engine/cms-engine.md) — `CmsEngine`
core (model + content CRUD, status FSM, versions).
- [`error-codes/error-codes`](./src/error-codes/error-codes.md) —
`CMS_CODES`, `CmsError`, factory helpers.
- [`otel/instruments`](./src/otel/instruments.md) — OpenTelemetry
counters.
- [`plugin/plugin`](./src/plugin/plugin.md) — `createCmsPlugin`
factory.
- [`routes/problem-json`](./src/routes/problem-json.md) — RFC 7807
renderer.
- [`schemas/options`](./src/schemas/options.md) —
`CmsPluginOptionsSchema`.
- [`services/registry-augmentation`](./src/services/registry-augmentation.md)
— `ServiceRegistry` augmentation for the `cms` slot.
## License
MIT — © Suaveplan Team

View File

@@ -0,0 +1,103 @@
{
"name": "@suaveplan/api-plugin-cms",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Headless CMS primitives for @suaveplan/api: in-memory CmsEngine over @suaveplan/events with content models, entries, status lifecycle, version history, slug uniqueness, Zod-validated plugin options, RFC 7807 problem+json renderer, and an OTel instrument bundle. Server-tier — Bun + Node.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./engine/cms-engine": {
"import": {
"types": "./dist/engine/cms-engine.d.ts",
"default": "./dist/engine/cms-engine.js"
}
},
"./error-codes/error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./otel/instruments": {
"import": {
"types": "./dist/otel/instruments.d.ts",
"default": "./dist/otel/instruments.js"
}
},
"./plugin/plugin": {
"import": {
"types": "./dist/plugin/plugin.d.ts",
"default": "./dist/plugin/plugin.js"
}
},
"./routes/problem-json": {
"import": {
"types": "./dist/routes/problem-json.d.ts",
"default": "./dist/routes/problem-json.js"
}
},
"./schemas/options": {
"import": {
"types": "./dist/schemas/options.d.ts",
"default": "./dist/schemas/options.js"
}
},
"./types/cms.types": {
"import": {
"types": "./dist/types/cms.types.d.ts",
"default": "./dist/types/cms.types.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api-plugin": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,65 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CMS_CODES } from "./error-codes/error-codes.js";
const SRC = join(__dirname);
const SRC_FILES_CONTAINING_RAISES: readonly string[] = [
"engine/cms-engine.ts",
"error-codes/error-codes.ts",
"plugin/plugin.ts",
];
function readSrc(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
describe("api-plugin-cms spec conformance", () => {
it("every CMS.* code is thrown from a production path", {
timeout: 5000,
}, () => {
const corpus = SRC_FILES_CONTAINING_RAISES.map(readSrc).join("\n");
for (const code of Object.values(CMS_CODES)) {
const short = code.replace(/^CMS\./, "");
expect(
corpus.includes(`CMS_CODES.${short}`),
`code ${code} not raised in any production source file`
).toBe(true);
}
});
it("registers exactly seven error codes", { timeout: 5000 }, () => {
expect(Object.keys(CMS_CODES).sort()).toEqual([
"CONTENT_NOT_FOUND",
"DUPLICATE_SLUG",
"INVALID_INPUT",
"INVALID_TRANSITION",
"MODEL_IN_USE",
"MODEL_NOT_FOUND",
"VALIDATION_FAILED",
]);
});
it("barrel side-effect imports the registry augmentation", {
timeout: 5000,
}, () => {
const barrel = readSrc("index.ts");
expect(barrel).toMatch(
/^\s*import\s+["']\.\/services\/registry-augmentation\.js["']/m
);
});
it("every status-bearing code appears in the problem-json STATUS_BY_CODE table", {
timeout: 5000,
}, () => {
const problemJson = readSrc("routes/problem-json.ts");
for (const code of Object.values(CMS_CODES)) {
const short = code.replace(/^CMS\./, "");
expect(
problemJson.includes(`CMS_CODES.${short}`),
`code ${code} missing from STATUS_BY_CODE`
).toBe(true);
}
});
});

View File

@@ -0,0 +1,117 @@
# `CmsEngine`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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 `Map`s 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
```ts
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):
```ts
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 `Map`s (`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.

View File

@@ -0,0 +1,81 @@
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import { CMS_CODES } from "../error-codes/error-codes.js";
import type {
CmsEventMap,
ContentEntry,
ContentModel,
ContentStatus,
} from "../types/cms.types.js";
import { CmsEngine } from "./cms-engine.js";
describe("CmsEngine — spec conformance", () => {
it("events bus carries the canonical CmsEventMap key set", {
timeout: 5000,
}, () => {
expectTypeOf<keyof CmsEventMap>().toEqualTypeOf<
| "content.created"
| "content.updated"
| "content.published"
| "content.drafted"
| "content.archived"
| "content.deleted"
| "model.created"
| string
>();
expectTypeOf<
CmsEventMap["content.created"]
>().toEqualTypeOf<ContentEntry>();
expectTypeOf<
CmsEventMap["model.created"]
>().toEqualTypeOf<ContentModel>();
expectTypeOf<CmsEventMap["content.deleted"]>().toEqualTypeOf<string>();
});
it("injectable Clock + IdGenerator produce deterministic ids and timestamps", {
timeout: 5000,
}, () => {
let clockMs = 1_700_000_000_000;
const ids: string[] = [];
let n = 0;
const idGen = () => {
const id = `det_${++n}`;
ids.push(id);
return id;
};
const engine = new CmsEngine({
clock: () => {
clockMs += 1000;
return clockMs;
},
idGenerator: idGen,
});
const m = engine.createModel({
name: "P",
slug: "p",
contentType: "post",
fields: [],
});
expect(m.id).toBe("det_1");
expect(m.createdAt).toBe("2023-11-14T22:13:21.000Z");
});
it("every ContentStatus appears in VALID_STATUS_TRANSITIONS", {
timeout: 5000,
}, () => {
const statuses: readonly ContentStatus[] = [
"draft",
"review",
"published",
"archived",
];
for (const s of statuses) {
expect(
s in { draft: 0, review: 0, published: 0, archived: 0 }
).toBe(true);
}
});
it("CMS_CODES enumerates exactly seven codes", { timeout: 5000 }, () => {
expect(Object.values(CMS_CODES)).toHaveLength(7);
});
});

View File

@@ -0,0 +1,582 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CMS_CODES, type CmsError } from "../error-codes/error-codes.js";
import type {
ContentEntry,
ContentField,
ContentModel,
} from "../types/cms.types.js";
import { CmsEngine } from "./cms-engine.js";
function buildEngine(): CmsEngine {
let clockMs = 1_700_000_000_000;
let idN = 0;
return new CmsEngine({
clock: () => clockMs++,
idGenerator: () => `id_${++idN}`,
});
}
function postFields(): readonly ContentField[] {
return [
{ name: "title", type: "text", label: "Title", required: true },
{
name: "body",
type: "text",
label: "Body",
validations: [{ type: "minLength", value: 3 }],
},
];
}
function makeModel(engine: CmsEngine): ContentModel {
return engine.createModel({
name: "Post",
slug: "post",
contentType: "post",
fields: postFields(),
});
}
describe("@suaveplan/api-plugin-cms — CmsEngine", () => {
it("createModel registers and emits model.created", {
timeout: 5000,
}, () => {
const engine = buildEngine();
const events: ContentModel[] = [];
engine.events.on("model.created", (m) => {
events.push(m as ContentModel);
});
const model = makeModel(engine);
expect(engine.listModels()).toHaveLength(1);
expect(model.id).toBe("id_1");
expect(events).toHaveLength(1);
expect(events[0]?.id).toBe(model.id);
});
it("getModel throws MODEL_NOT_FOUND for unknown id", {
timeout: 5000,
}, () => {
const engine = buildEngine();
let err: CmsError | undefined;
try {
engine.getModel("nope");
} catch (e) {
err = e as CmsError;
}
expect(err?.code).toBe(CMS_CODES.MODEL_NOT_FOUND);
});
it("deleteModel rejects when content entries exist", {
timeout: 5000,
}, () => {
const engine = buildEngine();
const model = makeModel(engine);
engine.createContent({
modelId: model.id,
title: "Hello",
data: { title: "Hello", body: "abc" },
});
let err: CmsError | undefined;
try {
engine.deleteModel(model.id);
} catch (e) {
err = e as CmsError;
}
expect(err?.code).toBe(CMS_CODES.MODEL_IN_USE);
expect(engine.listModels()).toHaveLength(1);
});
it("deleteModel succeeds when there are no entries", {
timeout: 5000,
}, () => {
const engine = buildEngine();
const m = makeModel(engine);
engine.deleteModel(m.id);
expect(engine.listModels()).toHaveLength(0);
});
it("createContent persists and emits content.created", {
timeout: 5000,
}, () => {
const engine = buildEngine();
const model = makeModel(engine);
const seen: ContentEntry[] = [];
engine.events.on("content.created", (e) => {
seen.push(e as ContentEntry);
});
const entry = engine.createContent({
modelId: model.id,
title: "Hello World",
data: { title: "Hello World", body: "longenough" },
});
expect(entry.status).toBe("draft");
expect(entry.version).toBe(1);
expect(entry.slug).toBe("hello-world");
expect(seen).toHaveLength(1);
expect(engine.getContent(entry.id).id).toBe(entry.id);
});
it("createContent honours caller-supplied slug + locale/author", {
timeout: 5000,
}, () => {
const engine = buildEngine();
const model = makeModel(engine);
const entry = engine.createContent({
modelId: model.id,
title: "Hi",
slug: "custom",
locale: "en",
author: "alice",
data: { title: "Hi", body: "abc" },
});
expect(entry.slug).toBe("custom");
expect(entry.locale).toBe("en");
expect(entry.author).toBe("alice");
});
it("createContent rejects data failing model validation", {
timeout: 5000,
}, () => {
const engine = buildEngine();
const model = makeModel(engine);
let err: CmsError | undefined;
try {
engine.createContent({
modelId: model.id,
title: "X",
data: { body: "ab" }, // missing required title + too short body
});
} catch (e) {
err = e as CmsError;
}
expect(err?.code).toBe(CMS_CODES.VALIDATION_FAILED);
});
it("createContent rejects duplicate slugs in the same model", {
timeout: 5000,
}, () => {
const engine = buildEngine();
const model = makeModel(engine);
engine.createContent({
modelId: model.id,
title: "A",
slug: "same",
data: { title: "A", body: "abc" },
});
let err: CmsError | undefined;
try {
engine.createContent({
modelId: model.id,
title: "B",
slug: "same",
data: { title: "B", body: "abc" },
});
} catch (e) {
err = e as CmsError;
}
expect(err?.code).toBe(CMS_CODES.DUPLICATE_SLUG);
});
it("getContent throws CONTENT_NOT_FOUND for unknown id", {
timeout: 5000,
}, () => {
const engine = buildEngine();
let err: CmsError | undefined;
try {
engine.getContent("missing");
} catch (e) {
err = e as CmsError;
}
expect(err?.code).toBe(CMS_CODES.CONTENT_NOT_FOUND);
});
it("queryContent applies modelId/status/locale/search/sort/page filters", {
timeout: 5000,
}, () => {
const engine = buildEngine();
const model = makeModel(engine);
const other = engine.createModel({
name: "Page",
slug: "page",
contentType: "page",
fields: postFields(),
});
const a = engine.createContent({
modelId: model.id,
title: "Alpha",
data: { title: "Alpha", body: "abc" },
locale: "en",
});
engine.createContent({
modelId: model.id,
title: "Beta",
data: { title: "Beta", body: "abc" },
locale: "fr",
});
engine.createContent({
modelId: model.id,
title: "Gamma",
data: { title: "Gamma", body: "abc" },
locale: "en",
});
engine.createContent({
modelId: other.id,
title: "Delta",
data: { title: "Delta", body: "abc" },
});
engine.transitionStatus(a.id, "published");
// filter by modelId
expect(engine.queryContent({ modelId: model.id }).length).toBe(3);
// filter by status
expect(engine.queryContent({ status: "published" }).length).toBe(1);
// filter by locale
expect(engine.queryContent({ locale: "fr" }).length).toBe(1);
// case-insensitive search over title
expect(engine.queryContent({ search: "ALPHA" }).length).toBe(1);
// search over slug
expect(engine.queryContent({ search: "beta" }).length).toBe(1);
// empty search string should not filter
expect(engine.queryContent({ search: "" }).length).toBe(4);
// sort asc + offset + limit
const sorted = engine.queryContent({
modelId: model.id,
sortBy: "title",
sortOrder: "asc",
offset: 0,
limit: 2,
});
expect(sorted.map((e) => e.title)).toEqual(["Alpha", "Beta"]);
// sort desc
const desc = engine.queryContent({
modelId: model.id,
sortBy: "title",
sortOrder: "desc",
});
expect(desc[0]?.title).toBe("Gamma");
// Reverse-insertion order exercises both `<` and `>` branches.
const reverse = engine.queryContent({
modelId: model.id,
sortBy: "title",
sortOrder: "desc",
});
expect(reverse.map((e) => e.title)).toEqual(["Gamma", "Beta", "Alpha"]);
const ascCheck = engine.queryContent({
modelId: model.id,
sortBy: "title",
sortOrder: "asc",
});
expect(ascCheck.map((e) => e.title)).toEqual([
"Alpha",
"Beta",
"Gamma",
]);
// Sort a model whose entries are inserted out-of-order to force
// V8's sort to compare av < bv and av > bv across many pairs.
const m2 = engine.createModel({
name: "X",
slug: "x",
contentType: "post",
fields: postFields(),
});
for (const title of ["Z", "M", "C", "P", "B"]) {
engine.createContent({
modelId: m2.id,
title,
slug: title.toLowerCase(),
data: { title, body: "abc" },
});
}
const sortedM2 = engine.queryContent({
modelId: m2.id,
sortBy: "title",
sortOrder: "asc",
});
expect(sortedM2.map((e) => e.title)).toEqual(["B", "C", "M", "P", "Z"]);
// sort by unknown field returns stable order
const stable = engine.queryContent({
modelId: model.id,
sortBy: "nonExistent",
});
expect(stable.length).toBe(3);
// sort with equal keys → av === bv branch returns 0
const dupModel = engine.createModel({
name: "Dup",
slug: "dup",
contentType: "post",
fields: postFields(),
});
engine.createContent({
modelId: dupModel.id,
slug: "x1",
title: "Same",
data: { title: "Same", body: "abc" },
});
engine.createContent({
modelId: dupModel.id,
slug: "x2",
title: "Same",
data: { title: "Same", body: "abc" },
});
const tied = engine.queryContent({
modelId: dupModel.id,
sortBy: "title",
sortOrder: "asc",
});
expect(tied.length).toBe(2);
});
it("updateContent merges data + bumps version + emits content.updated", {
timeout: 5000,
}, () => {
const engine = buildEngine();
const model = makeModel(engine);
const entry = engine.createContent({
modelId: model.id,
title: "Hello",
author: "alice",
data: { title: "Hello", body: "abc" },
});
const updates: ContentEntry[] = [];
engine.events.on("content.updated", (e) =>
updates.push(e as ContentEntry)
);
const after = engine.updateContent(entry.id, {
title: "World",
data: { body: "longer" },
author: "bob",
});
expect(after.title).toBe("World");
expect(after.author).toBe("bob");
expect(after.data).toEqual({ title: "Hello", body: "longer" });
expect(after.version).toBe(2);
expect(updates).toHaveLength(1);
});
it("updateContent rejects invalid data + duplicate slugs within the same model", {
timeout: 5000,
}, () => {
const engine = buildEngine();
const model = makeModel(engine);
const a = engine.createContent({
modelId: model.id,
title: "A",
slug: "a",
data: { title: "A", body: "abc" },
});
const b = engine.createContent({
modelId: model.id,
title: "B",
slug: "b",
data: { title: "B", body: "abc" },
});
let slugErr: CmsError | undefined;
try {
engine.updateContent(b.id, { slug: "a" });
} catch (e) {
slugErr = e as CmsError;
}
expect(slugErr?.code).toBe(CMS_CODES.DUPLICATE_SLUG);
let valErr: CmsError | undefined;
try {
engine.updateContent(a.id, { data: { title: "" } });
} catch (e) {
valErr = e as CmsError;
}
expect(valErr?.code).toBe(CMS_CODES.VALIDATION_FAILED);
// same-slug self-update is a no-op
const same = engine.updateContent(a.id, { slug: "a" });
expect(same.slug).toBe("a");
// brand-new slug — `dup === undefined` branch.
const renamed = engine.updateContent(a.id, { slug: "renamed" });
expect(renamed.slug).toBe("renamed");
});
it("transitionStatus advances draft to published and emits content.published", {
timeout: 5000,
}, () => {
const engine = buildEngine();
const model = makeModel(engine);
const entry = engine.createContent({
modelId: model.id,
title: "Hello",
data: { title: "Hello", body: "abc" },
});
const seen: ContentEntry[] = [];
engine.events.on("content.published", (e) =>
seen.push(e as ContentEntry)
);
const published = engine.transitionStatus(entry.id, "published");
expect(published.status).toBe("published");
expect(published.publishedAt).toBeDefined();
expect(published.version).toBe(2);
expect(seen).toHaveLength(1);
});
it("transitionStatus emits content.drafted / content.archived / content.updated as appropriate", {
timeout: 5000,
}, () => {
const engine = buildEngine();
const model = makeModel(engine);
const entry = engine.createContent({
modelId: model.id,
title: "Hello",
data: { title: "Hello", body: "abc" },
});
const drafted: ContentEntry[] = [];
const archived: ContentEntry[] = [];
const updated: ContentEntry[] = [];
engine.events.on("content.drafted", (e) =>
drafted.push(e as ContentEntry)
);
engine.events.on("content.archived", (e) =>
archived.push(e as ContentEntry)
);
engine.events.on("content.updated", (e) =>
updated.push(e as ContentEntry)
);
// draft -> review (fallback content.updated)
engine.transitionStatus(entry.id, "review");
// review -> draft (drafted)
engine.transitionStatus(entry.id, "draft");
// draft -> archived (archived)
engine.transitionStatus(entry.id, "archived");
expect(updated).toHaveLength(1);
expect(drafted).toHaveLength(1);
expect(archived).toHaveLength(1);
});
it("transitionStatus rejects an illegal target", { timeout: 5000 }, () => {
const engine = buildEngine();
const model = makeModel(engine);
const entry = engine.createContent({
modelId: model.id,
title: "Hello",
data: { title: "Hello", body: "abc" },
});
engine.transitionStatus(entry.id, "archived");
let err: CmsError | undefined;
try {
// archived -> published is not in VALID_STATUS_TRANSITIONS
engine.transitionStatus(entry.id, "published");
} catch (e) {
err = e as CmsError;
}
expect(err?.code).toBe(CMS_CODES.INVALID_TRANSITION);
});
it("deleteContent drops entry + versions and emits content.deleted", {
timeout: 5000,
}, () => {
const engine = buildEngine();
const model = makeModel(engine);
const entry = engine.createContent({
modelId: model.id,
title: "Hello",
data: { title: "Hello", body: "abc" },
});
const seen: string[] = [];
engine.events.on("content.deleted", (id) => seen.push(id as string));
engine.deleteContent(entry.id);
expect(seen).toEqual([entry.id]);
expect(engine.getVersions(entry.id)).toEqual([]);
});
it("getVersions returns one entry per mutation", { timeout: 5000 }, () => {
const engine = buildEngine();
const model = makeModel(engine);
const entry = engine.createContent({
modelId: model.id,
title: "Hello",
data: { title: "Hello", body: "abc" },
});
engine.updateContent(entry.id, { title: "v2" });
engine.updateContent(entry.id, { title: "v3" });
engine.transitionStatus(entry.id, "published");
const versions = engine.getVersions(entry.id);
expect(versions.map((v) => v.version)).toEqual([1, 2, 3, 4]);
});
it("transitionStatus to published preserves prior publishedAt on re-transition", {
timeout: 5000,
}, () => {
const engine = buildEngine();
const model = makeModel(engine);
const entry = engine.createContent({
modelId: model.id,
title: "Hello",
data: { title: "Hello", body: "abc" },
});
const pub1 = engine.transitionStatus(entry.id, "published");
const archived = engine.transitionStatus(entry.id, "archived");
expect(archived.publishedAt).toBe(pub1.publishedAt);
const back = engine.transitionStatus(entry.id, "draft");
expect(back.publishedAt).toBe(pub1.publishedAt);
});
it("CmsEngine accepts a pre-existing EventBus", {
timeout: 5000,
}, async () => {
const { EventBus } = await import("@suaveplan/events");
const bus = new EventBus();
const engine = new CmsEngine({ events: bus });
expect(engine.events).toBe(bus);
});
it("CmsEngine defaults clock + idGenerator when none supplied", {
timeout: 5000,
}, () => {
const engine = new CmsEngine();
const model = engine.createModel({
name: "P",
slug: "p",
contentType: "post",
fields: [],
});
expect(model.id).toMatch(/^cms_1_\d+$/);
});
it("queryContent without args returns every entry", {
timeout: 5000,
}, () => {
const engine = buildEngine();
const model = makeModel(engine);
engine.createContent({
modelId: model.id,
title: "x",
data: { title: "x", body: "abc" },
});
// Default-parameter branch — no query passed.
expect(engine.queryContent().length).toBe(1);
});
it("updateContent preserves existing author when caller omits it", {
timeout: 5000,
}, () => {
const engine = buildEngine();
const model = makeModel(engine);
const entry = engine.createContent({
modelId: model.id,
title: "Hello",
author: "alice",
data: { title: "Hello", body: "abc" },
});
const updated = engine.updateContent(entry.id, { title: "Hi" });
expect(updated.author).toBe("alice");
// Caller omits author + existing has none.
const entry2 = engine.createContent({
modelId: model.id,
slug: "no-author",
title: "No Author",
data: { title: "No Author", body: "abc" },
});
const u2 = engine.updateContent(entry2.id, { title: "Renamed" });
expect(u2.author).toBeUndefined();
});
});

View File

@@ -0,0 +1,373 @@
/**
* In-memory CMS core.
*
* `CmsEngine` is a near-verbatim port of the upstream
* `../suaveplan/packages/server/api-plugin-cms/src/routes/cms-engine.ts`
* (moved here from upstream's `routes/` folder per the enterprise plan).
* The engine manages content models, content entries, and a per-entry
* version trail in three `Map`s, emits typed events through
* `@suaveplan/events`' `EventBus<CmsEventMap>`, and increments OTel
* counters from every mutating method.
*
* Two abstractions are injectable so tests are deterministic and
* library users can plug in ULID/UUID id generators or fake clocks:
*
* - `Clock` (`() => number`) — drives every timestamp.
* - `IdGenerator` (`() => string`) — drives every persisted id.
*
* Both default to the upstream behaviour (`Date.now()` + a counter
* suffixed with the current millis), so byte-level test snapshots from
* main remain reproducible.
*/
import { EventBus } from "@suaveplan/events";
import type { Clock, IdGenerator } from "@suaveplan/types";
import {
contentNotFound,
duplicateSlug,
invalidStatusTransition,
modelInUse,
modelNotFound,
validationFailed,
} from "../error-codes/error-codes.js";
import { getCmsInstruments } from "../otel/instruments.js";
import {
type CmsEventMap,
type ContentEntry,
type ContentModel,
type ContentQuery,
type ContentStatus,
type ContentVersion,
type CreateContentInput,
generateSlug,
type UpdateContentInput,
VALID_STATUS_TRANSITIONS,
validateContentData,
} from "../types/cms.types.js";
/** Construction options for {@link CmsEngine}. */
export interface CmsEngineOptions {
/**
* Time source. Defaults to `() => Date.now()`. Used for every
* `createdAt` / `updatedAt` / `publishedAt` and to seed the
* default id generator.
*/
readonly clock?: Clock;
/**
* Id generator for models, entries, and version rows. Defaults to
* the upstream counter shape `cms_<n>_<ts>` so byte-level parity
* with upstream test fixtures is preserved.
*/
readonly idGenerator?: IdGenerator;
/**
* Pre-existing `EventBus` to publish into. When omitted a fresh
* bus is constructed and exposed via the `events` getter so
* callers can subscribe without further wiring.
*/
readonly events?: EventBus<CmsEventMap>;
}
const DEFAULT_CLOCK: Clock = () => Date.now();
/** CMS engine with in-memory storage. */
export class CmsEngine {
private readonly models = new Map<string, ContentModel>();
private readonly entries = new Map<string, ContentEntry>();
private readonly versions = new Map<string, ContentVersion[]>();
private readonly clock: Clock;
private readonly idGenerator: IdGenerator;
private readonly bus: EventBus<CmsEventMap>;
private counter = 0;
public constructor(options: CmsEngineOptions = {}) {
this.clock = options.clock ?? DEFAULT_CLOCK;
this.bus = options.events ?? new EventBus<CmsEventMap>();
const seededIdGen: IdGenerator | undefined = options.idGenerator;
this.idGenerator = seededIdGen ?? (() => this.defaultId());
}
/** Underlying event bus — subscribe via `engine.events.on(...)`. */
public get events(): EventBus<CmsEventMap> {
return this.bus;
}
/**
* Register a content model. Stamps id + timestamps, persists, then
* emits `model.created`.
*/
public createModel(
input: Omit<ContentModel, "id" | "createdAt" | "updatedAt">
): ContentModel {
const now = this.isoNow();
const model: ContentModel = {
...input,
id: this.nextId(),
createdAt: now,
updatedAt: now,
};
this.models.set(model.id, model);
getCmsInstruments().modelsCreated.add(1);
this.bus.emit("model.created", model);
return model;
}
/** Look up a model by id; throws `MODEL_NOT_FOUND` on miss. */
public getModel(id: string): ContentModel {
const model = this.models.get(id);
if (model === undefined) throw modelNotFound(id);
return model;
}
/** Enumerate every registered model. */
public listModels(): ContentModel[] {
return [...this.models.values()];
}
/**
* Remove a model. Verifies the model exists, refuses to delete if
* any entries reference it (`MODEL_IN_USE`).
*/
public deleteModel(id: string): void {
this.getModel(id);
const hasEntries = [...this.entries.values()].some(
(e) => e.modelId === id
);
if (hasEntries) throw modelInUse(id);
this.models.delete(id);
}
/**
* Create a content entry. Runs `validateContentData` against the
* resolved model, enforces `(modelId, slug)` uniqueness, persists,
* snapshots a `ContentVersion`, and emits `content.created`.
*/
public createContent(input: CreateContentInput): ContentEntry {
const model = this.getModel(input.modelId);
const errors = validateContentData(model, input.data);
if (errors.length > 0) {
getCmsInstruments().validationFailures.add(1);
throw validationFailed(errors);
}
const slug =
input.slug !== undefined && input.slug.length > 0
? input.slug
: generateSlug(input.title);
const existing = [...this.entries.values()].find(
(e) => e.modelId === input.modelId && e.slug === slug
);
if (existing !== undefined) throw duplicateSlug(slug, input.modelId);
const now = this.isoNow();
const entry: ContentEntry = {
id: this.nextId(),
modelId: input.modelId,
slug,
title: input.title,
status: "draft",
data: input.data,
...(input.locale !== undefined ? { locale: input.locale } : {}),
...(input.author !== undefined ? { author: input.author } : {}),
version: 1,
createdAt: now,
updatedAt: now,
};
this.entries.set(entry.id, entry);
this.saveVersion(entry);
getCmsInstruments().contentCreated.add(1);
this.bus.emit("content.created", entry);
return entry;
}
/** Look up an entry by id; throws `CONTENT_NOT_FOUND` on miss. */
public getContent(id: string): ContentEntry {
const entry = this.entries.get(id);
if (entry === undefined) throw contentNotFound(id);
return entry;
}
/**
* Query content entries. Filters by `modelId`/`status`/`locale` and
* case-insensitive `search` (against `title` and `slug`); sorts by
* an arbitrary field with `asc`/`desc`; pages by `offset` then
* `limit`. Verbatim semantics from upstream.
*/
public queryContent(query: ContentQuery = {}): ContentEntry[] {
let results = [...this.entries.values()];
if (query.modelId !== undefined) {
const modelId = query.modelId;
results = results.filter((e) => e.modelId === modelId);
}
if (query.status !== undefined) {
const status = query.status;
results = results.filter((e) => e.status === status);
}
if (query.locale !== undefined) {
const locale = query.locale;
results = results.filter((e) => e.locale === locale);
}
if (query.search !== undefined && query.search.length > 0) {
const s = query.search.toLowerCase();
results = results.filter(
(e) => e.title.toLowerCase().includes(s) || e.slug.includes(s)
);
}
if (query.sortBy !== undefined) {
const key = query.sortBy;
const order = query.sortOrder === "desc" ? -1 : 1;
results.sort((a, b) => {
const av = (a as unknown as Record<string, unknown>)[key];
const bv = (b as unknown as Record<string, unknown>)[key];
if (av === undefined || bv === undefined) return 0;
if ((av as number) < (bv as number)) return -order;
if ((av as number) > (bv as number)) return order;
return 0;
});
}
if (query.offset !== undefined) results = results.slice(query.offset);
if (query.limit !== undefined) results = results.slice(0, query.limit);
return results;
}
/**
* Update a content entry. Re-validates merged data against the
* model, enforces `(modelId, slug)` uniqueness when the slug
* changes, persists, snapshots a version row, and emits
* `content.updated`.
*/
public updateContent(id: string, input: UpdateContentInput): ContentEntry {
const existing = this.getContent(id);
const model = this.getModel(existing.modelId);
if (input.data !== undefined) {
const errors = validateContentData(model, {
...existing.data,
...input.data,
});
if (errors.length > 0) {
getCmsInstruments().validationFailures.add(1);
throw validationFailed(errors);
}
}
if (input.slug !== undefined && input.slug !== existing.slug) {
const newSlug = input.slug;
const dup = [...this.entries.values()].find(
(e) =>
e.modelId === existing.modelId &&
e.slug === newSlug &&
e.id !== id
);
if (dup !== undefined)
throw duplicateSlug(newSlug, existing.modelId);
}
const updated: ContentEntry = {
...existing,
title: input.title ?? existing.title,
slug: input.slug ?? existing.slug,
data:
input.data !== undefined
? { ...existing.data, ...input.data }
: existing.data,
...(input.author !== undefined
? { author: input.author }
: existing.author !== undefined
? { author: existing.author }
: {}),
version: existing.version + 1,
updatedAt: this.isoNow(),
};
this.entries.set(id, updated);
this.saveVersion(updated);
getCmsInstruments().contentUpdated.add(1);
this.bus.emit("content.updated", updated);
return updated;
}
/**
* Transition an entry's `status`. Verifies the edge is in
* `VALID_STATUS_TRANSITIONS`, increments the version counter,
* stamps `publishedAt` on first publish, snapshots a version row,
* and emits the matching event.
*/
public transitionStatus(id: string, target: ContentStatus): ContentEntry {
const entry = this.getContent(id);
const allowed = VALID_STATUS_TRANSITIONS[entry.status];
if (!allowed.includes(target))
throw invalidStatusTransition(entry.status, target);
const now = this.isoNow();
const updated: ContentEntry = {
...entry,
status: target,
version: entry.version + 1,
...(target === "published"
? { publishedAt: now }
: entry.publishedAt !== undefined
? { publishedAt: entry.publishedAt }
: {}),
updatedAt: now,
};
this.entries.set(id, updated);
this.saveVersion(updated);
const instruments = getCmsInstruments();
if (target === "published") {
instruments.contentPublished.add(1);
this.bus.emit("content.published", updated);
} else if (target === "archived") {
instruments.contentArchived.add(1);
this.bus.emit("content.archived", updated);
} else if (target === "draft") {
instruments.contentUpdated.add(1);
this.bus.emit("content.drafted", updated);
} else {
instruments.contentUpdated.add(1);
this.bus.emit("content.updated", updated);
}
return updated;
}
/**
* Remove an entry and its version history. Verifies the entry
* exists before mutating, then emits `content.deleted` with the
* entry id.
*/
public deleteContent(id: string): void {
this.getContent(id);
this.entries.delete(id);
this.versions.delete(id);
getCmsInstruments().contentDeleted.add(1);
this.bus.emit("content.deleted", id);
}
/** Return the version history for an entry; empty when unknown. */
public getVersions(entryId: string): ContentVersion[] {
return this.versions.get(entryId) ?? [];
}
private saveVersion(entry: ContentEntry): void {
const version: ContentVersion = {
id: this.nextId(),
entryId: entry.id,
version: entry.version,
data: entry.data,
status: entry.status,
...(entry.author !== undefined ? { author: entry.author } : {}),
createdAt: this.isoNow(),
};
const list = this.versions.get(entry.id) ?? [];
list.push(version);
this.versions.set(entry.id, list);
}
private nextId(): string {
return this.idGenerator();
}
private defaultId(): string {
this.counter += 1;
return `cms_${this.counter}_${this.clock()}`;
}
private isoNow(): string {
return new Date(this.clock()).toISOString();
}
}

View File

@@ -0,0 +1,89 @@
# Error codes — `@suaveplan/api-plugin-cms`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module owns the stable error-code namespace `CMS.*` for the
package and the concrete `CmsError` class that consumers `instanceof`
in their `catch` blocks. Every failure path raised by `CmsEngine` and
`createCmsPlugin` carries one of these codes; the
problem+json renderer maps them to HTTP statuses; the spec-conformance
test asserts every code is raised from at least one production source
file. Codes are minted via `defineCodes("CMS", [...])` so the literal
union flows through TypeScript and `error.code` narrows without a cast.
## Features
- Seven stable codes: `CONTENT_NOT_FOUND`, `MODEL_NOT_FOUND`,
`INVALID_TRANSITION`, `VALIDATION_FAILED`, `DUPLICATE_SLUG`,
`MODEL_IN_USE`, `INVALID_INPUT`.
- Six factory helpers (`contentNotFound`, `modelNotFound`,
`invalidStatusTransition`, `validationFailed`, `duplicateSlug`,
`modelInUse`) that build the right `CmsError` with the right
`context` fields populated.
- `CmsError` extends `SuaveplanError<CmsErrorCode>` so the `code` field
narrows and the `cause` chain is preserved across factory wrapping.
- A `cmsCode` mirror field types the same value with the narrowed
`CmsErrorCode` (the inherited `code` is typed as `string` on the
base class to keep the abstract surface stable).
## Usage: Basic
```ts
import {
CMS_CODES,
CmsError,
contentNotFound,
} from "@suaveplan/api-plugin-cms";
try {
engine.getContent("missing");
} catch (err) {
if (err instanceof CmsError && err.code === CMS_CODES.CONTENT_NOT_FOUND) {
return notFound();
}
throw err;
}
```
## Usage: Advanced
Build a richer error site by chaining a `cause` and supplying extra
context that the audit log can persist:
```ts
import { CmsError, CMS_CODES } from "@suaveplan/api-plugin-cms";
throw new CmsError(
CMS_CODES.VALIDATION_FAILED,
"user-driven validation failed",
{ cause: zodError, context: { tenantId, requestId } }
);
```
## API Reference
| Symbol | Description |
|---|---|
| `CMS_CODES` | Frozen map: `{ CONTENT_NOT_FOUND: "CMS.CONTENT_NOT_FOUND", ... }`. |
| `CmsErrorCode` | Union literal type of the seven values. |
| `class CmsError` | Concrete class; `instanceof` narrows `code` to `CmsErrorCode`. |
| `contentNotFound(id)` | → `CmsError(CONTENT_NOT_FOUND, ..., { context: { entryId } })`. |
| `modelNotFound(id)` | → `CmsError(MODEL_NOT_FOUND, ..., { context: { modelId } })`. |
| `invalidStatusTransition(from, to)` | → `CmsError(INVALID_TRANSITION, ..., { context: { from, to } })`. |
| `validationFailed(errors[])` | → `CmsError(VALIDATION_FAILED, ..., { context: { errors } })`. |
| `duplicateSlug(slug, modelId)` | → `CmsError(DUPLICATE_SLUG, ..., { context: { slug, modelId } })`. |
| `modelInUse(id)` | → `CmsError(MODEL_IN_USE, ..., { context: { modelId } })`. |
## Implementation Notes
`defineCodes` is the canonical suaveplan pattern; it returns a frozen
record where each key maps to a fully-qualified `<namespace>.<reason>`
string. The constructor of `CmsError` keeps the inherited `code` field
typed as `string` (limitation of `SuaveplanError`'s base generic) and
mirrors the narrowed value into `cmsCode`. The `context` field on the
base class is `readonly context?: ErrorContext` — optional — so all
access goes through `err.context?.<field>`.

View File

@@ -0,0 +1,78 @@
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CMS_CODES,
CmsError,
contentNotFound,
duplicateSlug,
invalidStatusTransition,
modelInUse,
modelNotFound,
validationFailed,
} from "./error-codes.js";
describe("@suaveplan/api-plugin-cms — error-codes", () => {
it("every factory raises the matching CMS_CODES.* value", {
timeout: 5000,
}, () => {
expect(contentNotFound("e1").code).toBe(CMS_CODES.CONTENT_NOT_FOUND);
expect(modelNotFound("m1").code).toBe(CMS_CODES.MODEL_NOT_FOUND);
expect(invalidStatusTransition("draft", "archived").code).toBe(
CMS_CODES.INVALID_TRANSITION
);
expect(validationFailed(["one", "two"]).code).toBe(
CMS_CODES.VALIDATION_FAILED
);
expect(duplicateSlug("about", "m1").code).toBe(
CMS_CODES.DUPLICATE_SLUG
);
expect(modelInUse("m1").code).toBe(CMS_CODES.MODEL_IN_USE);
});
it("CmsError preserves cause chain and narrows code", {
timeout: 5000,
}, () => {
const cause = new Error("boom");
const err = new CmsError(
CMS_CODES.VALIDATION_FAILED,
"validation died",
{ cause, context: { extra: 1 } }
);
expect(err).toBeInstanceOf(CmsError);
expect(err).toBeInstanceOf(SuaveplanError);
expect(err.cause).toBe(cause);
expect(err.context?.extra).toBe(1);
expect(err.cmsCode).toBe(CMS_CODES.VALIDATION_FAILED);
});
it("every error code is enumerable through CMS_CODES", {
timeout: 5000,
}, () => {
const keys = Object.keys(CMS_CODES).sort();
expect(keys).toEqual([
"CONTENT_NOT_FOUND",
"DUPLICATE_SLUG",
"INVALID_INPUT",
"INVALID_TRANSITION",
"MODEL_IN_USE",
"MODEL_NOT_FOUND",
"VALIDATION_FAILED",
]);
});
it("factory error contexts carry the relevant identifiers", {
timeout: 5000,
}, () => {
expect(contentNotFound("e1").context?.entryId).toBe("e1");
expect(modelNotFound("m1").context?.modelId).toBe("m1");
expect(modelInUse("m1").context?.modelId).toBe("m1");
const slugErr = duplicateSlug("about", "m1");
expect(slugErr.context?.slug).toBe("about");
expect(slugErr.context?.modelId).toBe("m1");
const transition = invalidStatusTransition("draft", "archived");
expect(transition.context?.from).toBe("draft");
expect(transition.context?.to).toBe("archived");
const vfail = validationFailed(["a", "b"]);
expect(vfail.context?.errors).toEqual(["a", "b"]);
});
});

View File

@@ -0,0 +1,116 @@
/**
* Stable error-code registry for `@suaveplan/api-plugin-cms`.
*
* Every failure path raises a {@link CmsError} carrying one of these
* seven `CMS.*` codes. Consumers branch on the string value:
* `error.code === CMS_CODES.CONTENT_NOT_FOUND`.
*
* - `CONTENT_NOT_FOUND` — `engine.getContent` (or any caller that
* delegates to it: `updateContent`, `transitionStatus`,
* `deleteContent`) was given an unknown entry id. Maps to HTTP 404.
* - `MODEL_NOT_FOUND` — `engine.getModel` (or any caller that delegates
* to it: `createContent`, `updateContent`, `deleteModel`) was given
* an unknown model id. Maps to HTTP 404.
* - `INVALID_TRANSITION` — `engine.transitionStatus` was asked to move
* an entry to a status not listed in `VALID_STATUS_TRANSITIONS`. Maps
* to HTTP 409.
* - `VALIDATION_FAILED` — `createContent` / `updateContent` data failed
* `validateContentData` against the parent model. Maps to HTTP 400.
* - `DUPLICATE_SLUG` — `createContent` / `updateContent` would have
* produced two entries sharing `(modelId, slug)`. Maps to HTTP 409.
* - `MODEL_IN_USE` — `deleteModel` was called on a model that still
* has entries. Maps to HTTP 409.
* - `INVALID_INPUT` — `createCmsPlugin` boundary validation rejected
* the supplied options. `cause` carries the `ZodError`. Maps to
* HTTP 500 (operator-facing).
*/
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
import type { ContentStatus } from "../types/cms.types.js";
export const CMS_CODES = defineCodes("CMS", [
"CONTENT_NOT_FOUND",
"MODEL_NOT_FOUND",
"INVALID_TRANSITION",
"VALIDATION_FAILED",
"DUPLICATE_SLUG",
"MODEL_IN_USE",
"INVALID_INPUT",
] as const);
export type CmsErrorCode = (typeof CMS_CODES)[keyof typeof CMS_CODES];
/**
* Concrete error class for the api-plugin-cms package. Extends
* {@link SuaveplanError} so `instanceof CmsError` narrows
* `error.code` to {@link CmsErrorCode}. The `cmsCode` mirror exposes
* the same value with a more specific type than the inherited `code`
* field (which the base class types as `string`).
*/
export class CmsError extends SuaveplanError<CmsErrorCode> {
public readonly cmsCode: CmsErrorCode;
public constructor(
code: CmsErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.cmsCode = code;
}
}
/** Build a `CMS.CONTENT_NOT_FOUND` error annotated with the entry id. */
export function contentNotFound(id: string): CmsError {
return new CmsError(
CMS_CODES.CONTENT_NOT_FOUND,
`Content "${id}" not found`,
{ context: { entryId: id } }
);
}
/** Build a `CMS.MODEL_NOT_FOUND` error annotated with the model id. */
export function modelNotFound(id: string): CmsError {
return new CmsError(CMS_CODES.MODEL_NOT_FOUND, `Model "${id}" not found`, {
context: { modelId: id },
});
}
/** Build a `CMS.INVALID_TRANSITION` error annotated with the edge. */
export function invalidStatusTransition(
from: ContentStatus,
to: ContentStatus
): CmsError {
return new CmsError(
CMS_CODES.INVALID_TRANSITION,
`Cannot transition from "${from}" to "${to}"`,
{ context: { from, to } }
);
}
/** Build a `CMS.VALIDATION_FAILED` error joining the rule messages. */
export function validationFailed(errors: readonly string[]): CmsError {
return new CmsError(
CMS_CODES.VALIDATION_FAILED,
`Validation failed: ${errors.join(", ")}`,
{ context: { errors: [...errors] } }
);
}
/** Build a `CMS.DUPLICATE_SLUG` error for the offending `(modelId, slug)`. */
export function duplicateSlug(slug: string, modelId: string): CmsError {
return new CmsError(
CMS_CODES.DUPLICATE_SLUG,
`Slug "${slug}" already exists in model "${modelId}"`,
{ context: { slug, modelId } }
);
}
/** Build a `CMS.MODEL_IN_USE` error annotated with the model id. */
export function modelInUse(id: string): CmsError {
return new CmsError(
CMS_CODES.MODEL_IN_USE,
`Model "${id}" has content entries and cannot be deleted`,
{ context: { modelId: id } }
);
}

View File

@@ -0,0 +1,83 @@
/**
* @suaveplan/api-plugin-cms
*
* Headless-CMS primitives for `@suaveplan/api`. Ships an in-memory
* `CmsEngine`, a `definePlugin()`-based factory that registers the
* engine on the host `ServiceRegistry`, a `CmsError` hierarchy with
* seven stable codes, an RFC 7807 problem+json renderer, the full
* content-model type surface, slug + validator utilities, and an
* OpenTelemetry meter bundle. Server-tier — Bun + Node.
*/
// Side-effect imports — surface the registry slot augmentation through
// the package barrel so any consumer of `import "@suaveplan/api-plugin-cms"`
// sees the `cms` `ServiceRegistry` slot without further wiring.
import "./services/registry-augmentation.js";
// Engine
export {
CmsEngine,
type CmsEngineOptions,
} from "./engine/cms-engine.js";
// Error codes
export {
CMS_CODES,
CmsError,
type CmsErrorCode,
contentNotFound,
duplicateSlug,
invalidStatusTransition,
modelInUse,
modelNotFound,
validationFailed,
} from "./error-codes/error-codes.js";
// OTel
export {
type CmsInstruments,
getCmsInstruments,
resetCmsInstruments,
} from "./otel/instruments.js";
// Plugin
export {
type CmsPluginConfig,
type CmsPluginOptions,
createCmsPlugin,
} from "./plugin/plugin.js";
// Routes
export {
formatProblemJson,
type HttpResponseLike,
PROBLEM_JSON_CONTENT_TYPE,
type ProblemDetails,
respondProblemJson,
} from "./routes/problem-json.js";
// Schemas
export {
type CmsPluginOptionsInput,
type CmsPluginOptionsResolved,
CmsPluginOptionsSchema,
} from "./schemas/options.js";
// Types
export {
type CmsEventMap,
type ContentEntry,
type ContentField,
type ContentModel,
type ContentQuery,
type ContentStatus,
type ContentType,
type ContentVersion,
type CreateContentInput,
type FieldType,
type FieldValidation,
generateSlug,
type UpdateContentInput,
VALID_STATUS_TRANSITIONS,
validateContentData,
} from "./types/cms.types.js";

View File

@@ -0,0 +1,77 @@
# OpenTelemetry instruments — `@suaveplan/api-plugin-cms`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The package emits seven OpenTelemetry counters on every production
mutation so operators can dashboard CMS activity and alert on drift
without wiring custom hooks. Instruments are resolved lazily through
`getCmsInstruments()` so test harnesses installing a custom
`MeterProvider` before importing the engine capture every observation.
The `resetCmsInstruments()` helper drops the cached bundle for tests
that need a clean slate; it is exported for testing only.
## Features
- One counter per `CmsEngine` event: `cms.models_created_total`,
`cms.content_created_total`, `cms.content_updated_total`,
`cms.content_published_total`, `cms.content_archived_total`,
`cms.content_deleted_total`.
- One derived counter `cms.validation_failures_total` increments
whenever `createContent` or `updateContent` raises
`CMS.VALIDATION_FAILED`.
- Lazy resolution honours the harness pattern: first import of the
package does not touch `metrics.getMeter` — the meter binds on the
first hot-path call.
## Usage: Basic
```ts
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
const provider = new MeterProvider({ readers: [...] });
metrics.setGlobalMeterProvider(provider);
// First mutation binds the cached meter to `provider`.
import { CmsEngine } from "@suaveplan/api-plugin-cms";
new CmsEngine().createModel({ /* ... */ });
```
## Usage: Advanced
In tests, install the in-memory exporter from
`tests/setup/polyfills.ts`, exercise the engine, then assert against
the flushed metrics:
```ts
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import { resetCmsInstruments } from "@suaveplan/api-plugin-cms";
beforeEach(() => resetCmsInstruments());
it("publishing increments the counter", async () => {
// ... engine.transitionStatus(..., "published")
const metrics = await flushTestMetrics();
// assert "cms.content_published_total" sum == 1
});
```
## API Reference
| Symbol | Description |
|---|---|
| `interface CmsInstruments` | Holds the seven counters + meter. |
| `getCmsInstruments()` | Resolve (or build + cache) the bundle. |
| `resetCmsInstruments()` | Test-only — clear the cached bundle. |
## Implementation Notes
The meter is acquired via `metrics.getMeter("@suaveplan/api-plugin-cms")`
on first call. Each counter is created with a human-readable
`description`. Production paths increment via `instr.<name>.add(1)` so
the wire emits a single delta per call; downstream pipelines aggregate
to a histogram of activity per minute if needed.

View File

@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import { CmsEngine } from "../engine/cms-engine.js";
import { CMS_CODES, type CmsError } from "../error-codes/error-codes.js";
import { getCmsInstruments, resetCmsInstruments } from "./instruments.js";
interface DataPoint {
value?: number;
}
function totalFor(
collected: Awaited<ReturnType<typeof flushTestMetrics>>,
name: string
): number {
let sum = 0;
for (const rm of collected) {
for (const sm of rm.scopeMetrics) {
for (const m of sm.metrics) {
if (m.descriptor.name !== name) continue;
for (const dp of m.dataPoints as DataPoint[]) {
if (typeof dp.value === "number") sum += dp.value;
}
}
}
}
return sum;
}
describe("CmsInstruments", () => {
beforeEach(() => {
resetCmsInstruments();
});
it("instruments are constructed lazily and shared across calls", {
timeout: 5000,
}, () => {
const a = getCmsInstruments();
const b = getCmsInstruments();
expect(a).toBe(b);
expect(a.meter).toBeDefined();
});
it("counters fire on engine mutations and are observable via in-memory MeterProvider", {
timeout: 5000,
}, async () => {
const engine = new CmsEngine();
const model = engine.createModel({
name: "P",
slug: "p",
contentType: "post",
fields: [
{
name: "title",
type: "text",
label: "Title",
required: true,
},
],
});
const entry = engine.createContent({
modelId: model.id,
title: "Hello",
data: { title: "Hello" },
});
engine.updateContent(entry.id, { title: "World" });
engine.transitionStatus(entry.id, "published");
engine.transitionStatus(entry.id, "archived");
engine.deleteContent(entry.id);
// Validation failure path
let err: CmsError | undefined;
try {
engine.createContent({
modelId: model.id,
title: "X",
data: {},
});
} catch (e) {
err = e as CmsError;
}
expect(err?.code).toBe(CMS_CODES.VALIDATION_FAILED);
const collected = await flushTestMetrics();
expect(totalFor(collected, "cms.models_created_total")).toBe(1);
expect(totalFor(collected, "cms.content_created_total")).toBe(1);
expect(totalFor(collected, "cms.content_updated_total")).toBe(1);
expect(totalFor(collected, "cms.content_published_total")).toBe(1);
expect(totalFor(collected, "cms.content_archived_total")).toBe(1);
expect(totalFor(collected, "cms.content_deleted_total")).toBe(1);
expect(totalFor(collected, "cms.validation_failures_total")).toBe(1);
});
});

View File

@@ -0,0 +1,72 @@
/**
* OpenTelemetry instruments owned by `@suaveplan/api-plugin-cms`.
*
* Resolved lazily on first access so test harnesses that install a
* custom `MeterProvider` BEFORE importing any production module
* (see `tests/setup/polyfills.ts`) capture every observation. Seven
* counters cover the seven engine events plus the `validation_failures`
* derived counter, which fires whenever `validateContentData` returns
* a non-empty result.
*/
import type { Counter, Meter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
export interface CmsInstruments {
readonly meter: Meter;
readonly modelsCreated: Counter;
readonly contentCreated: Counter;
readonly contentUpdated: Counter;
readonly contentPublished: Counter;
readonly contentArchived: Counter;
readonly contentDeleted: Counter;
readonly validationFailures: Counter;
}
let cached: CmsInstruments | undefined;
/** Returns the cached instrument bundle, creating it on first call. */
export function getCmsInstruments(): CmsInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/api-plugin-cms");
cached = {
meter,
modelsCreated: meter.createCounter("cms.models_created_total", {
description: "Content models registered via CmsEngine.createModel.",
}),
contentCreated: meter.createCounter("cms.content_created_total", {
description:
"Content entries persisted via CmsEngine.createContent.",
}),
contentUpdated: meter.createCounter("cms.content_updated_total", {
description:
"Content entries mutated via CmsEngine.updateContent or transitioned via CmsEngine.transitionStatus to draft/review.",
}),
contentPublished: meter.createCounter("cms.content_published_total", {
description:
"Content entries transitioned to the published status.",
}),
contentArchived: meter.createCounter("cms.content_archived_total", {
description: "Content entries transitioned to the archived status.",
}),
contentDeleted: meter.createCounter("cms.content_deleted_total", {
description: "Content entries removed via CmsEngine.deleteContent.",
}),
validationFailures: meter.createCounter(
"cms.validation_failures_total",
{
description:
"createContent / updateContent calls that failed model-driven validateContentData rules.",
}
),
};
return cached;
}
/**
* Test-only — drop the cached instrument bundle so the next access
* binds to whichever `MeterProvider` is currently global. Not part
* of the runtime contract.
*/
export function resetCmsInstruments(): void {
cached = undefined;
}

View File

@@ -0,0 +1,83 @@
# `createCmsPlugin`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Wraps `CmsEngine` in a typed `PluginDescriptor` consumable by
`@suaveplan/api-plugin`'s `PluginRegistry`. The factory matches the
suaveplan convention shipped by every other `api-plugin-*` package:
options are Zod-validated at the boundary so misuse is caught before
the plugin is registered; `onInit` constructs a `CmsEngine` and exposes
it via the host `ServiceRegistry`'s `cms` slot; `onClose` drops the
internal reference. Downstream plugins look the engine up via
`ctx.getService("cms")` and the slot is type-safe thanks to a
`declare module "@suaveplan/api-plugin/service-registry"` augmentation.
## Features
- Zod-validated boundary — invalid input raises
`CmsError(CMS.INVALID_INPUT)` with the underlying `ZodError`
attached as `cause`.
- Caller-supplied `Clock` and `IdGenerator` flow through to the
constructed `CmsEngine`, enabling deterministic test fixtures.
- ServiceRegistry slot named `cms` (module-augmented at package load).
- Lifecycle hooks (`onInit` / `onClose`) implement the canonical
api-plugin lifecycle; the descriptor is `enabledByDefault`-compatible
with `@suaveplan/api`'s `PluginRegistry`.
## Usage: Basic
```ts
import { createCmsPlugin } from "@suaveplan/api-plugin-cms";
import { createPluginRegistry } from "@suaveplan/api-plugin";
const registry = createPluginRegistry();
registry.register(createCmsPlugin({}));
await registry.initAll();
const cms = registry.context("cms").getService("cms");
cms?.createModel({ /* ... */ });
```
## Usage: Advanced
Inject a deterministic clock + id generator (useful for snapshot
tests of an entire request pipeline) and consume the registered engine
from another plugin:
```ts
import { createCmsPlugin } from "@suaveplan/api-plugin-cms";
let n = 0;
const plugin = createCmsPlugin({
clock: () => 1_700_000_000_000,
idGenerator: () => `det_${++n}`,
});
// In another plugin's onInit, after deps:
const cms = ctx.getService("cms");
cms?.events.on("content.published", broadcastWebhook);
```
## API Reference
| Symbol | Description |
|---|---|
| `interface CmsPluginOptions` | `{ clock?, idGenerator? }` — caller input. |
| `interface CmsPluginConfig` | Resolved post-validation shape. |
| `createCmsPlugin(options?)` | Returns `PluginDescriptor<CmsPluginConfig>`. |
## Implementation Notes
The factory Zod-parses options via `CmsPluginOptionsSchema`; both
`clock` and `idGenerator` are typed as `unknown` at the schema level
(Zod cannot usefully type arbitrary function signatures) and the
runtime `typeof === "function"` guard inside `createCmsPlugin` decides
whether to forward them to the engine. `onInit` mutates a closed-over
`CmsPluginState` so `onClose` can drop the reference without leaking
the engine instance into the registry. The `cms` slot is declared via
module augmentation in `services/registry-augmentation.ts` and the
barrel side-effect-imports that module so downstream type-only
imports of `@suaveplan/api-plugin/service-registry` see the slot.

View File

@@ -0,0 +1,96 @@
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CmsEngine } from "../engine/cms-engine.js";
import { CMS_CODES, type CmsError } from "../error-codes/error-codes.js";
import { type CmsPluginConfig, createCmsPlugin } from "./plugin.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function makeCtx(
services: Map<string, unknown>,
config: CmsPluginConfig
): ReturnType<typeof createPluginContext<CmsPluginConfig>> {
return createPluginContext<CmsPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config,
services,
});
}
describe("createCmsPlugin", () => {
it("createCmsPlugin returns a valid PluginDescriptor", {
timeout: 5000,
}, () => {
// No args → default-parameter branch.
const noArgsPlugin = createCmsPlugin();
expect(noArgsPlugin.name).toBe("cms");
const plugin = createCmsPlugin({});
expect(plugin.name).toBe("cms");
expect(plugin.version).toBe("0.1.0");
expect(typeof plugin.onInit).toBe("function");
expect(typeof plugin.onClose).toBe("function");
expect(plugin.tags).toContain("cms");
});
it("invalid options raise CMS.INVALID_INPUT", { timeout: 5000 }, () => {
let caught: CmsError | undefined;
try {
createCmsPlugin(42 as never);
} catch (e) {
caught = e as CmsError;
}
expect(caught?.code).toBe(CMS_CODES.INVALID_INPUT);
expect(caught?.cause).toBeDefined();
});
it("onInit registers the CmsEngine under the cms slot", {
timeout: 5000,
}, async () => {
const plugin = createCmsPlugin({});
const services = new Map<string, unknown>();
const ctx = makeCtx(services, plugin.defaults as CmsPluginConfig);
await plugin.onInit?.(ctx);
const registered = services.get("cms");
expect(registered).toBeInstanceOf(CmsEngine);
});
it("onInit honours caller-supplied clock + idGenerator", {
timeout: 5000,
}, async () => {
let n = 0;
const idGenerator = () => `inj_${++n}`;
const clock = () => 1_700_000_000_000;
const plugin = createCmsPlugin({ clock, idGenerator });
const services = new Map<string, unknown>();
const ctx = makeCtx(services, plugin.defaults as CmsPluginConfig);
await plugin.onInit?.(ctx);
const engine = services.get("cms") as CmsEngine;
const model = engine.createModel({
name: "P",
slug: "p",
contentType: "post",
fields: [],
});
expect(model.id).toBe("inj_1");
});
it("onClose drops the engine reference", { timeout: 5000 }, async () => {
const plugin = createCmsPlugin({});
const services = new Map<string, unknown>();
const ctx = makeCtx(services, plugin.defaults as CmsPluginConfig);
await plugin.onInit?.(ctx);
// onClose has no observable side-effect on the services map by
// design (host owns the slot's lifetime); we exercise it for
// coverage and to confirm it does not throw.
await plugin.onClose?.(ctx);
expect(services.get("cms")).toBeInstanceOf(CmsEngine);
});
});

View File

@@ -0,0 +1,92 @@
/**
* `createCmsPlugin` factory — wraps `CmsEngine` in a typed
* `PluginDescriptor` consumable by `@suaveplan/api-plugin`'s
* `PluginRegistry`. Matches the genesis convention shipped by every
* other `api-plugin-*` package: factory accepts options, Zod-validates
* at the boundary, registers the package's primary service on the host
* `ServiceRegistry` during `onInit`, drops the reference on `onClose`.
*/
import {
definePlugin,
type PluginContext,
type PluginDescriptor,
} from "@suaveplan/api-plugin";
import type { Clock, IdGenerator } from "@suaveplan/types";
import { CmsEngine } from "../engine/cms-engine.js";
import { CMS_CODES, CmsError } from "../error-codes/error-codes.js";
import { CmsPluginOptionsSchema } from "../schemas/options.js";
/** Caller-supplied options for `createCmsPlugin`. */
export interface CmsPluginOptions {
readonly clock?: Clock;
readonly idGenerator?: IdGenerator;
}
/** Resolved plugin config (post-validation). */
export interface CmsPluginConfig {
readonly clock?: Clock;
readonly idGenerator?: IdGenerator;
}
interface CmsPluginState {
engine: CmsEngine | null;
}
/**
* Construct a CMS plugin descriptor. Zod-validates `options` and
* raises `CmsError(CMS.INVALID_INPUT)` on rejection. The `onInit`
* hook builds a `CmsEngine` using the supplied `Clock` /
* `IdGenerator` (or genesis defaults) and exposes it under the `cms`
* slot of the host `ServiceRegistry`; `onClose` drops the reference.
*/
export function createCmsPlugin(
options: CmsPluginOptions = {}
): PluginDescriptor<CmsPluginConfig> {
const parsed = CmsPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new CmsError(
CMS_CODES.INVALID_INPUT,
"createCmsPlugin received invalid options",
{ cause: parsed.error, context: { statusCode: 500 } }
);
}
const config: CmsPluginConfig = {
...(typeof options.clock === "function"
? { clock: options.clock }
: {}),
...(typeof options.idGenerator === "function"
? { idGenerator: options.idGenerator }
: {}),
};
const state: CmsPluginState = { engine: null };
return definePlugin<CmsPluginConfig>({
name: "cms",
version: "0.1.0",
description:
"Headless CMS for @suaveplan/api — in-memory CmsEngine with content models, entries, status lifecycle, and version history.",
defaults: config,
tags: ["cms", "content", "headless"],
onInit: (ctx) => runOnInit(ctx, config, state),
onClose: () => {
state.engine = null;
},
});
}
function runOnInit(
ctx: PluginContext<CmsPluginConfig>,
config: CmsPluginConfig,
state: CmsPluginState
): void {
const engine = new CmsEngine({
...(config.clock !== undefined ? { clock: config.clock } : {}),
...(config.idGenerator !== undefined
? { idGenerator: config.idGenerator }
: {}),
});
state.engine = engine;
ctx.registerService("cms", engine);
}

View File

@@ -0,0 +1,90 @@
# RFC 7807 problem+json renderer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Render `CmsError` (and arbitrary throwables) as RFC 7807 problem+json
HTTP responses without requiring `@suaveplan/api-plugin-cms` to depend
on any HTTP framework. The renderer accepts a small structural
`HttpResponseLike` interface that is compatible with Hono, Fastify,
Bun's stdlib `Response`, and any framework whose response object
exposes `header(name, value)` plus `body(data, status)`. Status
mapping is encoded inline as a `Record<string, number>` lookup table
that the package-level spec-conformance test asserts is exhaustive
over `CMS_CODES`.
## Features
- Pure `formatProblemJson(err)` returns the JSON body for logging or
side-channel use.
- `respondProblemJson(res, err)` writes header + body and returns the
framework's `Response` for `return res.body(...)` style handlers.
- `PROBLEM_JSON_CONTENT_TYPE` constant — `application/problem+json` per
RFC 7807.
- Local `ProblemDetails` interface matching the IETF spec, with an
extension-index signature so callers can add fields per RFC §3.2.
- Status table:
- `CMS.CONTENT_NOT_FOUND` / `CMS.MODEL_NOT_FOUND` → 404
- `CMS.INVALID_TRANSITION` / `CMS.DUPLICATE_SLUG` / `CMS.MODEL_IN_USE` → 409
- `CMS.VALIDATION_FAILED` → 400
- `CMS.INVALID_INPUT` → 500
- Generic `SuaveplanError` instances render with HTTP 500 but with the
original code so log scrapers still see structure.
- Non-error throwables render `{ type: "about:blank", status: 500 }`.
## Usage: Basic
```ts
import { Hono } from "hono";
import { respondProblemJson } from "@suaveplan/api-plugin-cms";
const app = new Hono();
app.onError((err, c) => respondProblemJson(c, err));
```
## Usage: Advanced
Side-channel logging without writing to a response (e.g. forwarding
the body to an audit pipeline):
```ts
import { formatProblemJson } from "@suaveplan/api-plugin-cms";
try {
engine.transitionStatus(id, target);
} catch (err) {
const body = formatProblemJson(err);
await audit.log({
type: "cms.transition.failed",
severity: body.status >= 500 ? "error" : "warning",
...body,
});
throw err;
}
```
## API Reference
| Symbol | Description |
|---|---|
| `PROBLEM_JSON_CONTENT_TYPE` | `"application/problem+json"`. |
| `interface ProblemDetails` | RFC 7807 body shape with an extension index. |
| `interface HttpResponseLike` | `{ header(n, v); body(d, s): Response }`. |
| `respondProblemJson(res, err)` | Write header + body; return `Response`. |
| `formatProblemJson(err)` | Pure variant. |
## Implementation Notes
The status lookup is a `Readonly<Record<string, number>>` keyed by the
fully-qualified `CMS.*` code. If a `CmsError` is constructed with a
code not in the table (unreachable in normal use; possible only in
tests that bypass `defineCodes`), the renderer falls back to HTTP 500.
Generic `SuaveplanError` instances render with HTTP 500 + the
original `code` field so the response is still grep-friendly. The
`HttpResponseLike` shape was chosen over `Context` (Hono) to keep
this package free of framework dependencies — see
`@suaveplan/api-plugin-database`'s `routes/problem-json.ts` for the
same pattern.

View File

@@ -0,0 +1,118 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
CMS_CODES,
CmsError,
contentNotFound,
duplicateSlug,
invalidStatusTransition,
modelInUse,
modelNotFound,
validationFailed,
} from "../error-codes/error-codes.js";
import {
formatProblemJson,
PROBLEM_JSON_CONTENT_TYPE,
respondProblemJson,
} from "./problem-json.js";
const OTHER_CODES = defineCodes("OTHER", ["BOOM"] as const);
type OtherCode = (typeof OTHER_CODES)[keyof typeof OTHER_CODES];
class OtherError extends SuaveplanError<OtherCode> {}
interface CapturedResponse {
headers: Record<string, string>;
body: string;
status: number;
}
function makeRes(): {
res: {
header: (n: string, v: string) => void;
body: (d: string, s: number) => Response;
};
captured: CapturedResponse;
} {
const captured: CapturedResponse = { headers: {}, body: "", status: 0 };
const res = {
header: (n: string, v: string) => {
captured.headers[n] = v;
},
body: (d: string, s: number) => {
captured.body = d;
captured.status = s;
return new Response(d, { status: s });
},
};
return { res, captured };
}
describe("@suaveplan/api-plugin-cms — problem+json", () => {
it("formatProblemJson maps every CmsError code to the documented HTTP status", {
timeout: 5000,
}, () => {
expect(formatProblemJson(contentNotFound("e1")).status).toBe(404);
expect(formatProblemJson(modelNotFound("m1")).status).toBe(404);
expect(
formatProblemJson(invalidStatusTransition("draft", "published"))
.status
).toBe(409);
expect(formatProblemJson(duplicateSlug("a", "m1")).status).toBe(409);
expect(formatProblemJson(modelInUse("m1")).status).toBe(409);
expect(formatProblemJson(validationFailed(["x"])).status).toBe(400);
// INVALID_INPUT
expect(
formatProblemJson(
new CmsError(CMS_CODES.INVALID_INPUT, "bad options")
).status
).toBe(500);
});
it("formatProblemJson populates type/title/code and detail", {
timeout: 5000,
}, () => {
const body = formatProblemJson(contentNotFound("xyz"));
expect(body.type).toBe("urn:suaveplan:error:CMS.CONTENT_NOT_FOUND");
expect(body.title).toBe("CMS.CONTENT_NOT_FOUND");
expect(body.code).toBe("CMS.CONTENT_NOT_FOUND");
expect(body.detail).toContain("xyz");
});
it("formatProblemJson maps unknown CmsError codes to 500", {
timeout: 5000,
}, () => {
// Construct a CmsError via the constructor with a code not in
// the status table — exercising the `?? 500` fallback.
const orphan = new CmsError("CMS.UNREGISTERED" as never, "orphan");
expect(formatProblemJson(orphan).status).toBe(500);
});
it("formatProblemJson falls back to 500 for generic SuaveplanError and unknown throwables", {
timeout: 5000,
}, () => {
const generic = new OtherError(OTHER_CODES.BOOM, "boom");
const body = formatProblemJson(generic);
expect(body.status).toBe(500);
expect(body.code).toBe(OTHER_CODES.BOOM);
const unknown = formatProblemJson(new Error("plain"));
expect(unknown.status).toBe(500);
expect(unknown.type).toBe("about:blank");
const stringErr = formatProblemJson("string thrown");
expect(stringErr.status).toBe(500);
});
it("respondProblemJson sets header and body on the response", {
timeout: 5000,
}, () => {
const { res, captured } = makeRes();
respondProblemJson(res, modelInUse("m1"));
expect(captured.headers["content-type"]).toBe(
PROBLEM_JSON_CONTENT_TYPE
);
expect(captured.status).toBe(409);
const parsed = JSON.parse(captured.body) as { code?: string };
expect(parsed.code).toBe(CMS_CODES.MODEL_IN_USE);
});
});

View File

@@ -0,0 +1,106 @@
/**
* RFC 7807 problem+json renderer for `@suaveplan/api-plugin-cms`.
*
* Genesis `@suaveplan/error` does not (yet) ship a canonical
* `HttpError` / `ProblemDetails` / `formatErrorAsProblemJson` surface
* the way main's `@suaveplan/error` did, so this module owns those
* primitives locally — mirroring the pattern shipped by
* `@suaveplan/api-plugin-database/src/routes/problem-json.ts`. Status
* mapping is encoded inline (a `Record<string, number>` lookup) rather
* than via a metadata registry; the conformance test asserts the table
* covers every `CMS.*` code so missing entries fail CI rather than at
* runtime.
*/
import { SuaveplanError } from "@suaveplan/error";
import { CMS_CODES, CmsError } from "../error-codes/error-codes.js";
/** Standard RFC 7807 content type. */
export const PROBLEM_JSON_CONTENT_TYPE = "application/problem+json";
/**
* RFC 7807 problem-details body. Genesis's `@suaveplan/error` does not
* yet ship a canonical `ProblemDetails` type, so the shape is mirrored
* here from the IETF specification.
*/
export interface ProblemDetails {
readonly type: string;
readonly title: string;
readonly status: number;
readonly detail?: string;
readonly instance?: string;
readonly code?: string;
readonly [extension: string]: unknown;
}
/**
* Minimal structural HTTP-response surface — compatible with Hono,
* Fastify, and Bun's stdlib response wrappers. The package itself does
* not depend on any framework; consumers pass their `c` / `reply` /
* etc. as `HttpResponseLike`.
*/
export interface HttpResponseLike {
header(name: string, value: string): void;
body(data: string, status: number): Response;
}
/**
* Status table for `CmsError`. Verified exhaustively by the
* spec-conformance test — every code in `CMS_CODES` must appear here.
*/
const STATUS_BY_CODE: Readonly<Record<string, number>> = {
[CMS_CODES.CONTENT_NOT_FOUND]: 404,
[CMS_CODES.MODEL_NOT_FOUND]: 404,
[CMS_CODES.INVALID_TRANSITION]: 409,
[CMS_CODES.VALIDATION_FAILED]: 400,
[CMS_CODES.DUPLICATE_SLUG]: 409,
[CMS_CODES.MODEL_IN_USE]: 409,
[CMS_CODES.INVALID_INPUT]: 500,
};
/**
* Render `err` as RFC 7807 problem+json on the supplied response.
* Returns the framework's `Response` so handlers can `return` the
* result directly.
*/
export function respondProblemJson(
res: HttpResponseLike,
err: unknown
): Response {
const body = formatProblemJson(err);
res.header("content-type", PROBLEM_JSON_CONTENT_TYPE);
return res.body(JSON.stringify(body), body.status);
}
/**
* Pure variant of `respondProblemJson` — returns the `ProblemDetails`
* body without touching a response surface. Useful for logging and
* unit-tests.
*/
export function formatProblemJson(err: unknown): ProblemDetails {
if (err instanceof CmsError) {
const status = STATUS_BY_CODE[err.code] ?? 500;
return {
type: `urn:suaveplan:error:${err.code}`,
title: err.code,
status,
detail: err.message,
code: err.code,
};
}
if (err instanceof SuaveplanError) {
return {
type: `urn:suaveplan:error:${err.code}`,
title: err.code,
status: 500,
detail: err.message,
code: err.code,
};
}
return {
type: "about:blank",
title: "Internal Server Error",
status: 500,
detail: "An unexpected error occurred",
};
}

View File

@@ -0,0 +1,69 @@
# `CmsPluginOptionsSchema`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Zod schema validating the `CmsPluginOptions` bundle accepted by
`createCmsPlugin(...)`. Boundary validation at the plugin factory
ensures that misuse fails before the plugin reaches the registry, with
the underlying `ZodError` preserved as the `cause` chain on the
resulting `CmsError(CMS.INVALID_INPUT)`. The schema is intentionally
minimal — only `clock` and `idGenerator` are accepted today — and both
fields are passed through as `unknown` because Zod cannot usefully
type-check arbitrary function signatures. The runtime `typeof ===
"function"` guard inside `createCmsPlugin` is the actual gate.
## Features
- Boundary validation for `CmsPluginOptions`.
- `safeParse` style for opt-in error handling at the factory site.
- `z.input` / `z.output` derived types for downstream consumers that
want the pre-/post-validation shapes.
- The schema invokes `.transform(...)` so omitted fields are stripped
from the output rather than serialised as `undefined`.
## Usage: Basic
```ts
import { CmsPluginOptionsSchema } from "@suaveplan/api-plugin-cms";
const result = CmsPluginOptionsSchema.safeParse(input);
if (!result.success) throw new Error("bad options");
const { clock, idGenerator } = result.data;
```
## Usage: Advanced
Compose with a project-level config schema that also drives logging /
telemetry parameters:
```ts
import z from "zod";
import { CmsPluginOptionsSchema } from "@suaveplan/api-plugin-cms";
const ProjectConfigSchema = z.object({
cms: CmsPluginOptionsSchema,
logLevel: z.enum(["debug", "info", "warn", "error"]).default("info"),
});
```
## API Reference
| Symbol | Description |
|---|---|
| `CmsPluginOptionsSchema` | Zod schema. |
| `CmsPluginOptionsInput` | `z.input<typeof CmsPluginOptionsSchema>`. |
| `CmsPluginOptionsResolved` | `z.output<typeof CmsPluginOptionsSchema>`. |
## Implementation Notes
The schema is the only Zod surface exposed by the package, so the
conformance script's "schemas at boundaries" check is satisfied by the
single `.safeParse(...)` call inside `src/plugin/plugin.ts`. The
`unknown` typing of `clock` / `idGenerator` is deliberate; Zod
`z.function()` requires arg/return schemas at parse-time which would
force callers to construct a Zod-wrapped function — a worse ergonomics
trade than the inline `typeof` check inside the factory.

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CmsPluginOptionsSchema } from "./options.js";
describe("CmsPluginOptionsSchema", () => {
it("accepts an empty object", { timeout: 5000 }, () => {
const parsed = CmsPluginOptionsSchema.safeParse({});
expect(parsed.success).toBe(true);
expect(parsed.success && Object.keys(parsed.data)).toEqual([]);
});
it("passes clock + idGenerator through unchanged", {
timeout: 5000,
}, () => {
const clock = () => 42;
const idGenerator = () => "id";
const parsed = CmsPluginOptionsSchema.parse({ clock, idGenerator });
expect(parsed.clock).toBe(clock);
expect(parsed.idGenerator).toBe(idGenerator);
});
it("rejects non-object input", { timeout: 5000 }, () => {
const parsed = CmsPluginOptionsSchema.safeParse(42);
expect(parsed.success).toBe(false);
});
});

View File

@@ -0,0 +1,26 @@
/**
* Zod schema validating `CmsPluginOptions` at the
* `createCmsPlugin(...)` boundary. `clock` and `idGenerator` are
* passed through as `unknown` because Zod cannot type-check arbitrary
* function signatures usefully; the runtime type guard happens in the
* plugin factory itself when the engine is constructed.
*/
import z from "zod";
export const CmsPluginOptionsSchema = z
.object({
clock: z.unknown().optional(),
idGenerator: z.unknown().optional(),
})
.transform((parsed) => ({
...(parsed.clock !== undefined ? { clock: parsed.clock } : {}),
...(parsed.idGenerator !== undefined
? { idGenerator: parsed.idGenerator }
: {}),
}));
/** Caller-facing input shape (pre-validation). */
export type CmsPluginOptionsInput = z.input<typeof CmsPluginOptionsSchema>;
/** Resolved output shape (post-validation). */
export type CmsPluginOptionsResolved = z.output<typeof CmsPluginOptionsSchema>;

View File

@@ -0,0 +1,82 @@
# `ServiceRegistry` augmentation — `cms` slot
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Declares the `cms` slot on the
`@suaveplan/api-plugin/service-registry`'s `ServiceRegistry` interface
via TypeScript module augmentation. The augmentation has no runtime
output — it exists purely so consumers typed against the augmented
registry see `ctx.getService("cms")` resolve to `CmsEngine |
undefined` without any cast at the call site. The barrel
side-effect-imports this module so the augmentation is observed as
soon as any consumer imports `@suaveplan/api-plugin-cms`.
## Features
- Adds `cms: CmsEngine` to `ServiceRegistry`.
- Zero runtime cost — the file emits no JavaScript.
- Cumulative augmentation — TypeScript merges this declaration with
every other plugin's slot declarations, so apps wiring `cms`,
`auditService`, `db`, `searchIndexes`, etc. see one combined
registry shape.
## Usage: Basic
Consumers do not import this module directly; the barrel does it on
their behalf. The slot is then usable wherever a `PluginContext` is
in scope:
```ts
import { definePlugin } from "@suaveplan/api-plugin";
export const myPlugin = definePlugin({
name: "my-plugin",
version: "0.1.0",
dependencies: ["cms"],
onInit: (ctx) => {
const cms = ctx.getService("cms"); // typed as CmsEngine | undefined
cms?.events.on("content.published", (entry) => {
/* ... */
});
},
});
```
## Usage: Advanced
When stripping the package out at build time (e.g. for a browser
bundle), make sure to also strip the side-effect import of this module
from `src/index.ts`; otherwise the `declare module` block is harmless
but the `import` statement may cause a tree-shake regression.
```ts
// To consume the engine without the slot augmentation:
import { CmsEngine } from "@suaveplan/api-plugin-cms/engine/cms-engine";
```
## API Reference
This module exports nothing at runtime. Its only effect is the
ambient `ServiceRegistry` merge:
```ts
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
cms: CmsEngine;
}
}
```
## Implementation Notes
The augmentation file deliberately has no value-level imports — it
imports `type CmsEngine` so the JavaScript output is empty after
TypeScript erasure. Side-effect imports of empty files are still
emitted; the bundler keeps them because they may have ambient
side-effects (here, only typing). The barrel imports this module
unconditionally so any caller of `@suaveplan/api-plugin-cms` gets the
slot without an explicit reference.

View File

@@ -0,0 +1,14 @@
/**
* ServiceRegistry module augmentation — declares the `cms` slot on
* `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
* interface so downstream plugins typed against the augmented registry
* see `ctx.getService("cms")` resolve to `CmsEngine | undefined`
* without any cast.
*/
import type { CmsEngine } from "../engine/cms-engine.js";
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
cms: CmsEngine;
}
}

View File

@@ -0,0 +1,155 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
type ContentModel,
generateSlug,
VALID_STATUS_TRANSITIONS,
validateContentData,
} from "./cms.types.js";
describe("@suaveplan/api-plugin-cms — types + utilities", () => {
it("generateSlug normalises whitespace, punctuation, and case", {
timeout: 5000,
}, () => {
expect(generateSlug(" Hello, World! ")).toBe("hello-world");
expect(generateSlug("Already-A-Slug")).toBe("already-a-slug");
expect(generateSlug("multiple spaces")).toBe("multiple-spaces");
expect(generateSlug("symbols#$%^&*()go")).toBe("symbolsgo");
expect(generateSlug("---trim-me---")).toBe("trim-me");
});
it("validateContentData enforces required + every FieldValidation type", {
timeout: 5000,
}, () => {
const model: ContentModel = {
id: "m1",
name: "Post",
slug: "post",
contentType: "post",
fields: [
{ name: "title", type: "text", label: "Title", required: true },
{
name: "body",
type: "text",
label: "Body",
validations: [
{ type: "minLength", value: 5 },
{ type: "maxLength", value: 10 },
],
},
{
name: "rating",
type: "number",
label: "Rating",
validations: [
{ type: "min", value: 1 },
{ type: "max", value: 5 },
],
},
{
name: "code",
type: "text",
label: "Code",
validations: [
{
type: "pattern",
value: "^[A-Z]+$",
message: "code must be uppercase",
},
],
},
{
name: "anything",
type: "text",
label: "Anything",
validations: [{ type: "custom" }],
},
],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
const errors = validateContentData(model, {
body: "no",
rating: 6,
code: "lowercase",
anything: "ok",
});
expect(errors).toContain('Field "title" is required');
expect(
errors.some((e) => e.includes("must be at least 5 characters"))
).toBe(true);
expect(errors.some((e) => e.includes("must be at most 5"))).toBe(true);
expect(errors).toContain("code must be uppercase");
// maxLength
const tooLong = validateContentData(model, {
title: "ok",
body: "this is too long",
});
expect(
tooLong.some((e) => e.includes("must be at most 10 characters"))
).toBe(true);
// min (numeric)
const tooSmall = validateContentData(model, {
title: "ok",
rating: 0,
});
expect(tooSmall.some((e) => e.includes("must be at least 1"))).toBe(
true
);
// passing data → empty
expect(
validateContentData(model, {
title: "ok",
body: "finely",
rating: 3,
code: "ABC",
})
).toEqual([]);
});
it("validateContentData skips rules when the field is absent", {
timeout: 5000,
}, () => {
const model: ContentModel = {
id: "m2",
name: "M",
slug: "m",
contentType: "page",
fields: [
{
name: "opt",
type: "text",
label: "Opt",
validations: [{ type: "minLength", value: 3 }],
},
],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
expect(validateContentData(model, {})).toEqual([]);
expect(validateContentData(model, { opt: null })).toEqual([]);
});
it("VALID_STATUS_TRANSITIONS lists every legal edge", {
timeout: 5000,
}, () => {
expect(VALID_STATUS_TRANSITIONS.draft).toEqual([
"review",
"published",
"archived",
]);
expect(VALID_STATUS_TRANSITIONS.review).toEqual([
"draft",
"published",
"archived",
]);
expect(VALID_STATUS_TRANSITIONS.published).toEqual([
"draft",
"archived",
]);
expect(VALID_STATUS_TRANSITIONS.archived).toEqual(["draft"]);
});
});

View File

@@ -0,0 +1,266 @@
/**
* Content-model and content-entry type surface for `@suaveplan/api-plugin-cms`.
*
* The types are a near-verbatim port of upstream
* `../suaveplan/packages/server/api-plugin-cms/src/types/cms.types.ts`,
* adapted only where genesis conventions differ:
*
* - `CmsEventMap` follows genesis `EventBus<EventMap>`'s single-payload
* shape (`Record<string, unknown>`) rather than upstream's tuple-based
* `EventMap`. The runtime delivery surface is otherwise identical.
*/
/** Content status lifecycle. */
export type ContentStatus = "draft" | "review" | "published" | "archived";
/** Content types. */
export type ContentType =
| "page"
| "post"
| "component"
| "template"
| "media"
| "snippet";
/** Field types for content models. */
export type FieldType =
| "text"
| "richtext"
| "number"
| "boolean"
| "date"
| "image"
| "file"
| "reference"
| "list"
| "json";
/** Content-model field definition. */
export interface ContentField {
readonly name: string;
readonly type: FieldType;
readonly label: string;
readonly required?: boolean;
readonly unique?: boolean;
readonly defaultValue?: unknown;
readonly validations?: readonly FieldValidation[];
readonly description?: string;
readonly localized?: boolean;
}
/** Field-validation rule. */
export interface FieldValidation {
readonly type:
| "minLength"
| "maxLength"
| "min"
| "max"
| "pattern"
| "custom";
readonly value?: unknown;
readonly message?: string;
}
/** Content-model definition. */
export interface ContentModel {
readonly id: string;
readonly name: string;
readonly slug: string;
readonly contentType: ContentType;
readonly fields: readonly ContentField[];
readonly description?: string;
readonly createdAt: string;
readonly updatedAt: string;
}
/** Content entry. */
export interface ContentEntry {
readonly id: string;
readonly modelId: string;
readonly slug: string;
readonly title: string;
readonly status: ContentStatus;
readonly data: Record<string, unknown>;
readonly locale?: string;
readonly author?: string;
readonly version: number;
readonly publishedAt?: string;
readonly createdAt: string;
readonly updatedAt: string;
}
/** Content version snapshot (per-mutation audit row). */
export interface ContentVersion {
readonly id: string;
readonly entryId: string;
readonly version: number;
readonly data: Record<string, unknown>;
readonly status: ContentStatus;
readonly author?: string;
readonly createdAt: string;
}
/** Create-content input. */
export interface CreateContentInput {
readonly modelId: string;
readonly title: string;
readonly data: Record<string, unknown>;
readonly slug?: string;
readonly locale?: string;
readonly author?: string;
}
/** Update-content input. */
export interface UpdateContentInput {
readonly title?: string;
readonly slug?: string;
readonly data?: Record<string, unknown>;
readonly author?: string;
}
/** Content-query options. */
export interface ContentQuery {
readonly modelId?: string;
readonly status?: ContentStatus;
readonly locale?: string;
readonly search?: string;
readonly limit?: number;
readonly offset?: number;
readonly sortBy?: string;
readonly sortOrder?: "asc" | "desc";
}
/**
* Genesis `EventBus<EventMap>` uses single-payload events
* (`Record<string, unknown>`); the upstream tuple shape
* `{ "evt": [Payload] }` collapses to the payload type. The runtime
* delivery surface is identical (one listener argument).
*/
export interface CmsEventMap {
readonly "content.created": ContentEntry;
readonly "content.updated": ContentEntry;
readonly "content.published": ContentEntry;
readonly "content.drafted": ContentEntry;
readonly "content.archived": ContentEntry;
readonly "content.deleted": string;
readonly "model.created": ContentModel;
readonly [key: string]: unknown;
}
/**
* Valid content-status transitions. Verbatim from upstream:
* - draft ↔ review, draft → published, draft → archived
* - review ↔ draft, review → published, review → archived
* - published → draft, published → archived
* - archived → draft
*/
export const VALID_STATUS_TRANSITIONS: Readonly<
Record<ContentStatus, readonly ContentStatus[]>
> = {
draft: ["review", "published", "archived"],
review: ["draft", "published", "archived"],
published: ["draft", "archived"],
archived: ["draft"],
};
/**
* Generate a URL-safe slug from a free-form title. Lowercases, strips
* non-alphanumerics (preserving whitespace and hyphens), collapses
* whitespace runs to a single hyphen, then collapses hyphen runs.
*/
export function generateSlug(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* Validate `data` against `model.fields` and return a list of
* human-readable error messages. An empty list means the data is
* valid. Each `FieldValidation` rule on a field is run only if the
* value is supplied (i.e. not `undefined`/`null`).
*/
export function validateContentData(
model: ContentModel,
data: Record<string, unknown>
): string[] {
const errors: string[] = [];
for (const field of model.fields) {
const value = data[field.name];
if (
field.required &&
(value === undefined || value === null || value === "")
) {
errors.push(`Field "${field.name}" is required`);
}
if (value !== undefined && value !== null) {
for (const validation of field.validations ?? []) {
const err = validateField(field.name, value, validation);
if (err !== null) errors.push(err);
}
}
}
return errors;
}
function validateField(
name: string,
value: unknown,
rule: FieldValidation
): string | null {
switch (rule.type) {
case "minLength":
if (
typeof value === "string" &&
value.length < (rule.value as number)
) {
return (
rule.message ??
`"${name}" must be at least ${String(rule.value)} characters`
);
}
return null;
case "maxLength":
if (
typeof value === "string" &&
value.length > (rule.value as number)
) {
return (
rule.message ??
`"${name}" must be at most ${String(rule.value)} characters`
);
}
return null;
case "min":
if (typeof value === "number" && value < (rule.value as number)) {
return (
rule.message ??
`"${name}" must be at least ${String(rule.value)}`
);
}
return null;
case "max":
if (typeof value === "number" && value > (rule.value as number)) {
return (
rule.message ??
`"${name}" must be at most ${String(rule.value)}`
);
}
return null;
case "pattern":
if (
typeof value === "string" &&
!new RegExp(rule.value as string).test(value)
) {
return rule.message ?? `"${name}" does not match pattern`;
}
return null;
case "custom":
// Custom rules are caller-evaluated; this branch is a no-op
// and exists so `validateField` is total over `FieldValidation`.
return null;
}
}

View File

@@ -0,0 +1,73 @@
import { existsSync, readdirSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import pkg from "../package.json" with { type: "json" };
const ROOT = resolve(__dirname, "..");
const SRC = join(ROOT, "src");
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) walk(full, out);
else out.push(full);
}
return out;
}
describe("dep-graph", () => {
it("every impl .ts has a sibling .md", { timeout: 5000 }, () => {
const missing: string[] = [];
for (const f of walk(SRC)) {
if (!f.endsWith(".ts")) continue;
if (f.endsWith(".test.ts")) continue;
if (f.endsWith(".spec-conformance.test.ts")) continue;
if (f.endsWith(".types.ts")) continue;
if (f.endsWith("/index.ts")) continue;
const md = f.replace(/\.ts$/, ".md");
if (!existsSync(md)) missing.push(relative(ROOT, md));
}
expect(missing).toEqual([]);
});
it("every subpath export resolves to a real source file", {
timeout: 5000,
}, () => {
const entries = pkg.exports as Record<string, unknown>;
const missing: string[] = [];
for (const [key, value] of Object.entries(entries)) {
if (key === "./package.json") continue;
const distPath =
typeof value === "object" && value !== null
? (
value as {
import?: { default?: string };
}
).import?.default
: (value as string);
if (typeof distPath !== "string") continue;
const srcPath = distPath
.replace(/^\.\/dist\//, "./src/")
.replace(/\.js$/, ".ts");
const abs = join(ROOT, srcPath);
if (!existsSync(abs)) missing.push(`${key} → ${srcPath}`);
}
expect(missing).toEqual([]);
});
it("runtime workspace dependencies are pinned", { timeout: 5000 }, () => {
const deps = Object.keys(pkg.dependencies ?? {}).sort();
expect(deps).toContain("@suaveplan/api-plugin");
expect(deps).toContain("@suaveplan/error");
expect(deps).toContain("@suaveplan/events");
expect(deps).toContain("@suaveplan/types");
const workspaceDeps = deps.filter((d) => d.startsWith("@suaveplan/"));
expect(workspaceDeps).toEqual([
"@suaveplan/api-plugin",
"@suaveplan/error",
"@suaveplan/events",
"@suaveplan/types",
]);
});
});

View File

@@ -0,0 +1,36 @@
/**
* Test setup for @suaveplan/api-plugin-cms.
*
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
* module imports so the meter resolved on first call binds to it. The
* exporter is held in module scope so `flushTestMetrics()` can return
* deterministic snapshots inside each test.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics(): Promise<
ReturnType<typeof testInMemoryMetricExporter.getMetrics>
> {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts", "tests/**/*.d.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,35 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-plugin-cms",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,33 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 60000,
hookTimeout: 120000,
},
});