Initial Commit
This commit is contained in:
97
packages/api-stack/api/api-plugin-cms/README.md
Normal file
97
packages/api-stack/api/api-plugin-cms/README.md
Normal 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
|
||||
|
||||
103
packages/api-stack/api/api-plugin-cms/package.json
Normal file
103
packages/api-stack/api/api-plugin-cms/package.json
Normal 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"]
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
117
packages/api-stack/api/api-plugin-cms/src/engine/cms-engine.md
Normal file
117
packages/api-stack/api/api-plugin-cms/src/engine/cms-engine.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
373
packages/api-stack/api/api-plugin-cms/src/engine/cms-engine.ts
Normal file
373
packages/api-stack/api/api-plugin-cms/src/engine/cms-engine.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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>`.
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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 } }
|
||||
);
|
||||
}
|
||||
83
packages/api-stack/api/api-plugin-cms/src/index.ts
Normal file
83
packages/api-stack/api/api-plugin-cms/src/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
83
packages/api-stack/api/api-plugin-cms/src/plugin/plugin.md
Normal file
83
packages/api-stack/api/api-plugin-cms/src/plugin/plugin.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
92
packages/api-stack/api/api-plugin-cms/src/plugin/plugin.ts
Normal file
92
packages/api-stack/api/api-plugin-cms/src/plugin/plugin.ts
Normal 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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
106
packages/api-stack/api/api-plugin-cms/src/routes/problem-json.ts
Normal file
106
packages/api-stack/api/api-plugin-cms/src/routes/problem-json.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
69
packages/api-stack/api/api-plugin-cms/src/schemas/options.md
Normal file
69
packages/api-stack/api/api-plugin-cms/src/schemas/options.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
26
packages/api-stack/api/api-plugin-cms/src/schemas/options.ts
Normal file
26
packages/api-stack/api/api-plugin-cms/src/schemas/options.ts
Normal 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>;
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
266
packages/api-stack/api/api-plugin-cms/src/types/cms.types.ts
Normal file
266
packages/api-stack/api/api-plugin-cms/src/types/cms.types.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-cms/tsconfig.json
Normal file
12
packages/api-stack/api/api-plugin-cms/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-cms/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-plugin-cms/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
35
packages/api-stack/api/api-plugin-cms/vite.config.ts
Normal file
35
packages/api-stack/api/api-plugin-cms/vite.config.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
33
packages/api-stack/api/api-plugin-cms/vitest.config.ts
Normal file
33
packages/api-stack/api/api-plugin-cms/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user