Initial Commit
This commit is contained in:
131
openspec/archive/2026-05-19-add-api-plugin-cms/proposal.md
Normal file
131
openspec/archive/2026-05-19-add-api-plugin-cms/proposal.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Add @suaveplan/api-plugin-cms
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-cms`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
|
||||
## Why
|
||||
|
||||
Headless-CMS primitives for `@suaveplan/api` applications. The upstream
|
||||
package on main (`/mnt/s/web/suaveplan/packages/server/api-plugin-cms`)
|
||||
ships a `CmsEngine` that manages flexible `ContentModel` definitions,
|
||||
`ContentEntry` records with `draft → review → published → archived`
|
||||
status transitions guarded by `VALID_STATUS_TRANSITIONS`, versioned
|
||||
`ContentVersion` history, slug uniqueness, an RFC 7807 problem+json
|
||||
helper, and a `CmsError` hierarchy registered into the shared error
|
||||
metadata registry on module load. Genesis needs that surface ported at
|
||||
full functional parity so downstream `@suaveplan/api` apps can author
|
||||
content models, validate entries, and walk the status lifecycle without
|
||||
reaching for main.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `CmsEngine` — in-memory CMS core managing models, entries, and
|
||||
version history; emits eight typed events through `@suaveplan/events`.
|
||||
- ADDED: `CmsError`, `CMS_CODES`, `CmsErrorCode` — `SuaveplanError`
|
||||
subclass and seven stable error codes (`CONTENT_NOT_FOUND`,
|
||||
`MODEL_NOT_FOUND`, `INVALID_TRANSITION`, `VALIDATION_FAILED`,
|
||||
`DUPLICATE_SLUG`, `MODEL_IN_USE`, `INVALID_INPUT`). The first six are
|
||||
verbatim upstream; `INVALID_INPUT` is raised by the new
|
||||
`createCmsPlugin` factory when Zod boundary validation fails (genesis
|
||||
convention, mirrors `api-plugin-audit` / `api-plugin-database`).
|
||||
- ADDED: `contentNotFound`, `modelNotFound`, `invalidStatusTransition`,
|
||||
`validationFailed`, `duplicateSlug`, `modelInUse` — error-factory
|
||||
helpers.
|
||||
- ADDED: `respondProblemJson`, `formatProblemJson`,
|
||||
`PROBLEM_JSON_CONTENT_TYPE`, `HttpResponseLike`, `ProblemDetails` —
|
||||
RFC 7807 problem+json rendering.
|
||||
- ADDED: `ContentStatus`, `ContentType`, `FieldType`, `ContentField`,
|
||||
`FieldValidation`, `ContentModel`, `ContentEntry`, `ContentVersion`,
|
||||
`CreateContentInput`, `UpdateContentInput`, `ContentQuery`,
|
||||
`CmsEventMap` — content-model types.
|
||||
- ADDED: `generateSlug`, `validateContentData`,
|
||||
`VALID_STATUS_TRANSITIONS` — utility surface.
|
||||
- ADDED: `createCmsPlugin` — `definePlugin()` factory that registers
|
||||
the constructed `CmsEngine` as the `cms` slot on the host
|
||||
`ServiceRegistry`, matching the genesis api-plugin convention.
|
||||
- ADDED: `CmsPluginOptionsSchema`, `CmsPluginOptions`, `CmsPluginConfig`
|
||||
— Zod-validated plugin options bundle (injectable
|
||||
`Clock` / `IdGenerator`).
|
||||
- ADDED: `CmsInstruments`, `getCmsInstruments`, `resetCmsInstruments` —
|
||||
OTel meter bundle (content created / updated / published / archived /
|
||||
deleted counters; model created counter; validation-failure counter).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- SurrealDB storage adapter — upstream is in-memory only; matched.
|
||||
- HTTP route mounting on `app` — upstream ships `respondProblemJson` but
|
||||
no actual routes; matched.
|
||||
- Auth/role gating, audit-log integration, webhook/preview tokens,
|
||||
rich-text/media handling, full-text search hook — every item listed
|
||||
under §4 of `enterprise/api-plugin-cms.md` is an aspirational future
|
||||
enhancement, not in the upstream baseline.
|
||||
- Counter-based ID replacement with ULID — kept as an injectable
|
||||
`IdGenerator` so callers wanting ULID supply it; default mirrors
|
||||
upstream `cms_<n>_<ts>` for byte-identical parity.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from
|
||||
`../suaveplan/enterprise/api-plugin-cms.md` and the upstream source at
|
||||
`../suaveplan/packages/server/api-plugin-cms/`.
|
||||
|
||||
| Requirement | Source lines | Notes |
|
||||
|---|---|---|
|
||||
| `CmsEngine` lifecycle (`createModel`, `getModel`, `listModels`, `deleteModel`) | upstream `src/routes/cms-engine.ts` L38–L70 | verbatim port — same method bodies, adapted to `EventBus<CmsEventMap>` instead of upstream `EventEmitter` |
|
||||
| Content entry CRUD (`createContent`, `getContent`, `queryContent`, `updateContent`, `deleteContent`) | upstream `src/routes/cms-engine.ts` L71–L210 | verbatim port — query filtering / sorting / paging preserved |
|
||||
| Status-transition guard via `VALID_STATUS_TRANSITIONS` | upstream `src/routes/cms-engine.ts` L179–L202 + `src/types/cms.types.ts` L134–L142 | verbatim |
|
||||
| Versioned history (`getVersions`, `saveVersion`) | upstream `src/routes/cms-engine.ts` L213–L230 | verbatim |
|
||||
| `CmsError` + six factory helpers | upstream `src/errors/cms-errors.ts` L1–L71 | adapted from `BaseError<Code, Context>` to genesis `SuaveplanError<Code>` (genesis has no second-generic context — context lives on `options.context`) |
|
||||
| Stable code registry (`CMS_CODES`) | upstream `src/errors/error-codes.ts` L20–L74 | adapted from upstream `ErrorCodes.registerMany(metadata)` (no equivalent in genesis `@suaveplan/error`) to `defineCodes("CMS", [...])`; HTTP-status metadata folded into the problem-json renderer (mirrors `api-plugin-database`) |
|
||||
| Side-effect import of code registry on barrel load | upstream `src/index.ts` L3 | matched — `src/index.ts` side-effect-imports `./error-codes/error-codes.js` |
|
||||
| `respondProblemJson`, `PROBLEM_JSON_CONTENT_TYPE` | upstream `src/routes/problem-json.ts` L1–L47 | adapted — genesis `@suaveplan/error` does not yet ship `HttpError` / `formatErrorAsProblemJson` / `ProblemDetails`, so a local `formatProblemJson` mirrors `api-plugin-database`'s pattern |
|
||||
| `ContentStatus`, `ContentType`, `FieldType`, `ContentField`, `FieldValidation`, `ContentModel`, `ContentEntry`, `ContentVersion`, `CreateContentInput`, `UpdateContentInput`, `ContentQuery`, `CmsEventMap` | upstream `src/types/cms.types.ts` L1–L131 | verbatim |
|
||||
| `generateSlug` | upstream `src/types/cms.types.ts` L145–L152 | verbatim |
|
||||
| `validateContentData` + private `validateField` | upstream `src/types/cms.types.ts` L155–L230 | verbatim |
|
||||
| `VALID_STATUS_TRANSITIONS` | upstream `src/types/cms.types.ts` L134–L142 | verbatim |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| `createCmsPlugin` factory + `cms` ServiceRegistry slot | Every other genesis `api-plugin-*` ships a `definePlugin()` factory wiring its primary service into the registry. Matching the convention costs <50 lines and aligns with `enterprise/api-plugin-cms.md` §3 "Clarify identity → wrap in `definePlugin()`". |
|
||||
| `CmsPluginOptionsSchema` (Zod) | Genesis-wide policy: every public option bundle is Zod-validated at the boundary (CLAUDE.md). |
|
||||
| `Clock` / `IdGenerator` injection | Genesis-wide policy: no `new Date()` in hot paths, no non-deterministic ID generation in tests (CLAUDE.md "Code Organization"). Default factory mirrors upstream behavior byte-for-byte. |
|
||||
| OTel instrument bundle | Genesis-wide policy: every plugin emits OTel metrics from its production hot path and asserts them with an in-memory `MeterProvider`. |
|
||||
| 100% test coverage | Genesis-wide policy, not package-specific. |
|
||||
|
||||
## Verification approach
|
||||
|
||||
Every requirement in the spec delta is pinned by concrete `it(...)`
|
||||
titles. Test files that will exist after this change ships:
|
||||
|
||||
- `src/engine/cms-engine.test.ts` — covers model + content CRUD,
|
||||
query filtering / sorting / paging, status transitions, version
|
||||
history, event emission, and every error-throwing path.
|
||||
- `src/engine/cms-engine.spec-conformance.test.ts` — pins type-level
|
||||
claims (`expectTypeOf` on `CmsEngine`'s event map), determinism with
|
||||
injected `Clock` / `IdGenerator`, and the full `CMS_CODES` enumeration.
|
||||
- `src/error-codes/error-codes.test.ts` — every factory raises the
|
||||
expected code; `cause` chain is preserved; `instanceof CmsError`
|
||||
narrows.
|
||||
- `src/routes/problem-json.test.ts` — covers `CmsError` → 4xx/409
|
||||
mapping, generic `SuaveplanError` → 500, non-error throwables → 500.
|
||||
- `src/schemas/options.test.ts` — Zod boundary accept/reject,
|
||||
default-fill behavior.
|
||||
- `src/plugin/plugin.test.ts` — factory returns valid `PluginDescriptor`,
|
||||
`onInit` registers `cms` in the `ServiceRegistry`, `onClose` clears it,
|
||||
invalid options raise `CMS_CODES.INVALID_INPUT`.
|
||||
- `src/otel/instruments.test.ts` — instruments are emitted from the
|
||||
engine and observed via in-memory `MeterProvider`.
|
||||
- `src/types/cms.types.test.ts` — `generateSlug`, `validateContentData`,
|
||||
`VALID_STATUS_TRANSITIONS` shape.
|
||||
- `src/api-plugin-cms.spec-conformance.test.ts` — every `CMS.*` code is
|
||||
thrown from at least one production path; barrel side-effect-imports
|
||||
the code registry.
|
||||
- `tests/dep-graph.test.ts` — pins the eight runtime workspace deps and
|
||||
forbids drift.
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only. New package; no existing consumers.
|
||||
@@ -0,0 +1,200 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-plugin-cms (add-api-plugin-cms)
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-cms`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. (See `openspec/AGENTS.md` Section 13.)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Content-model lifecycle
|
||||
|
||||
The package SHALL expose a `CmsEngine` class that manages `ContentModel` records in an in-memory `Map`, supporting `createModel`, `getModel`, `listModels`, and `deleteModel`. Deleting a model with existing content entries SHALL raise `CmsError(CMS.MODEL_IN_USE)`; looking up an unknown model SHALL raise `CmsError(CMS.MODEL_NOT_FOUND)`. Successful `createModel` SHALL emit a `model.created` event on the engine's bound `EventBus<CmsEventMap>`.
|
||||
|
||||
#### Scenario: createModel registers and emits
|
||||
|
||||
- **WHEN** the caller invokes `engine.createModel({ name, slug, contentType, fields })`
|
||||
- **THEN** the returned `ContentModel` has `id`, `createdAt`, and `updatedAt` populated and the `model.created` event is emitted to every listener
|
||||
|
||||
#### Scenario: deleteModel rejects models with entries
|
||||
|
||||
- **WHEN** the caller invokes `engine.deleteModel(id)` for a model that has at least one `ContentEntry`
|
||||
- **THEN** `CmsError(CMS.MODEL_IN_USE)` is thrown and the model remains in `listModels()`
|
||||
|
||||
#### Scenario: getModel rejects unknown ids
|
||||
|
||||
- **WHEN** the caller invokes `engine.getModel("unknown")`
|
||||
- **THEN** `CmsError(CMS.MODEL_NOT_FOUND)` is thrown with `context.modelId === "unknown"`
|
||||
|
||||
**Verified by:** `src/engine/cms-engine.test.ts::"createModel registers and emits model.created"` AND `src/engine/cms-engine.test.ts::"deleteModel rejects when content entries exist"` AND `src/engine/cms-engine.test.ts::"getModel throws MODEL_NOT_FOUND for unknown id"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Content-entry CRUD and querying
|
||||
|
||||
The package SHALL expose `createContent`, `getContent`, `queryContent`, `updateContent`, and `deleteContent` on `CmsEngine`. `createContent` SHALL run `validateContentData` against the parent model and raise `CmsError(CMS.VALIDATION_FAILED)` on any error. Slug uniqueness within the same `modelId` SHALL be enforced — collisions raise `CmsError(CMS.DUPLICATE_SLUG)`. `queryContent` SHALL filter by `modelId`, `status`, `locale`, and case-insensitive `search` over `title`/`slug`; sort by an arbitrary field with `asc`/`desc`; and slice by `offset`/`limit`.
|
||||
|
||||
#### Scenario: createContent validates and persists
|
||||
|
||||
- **WHEN** the caller invokes `engine.createContent({ modelId, title, data, ... })` with data passing model validation
|
||||
- **THEN** a `ContentEntry` is returned with `status: "draft"`, `version: 1`, generated `slug` when omitted, and the `content.created` event is emitted
|
||||
|
||||
#### Scenario: createContent rejects invalid data
|
||||
|
||||
- **WHEN** the caller invokes `engine.createContent(...)` with data violating a `required`/`minLength`/`pattern` rule on the model
|
||||
- **THEN** `CmsError(CMS.VALIDATION_FAILED)` is thrown carrying the joined error list in `message`
|
||||
|
||||
#### Scenario: queryContent supports the documented filter/sort/page surface
|
||||
|
||||
- **WHEN** the caller invokes `engine.queryContent({ modelId, status, locale, search, sortBy, sortOrder, offset, limit })`
|
||||
- **THEN** results reflect every filter, are sorted, and are paged in the same order as the upstream implementation
|
||||
|
||||
#### Scenario: updateContent honours slug uniqueness
|
||||
|
||||
- **WHEN** the caller invokes `engine.updateContent(id, { slug: someOtherEntrysSlug })` within the same `modelId`
|
||||
- **THEN** `CmsError(CMS.DUPLICATE_SLUG)` is thrown and the existing entry remains unchanged
|
||||
|
||||
**Verified by:** `src/engine/cms-engine.test.ts::"createContent persists and emits content.created"` AND `src/engine/cms-engine.test.ts::"createContent rejects data failing model validation"` AND `src/engine/cms-engine.test.ts::"queryContent applies modelId/status/locale/search/sort/page filters"` AND `src/engine/cms-engine.test.ts::"updateContent rejects duplicate slugs within the same model"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Status-transition guard
|
||||
|
||||
The package SHALL expose `VALID_STATUS_TRANSITIONS` (a `Record<ContentStatus, readonly ContentStatus[]>`) and a `CmsEngine.transitionStatus(id, target)` method that consults the table; disallowed transitions SHALL raise `CmsError(CMS.INVALID_TRANSITION)`. On transition the entry's `version` SHALL increment and the matching event (`content.published` / `content.drafted` / `content.archived` / fallback `content.updated`) SHALL be emitted. Transitions to `published` SHALL stamp `publishedAt` with the current clock reading.
|
||||
|
||||
#### Scenario: legal transition advances state
|
||||
|
||||
- **WHEN** the caller invokes `engine.transitionStatus(id, "published")` on a `draft` entry
|
||||
- **THEN** the entry's `status` is now `"published"`, `version` is incremented, `publishedAt` is set, and `content.published` is emitted
|
||||
|
||||
#### Scenario: illegal transition raises
|
||||
|
||||
- **WHEN** the caller invokes `engine.transitionStatus(id, "published")` on an `archived` entry (not in `VALID_STATUS_TRANSITIONS["archived"]`)
|
||||
- **THEN** `CmsError(CMS.INVALID_TRANSITION)` is thrown and the entry is unchanged
|
||||
|
||||
**Verified by:** `src/engine/cms-engine.test.ts::"transitionStatus advances draft to published and emits content.published"` AND `src/engine/cms-engine.test.ts::"transitionStatus rejects an illegal target"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Versioned history
|
||||
|
||||
The package SHALL retain a `ContentVersion[]` per entry, appended on every `createContent`, `updateContent`, and `transitionStatus` call. `engine.getVersions(entryId)` SHALL return the full history; deleting the entry SHALL drop its versions.
|
||||
|
||||
#### Scenario: versions accumulate
|
||||
|
||||
- **WHEN** the caller creates an entry then runs two updates and one transition
|
||||
- **THEN** `engine.getVersions(id)` returns four versions with monotonically increasing `version` fields
|
||||
|
||||
**Verified by:** `src/engine/cms-engine.test.ts::"getVersions returns one entry per mutation"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Error-code registry
|
||||
|
||||
The package SHALL define exactly six stable error codes via `defineCodes("CMS", [...])`: `CONTENT_NOT_FOUND`, `MODEL_NOT_FOUND`, `INVALID_TRANSITION`, `VALIDATION_FAILED`, `DUPLICATE_SLUG`, `MODEL_IN_USE`. Each code SHALL be raised by at least one production code path. The `CmsError` class SHALL extend `SuaveplanError<CmsErrorCode>` and expose a `cmsCode` readonly mirror for `instanceof` narrowing.
|
||||
|
||||
#### Scenario: every CMS.* code is raised from a production path
|
||||
|
||||
- **WHEN** the spec-conformance audit scans `src/engine/`, `src/error-codes/`, and `src/routes/`
|
||||
- **THEN** every value of `CMS_CODES` appears in at least one `new CmsError(...)` or `CMS_CODES.<NAME>` call site
|
||||
|
||||
#### Scenario: CmsError preserves cause chain
|
||||
|
||||
- **WHEN** a factory helper is invoked with a `cause`
|
||||
- **THEN** the resulting `CmsError`'s `.cause` references the supplied error and `instanceof CmsError` narrows `.code` to `CmsErrorCode`
|
||||
|
||||
**Verified by:** `src/api-plugin-cms.spec-conformance.test.ts::"every CMS.* code is thrown from a production path"` AND `src/error-codes/error-codes.test.ts::"every factory raises the matching CMS_CODES.* value"` AND `src/error-codes/error-codes.test.ts::"CmsError preserves cause chain and narrows code"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RFC 7807 problem+json rendering
|
||||
|
||||
The package SHALL export `respondProblemJson(res, err)` and `formatProblemJson(err)` plus the `PROBLEM_JSON_CONTENT_TYPE` constant. `CmsError` instances SHALL map to specific HTTP statuses (404 for `CONTENT_NOT_FOUND`/`MODEL_NOT_FOUND`; 409 for `INVALID_TRANSITION`/`DUPLICATE_SLUG`/`MODEL_IN_USE`; 400 for `VALIDATION_FAILED`). Generic `SuaveplanError` instances SHALL fall through to 500; non-error throwables SHALL render `{ type: "about:blank", status: 500, title: "Internal Server Error" }`.
|
||||
|
||||
#### Scenario: CmsError renders the documented status
|
||||
|
||||
- **WHEN** the caller invokes `formatProblemJson(contentNotFound("x"))`
|
||||
- **THEN** the returned body has `status: 404`, `code: "CMS.CONTENT_NOT_FOUND"`, and `type: "urn:suaveplan:error:CMS.CONTENT_NOT_FOUND"`
|
||||
|
||||
#### Scenario: respondProblemJson writes header + body to the response
|
||||
|
||||
- **WHEN** the caller invokes `respondProblemJson(res, modelInUse("m1"))` against a `HttpResponseLike` double
|
||||
- **THEN** the content-type header is `application/problem+json`, the body is the JSON-stringified `ProblemDetails`, and the status is 409
|
||||
|
||||
**Verified by:** `src/routes/problem-json.test.ts::"formatProblemJson maps every CmsError code to the documented HTTP status"` AND `src/routes/problem-json.test.ts::"respondProblemJson sets header and body on the response"` AND `src/routes/problem-json.test.ts::"formatProblemJson falls back to 500 for generic SuaveplanError and unknown throwables"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Content-model types, slug utility, and validator
|
||||
|
||||
The package SHALL export the full type surface (`ContentStatus`, `ContentType`, `FieldType`, `ContentField`, `FieldValidation`, `ContentModel`, `ContentEntry`, `ContentVersion`, `CreateContentInput`, `UpdateContentInput`, `ContentQuery`, `CmsEventMap`). It SHALL export `generateSlug` (lowercases, strips non-alphanumerics, collapses whitespace and hyphens) and `validateContentData` (runs `required` plus per-field `minLength`/`maxLength`/`min`/`max`/`pattern`/`custom` rules and returns a `string[]` of error messages).
|
||||
|
||||
#### Scenario: generateSlug normalises arbitrary titles
|
||||
|
||||
- **WHEN** the caller invokes `generateSlug(" Hello, World! ")`
|
||||
- **THEN** the result is `"hello-world"`
|
||||
|
||||
#### Scenario: validateContentData reports every failing rule
|
||||
|
||||
- **WHEN** the caller invokes `validateContentData(model, data)` on data that violates two rules
|
||||
- **THEN** the result is a 2-length array of human-readable messages
|
||||
|
||||
**Verified by:** `src/types/cms.types.test.ts::"generateSlug normalises whitespace, punctuation, and case"` AND `src/types/cms.types.test.ts::"validateContentData enforces required + every FieldValidation type"` AND `src/types/cms.types.test.ts::"VALID_STATUS_TRANSITIONS lists every legal edge"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Plugin factory and ServiceRegistry slot
|
||||
|
||||
The package SHALL export `createCmsPlugin(options)` that returns a `PluginDescriptor<CmsPluginConfig>` consumable by `@suaveplan/api-plugin`'s `PluginRegistry`. Options SHALL be Zod-validated; invalid input raises `CmsError(CMS.INVALID_INPUT)` carrying the `ZodError`. `onInit` SHALL construct a `CmsEngine` using the supplied `Clock`/`IdGenerator` (or genesis defaults) and register it under the `cms` slot of the host `ServiceRegistry`. `onClose` SHALL drop the engine reference.
|
||||
|
||||
#### Scenario: createCmsPlugin returns a usable descriptor
|
||||
|
||||
- **WHEN** the caller invokes `createCmsPlugin({})`
|
||||
- **THEN** the returned descriptor has `name === "cms"`, `version === "0.1.0"`, and exposes `onInit` / `onClose` functions
|
||||
|
||||
#### Scenario: invalid options raise CMS.INVALID_INPUT
|
||||
|
||||
- **WHEN** the caller invokes `createCmsPlugin({ defaultStatus: 42 } as never)`
|
||||
- **THEN** `CmsError(CMS.INVALID_INPUT)` is thrown with the `ZodError` attached as `cause`
|
||||
|
||||
#### Scenario: onInit registers the engine
|
||||
|
||||
- **WHEN** the registry executes the plugin's `onInit` against a `PluginContext`
|
||||
- **THEN** `ctx.getService("cms")` returns a `CmsEngine` instance
|
||||
|
||||
**Verified by:** `src/plugin/plugin.test.ts::"createCmsPlugin returns a valid PluginDescriptor"` AND `src/plugin/plugin.test.ts::"invalid options raise CMS.INVALID_INPUT"` AND `src/plugin/plugin.test.ts::"onInit registers the CmsEngine under the cms slot"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OpenTelemetry instruments
|
||||
|
||||
The package SHALL expose a `CmsInstruments` bundle resolved lazily via `getCmsInstruments()` exposing seven counters (`cms.models_created_total`, `cms.content_created_total`, `cms.content_updated_total`, `cms.content_published_total`, `cms.content_archived_total`, `cms.content_deleted_total`, `cms.validation_failures_total`). Every counter SHALL be incremented from the engine method that produces the corresponding event. A `resetCmsInstruments()` test helper SHALL be exported.
|
||||
|
||||
#### Scenario: counters fire on engine mutations
|
||||
|
||||
- **WHEN** the test installs an in-memory `MeterProvider`, calls `resetCmsInstruments()`, creates a model + entry, publishes the entry, and flushes
|
||||
- **THEN** the exported metrics include `cms.models_created_total == 1`, `cms.content_created_total == 1`, and `cms.content_published_total == 1`
|
||||
|
||||
**Verified by:** `src/otel/instruments.test.ts::"counters fire on engine mutations and are observable via in-memory MeterProvider"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Barrel side-effect registration and dependency invariants
|
||||
|
||||
The package barrel (`src/index.ts`) SHALL side-effect-import `./error-codes/error-codes.js` and `./services/registry-augmentation.js` so consumers of `import "@suaveplan/api-plugin-cms"` see both the `CMS_CODES` namespace and the `cms` `ServiceRegistry` slot without further wiring. The package SHALL declare exactly the runtime workspace dependencies enumerated in `package.json` and nothing more.
|
||||
|
||||
#### Scenario: barrel side-effects fire on load
|
||||
|
||||
- **WHEN** any consumer imports `@suaveplan/api-plugin-cms`
|
||||
- **THEN** `CMS_CODES.CONTENT_NOT_FOUND === "CMS.CONTENT_NOT_FOUND"` is observable without an additional import of `./error-codes/error-codes.js`
|
||||
|
||||
#### Scenario: dependency set is pinned
|
||||
|
||||
- **WHEN** the dep-graph test runs against `package.json`
|
||||
- **THEN** the runtime workspace deps are exactly `@suaveplan/api-plugin`, `@suaveplan/error`, `@suaveplan/events`, `@suaveplan/types`
|
||||
|
||||
**Verified by:** `src/api-plugin-cms.spec-conformance.test.ts::"barrel side-effect imports the error-codes registry"` AND `tests/dep-graph.test.ts::"runtime workspace dependencies are pinned"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
71
openspec/archive/2026-05-19-add-api-plugin-cms/tasks.md
Normal file
71
openspec/archive/2026-05-19-add-api-plugin-cms/tasks.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# Tasks — Add @suaveplan/api-plugin-cms
|
||||
|
||||
> Every checkbox MUST reflect work actually done in this change. Do not retroactively tick boxes for work that was already in main when this change opened. Do not tick a box for a test that doesn't exist or doesn't pin the requirement it claims to verify. Retroactive ticking is the failure mode this template guards against.
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/api-plugin-cms.md` end-to-end
|
||||
- [x] 1.2 Read `openspec/specs/` for prior art (api-plugin-database, api-plugin-audit) — no existing `api-plugin-cms/spec.md`
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.5 Fill in `## Source mapping` table — every requirement cites a line range OR appears in the "no source line" table with justification
|
||||
- [x] 1.6 Draft `specs/api-plugin-cms/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 `src/types/cms.types.ts` — verbatim port of upstream content-model types, slug + validator utilities, status transitions, event map (adapted to `EventBus<EventMap>` single-payload style)
|
||||
- [x] 2.2 `src/error-codes/error-codes.ts` — `defineCodes("CMS", [...])` + `CmsError extends SuaveplanError<CmsErrorCode>` + six factory helpers
|
||||
- [x] 2.3 `src/engine/cms-engine.ts` — port `CmsEngine` (model CRUD, content CRUD, query, transitions, versions); compose with `EventBus<CmsEventMap>`; inject `Clock` + `IdGenerator`; emit OTel counters
|
||||
- [x] 2.4 `src/routes/problem-json.ts` — local `ProblemDetails` + `formatProblemJson` + `respondProblemJson` with full `CMS_CODES` → HTTP status table
|
||||
- [x] 2.5 `src/schemas/options.ts` — Zod `CmsPluginOptionsSchema` with `Clock` / `IdGenerator` accepted as opaque `unknown` (genesis convention from audit plugin)
|
||||
- [x] 2.6 `src/otel/instruments.ts` — `CmsInstruments` bundle with counters for the seven engine events + validation failures
|
||||
- [x] 2.7 `src/services/registry-augmentation.ts` — `declare module "@suaveplan/api-plugin/service-registry"` adding the `cms: CmsEngine` slot
|
||||
- [x] 2.8 `src/plugin/plugin.ts` — `createCmsPlugin(options)` factory returning a `PluginDescriptor`; `onInit` constructs `CmsEngine`, registers it; `onClose` drops the reference
|
||||
- [x] 2.9 `src/index.ts` — barrel with explicit named re-exports + side-effect imports of code registry and registry augmentation
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 `src/types/cms.types.test.ts` — `generateSlug`, `validateContentData`, `VALID_STATUS_TRANSITIONS`
|
||||
- [x] 3.2 `src/error-codes/error-codes.test.ts` — every factory raises expected code; cause chain; `instanceof` narrowing
|
||||
- [x] 3.3 `src/engine/cms-engine.test.ts` — model + content CRUD, query, transitions, versions, events
|
||||
- [x] 3.4 `src/engine/cms-engine.spec-conformance.test.ts` — determinism with injected Clock/IdGenerator; full code enumeration
|
||||
- [x] 3.5 `src/routes/problem-json.test.ts` — CmsError mapping, generic SuaveplanError, non-error throwables
|
||||
- [x] 3.6 `src/schemas/options.test.ts` — Zod boundary
|
||||
- [x] 3.7 `src/plugin/plugin.test.ts` — descriptor shape, onInit/onClose
|
||||
- [x] 3.8 `src/otel/instruments.test.ts` — counter emission asserted with in-memory `MeterProvider`
|
||||
- [x] 3.9 `src/api-plugin-cms.spec-conformance.test.ts` — every code thrown from production source, side-effect barrel imports
|
||||
- [x] 3.10 `tests/dep-graph.test.ts` — pins runtime dep set
|
||||
- [x] 3.11 Every `it(...)` carries `{ timeout: 5000 }`
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference)
|
||||
- [x] 4.2 README at package root linking every co-located `.md`
|
||||
- [x] 4.3 CLAUDE.md package listing — covered by the api-plugin family entry already present (no edit needed for additive plugin)
|
||||
|
||||
## 5. Pre-archive verification (the 5-gate triple)
|
||||
|
||||
- [x] 5.1 `bun run check` — Biome 0/0/0
|
||||
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-cms` — green
|
||||
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-plugin-cms` — 100/100/100/100
|
||||
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-cms` — exit 0, no `error` findings
|
||||
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-plugin-cms` — green
|
||||
|
||||
## 6. Pre-archive 9-row checklist (skill §7)
|
||||
|
||||
- [x] 6.1 Generics threaded; `expectTypeOf` on every literal-type claim (event-map keys)
|
||||
- [x] 6.2 Zod schemas wired at every declared boundary (`createCmsPlugin`)
|
||||
- [x] 6.3 Every error code in `error-codes.ts` thrown by ≥1 production path
|
||||
- [x] 6.4 Strict-mode behaviors tested both on and off — N/A; no strict-mode flag in this package
|
||||
- [x] 6.5 OTel instruments emitted from production paths and asserted with real `MeterProvider`
|
||||
- [x] 6.6 Lifecycle dedup tested where claimed — N/A; engine state is per-instance, no in-flight Promise dedup
|
||||
- [x] 6.7 No retroactive tick — every box reflects work done in this change
|
||||
- [x] 6.8 No `export *` in any feature-module barrel (no feature-module barrels; flat module per dir)
|
||||
- [x] 6.9 No direct `vitest` / `bun:test` imports — every test imports `@suaveplan/testing/runner`
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [x] 7.1 Fold spec delta into `openspec/specs/api-plugin-cms/spec.md`
|
||||
- [x] 7.2 Move `openspec/changes/add-api-plugin-cms/` → `openspec/archive/2026-05-19-add-api-plugin-cms/`
|
||||
- [x] 7.3 Refresh `MANIFEST.md` — skipped per session protocol (concurrent sessions own MANIFEST.md)
|
||||
Reference in New Issue
Block a user