Files
suaveplan/openspec/archive/2026-05-19-add-api-plugin-cms/proposal.md
2026-05-27 23:29:53 +01:00

8.3 KiB
Raw Blame History

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.