diff --git a/.changeset/document-index-entry.md b/.changeset/document-index-entry.md new file mode 100644 index 0000000..d818ff0 --- /dev/null +++ b/.changeset/document-index-entry.md @@ -0,0 +1,38 @@ +--- +"@contentrain/query": major +--- + +feat(sdk)!: CDN `document().all()` returns index entries, so reading `.body` no longer compiles + +On the CDN, `document(model).all()` reads the model's `_index`, which carries +frontmatter only — the body lives in the per-slug document and is reachable via +`bySlug()`. But `all()` was typed `Promise`, and the generated document type +declares `body: string`. So `entry.body` type-checked, returned `undefined` at +runtime, and pages rendered their headings and dropped their prose — with no +error, no warning, and a passing typecheck. + +The trap is that the bundled runtime's `all()` *does* carry bodies. Code written +and tested against bundled delivery compiled and passed, then quietly rendered +empty on CDN. The two modes genuinely differ in shape, so they no longer share +one return type. + +BREAKING: `CdnDocumentQuery.all()` now returns `DocumentIndexEntry[]` +(`Omit`), and `first()` returns `DocumentIndexEntry | undefined`. +Reading `.body` off either is now a compile error. Fetch the body with +`bySlug(slug)`, which returns `{ frontmatter, body, html }`. Runtime behaviour is +unchanged — this only makes the types tell the truth about what was already +being returned. + +```ts +// before — compiled, rendered nothing +const sections = await client.document('guide-sections').all() +sections.map(s => renderMarkdown(s.body)) // undefined at runtime + +// after — the first line is a compile error; fetch bodies explicitly +const index = await client.document('guide-sections').all() +const full = await Promise.all(index.map(s => q.bySlug(s.slug))) +full.map(d => renderMarkdown(d!.body)) +``` + +Type-level contracts are now asserted in `*.test-d.ts` via `vitest --typecheck`, +since `tsconfig.json` only covers `src` and never type-checked the tests. diff --git a/.changeset/publish-status-fixes.md b/.changeset/publish-status-fixes.md new file mode 100644 index 0000000..6e97a61 --- /dev/null +++ b/.changeset/publish-status-fixes.md @@ -0,0 +1,61 @@ +--- +"@contentrain/mcp": minor +--- + +fix(mcp): stop content_save unpublishing entries, and make bulk update_status persist every id + +Four publish-status bugs, all found on a live project against the CDN. Each one +reported success while content quietly stopped being delivered. + +**`contentrain_content_save` no longer resets an entry's status.** It rebuilt +meta from scratch on every write, so editing one field silently moved a +`published` entry to `draft` — and the next CDN build served the collection as +`{}`. Editing a field is not a publish decision, and per this repo's own split +(MCP is deterministic infra; the agent is intelligence) MCP should never have +been making it. An existing entry now keeps its `status`, `approved_by` and +`version`; only a genuinely new entry starts at `draft`. `source`/`updated_by` +still describe the current write. The same reset lived in a second copy behind +`contentrain_apply` and scaffolding — both now share one `mergeEntryMeta`. + +**`contentrain_bulk update_status` no longer drops entries.** It launched one +`writeMeta` per entry ID through `Promise.all`, and every call read the same +snapshot of the shared `{locale}.json` and rewrote the whole file — so N-1 +updates were lost while the response reported all N as updated. It is now a +single read-modify-write per locale file, and `updated` counts what actually +persisted. `copy_locale` had the identical race and wrote 1 meta record instead +of N. Neither had any test coverage; `bulk` now has a suite. + +**`update_status` works on singletons and dictionaries.** The `entry_ids` guard +ran before the model-kind guard, so a singleton had no reachable path: omitting +`entry_ids` failed with "requires entry_ids", supplying them failed with "only +supported for collection models". Call it without `entry_ids` for these kinds. +It also takes an optional `locale` now, instead of always rewriting every +supported locale. + +**Non-i18n models keep exactly one meta record.** Content collapses to a single +`data.json` while meta was still derived from the caller's locale, so one +content file could end up with `meta/{id}/tr.json` *and* `meta/{id}/en.json` +and readers disagreed about which was authoritative. `metaFilePath` now takes +`i18n` and the default locale and pins the record there. This also fixes +non-i18n collection deletes, which looked for `meta/{id}/data.json` — a file +that never existed — and orphaned the meta entry. + +**`contentrain_doctor`'s SDK freshness check works again.** It compared +directory mtimes, but `generate` rewrites the client files in place (which never +moves the directory's mtime) while a selective sync recreates model files via +`git checkout` (which does). Once you had saved a model, it reported "Stale" +permanently. It now compares the newest file mtime under each directory. + +**`contentrain_validate` gained two checks** for the class of failure above, +since it reported 0 errors throughout: a notice for drafts sitting alongside +published entries in one collection, and a warning for a non-i18n model whose +meta layout disagrees with its content layout. Neither is auto-fixed — +publishing is a content decision. + +MIGRATION — read before upgrading Studio. Projects that ran an affected version +have singletons and entries sitting at `draft` that were never meant to be. That +is currently harmless, because the CDN publishes singletons and dictionaries +regardless of status. When Studio starts enforcing status for those kinds, that +content will disappear from the CDN. Upgrade here first, run +`contentrain_validate` to find the drift, restore it with `contentrain_bulk +update_status`, and only then take the Studio change. diff --git a/CLAUDE.md b/CLAUDE.md index 4a197a5..46d5da2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,7 +106,7 @@ When working with Contentrain content operations (models, content, normalize, va npx oxlint packages//src/ packages//tests/ ``` - `sort()` → `toSorted()` (immutable) -- `no-await-in-loop` → use `Promise.all()` for parallel I/O +- `no-await-in-loop` → use `Promise.all()` for parallel I/O — **but only for reads, or for writes to distinct paths.** Never parallelize a read-modify-write over shared state: each call reads the same snapshot and rewrites the whole file, so all but the last-settling write are silently lost. A sequential `await` loop is correct there; accumulate in memory and write once (see `writeMetaEntries`). This rule applied blindly to `.contentrain/meta/{locale}.json` is what made `contentrain_bulk update_status` persist 1 of N entries while reporting success. - No unused imports/variables - Fix ALL warnings, not just errors diff --git a/docs/packages/mcp.md b/docs/packages/mcp.md index 9b2de1e..b7ca8fa 100644 --- a/docs/packages/mcp.md +++ b/docs/packages/mcp.md @@ -124,6 +124,29 @@ The MCP server exposes **24 tools** — 19 core + 5 media — organized by funct | `contentrain_apply` | Extract or reuse | Two-phase normalize: extract content or patch source files | | `contentrain_bulk` | Batch operations | Bulk locale copy, status updates, and deletes | +### Publish status + +Entry status lives in `.contentrain/meta/`, not in content, and it decides CDN +delivery: a collection entry is served only when its status is `published`. + +- **`contentrain_content_save` never changes status.** Editing a field is not a + publish decision — an existing entry keeps its `status`, `approved_by`, and + `version`. Only a brand-new entry starts at `draft`. +- **`contentrain_bulk update_status` is the only way to publish.** Pass + `entry_ids` for collections; omit them for singletons and dictionaries, which + have one meta record per locale. Pass `locale` to scope the change, or every + supported locale is rewritten. +- **`contentrain_validate` flags publish-state drift** — drafts sitting beside + published entries in one collection — as a notice. It never auto-fixes it: + publishing is a content decision, and MCP does not make those. + +::: tip Non-i18n models +A model with `i18n: false` keeps all content in one `data.json`, so it has +exactly one meta record — at the **default locale**, never `data.json`. Saving +under a different locale does not move it. `contentrain_validate` warns when +stray per-locale meta files exist from older versions. +::: + ### Media Tools (Provider Media Facet) A deterministic passthrough to the provider's optional media stack (`RepoProvider.media`) — registered **only when the provider exposes one** (e.g. Studio MCP Cloud). The flow: list assets → pick a `media/...` path → reference it via `contentrain_content_save` (normalized to absolute delivery URLs when `mediaBaseUrl` is set). diff --git a/docs/packages/sdk.md b/docs/packages/sdk.md index 4bcbd3d..0e77199 100644 --- a/docs/packages/sdk.md +++ b/docs/packages/sdk.md @@ -425,6 +425,34 @@ const t = await client.dictionary('ui').locale('en').get() const doc = await client.document('docs').locale('en').bySlug('intro') ``` +### CDN Documents — `all()` has no bodies + +The CDN stores documents in two places, so `all()` and `bySlug()` return +different things: + +| Call | Reads | Returns | +|---|---|---| +| `all()` / `first()` | `documents/{model}/_index/{locale}.json` | `DocumentIndexEntry` — frontmatter only, **no `body`** | +| `bySlug(slug)` | `documents/{model}/{slug}/{locale}.json` | `{ frontmatter: T, body, html }` | + +```ts +const q = client.document('guide-sections').locale('tr') + +// Listing: frontmatter only — one request. +const index = await q.sort('order', 'asc').all() + +// Need the prose? One request per document. +const full = await Promise.all(index.map(s => q.bySlug(s.slug))) +full.map(d => renderMarkdown(d!.body)) +``` + +::: warning The bundled client differs +The local `#contentrain` client's `document(...).all()` **does** include bodies — +it reads the content files directly. Reading `.body` off a CDN `all()` result is +a compile error, so code written against bundled delivery fails to build rather +than silently rendering empty pages after a CDN switch. +::: + ### CDN Collection Operators ```ts diff --git a/packages/mcp/src/core/content-manager.ts b/packages/mcp/src/core/content-manager.ts index adafd76..7bfb894 100644 --- a/packages/mcp/src/core/content-manager.ts +++ b/packages/mcp/src/core/content-manager.ts @@ -3,7 +3,7 @@ import { validateSlug, validateEntryId, validateLocale, generateEntryId, parseMa import { join } from 'node:path' import { rm } from 'node:fs/promises' import { contentrainDir, readDir, readJson, readText, writeJson, writeText } from '../util/fs.js' -import { writeMeta, deleteMeta } from './meta-manager.js' +import { readMeta, writeMeta, deleteMeta, mergeEntryMeta } from './meta-manager.js' import { readModel } from './model-manager.js' import type { RepoReader } from './contracts/index.js' import { contentDirPath, contentFilePath, documentFilePath } from './ops/paths.js' @@ -75,6 +75,8 @@ export interface DeleteOpts { slug?: string locale?: string keys?: string[] + /** `config.locales.default` — places a non-i18n model's single meta record. */ + defaultLocale: string } export interface ListOpts { @@ -85,23 +87,6 @@ export interface ListOpts { offset?: number } -// ─── Default meta ─── - -function defaultMeta(data?: Record): EntryMeta { - const meta: EntryMeta = { - status: 'draft', - source: 'agent', - updated_by: 'contentrain-mcp', - } - if (data?.['publish_at'] !== undefined) { - meta.publish_at = data['publish_at'] as string - } - if (data?.['expire_at'] !== undefined) { - meta.expire_at = data['expire_at'] as string - } - return meta -} - // ─── writeContent ─── export async function writeContent( @@ -137,7 +122,8 @@ export async function writeContent( case 'singleton': { const filePath = resolveJsonFilePath(resolveContentDir(projectRoot, model), model, locale) await writeJson(filePath, entry.data) - await writeMeta(projectRoot, model, { locale }, defaultMeta(entry.data)) + const prevMeta = await readMeta(projectRoot, model, { locale, defaultLocale }) as EntryMeta | null + await writeMeta(projectRoot, model, { locale, defaultLocale }, mergeEntryMeta(prevMeta ?? undefined, entry.data)) results.push({ action: 'updated', locale }) break } @@ -158,7 +144,8 @@ export async function writeContent( } await writeJson(filePath, sorted) - await writeMeta(projectRoot, model, { locale, entryId: id }, defaultMeta(entry.data)) + const prevMetaMap = await readMeta(projectRoot, model, { locale, defaultLocale }) as Record | null + await writeMeta(projectRoot, model, { locale, entryId: id, defaultLocale }, mergeEntryMeta(prevMetaMap?.[id], entry.data)) results.push({ action, id, locale }) break } @@ -181,7 +168,8 @@ export async function writeContent( const mdContent = serializeMarkdownFrontmatter(fmData, bodyContent) await writeText(docPath, mdContent) - await writeMeta(projectRoot, model, { locale, slug }, defaultMeta(entry.data)) + const prevMeta = await readMeta(projectRoot, model, { locale, slug, defaultLocale }) as EntryMeta | null + await writeMeta(projectRoot, model, { locale, slug, defaultLocale }, mergeEntryMeta(prevMeta ?? undefined, entry.data)) results.push({ action, slug, locale }) break } @@ -235,7 +223,8 @@ export async function writeContent( const merged = { ...existing, ...entry.data as Record } await writeJson(filePath, merged) - await writeMeta(projectRoot, model, { locale }, defaultMeta(entry.data)) + const prevMeta = await readMeta(projectRoot, model, { locale, defaultLocale }) as EntryMeta | null + await writeMeta(projectRoot, model, { locale, defaultLocale }, mergeEntryMeta(prevMeta ?? undefined, entry.data)) results.push({ action: 'updated', locale, ...(advisories.length > 0 ? { advisories } : {}) }) break } @@ -273,7 +262,7 @@ export async function deleteContent( } for (const loc of locales) { - await deleteMeta(projectRoot, model, { locale: loc, entryId: opts.id }) + await deleteMeta(projectRoot, model, { locale: loc, entryId: opts.id, defaultLocale: opts.defaultLocale }) } break } @@ -310,7 +299,7 @@ export async function deleteContent( } removed.push(`${model.content_path ?? `content/${model.domain}/${model.id}`}/${opts.slug}`) - await deleteMeta(projectRoot, model, { slug: opts.slug, locale: opts.locale }) + await deleteMeta(projectRoot, model, { slug: opts.slug, locale: opts.locale, defaultLocale: opts.defaultLocale }) break } @@ -322,7 +311,7 @@ export async function deleteContent( removed.push(model.i18n ? `content/${model.domain}/${model.id}/${locale}.json` : `content/${model.domain}/${model.id}/data.json`) - await deleteMeta(projectRoot, model, { locale: model.i18n ? locale : undefined }) + await deleteMeta(projectRoot, model, { locale: model.i18n ? locale : undefined, defaultLocale: opts.defaultLocale }) break } @@ -353,7 +342,7 @@ export async function deleteContent( removed.push(model.i18n ? `content/${model.domain}/${model.id}/${locale}.json` : `content/${model.domain}/${model.id}/data.json`) - await deleteMeta(projectRoot, model, { locale: model.i18n ? locale : undefined }) + await deleteMeta(projectRoot, model, { locale: model.i18n ? locale : undefined, defaultLocale: opts.defaultLocale }) } break } diff --git a/packages/mcp/src/core/doctor.ts b/packages/mcp/src/core/doctor.ts index a40af60..7f19258 100644 --- a/packages/mcp/src/core/doctor.ts +++ b/packages/mcp/src/core/doctor.ts @@ -1,5 +1,6 @@ import { join } from 'node:path' -import { stat } from 'node:fs/promises' +import { readdir, stat } from 'node:fs/promises' +import type { Dirent } from 'node:fs' import { simpleGit } from 'simple-git' import type { ContentrainConfig, ModelDefinition } from '@contentrain/types' import { readConfig } from './config.js' @@ -240,14 +241,21 @@ export async function runDoctor( const modelsDir = join(crDir, 'models') if (await pathExists(clientDir) && await pathExists(modelsDir)) { try { - const [clientStat, modelsStat] = await Promise.all([stat(clientDir), stat(modelsDir)]) - const fresh = clientStat.mtimeMs >= modelsStat.mtimeMs - checks.push({ - name: 'SDK client', - pass: fresh, - detail: fresh ? 'Up to date' : 'Stale — run `contentrain generate`', - severity: fresh ? undefined : 'warning', - }) + const [clientMtime, modelsMtime] = await Promise.all([ + newestFileMtime(clientDir), + newestFileMtime(modelsDir), + ]) + if (clientMtime === null || modelsMtime === null) { + checks.push({ name: 'SDK client', pass: true, detail: 'Could not check' }) + } else { + const fresh = clientMtime >= modelsMtime + checks.push({ + name: 'SDK client', + pass: fresh, + detail: fresh ? 'Up to date' : 'Stale — run `contentrain generate`', + severity: fresh ? undefined : 'warning', + }) + } } catch { checks.push({ name: 'SDK client', pass: true, detail: 'Could not check' }) } @@ -303,6 +311,27 @@ export async function runDoctor( return report } +/** + * Newest mtime among the files under `dir`, recursively — null if it holds none. + * + * Stat the files, never the directory. A directory's mtime only moves when an + * entry is added, removed, or renamed inside it: `generate` rewrites the client + * files in place, so `.contentrain/client` never moves after the first run, + * while a selective sync recreates model files via `git checkout`, which does + * move `.contentrain/models`. Comparing the two directories therefore reported + * "stale" permanently after any model save. + */ +async function newestFileMtime(dir: string): Promise { + const entries: Dirent[] = await readdir(dir, { withFileTypes: true }).catch(() => []) + const mtimes = await Promise.all(entries.map(async (entry) => { + const full = join(dir, entry.name) + if (entry.isDirectory()) return newestFileMtime(full) + return stat(full).then(s => s.mtimeMs, () => null) + })) + const known = mtimes.filter((m): m is number => m !== null) + return known.length > 0 ? Math.max(...known) : null +} + async function findOrphanContent(projectRoot: string): Promise { const crDir = contentrainDir(projectRoot) const models = await listModels(projectRoot) diff --git a/packages/mcp/src/core/meta-manager.ts b/packages/mcp/src/core/meta-manager.ts index 7d28a0f..81c7b8f 100644 --- a/packages/mcp/src/core/meta-manager.ts +++ b/packages/mcp/src/core/meta-manager.ts @@ -7,16 +7,28 @@ interface MetaOpts { locale?: string entryId?: string slug?: string + /** + * `config.locales.default`. Required: it places a non-i18n model's single + * meta record, and stands in for an omitted locale on i18n models — the old + * hardcoded `'en'` fallback silently wrote to the wrong file on projects + * whose default was anything else. + */ + defaultLocale: string } +/** + * Absolute meta path. Non-i18n models keep exactly one meta record, pinned to + * the default locale — see `ops/paths.ts:metaFilePath` for the full rationale. + */ function metaPath(projectRoot: string, model: ModelDefinition, opts: MetaOpts): string { const base = join(contentrainDir(projectRoot), 'meta', model.id) + const effective = model.i18n ? (opts.locale ?? opts.defaultLocale) : opts.defaultLocale if (model.kind === 'document' && opts.slug) { - return join(base, opts.slug, `${opts.locale ?? 'en'}.json`) + return join(base, opts.slug, `${effective}.json`) } - return join(base, `${opts.locale ?? 'en'}.json`) + return join(base, `${effective}.json`) } export async function readMeta( @@ -27,6 +39,31 @@ export async function readMeta( return readJson(metaPath(projectRoot, model, opts)) } +/** + * Build the meta for a content write, merged over the entry's existing meta. + * + * `status` is a publish decision and belongs to whoever made it — editing a + * field must not unpublish an entry. Only an entry with no meta yet starts at + * `draft`. `source`/`updated_by` describe *this* write, so they are stamped + * every time; `approved_by`/`version` survive via the spread. + * + * Pass `existing: undefined` to mint meta for a genuinely new entry. + */ +export function mergeEntryMeta( + existing: EntryMeta | undefined, + data?: Record, +): EntryMeta { + const meta: EntryMeta = { + ...existing, + status: existing?.status ?? 'draft', + source: 'agent', + updated_by: 'contentrain-mcp', + } + if (data?.['publish_at'] !== undefined) meta.publish_at = data['publish_at'] as string + if (data?.['expire_at'] !== undefined) meta.expire_at = data['expire_at'] as string + return meta +} + export async function writeMeta( projectRoot: string, model: ModelDefinition, @@ -44,6 +81,30 @@ export async function writeMeta( } } +/** + * Apply many entry-meta updates to one collection locale file in a single + * read-modify-write, returning the entry IDs actually written. + * + * Do NOT loop `writeMeta` over entry IDs: every call rewrites the whole shared + * `{locale}.json`, so concurrent calls each start from the same snapshot and + * all but the last-settling one are silently lost. This function exists so that + * race cannot be reintroduced. + */ +export async function writeMetaEntries( + projectRoot: string, + model: ModelDefinition, + opts: MetaOpts, + updates: Record, +): Promise { + const ids = Object.keys(updates) + if (ids.length === 0) return [] + + const filePath = metaPath(projectRoot, model, opts) + const existing = await readJson>(filePath) ?? {} + await writeJson(filePath, { ...existing, ...updates }) + return ids +} + export async function deleteMeta( projectRoot: string, model: ModelDefinition, diff --git a/packages/mcp/src/core/ops/content-delete.ts b/packages/mcp/src/core/ops/content-delete.ts index a95ccbc..456f3f1 100644 --- a/packages/mcp/src/core/ops/content-delete.ts +++ b/packages/mcp/src/core/ops/content-delete.ts @@ -11,6 +11,11 @@ export interface ContentDeleteInput { slug?: string locale?: string keys?: string[] + /** + * `config.locales.default` — where a non-i18n model's single meta record + * lives. Required to resolve meta paths; see `metaFilePath`. + */ + defaultLocale: string } export type ContentDeletePlan = OpPlan @@ -83,7 +88,9 @@ export async function planContentDelete( } for (const loc of locales) { - const mPath = metaFilePath(model, loc) + // For a non-i18n model `loc` is the pseudo-locale "data" (discovered from + // data.json); metaFilePath maps it onto the default locale regardless. + const mPath = metaFilePath(model, loc, input.defaultLocale) const meta = await tryReadJson>(reader, mPath) if (!meta || !(input.id in meta)) continue delete meta[input.id] @@ -108,8 +115,10 @@ export async function planContentDelete( : `content/${model.domain}/${model.id}/data.json`) if (model.i18n) { - changes.push({ path: metaFilePath(model, locale), content: null }) + changes.push({ path: metaFilePath(model, locale, input.defaultLocale), content: null }) } else { + // Removing data.json leaves a non-i18n model with no content at all, so + // every meta file under it goes — including strays left by older writes. await pushAllMetaForModel(reader, model.id, changes) } break @@ -140,7 +149,7 @@ export async function planContentDelete( ? `content/${model.domain}/${model.id}/${locale}.json` : `content/${model.domain}/${model.id}/data.json`) if (model.i18n) { - changes.push({ path: metaFilePath(model, locale), content: null }) + changes.push({ path: metaFilePath(model, locale, input.defaultLocale), content: null }) } else { await pushAllMetaForModel(reader, model.id, changes) } diff --git a/packages/mcp/src/core/ops/content-save.ts b/packages/mcp/src/core/ops/content-save.ts index 91b6aff..2daa3ba 100644 --- a/packages/mcp/src/core/ops/content-save.ts +++ b/packages/mcp/src/core/ops/content-save.ts @@ -6,6 +6,7 @@ import { canonicalStringify, serializeMarkdownFrontmatter } from '../serializati import { rewriteEntryMedia, rewriteMarkdownMedia } from '../media/media-rewrite.js' import type { ContentSaveEntryResult, ContentSavePlan } from './types.js' import { contentFilePath, documentFilePath, metaFilePath } from './paths.js' +import { mergeEntryMeta } from '../meta-manager.js' interface PlanInput { model: ModelDefinition @@ -54,6 +55,21 @@ export async function planContentSave(reader: RepoReader, input: PlanInput): Pro } } + /** Absent file and absent entry both mean "no prior meta" — hence undefined, not `{}`. */ + async function readJsonOrUndefined(path: string): Promise { + try { + return JSON.parse(await reader.readFile(path)) as T + } catch { + return undefined + } + } + + /** Prior meta for a whole-file (non-collection) meta path, honouring the accumulator. */ + async function priorMeta(mPath: string): Promise { + return (metaByPath.get(mPath) as EntryMeta | undefined) + ?? await readJsonOrUndefined(mPath) + } + const defaultLocale = config.locales.default for (const entry of entries) { @@ -73,9 +89,9 @@ export async function planContentSave(reader: RepoReader, input: PlanInput): Pro switch (model.kind) { case 'singleton': { const cPath = contentFilePath(model, locale) - const mPath = metaFilePath(model, locale) + const mPath = metaFilePath(model, locale, defaultLocale) contentByPath.set(cPath, normalizeMedia(entry.data)) - metaByPath.set(mPath, defaultMeta(entry.data)) + metaByPath.set(mPath, mergeEntryMeta(await priorMeta(mPath), entry.data)) result.push({ action: 'updated', locale }) break } @@ -84,7 +100,7 @@ export async function planContentSave(reader: RepoReader, input: PlanInput): Pro const isNew = !entry.id const id = entry.id ?? generateEntryId() const cPath = contentFilePath(model, locale) - const mPath = metaFilePath(model, locale) + const mPath = metaFilePath(model, locale, defaultLocale) const existing = (contentByPath.get(cPath) as Record | undefined) ?? await readJsonOrEmpty>(cPath) @@ -100,7 +116,7 @@ export async function planContentSave(reader: RepoReader, input: PlanInput): Pro const existingMeta = (metaByPath.get(mPath) as Record | undefined) ?? await readJsonOrEmpty>(mPath) - existingMeta[id] = defaultMeta(entry.data) + existingMeta[id] = mergeEntryMeta(existingMeta[id], entry.data) metaByPath.set(mPath, existingMeta) result.push({ action, id, locale }) @@ -109,7 +125,7 @@ export async function planContentSave(reader: RepoReader, input: PlanInput): Pro case 'dictionary': { const cPath = contentFilePath(model, locale) - const mPath = metaFilePath(model, locale) + const mPath = metaFilePath(model, locale, defaultLocale) const existing = (contentByPath.get(cPath) as Record | undefined) ?? await readJsonOrEmpty>(cPath) @@ -156,7 +172,7 @@ export async function planContentSave(reader: RepoReader, input: PlanInput): Pro advisories.push(...entryAdvisories) contentByPath.set(cPath, { ...existing, ...newData }) - metaByPath.set(mPath, defaultMeta(entry.data)) + metaByPath.set(mPath, mergeEntryMeta(await priorMeta(mPath), entry.data)) result.push({ action: 'updated', @@ -178,7 +194,7 @@ export async function planContentSave(reader: RepoReader, input: PlanInput): Pro if (!fmData['slug']) fmData['slug'] = slug const dPath = documentFilePath(model, locale, slug) - const mPath = metaFilePath(model, locale, slug) + const mPath = metaFilePath(model, locale, defaultLocale, slug) let existingRaw: string | null = null try { existingRaw = await reader.readFile(dPath) } @@ -189,7 +205,7 @@ export async function planContentSave(reader: RepoReader, input: PlanInput): Pro // markdown body has its `media/...` image/link targets rewritten too. const normalizedBody = mediaBaseUrl ? rewriteMarkdownMedia(bodyContent, mediaBaseUrl) : bodyContent markdownChanges.set(dPath, serializeMarkdownFrontmatter(normalizeMedia(fmData), normalizedBody)) - metaByPath.set(mPath, defaultMeta(entry.data)) + metaByPath.set(mPath, mergeEntryMeta(await priorMeta(mPath), entry.data)) result.push({ action, slug, locale }) break @@ -211,14 +227,3 @@ export async function planContentSave(reader: RepoReader, input: PlanInput): Pro return { changes, result, advisories } } - -function defaultMeta(data?: Record): EntryMeta { - const meta: EntryMeta = { - status: 'draft', - source: 'agent', - updated_by: 'contentrain-mcp', - } - if (data?.['publish_at'] !== undefined) meta.publish_at = data['publish_at'] as string - if (data?.['expire_at'] !== undefined) meta.expire_at = data['expire_at'] as string - return meta -} diff --git a/packages/mcp/src/core/ops/paths.ts b/packages/mcp/src/core/ops/paths.ts index 49b8f39..6ec5a9a 100644 --- a/packages/mcp/src/core/ops/paths.ts +++ b/packages/mcp/src/core/ops/paths.ts @@ -60,14 +60,26 @@ export function documentFilePath( } } +/** + * Content-root-relative meta path. + * + * `i18n` is in the Pick and `defaultLocale` is required on purpose. A non-i18n + * model keeps all its content in one `data.json`, so it has exactly one meta + * record — pinned to the default locale. Passing the caller's locale straight + * through used to fan meta out across locales for that single content file + * (`meta/{id}/tr.json` *and* `meta/{id}/en.json` for one `data.json`), leaving + * every reader to guess which file was authoritative. + */ export function metaFilePath( - model: Pick, + model: Pick, locale: string, + defaultLocale: string, slug?: string, ): string { const base = `.contentrain/meta/${model.id}` + const effective = model.i18n ? locale : defaultLocale if (model.kind === 'document' && slug) { - return `${base}/${slug}/${locale}.json` + return `${base}/${slug}/${effective}.json` } - return `${base}/${locale}.json` + return `${base}/${effective}.json` } diff --git a/packages/mcp/src/core/validator/project.ts b/packages/mcp/src/core/validator/project.ts index d6dc093..62bdfe0 100644 --- a/packages/mcp/src/core/validator/project.ts +++ b/packages/mcp/src/core/validator/project.ts @@ -266,7 +266,7 @@ async function validateCollectionModel( // Orphan meta check for (const locale of locales) { - const metaRelPath = metaFilePath(model, locale) + const metaRelPath = metaFilePath(model, locale, config.locales.default) const metaData = await readJsonViaReader>(reader, metaRelPath) const contentData = await readJsonViaReader>( reader, @@ -312,7 +312,7 @@ async function validateCollectionModel( message: `Orphan content: entry "${entryId}" has no metadata`, }) if (fix && projectRoot) { - await writeMeta(projectRoot, model, { locale, entryId }, { + await writeMeta(projectRoot, model, { locale, entryId, defaultLocale: config.locales.default }, { status: 'draft', source: 'import', updated_by: 'contentrain-mcp', @@ -321,11 +321,76 @@ async function validateCollectionModel( } } } + + // Publish-state drift. Drafts sitting alongside published entries in the + // same collection is the fingerprint of writes that reset status — the + // shape that silently emptied collections on the CDN. Advisory only: a + // genuine work-in-progress entry is indistinguishable, so this is a call + // for the agent to make, not an error. Never auto-fixed — publishing is a + // content decision, and MCP does not make those. + if (metaData) { + const entries = Object.entries(metaData) + const draftIds = entries.filter(([, m]) => m.status === 'draft').map(([id]) => id) + const publishedCount = entries.filter(([, m]) => m.status === 'published').length + if (publishedCount > 0 && draftIds.length > 0) { + issues.push({ + severity: 'notice', + model: model.id, + locale, + message: + `Publish-state drift: ${draftIds.length} draft entr${draftIds.length === 1 ? 'y' : 'ies'} ` + + `alongside ${publishedCount} published in the same collection — [${draftIds.join(', ')}]. ` + + 'If these were published before, restore them with contentrain_bulk update_status.', + }) + } + } } + await checkStrayNonI18nMeta(reader, model, config, issues) + return { entries: entriesChecked, fixed } } +/** + * Flag meta files a non-i18n model should not have. + * + * Such a model keeps all content in one `data.json` and therefore exactly one + * meta record, at the default locale. Earlier writes derived the meta path from + * the caller's locale, so saving under a non-default locale left a second meta + * file — and readers disagreed about which was authoritative. Reported rather + * than auto-removed: the stray may hold the only `published` status in the + * project, so deleting it silently could unpublish content. + */ +async function checkStrayNonI18nMeta( + reader: RepoReader, + model: ModelDefinition, + config: ContentrainConfig, + issues: ValidationError[], +): Promise { + if (model.i18n) return + + const expected = `${config.locales.default}.json` + let files: string[] + try { + files = await reader.listDirectory(`.contentrain/meta/${model.id}`) + } catch { + return + } + + const strays = files.filter(f => f.endsWith('.json') && f !== expected) + if (strays.length === 0) return + + issues.push({ + severity: 'warning', + model: model.id, + message: + `Meta layout mismatch: "${model.id}" has i18n disabled, so its content lives in a single data.json ` + + `and its meta belongs at ${expected} alone — but [${strays.join(', ')}] also exist. ` + + 'Readers may disagree about which file is authoritative. Merge any status you want to keep into ' + + `${expected}, then remove the extras.`, + }) +} + async function validateSingletonModel( reader: RepoReader, projectRoot: string | undefined, @@ -399,7 +464,7 @@ async function validateSingletonModel( } // Validate schedule fields in singleton meta - const singletonMetaData = await readJsonViaReader(reader, metaFilePath(model, locale)) + const singletonMetaData = await readJsonViaReader(reader, metaFilePath(model, locale, config.locales.default)) if (singletonMetaData) { validateScheduleFields(singletonMetaData, { model: model.id, locale }, issues) } diff --git a/packages/mcp/src/tools/bulk.ts b/packages/mcp/src/tools/bulk.ts index da672bb..ce90b2f 100644 --- a/packages/mcp/src/tools/bulk.ts +++ b/packages/mcp/src/tools/bulk.ts @@ -5,7 +5,7 @@ import type { ToolProvider } from '../server.js' import { readConfig } from '../core/config.js' import { readModel } from '../core/model-manager.js' import { resolveContentDir, resolveJsonFilePath, deleteContent } from '../core/content-manager.js' -import { readMeta, writeMeta } from '../core/meta-manager.js' +import { readMeta, writeMeta, writeMetaEntries } from '../core/meta-manager.js' import { createTransaction, buildBranchName } from '../git/transaction.js' import { checkBranchHealth } from '../git/branch-lifecycle.js' import { normalizeOperationError } from '../git/errors.js' @@ -26,7 +26,8 @@ export function registerBulkTools( model: z.string().describe('Model ID'), source_locale: z.string().optional().describe('Source locale for copy_locale operation'), target_locale: z.string().optional().describe('Target locale for copy_locale operation'), - entry_ids: z.array(z.string()).optional().describe('Entry IDs for update_status or delete_entries'), + entry_ids: z.array(z.string()).optional().describe('Entry IDs for update_status (collection models) or delete_entries'), + locale: z.string().optional().describe('Scope update_status to a single locale (i18n models only; defaults to every supported locale)'), status: z.enum(['draft', 'in_review', 'published', 'rejected', 'archived']).optional().describe('New status for update_status'), confirm: z.boolean().optional().describe('Must be true for delete_entries'), }, @@ -124,18 +125,19 @@ export function registerBulkTools( if (model.kind === 'collection') { const entries = sourceData as Record> - const entryIds = Object.keys(entries) - const metaOps = entryIds.map(entryId => - writeMeta(wt, model, { locale: input.target_locale!, entryId }, { + const updates: Record = {} + for (const entryId of Object.keys(entries)) { + updates[entryId] = { status: 'draft', source: 'agent', updated_by: 'contentrain-mcp', - }), - ) - await Promise.all(metaOps) - copiedCount = entryIds.length + } + } + // One read-modify-write for the whole locale file — see writeMetaEntries. + const written = await writeMetaEntries(wt, model, { locale: input.target_locale!, defaultLocale: config.locales.default }, updates) + copiedCount = written.length } else { - await writeMeta(wt, model, { locale: input.target_locale! }, { + await writeMeta(wt, model, { locale: input.target_locale!, defaultLocale: config.locales.default }, { status: 'draft', source: 'agent', updated_by: 'contentrain-mcp', @@ -176,60 +178,117 @@ export function registerBulkTools( } case 'update_status': { - if (!input.entry_ids || input.entry_ids.length === 0) { + if (!input.status) { return { - content: [{ type: 'text' as const, text: JSON.stringify({ error: 'update_status requires entry_ids' }) }], + content: [{ type: 'text' as const, text: JSON.stringify({ error: 'update_status requires status' }) }], isError: true, } } - if (!input.status) { + + // Documents keep meta per slug (meta/{model}/{slug}/{locale}.json), so + // they need a slug list rather than entry_ids. Rejected until that lands. + if (model.kind === 'document') { return { - content: [{ type: 'text' as const, text: JSON.stringify({ error: 'update_status requires status' }) }], + content: [{ type: 'text' as const, text: JSON.stringify({ + error: `Model "${input.model}" is a document model. update_status does not support documents yet — their meta is keyed by slug, not entry ID.`, + }) }], isError: true, } } - if (model.kind !== 'collection') { + // Only collections key meta by entry ID; singletons and dictionaries + // have exactly one meta record per locale. Checked before entry_ids so + // a singleton gets a usable error instead of a dead end. + const keyedByEntry = model.kind === 'collection' + if (keyedByEntry && (!input.entry_ids || input.entry_ids.length === 0)) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ error: `update_status requires entry_ids for collection model "${input.model}".` }) }], + isError: true, + } + } + if (!keyedByEntry && input.entry_ids && input.entry_ids.length > 0) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ + error: `Model "${input.model}" is a ${model.kind} model — it has one meta record per locale, so entry_ids do not apply. Omit entry_ids.`, + }) }], + isError: true, + } + } + + if (input.locale && !config.locales.supported.includes(input.locale)) { return { - content: [{ type: 'text' as const, text: JSON.stringify({ error: 'update_status with entry_ids is only supported for collection models' }) }], + content: [{ type: 'text' as const, text: JSON.stringify({ + error: `Locale "${input.locale}" is not supported. Supported: [${config.locales.supported.join(', ')}]`, + }) }], isError: true, } } + // A non-i18n model stores one meta record at the default locale, so + // fanning out over supported locales would rewrite the same file. + const targetLocales = model.i18n + ? (input.locale ? [input.locale] : config.locales.supported) + : [config.locales.default] + const branch = buildBranchName('bulk', input.model) const tx = await createTransaction(projectRoot, branch) try { - let updatedCount = 0 - const notFound: string[] = [] + // Counted from what is actually persisted, never from the input. + const updatedPerLocale: Record = {} + const foundIds = new Set() await tx.write(async (wt) => { - for (const locale of config.locales.supported) { - const metaData = await readMeta(wt, model, { locale }) as Record | null - - const updateOps: Array> = [] - for (const entryId of input.entry_ids!) { - const existing = metaData?.[entryId] - if (existing) { - updateOps.push(writeMeta(wt, model, { locale, entryId }, { - ...existing, - status: input.status!, - updated_by: 'contentrain-mcp', - })) - updatedCount++ - } else if (locale === config.locales.default) { - notFound.push(entryId) + for (const locale of targetLocales) { + if (keyedByEntry) { + const metaData = await readMeta(wt, model, { locale, defaultLocale: config.locales.default }) as Record | null + + const updates: Record = {} + for (const entryId of input.entry_ids!) { + const existing = metaData?.[entryId] + if (!existing) continue + updates[entryId] = { ...existing, status: input.status!, updated_by: 'contentrain-mcp' } + foundIds.add(entryId) } + + // One read-modify-write for the whole locale file — see writeMetaEntries. + const written = await writeMetaEntries(wt, model, { locale, defaultLocale: config.locales.default }, updates) + if (written.length > 0) updatedPerLocale[locale] = written + } else { + const existing = await readMeta(wt, model, { locale, defaultLocale: config.locales.default }) as EntryMeta | null + if (!existing) continue + await writeMeta(wt, model, { locale, defaultLocale: config.locales.default }, { + ...existing, + status: input.status!, + updated_by: 'contentrain-mcp', + }) + updatedPerLocale[locale] = [model.id] } - await Promise.all(updateOps) } - }) + const updatedCount = Object.values(updatedPerLocale).reduce((n, ids) => n + ids.length, 0) + const notFound = keyedByEntry + ? input.entry_ids!.filter(id => !foundIds.has(id)) + : [] + + if (updatedCount === 0) { + await tx.cleanup() + return { + content: [{ type: 'text' as const, text: JSON.stringify({ + error: keyedByEntry + ? `No meta records found for the given entry_ids in model "${input.model}" across locales [${targetLocales.join(', ')}]. Nothing was changed.` + : `No meta record found for model "${input.model}" across locales [${targetLocales.join(', ')}]. Nothing was changed.`, + not_found: notFound.length > 0 ? notFound : undefined, + }, null, 2) }], + isError: true, + } + } + await tx.commit(`[contentrain] bulk: update status → ${input.status} for ${input.model}`, { tool: 'contentrain_bulk', model: input.model, - entries: input.entry_ids!, + ...(keyedByEntry ? { entries: input.entry_ids! } : {}), }) const gitResult = await tx.complete() @@ -237,8 +296,9 @@ export function registerBulkTools( content: [{ type: 'text' as const, text: JSON.stringify({ status: 'committed', operation: 'update_status', - message: `Updated ${updatedCount} meta entries to status "${input.status}".`, + message: `Updated ${updatedCount} meta record(s) to status "${input.status}" across locales [${Object.keys(updatedPerLocale).join(', ')}].`, updated: updatedCount, + updated_by_locale: updatedPerLocale, not_found: notFound.length > 0 ? notFound : undefined, git: { branch, action: gitResult.action, commit: gitResult.commit, ...(gitResult.sync ? { sync: gitResult.sync } : {}) }, context_updated: true, @@ -286,7 +346,7 @@ export function registerBulkTools( await tx.write(async (wt) => { for (const entryId of input.entry_ids!) { - const removed = await deleteContent(wt, model, { id: entryId }) + const removed = await deleteContent(wt, model, { id: entryId, defaultLocale: config.locales.default }) allRemoved.push(...removed) } diff --git a/packages/mcp/src/tools/content.ts b/packages/mcp/src/tools/content.ts index 7a1ac12..f107360 100644 --- a/packages/mcp/src/tools/content.ts +++ b/packages/mcp/src/tools/content.ts @@ -266,6 +266,7 @@ export function registerContentTools( slug: input.slug, locale: input.locale, keys: input.keys, + defaultLocale: config.locales.default, }) } catch (error) { return { diff --git a/packages/mcp/src/tools/context.ts b/packages/mcp/src/tools/context.ts index 5db7300..c69f127 100644 --- a/packages/mcp/src/tools/context.ts +++ b/packages/mcp/src/tools/context.ts @@ -289,9 +289,13 @@ export function registerContextTools( }, }, i18n_disabled: { - description: 'When a model has i18n:false, locale is ignored in file paths.', + description: + 'When a model has i18n:false, locale is ignored in CONTENT paths — but not in meta paths. ' + + 'The model holds one content file, so it has exactly one meta record, and that record lives ' + + 'at the default locale. Saving under a non-default locale does not move it.', json_pattern: '{content-dir}/data.json (always "data.json", no locale suffix)', md_pattern: '{content-dir}/{slug}.md (no locale in path)', + meta_pattern: '.contentrain/meta/{model-id}/{default-locale}.json (always the default locale, never "data.json")', }, canonical_serialization: { description: 'All JSON files use deterministic serialization for clean git diffs.', diff --git a/packages/mcp/tests/core/content-save-meta.test.ts b/packages/mcp/tests/core/content-save-meta.test.ts new file mode 100644 index 0000000..e95bb3c --- /dev/null +++ b/packages/mcp/tests/core/content-save-meta.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from 'vitest' +import type { ContentrainConfig, EntryMeta, ModelDefinition, RepoReader } from '@contentrain/types' +import { planContentSave } from '../../src/core/ops/content-save.js' + +const config: ContentrainConfig = { + version: 1, + stack: 'other', + workflow: 'auto-merge', + locales: { default: 'en', supported: ['en'] }, + domains: ['blog'], +} + +/** A reader backed by an in-memory path→contents map; anything absent throws, as a real reader would. */ +function readerOf(files: Record): RepoReader { + return { + readFile: async (path: string) => { + if (!(path in files)) throw new Error(`not found: ${path}`) + return JSON.stringify(files[path]) + }, + } as unknown as RepoReader +} + +function metaChange(plan: Awaited>): Record { + const change = plan.changes.find(ch => ch.path.includes('/meta/')) + if (!change) throw new Error('no meta change in plan') + return JSON.parse(change.content) as Record +} + +const published: EntryMeta = { + status: 'published', + source: 'human', + updated_by: 'author@example.com', + approved_by: 'editor@example.com', + version: '2', +} + +describe('planContentSave meta status preservation', () => { + const singleton: ModelDefinition = { + id: 'hero', name: 'Hero', kind: 'singleton', domain: 'blog', i18n: true, + fields: { title: { type: 'string' } }, + } + const collection: ModelDefinition = { + id: 'posts', name: 'Posts', kind: 'collection', domain: 'blog', i18n: true, + fields: { title: { type: 'string' } }, + } + const dictionary: ModelDefinition = { + id: 'strings', name: 'Strings', kind: 'dictionary', domain: 'blog', i18n: true, + } + const document: ModelDefinition = { + id: 'guides', name: 'Guides', kind: 'document', domain: 'blog', i18n: true, + fields: { title: { type: 'string' }, slug: { type: 'slug' } }, + } + + it('preserves a published singleton status, approval and version', async () => { + const plan = await planContentSave( + readerOf({ '.contentrain/meta/hero/en.json': published }), + { model: singleton, config, entries: [{ locale: 'en', data: { title: 'Edited' } }] }, + ) + + const meta = metaChange(plan) + expect(meta['status']).toBe('published') + expect(meta['approved_by']).toBe('editor@example.com') + expect(meta['version']).toBe('2') + // Provenance reflects this write, so it is restamped. + expect(meta['source']).toBe('agent') + expect(meta['updated_by']).toBe('contentrain-mcp') + }) + + it('preserves a published collection entry and leaves siblings untouched', async () => { + const plan = await planContentSave( + readerOf({ + '.contentrain/content/blog/posts/en.json': { aaa111bbb222: { title: 'Old' } }, + '.contentrain/meta/posts/en.json': { + aaa111bbb222: published, + ccc333ddd444: { status: 'archived', source: 'human', updated_by: 'b@example.com' }, + }, + }), + { model: collection, config, entries: [{ id: 'aaa111bbb222', locale: 'en', data: { title: 'New' } }] }, + ) + + expect(plan.result[0]!.action).toBe('updated') + const meta = metaChange(plan) as Record> + expect(meta['aaa111bbb222']!['status']).toBe('published') + expect(meta['ccc333ddd444']!['status']).toBe('archived') + expect(meta['ccc333ddd444']!['updated_by']).toBe('b@example.com') + }) + + it('starts a new collection entry as draft', async () => { + const plan = await planContentSave( + readerOf({}), + { model: collection, config, entries: [{ locale: 'en', data: { title: 'Fresh' } }] }, + ) + + expect(plan.result[0]!.action).toBe('created') + const id = plan.result[0]!.id! + const meta = metaChange(plan) as Record> + expect(meta[id]!['status']).toBe('draft') + }) + + it('preserves a published dictionary status', async () => { + const plan = await planContentSave( + readerOf({ '.contentrain/meta/strings/en.json': published }), + { model: dictionary, config, entries: [{ locale: 'en', data: { 'a.b': 'value' } }] }, + ) + + expect(metaChange(plan)['status']).toBe('published') + }) + + it('preserves a published document status', async () => { + const plan = await planContentSave( + readerOf({ '.contentrain/meta/guides/hello/en.json': published }), + { model: document, config, entries: [{ slug: 'hello', locale: 'en', data: { title: 'Hi', body: 'v2' } }] }, + ) + + expect(metaChange(plan)['status']).toBe('published') + }) + + it('applies incoming publish_at without clobbering the preserved status', async () => { + const plan = await planContentSave( + readerOf({ '.contentrain/meta/hero/en.json': published }), + { + model: singleton, + config, + entries: [{ locale: 'en', data: { title: 'Edited', publish_at: '2026-01-01T00:00:00Z' } }], + }, + ) + + const meta = metaChange(plan) + expect(meta['status']).toBe('published') + expect(meta['publish_at']).toBe('2026-01-01T00:00:00Z') + }) + + // A non-i18n model keeps all content in one data.json, so it must keep exactly + // one meta record. Writing meta per locale produced two meta files for one + // content file, and readers disagreed about which was authoritative. + describe('non-i18n models pin meta to the default locale', () => { + const nonI18nConfig: ContentrainConfig = { + ...config, + locales: { default: 'tr', supported: ['en', 'tr'] }, + } + const nonI18nCollection: ModelDefinition = { + id: 'sponsors', name: 'Sponsors', kind: 'collection', domain: 'blog', i18n: false, + fields: { name: { type: 'string' } }, + } + + it('writes meta to the default locale even when saved under another locale', async () => { + const plan = await planContentSave( + readerOf({}), + { + model: nonI18nCollection, + config: nonI18nConfig, + entries: [{ locale: 'en', data: { name: 'Acme' } }], + }, + ) + + const metaPaths = plan.changes.filter(ch => ch.path.includes('/meta/')).map(ch => ch.path) + expect(metaPaths).toEqual(['.contentrain/meta/sponsors/tr.json']) + // Content stays locale-agnostic, as it always did. + expect(plan.changes.some(ch => ch.path === '.contentrain/content/blog/sponsors/data.json')).toBe(true) + }) + + it('produces a single meta file when the same entry is saved under two locales', async () => { + const plan = await planContentSave( + readerOf({}), + { + model: nonI18nCollection, + config: nonI18nConfig, + entries: [ + { id: 'aaa111bbb222', locale: 'en', data: { name: 'Acme' } }, + { id: 'aaa111bbb222', locale: 'tr', data: { name: 'Acme TR' } }, + ], + }, + ) + + const metaPaths = plan.changes.filter(ch => ch.path.includes('/meta/')).map(ch => ch.path) + expect(metaPaths).toEqual(['.contentrain/meta/sponsors/tr.json']) + }) + + it('preserves a published non-i18n entry read from the default-locale meta', async () => { + const plan = await planContentSave( + readerOf({ '.contentrain/meta/sponsors/tr.json': { aaa111bbb222: published } }), + { + model: nonI18nCollection, + config: nonI18nConfig, + entries: [{ id: 'aaa111bbb222', locale: 'en', data: { name: 'Acme Edited' } }], + }, + ) + + const meta = metaChange(plan) as Record> + expect(meta['aaa111bbb222']!['status']).toBe('published') + }) + }) + + it('batches two entries into one meta file without losing either status', async () => { + const plan = await planContentSave( + readerOf({ + '.contentrain/meta/posts/en.json': { + aaa111bbb222: published, + ccc333ddd444: { status: 'published', source: 'human', updated_by: 'b@example.com' }, + }, + }), + { + model: collection, + config, + entries: [ + { id: 'aaa111bbb222', locale: 'en', data: { title: 'One' } }, + { id: 'ccc333ddd444', locale: 'en', data: { title: 'Two' } }, + ], + }, + ) + + const meta = metaChange(plan) as Record> + expect(meta['aaa111bbb222']!['status']).toBe('published') + expect(meta['ccc333ddd444']!['status']).toBe('published') + }) +}) diff --git a/packages/mcp/tests/core/content.test.ts b/packages/mcp/tests/core/content.test.ts index 7451a1b..e836813 100644 --- a/packages/mcp/tests/core/content.test.ts +++ b/packages/mcp/tests/core/content.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm, mkdir } from 'node:fs/promises' import { tmpdir } from 'node:os' import type { ModelDefinition, ContentrainConfig } from '@contentrain/types' import { writeContent, deleteContent, listContent, parseFrontmatter, serializeFrontmatter } from '../../src/core/content-manager.js' -import { readJson, readText, contentrainDir } from '../../src/util/fs.js' +import { readJson, readText, writeJson, contentrainDir } from '../../src/util/fs.js' let testDir: string @@ -379,6 +379,123 @@ describe('writeContent', () => { expect(meta!['source']).toBe('agent') expect(meta!['updated_by']).toBe('contentrain-mcp') }) + + // Editing a field is not a publish decision — MCP must never silently + // unpublish an entry by rewriting its meta from scratch. + describe('meta status preservation', () => { + const metaFile = (...segments: string[]) => + join(contentrainDir(testDir), 'meta', ...segments) + + it('keeps a published singleton published across an edit', async () => { + await writeContent(testDir, singletonModel, [ + { locale: 'en', data: { title: 'First' } }, + ], config) + await writeJson(metaFile('hero', 'en.json'), { + status: 'published', + source: 'human', + updated_by: 'someone@example.com', + approved_by: 'editor@example.com', + version: '3', + }) + + await writeContent(testDir, singletonModel, [ + { locale: 'en', data: { title: 'Edited' } }, + ], config) + + const meta = await readJson>(metaFile('hero', 'en.json')) + expect(meta!['status']).toBe('published') + expect(meta!['approved_by']).toBe('editor@example.com') + expect(meta!['version']).toBe('3') + // Provenance describes *this* write, so it is restamped. + expect(meta!['source']).toBe('agent') + expect(meta!['updated_by']).toBe('contentrain-mcp') + }) + + it('keeps a published collection entry published and leaves siblings alone', async () => { + await writeContent(testDir, collectionModel, [ + { id: 'aaa111bbb222', locale: 'en', data: { name: 'Ada' } }, + { id: 'ccc333ddd444', locale: 'en', data: { name: 'Grace' } }, + ], config) + await writeJson(metaFile('authors', 'en.json'), { + aaa111bbb222: { status: 'published', source: 'human', updated_by: 'a@example.com' }, + ccc333ddd444: { status: 'archived', source: 'human', updated_by: 'b@example.com' }, + }) + + await writeContent(testDir, collectionModel, [ + { id: 'aaa111bbb222', locale: 'en', data: { name: 'Ada Lovelace' } }, + ], config) + + const meta = await readJson>>(metaFile('authors', 'en.json')) + expect(meta!['aaa111bbb222']!['status']).toBe('published') + // Untouched sibling keeps its own status verbatim. + expect(meta!['ccc333ddd444']!['status']).toBe('archived') + expect(meta!['ccc333ddd444']!['updated_by']).toBe('b@example.com') + }) + + it('starts a brand-new collection entry as draft', async () => { + await writeContent(testDir, collectionModel, [ + { id: 'eee555fff666', locale: 'en', data: { name: 'Alan' } }, + ], config) + + const meta = await readJson>>(metaFile('authors', 'en.json')) + expect(meta!['eee555fff666']!['status']).toBe('draft') + }) + + it('keeps a published document published across an edit', async () => { + await writeContent(testDir, documentModel, [ + { slug: 'hello', locale: 'en', data: { title: 'Hello', slug: 'hello', body: 'v1' } }, + ], config) + await writeJson(metaFile('blog-post', 'hello', 'en.json'), { + status: 'published', + source: 'human', + updated_by: 'a@example.com', + }) + + await writeContent(testDir, documentModel, [ + { slug: 'hello', locale: 'en', data: { title: 'Hello', slug: 'hello', body: 'v2' } }, + ], config) + + const meta = await readJson>(metaFile('blog-post', 'hello', 'en.json')) + expect(meta!['status']).toBe('published') + }) + + it('keeps a published dictionary published across an edit', async () => { + await writeContent(testDir, dictionaryModel, [ + { locale: 'en', data: { 'error.404': 'Not found' } }, + ], config) + await writeJson(metaFile('error-messages', 'en.json'), { + status: 'published', + source: 'human', + updated_by: 'a@example.com', + }) + + await writeContent(testDir, dictionaryModel, [ + { locale: 'en', data: { 'error.500': 'Server error' } }, + ], config) + + const meta = await readJson>(metaFile('error-messages', 'en.json')) + expect(meta!['status']).toBe('published') + }) + + it('still applies publish_at/expire_at from the incoming write', async () => { + await writeContent(testDir, singletonModel, [ + { locale: 'en', data: { title: 'First' } }, + ], config) + await writeJson(metaFile('hero', 'en.json'), { + status: 'published', + source: 'human', + updated_by: 'a@example.com', + }) + + await writeContent(testDir, singletonModel, [ + { locale: 'en', data: { title: 'Edited', publish_at: '2026-01-01T00:00:00Z' } }, + ], config) + + const meta = await readJson>(metaFile('hero', 'en.json')) + expect(meta!['status']).toBe('published') + expect(meta!['publish_at']).toBe('2026-01-01T00:00:00Z') + }) + }) }) // ─── deleteContent tests ─── diff --git a/packages/mcp/tests/core/doctor.test.ts b/packages/mcp/tests/core/doctor.test.ts index 54fe839..5528b80 100644 --- a/packages/mcp/tests/core/doctor.test.ts +++ b/packages/mcp/tests/core/doctor.test.ts @@ -148,6 +148,49 @@ describe('runDoctor', () => { expect(sdk?.pass).toBe(false) expect(sdk?.severity).toBe('warning') }) + + // `generate` rewrites the client files in place, which never moves the client + // directory's own mtime — so a directory-vs-directory comparison stayed stale + // forever once a model save had bumped .contentrain/models. + it('clears the stale warning after an in-place regenerate', async () => { + await seedMinimalProject(testDir) + const clientDir = join(testDir, '.contentrain', 'client') + await mkdir(join(clientDir, 'data'), { recursive: true }) + await writeFileSafe(join(clientDir, 'index.mjs'), '// generated\n') + await writeFileSafe(join(clientDir, 'data', 'posts.mjs'), 'export default {}\n') + + await new Promise(r => setTimeout(r, 20)) + await writeFileSafe(join(testDir, '.contentrain', 'models', 'new-model.json'), JSON.stringify({ + id: 'new-model', name: 'New', kind: 'singleton', domain: 'marketing', fields: {}, + })) + expect((await runDoctor(testDir)).checks.find(c => c.name === 'SDK client')?.pass).toBe(false) + + // Re-run generate: same files, new contents. The directory mtime does not move. + await new Promise(r => setTimeout(r, 20)) + await writeFileSafe(join(clientDir, 'index.mjs'), '// regenerated\n') + + const sdk = (await runDoctor(testDir)).checks.find(c => c.name === 'SDK client') + expect(sdk?.pass).toBe(true) + expect(sdk?.detail).toBe('Up to date') + }) + + it('treats a nested data file as evidence of a fresh client', async () => { + await seedMinimalProject(testDir) + const clientDir = join(testDir, '.contentrain', 'client') + await mkdir(join(clientDir, 'data'), { recursive: true }) + await writeFileSafe(join(clientDir, 'index.mjs'), '// generated\n') + + await new Promise(r => setTimeout(r, 20)) + await writeFileSafe(join(testDir, '.contentrain', 'models', 'new-model.json'), JSON.stringify({ + id: 'new-model', name: 'New', kind: 'singleton', domain: 'marketing', fields: {}, + })) + + // Only a nested emitted file is newer — the check must still see it. + await new Promise(r => setTimeout(r, 20)) + await writeFileSafe(join(clientDir, 'data', 'new-model.mjs'), 'export default {}\n') + + expect((await runDoctor(testDir)).checks.find(c => c.name === 'SDK client')?.pass).toBe(true) + }) }) describe('runDoctor — Remote branches check', () => { diff --git a/packages/mcp/tests/core/validator/status-drift.test.ts b/packages/mcp/tests/core/validator/status-drift.test.ts new file mode 100644 index 0000000..47c44bf --- /dev/null +++ b/packages/mcp/tests/core/validator/status-drift.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest' +import { validateProject } from '../../../src/core/validator/index.js' +import type { RepoReader } from '../../../src/core/contracts/index.js' + +function makeInMemoryReader(files: Record): RepoReader { + return { + async readFile(path) { + const content = files[path] + if (content === undefined) throw new Error(`File not found: ${path}`) + return content + }, + async listDirectory(path) { + const prefix = path.endsWith('/') ? path : `${path}/` + const children = new Set() + for (const filePath of Object.keys(files)) { + if (!filePath.startsWith(prefix)) continue + const rest = filePath.slice(prefix.length) + const firstSegment = rest.split('/')[0] + if (firstSegment) children.add(firstSegment) + } + return [...children].toSorted() + }, + async fileExists(path) { + return Object.hasOwn(files, path) + }, + } +} + +const CONFIG = JSON.stringify({ + version: 1, + stack: 'nuxt', + workflow: 'review', + locales: { default: 'tr', supported: ['en', 'tr'] }, + domains: ['blog'], +}) + +const I18N_MODEL = JSON.stringify({ + id: 'guides', name: 'Guides', kind: 'collection', domain: 'blog', i18n: true, + fields: { title: { type: 'string' } }, +}) + +const NON_I18N_MODEL = JSON.stringify({ + id: 'sponsors', name: 'Sponsors', kind: 'collection', domain: 'blog', i18n: false, + fields: { name: { type: 'string' } }, +}) + +const meta = (statuses: Record): string => + JSON.stringify(Object.fromEntries( + Object.entries(statuses).map(([id, status]) => [id, { status, source: 'agent', updated_by: 'x' }]), + )) + +const content = (ids: string[]): string => + JSON.stringify(Object.fromEntries(ids.map(id => [id, { title: id, name: id }]))) + +describe('publish-state drift notice', () => { + it('flags drafts sitting alongside published entries in one collection', async () => { + const reader = makeInMemoryReader({ + '.contentrain/config.json': CONFIG, + '.contentrain/models/guides.json': I18N_MODEL, + '.contentrain/content/blog/guides/tr.json': content(['aaa111bbb222', 'ccc333ddd444']), + '.contentrain/content/blog/guides/en.json': content(['aaa111bbb222', 'ccc333ddd444']), + '.contentrain/meta/guides/tr.json': meta({ aaa111bbb222: 'published', ccc333ddd444: 'draft' }), + '.contentrain/meta/guides/en.json': meta({ aaa111bbb222: 'published', ccc333ddd444: 'published' }), + }) + + const result = await validateProject(reader) + const drift = result.issues.filter(i => i.message.includes('Publish-state drift')) + expect(drift).toHaveLength(1) + expect(drift[0]!.severity).toBe('notice') + expect(drift[0]!.locale).toBe('tr') + expect(drift[0]!.message).toContain('ccc333ddd444') + // A notice must never fail validation — publishing is a content decision. + expect(result.valid).toBe(true) + }) + + it('stays quiet when every entry shares one status', async () => { + const reader = makeInMemoryReader({ + '.contentrain/config.json': CONFIG, + '.contentrain/models/guides.json': I18N_MODEL, + '.contentrain/content/blog/guides/tr.json': content(['aaa111bbb222', 'ccc333ddd444']), + '.contentrain/content/blog/guides/en.json': content(['aaa111bbb222', 'ccc333ddd444']), + '.contentrain/meta/guides/tr.json': meta({ aaa111bbb222: 'draft', ccc333ddd444: 'draft' }), + '.contentrain/meta/guides/en.json': meta({ aaa111bbb222: 'published', ccc333ddd444: 'published' }), + }) + + const result = await validateProject(reader) + expect(result.issues.filter(i => i.message.includes('Publish-state drift'))).toHaveLength(0) + }) +}) + +describe('non-i18n meta layout mismatch', () => { + it('flags a stray meta file at a non-default locale', async () => { + const reader = makeInMemoryReader({ + '.contentrain/config.json': CONFIG, + '.contentrain/models/sponsors.json': NON_I18N_MODEL, + '.contentrain/content/blog/sponsors/data.json': content(['aaa111bbb222']), + '.contentrain/meta/sponsors/tr.json': meta({ aaa111bbb222: 'published' }), + // Left behind by a write that derived the meta path from the caller's locale. + '.contentrain/meta/sponsors/en.json': meta({ aaa111bbb222: 'draft' }), + }) + + const result = await validateProject(reader) + const mismatch = result.issues.filter(i => i.message.includes('Meta layout mismatch')) + expect(mismatch).toHaveLength(1) + expect(mismatch[0]!.severity).toBe('warning') + expect(mismatch[0]!.message).toContain('en.json') + expect(mismatch[0]!.message).toContain('tr.json') + }) + + it('stays quiet when a non-i18n model has only its default-locale meta', async () => { + const reader = makeInMemoryReader({ + '.contentrain/config.json': CONFIG, + '.contentrain/models/sponsors.json': NON_I18N_MODEL, + '.contentrain/content/blog/sponsors/data.json': content(['aaa111bbb222']), + '.contentrain/meta/sponsors/tr.json': meta({ aaa111bbb222: 'published' }), + }) + + const result = await validateProject(reader) + expect(result.issues.filter(i => i.message.includes('Meta layout mismatch'))).toHaveLength(0) + }) + + it('stays quiet for an i18n model with per-locale meta', async () => { + const reader = makeInMemoryReader({ + '.contentrain/config.json': CONFIG, + '.contentrain/models/guides.json': I18N_MODEL, + '.contentrain/content/blog/guides/tr.json': content(['aaa111bbb222']), + '.contentrain/content/blog/guides/en.json': content(['aaa111bbb222']), + '.contentrain/meta/guides/tr.json': meta({ aaa111bbb222: 'published' }), + '.contentrain/meta/guides/en.json': meta({ aaa111bbb222: 'published' }), + }) + + const result = await validateProject(reader) + expect(result.issues.filter(i => i.message.includes('Meta layout mismatch'))).toHaveLength(0) + }) +}) diff --git a/packages/mcp/tests/tools/bulk.test.ts b/packages/mcp/tests/tools/bulk.test.ts new file mode 100644 index 0000000..90e89dd --- /dev/null +++ b/packages/mcp/tests/tools/bulk.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest' + +vi.setConfig({ testTimeout: 120000, hookTimeout: 120000 }) +import { join } from 'node:path' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { simpleGit } from 'simple-git' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import type { EntryMeta } from '@contentrain/types' +import { createServer } from '../../src/server.js' +import { readJson } from '../../src/util/fs.js' + +let testDir: string +let client: Client + +async function initProject(dir: string): Promise { + const git = simpleGit(dir) + await git.init() + await git.addConfig('user.name', 'Test') + await git.addConfig('user.email', 'test@test.com') + await writeFile(join(dir, '.gitkeep'), '') + await git.add('.') + await git.commit('initial') +} + +async function createTestClient(projectRoot: string): Promise { + const server = createServer(projectRoot) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + const c = new Client({ name: 'test-client', version: '1.0.0' }) + await Promise.all([c.connect(clientTransport), server.connect(serverTransport)]) + return c +} + +function parseResult(result: unknown): Record { + const content = (result as { content: Array<{ text: string }> }).content + return JSON.parse(content[0]!.text) as Record +} + +async function createModel( + c: Client, + id: string, + kind: string, + domain: string, + opts: { i18n?: boolean; fields?: Record } = {}, +): Promise { + await c.callTool({ + name: 'contentrain_model_save', + arguments: { id, name: id, kind, domain, i18n: opts.i18n ?? true, fields: opts.fields }, + }) + return createTestClient(testDir) +} + +function collectionMeta(model: string, locale: string): Promise | null> { + return readJson>(join(testDir, '.contentrain', 'meta', model, `${locale}.json`)) +} + +function recordMeta(model: string, locale: string): Promise { + return readJson(join(testDir, '.contentrain', 'meta', model, `${locale}.json`)) +} + +beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), 'cr-bulk-tool-test-')) + await initProject(testDir) + client = await createTestClient(testDir) + await client.callTool({ name: 'contentrain_init', arguments: { locales: ['en', 'tr'] } }) + client = await createTestClient(testDir) +}) + +afterEach(async () => { + await rm(testDir, { recursive: true, force: true }) +}) + +describe('contentrain_bulk update_status', () => { + const FIELDS = { title: { type: 'string', required: true } } + + /** Seed a collection with `count` entries in both locales, returning their IDs. */ + async function seedCollection(count: number): Promise { + client = await createModel(client, 'guides', 'collection', 'marketing', { fields: FIELDS }) + const result = await client.callTool({ + name: 'contentrain_content_save', + arguments: { + model: 'guides', + entries: Array.from({ length: count }, (_, i) => ({ + locale: 'en', + data: { title: `Guide ${i}` }, + })), + }, + }) + const results = parseResult(result)['results'] as Array> + return results.map(r => r['id'] as string) + } + + // The regression this file exists for: looping writeMeta over entry IDs made + // every call rewrite the same locale file from the same snapshot, so only the + // last-settling write survived while the response still claimed success for all. + // Both assertions ride one call: every test here pays a full init + model_save + // + content_save in git, so they are merged rather than seeded twice. + it('persists every entry_id and reports a count matching disk', async () => { + const ids = await seedCollection(5) + expect(ids).toHaveLength(5) + + const result = await client.callTool({ + name: 'contentrain_bulk', + arguments: { operation: 'update_status', model: 'guides', entry_ids: ids, status: 'published' }, + }) + + const data = parseResult(result) + expect(data['status']).toBe('committed') + + const meta = await collectionMeta('guides', 'en') + for (const id of ids) { + expect(meta![id]!.status, `entry ${id} should be published`).toBe('published') + } + + // The count must come from what persisted, not from entry_ids.length. + const persisted = Object.values(meta!).filter(m => m.status === 'published').length + expect(data['updated']).toBe(persisted) + expect(data['updated']).toBe(5) + }) + + it('scopes to a single locale when locale is given', async () => { + const ids = await seedCollection(2) + await client.callTool({ + name: 'contentrain_bulk', + arguments: { operation: 'copy_locale', model: 'guides', source_locale: 'en', target_locale: 'tr' }, + }) + + await client.callTool({ + name: 'contentrain_bulk', + arguments: { operation: 'update_status', model: 'guides', entry_ids: ids, status: 'published', locale: 'en' }, + }) + + const en = await collectionMeta('guides', 'en') + const tr = await collectionMeta('guides', 'tr') + expect(en![ids[0]!]!.status).toBe('published') + // The other locale must be left exactly as it was. + expect(tr![ids[0]!]!.status).toBe('draft') + }) + + it('updates a singleton status without entry_ids', async () => { + client = await createModel(client, 'hero', 'singleton', 'marketing', { fields: FIELDS }) + await client.callTool({ + name: 'contentrain_content_save', + arguments: { model: 'hero', entries: [{ locale: 'en', data: { title: 'Hi' } }] }, + }) + expect((await recordMeta('hero', 'en'))!.status).toBe('draft') + + const result = await client.callTool({ + name: 'contentrain_bulk', + arguments: { operation: 'update_status', model: 'hero', status: 'published' }, + }) + + expect(parseResult(result)['status']).toBe('committed') + expect((await recordMeta('hero', 'en'))!.status).toBe('published') + }) + + it('fails loudly when no entry_id matches instead of reporting success', async () => { + await seedCollection(1) + + const result = await client.callTool({ + name: 'contentrain_bulk', + arguments: { operation: 'update_status', model: 'guides', entry_ids: ['ffffffffffff'], status: 'published' }, + }) + + const data = parseResult(result) + expect(data['status']).not.toBe('committed') + expect(data['error']).toContain('Nothing was changed') + }) + + // Guards reject before any git work, so these need a model but no content. + describe('argument guards', () => { + it('rejects entry_ids for a singleton with a message that names the fix', async () => { + client = await createModel(client, 'hero', 'singleton', 'marketing', { fields: FIELDS }) + + const result = await client.callTool({ + name: 'contentrain_bulk', + arguments: { operation: 'update_status', model: 'hero', entry_ids: ['abc123def456'], status: 'published' }, + }) + + expect(parseResult(result)['error']).toContain('Omit entry_ids') + }) + + it('requires entry_ids for a collection, and status for both', async () => { + client = await createModel(client, 'guides', 'collection', 'marketing', { fields: FIELDS }) + + const noStatus = await client.callTool({ + name: 'contentrain_bulk', + arguments: { operation: 'update_status', model: 'guides', entry_ids: ['abc123def456'] }, + }) + expect(parseResult(noStatus)['error']).toContain('requires status') + + const noIds = await client.callTool({ + name: 'contentrain_bulk', + arguments: { operation: 'update_status', model: 'guides', status: 'published' }, + }) + expect(parseResult(noIds)['error']).toContain('requires entry_ids') + }) + }) +}) + +describe('contentrain_bulk copy_locale', () => { + // Twin of the update_status race: the meta for every copied entry went through + // its own concurrent read-modify-write of one shared file. + it('writes meta for every copied entry, not just one', async () => { + client = await createModel(client, 'guides', 'collection', 'marketing', { + fields: { title: { type: 'string', required: true } }, + }) + const saved = await client.callTool({ + name: 'contentrain_content_save', + arguments: { + model: 'guides', + entries: Array.from({ length: 4 }, (_, i) => ({ locale: 'en', data: { title: `G${i}` } })), + }, + }) + const ids = (parseResult(saved)['results'] as Array>).map(r => r['id'] as string) + + const result = await client.callTool({ + name: 'contentrain_bulk', + arguments: { operation: 'copy_locale', model: 'guides', source_locale: 'en', target_locale: 'tr' }, + }) + + const data = parseResult(result) + expect(data['status']).toBe('committed') + expect(data['copied']).toBe(4) + + const tr = await readJson>( + join(testDir, '.contentrain', 'meta', 'guides', 'tr.json'), + ) + expect(Object.keys(tr!)).toHaveLength(4) + for (const id of ids) { + expect(tr![id]!.status, `copied entry ${id} should have meta`).toBe('draft') + } + }) +}) diff --git a/packages/rules/essential/contentrain-essentials.md b/packages/rules/essential/contentrain-essentials.md index 4e708fc..5c6d1d2 100644 --- a/packages/rules/essential/contentrain-essentials.md +++ b/packages/rules/essential/contentrain-essentials.md @@ -33,6 +33,7 @@ MCP is **deterministic infrastructure**. The agent (you) is the **intelligence l - **NEVER** edit `.contentrain/context.json` — MCP writes, agents read - **NEVER** include system fields in content data: `id`, `status`, `source`, `updated_by`, `updated_at`, `createdAt`, `updatedAt` - **ALWAYS** use MCP tools — do not write `.contentrain/` JSON files directly +- **Publishing is yours, not MCP's.** `contentrain_content_save` preserves an existing entry's `status`; only a brand-new entry starts at `draft`. To change publish state, call `contentrain_bulk update_status` deliberately — and note that on the CDN a collection entry is delivered only when its status is `published`. ## MCP Tools diff --git a/packages/sdk/js/README.md b/packages/sdk/js/README.md index 57cf0bb..a9bbe8b 100644 --- a/packages/sdk/js/README.md +++ b/packages/sdk/js/README.md @@ -232,6 +232,35 @@ const t = await client.dictionary('ui').locale('en').get() const doc = await client.document('docs').locale('en').bySlug('intro') ``` +### ⚠️ `document()` on the CDN: `all()` has no bodies + +`all()` and `bySlug()` return different things, because the CDN stores documents +in two places: + +| Call | Reads | Returns | +|---|---|---| +| `all()` / `first()` | `documents/{model}/_index/{locale}.json` | `DocumentIndexEntry` — frontmatter only, **no `body`** | +| `bySlug(slug)` | `documents/{model}/{slug}/{locale}.json` | `{ frontmatter: T, body, html }` | + +Reading `.body` off an `all()` result is a compile error. Fetch bodies explicitly: + +```ts +const q = client.document('guide-sections').locale('tr') + +// Listing: frontmatter is all you need — one request. +const index = await q.sort('order', 'asc').all() +index.map(s => s.title) + +// Need the prose? One request per document. +const full = await Promise.all(index.map(s => q.bySlug(s.slug))) +full.map(d => renderMarkdown(d!.body)) +``` + +Note the **bundled** client's `document(...).all()` *does* include bodies — it +reads the content files directly. The two delivery modes genuinely differ here, +so they no longer share a return type. Code written against bundled delivery +will not silently render empty on CDN; it will fail to compile instead. + CDN collection queries support extended operators: ```ts diff --git a/packages/sdk/js/src/cdn/data-source.ts b/packages/sdk/js/src/cdn/data-source.ts index 9203c0d..3888a97 100644 --- a/packages/sdk/js/src/cdn/data-source.ts +++ b/packages/sdk/js/src/cdn/data-source.ts @@ -11,7 +11,22 @@ export interface DictionaryDataSource { get(locale: string): Promise> } +/** + * A document entry as it exists in the CDN's `_index` — frontmatter only. + * + * The body is not in the index; it lives in the per-slug document and is only + * reachable through `bySlug()`. This type exists so that reading `.body` off an + * `all()` result is a compile error rather than `undefined` at runtime: `all()` + * used to be typed `T[]`, and since the generated document type declares + * `body: string`, pages rendering `entry.body` type-checked, passed review, and + * silently shipped without their prose. + * + * Note the bundled runtime's `all()` *does* carry bodies. The two delivery modes + * genuinely differ in shape, so they no longer share one return type. + */ +export type DocumentIndexEntry = Omit + export interface DocumentDataSource { - getIndex(locale: string): Promise + getIndex(locale: string): Promise[]> getBySlug(slug: string, locale: string): Promise<{ frontmatter: T; body: string; html: string } | null> } diff --git a/packages/sdk/js/src/cdn/document-query.ts b/packages/sdk/js/src/cdn/document-query.ts index 9b7ba25..b062079 100644 --- a/packages/sdk/js/src/cdn/document-query.ts +++ b/packages/sdk/js/src/cdn/document-query.ts @@ -1,4 +1,4 @@ -import type { DocumentDataSource } from './data-source.js' +import type { DocumentDataSource, DocumentIndexEntry } from './data-source.js' import type { WhereOp, WhereClause } from '../shared/where.js' import { applyWhere } from '../shared/where.js' @@ -30,7 +30,13 @@ export class CdnDocumentQuery { return this } - async all(): Promise { + /** + * Every entry's frontmatter, from the model's `_index`. + * + * Bodies are not included — fetch one with {@link bySlug}. See + * {@link DocumentIndexEntry}. + */ + async all(): Promise[]> { let items = await this._source.getIndex(this._locale) for (const clause of this._filters) { @@ -58,11 +64,12 @@ export class CdnDocumentQuery { return items.length } - async first(): Promise { + async first(): Promise | undefined> { const items = await this.all() return items[0] } + /** The full document: frontmatter plus its rendered body. */ async bySlug(slug: string): Promise<{ frontmatter: T; body: string; html: string } | null> { return this._source.getBySlug(slug, this._locale) } diff --git a/packages/sdk/js/src/cdn/http-transport.ts b/packages/sdk/js/src/cdn/http-transport.ts index ce5f83f..96486bf 100644 --- a/packages/sdk/js/src/cdn/http-transport.ts +++ b/packages/sdk/js/src/cdn/http-transport.ts @@ -1,5 +1,5 @@ import { ContentrainError } from './errors.js' -import type { CollectionDataSource, SingletonDataSource, DictionaryDataSource, DocumentDataSource } from './data-source.js' +import type { CollectionDataSource, SingletonDataSource, DictionaryDataSource, DocumentDataSource, DocumentIndexEntry } from './data-source.js' interface CacheEntry { data: unknown @@ -166,7 +166,8 @@ export class HttpTransport { document(modelId: string): DocumentDataSource { return { - getIndex: (locale) => this.fetch(`documents/${modelId}/_index/${locale}.json`), + // `_index` carries frontmatter only — the body is in the per-slug document. + getIndex: (locale) => this.fetch[]>(`documents/${modelId}/_index/${locale}.json`), getBySlug: (slug, locale) => this.fetch(`documents/${modelId}/${slug}/${locale}.json`), } } diff --git a/packages/sdk/js/src/cdn/index.ts b/packages/sdk/js/src/cdn/index.ts index 0f594d8..2dbfacf 100644 --- a/packages/sdk/js/src/cdn/index.ts +++ b/packages/sdk/js/src/cdn/index.ts @@ -65,7 +65,7 @@ export function createContentrain(config: ContentrainCDNConfig) { // Re-exports export { ContentrainError } from './errors.js' -export type { CollectionDataSource, SingletonDataSource, DictionaryDataSource, DocumentDataSource } from './data-source.js' +export type { CollectionDataSource, SingletonDataSource, DictionaryDataSource, DocumentDataSource, DocumentIndexEntry } from './data-source.js' export { HttpTransport } from './http-transport.js' export type { TransportConfig } from './http-transport.js' export { CdnCollectionQuery } from './collection-query.js' diff --git a/packages/sdk/js/src/index.ts b/packages/sdk/js/src/index.ts index 30e629a..9976b8d 100644 --- a/packages/sdk/js/src/index.ts +++ b/packages/sdk/js/src/index.ts @@ -49,6 +49,7 @@ export async function createContentrainClient( // CDN client factory — async, HTTP-based export { createContentrain } from './cdn/index.js' export type { ContentrainCDNConfig, ContentrainCDNClient } from './cdn/index.js' +export type { DocumentIndexEntry } from './cdn/data-source.js' export { ContentrainError } from './cdn/errors.js' // CDN module re-exports — media, forms, metadata diff --git a/packages/sdk/js/tests/cdn/bundle-preload.test.ts b/packages/sdk/js/tests/cdn/bundle-preload.test.ts index b039bbd..fdb030b 100644 --- a/packages/sdk/js/tests/cdn/bundle-preload.test.ts +++ b/packages/sdk/js/tests/cdn/bundle-preload.test.ts @@ -140,7 +140,7 @@ describe('HttpTransport bundle preload', () => { expect(urls).toHaveLength(2) expect(urls.every(url => url.includes('_bundle/en.json'))).toBe(true) // Second bundle request was conditional - expect(mock.mock.calls[1][1]).toMatchObject({ + expect(mock.mock.calls[1]![1]).toMatchObject({ headers: expect.objectContaining({ 'If-None-Match': '"b1"' }), }) }) diff --git a/packages/sdk/js/tests/cdn/document-query.test-d.ts b/packages/sdk/js/tests/cdn/document-query.test-d.ts new file mode 100644 index 0000000..71dfa86 --- /dev/null +++ b/packages/sdk/js/tests/cdn/document-query.test-d.ts @@ -0,0 +1,44 @@ +import { describe, it, expectTypeOf } from 'vitest' +import { CdnDocumentQuery } from '../../src/cdn/document-query.js' +import type { DocumentDataSource, DocumentIndexEntry } from '../../src/cdn/data-source.js' + +/** The generated shape for a document model: flat frontmatter plus `body`. */ +interface GuideSection { + slug: string + title: string + platform: string + order: number + body: string +} + +declare const source: DocumentDataSource +const query = new CdnDocumentQuery(source, 'tr') + +/** + * The trap this type closes: `all()` used to be typed `T[]`, and the generated + * document type declares `body: string`. So `entry.body` compiled, returned + * `undefined` at runtime, and pages silently rendered without their prose. + */ +describe('CdnDocumentQuery type contract', () => { + it('all() yields index entries without a body', () => { + expectTypeOf(query.all()).resolves.toEqualTypeOf[]>() + expectTypeOf>().toHaveProperty('title') + expectTypeOf>().not.toHaveProperty('body') + }) + + it('makes reading .body off an all() result a compile error', async () => { + const items = await query.all() + // @ts-expect-error — body is not on an index entry; use bySlug() to get it. + void items[0]!.body + }) + + it('first() yields an index entry too', () => { + expectTypeOf(query.first()).resolves.toEqualTypeOf | undefined>() + }) + + it('bySlug() keeps the body', () => { + expectTypeOf(query.bySlug('x')).resolves.toEqualTypeOf< + { frontmatter: GuideSection; body: string; html: string } | null + >() + }) +}) diff --git a/packages/sdk/js/tests/cdn/document-query.test.ts b/packages/sdk/js/tests/cdn/document-query.test.ts new file mode 100644 index 0000000..9943e92 --- /dev/null +++ b/packages/sdk/js/tests/cdn/document-query.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { CdnDocumentQuery } from '../../src/cdn/document-query.js' +import type { DocumentDataSource, DocumentIndexEntry } from '../../src/cdn/data-source.js' + +/** The generated shape for a document model: flat frontmatter plus `body`. */ +interface GuideSection { + slug: string + title: string + platform: string + order: number + body: string +} + +/** + * Mirrors the real CDN: `_index` holds frontmatter only, per-slug docs hold the + * body. The cast models exactly what the transport does — an unchecked + * assertion over whatever JSON comes back. + */ +function sourceOf( + index: Array>, + bodies: Record = {}, +): DocumentDataSource { + return { + getIndex: async () => index as DocumentIndexEntry[], + getBySlug: async (slug) => { + const fm = index.find(i => i.slug === slug) + if (!fm) return null + return { + frontmatter: { ...fm, body: bodies[slug] ?? '' } as GuideSection, + body: bodies[slug] ?? '', + html: `

${bodies[slug] ?? ''}

`, + } + }, + } +} + +const INDEX = [ + { slug: 'tiktok-1', title: 'TikTok Basics', platform: 'tiktok', order: 2 }, + { slug: 'tiktok-2', title: 'TikTok Ads', platform: 'tiktok', order: 1 }, + { slug: 'yt-1', title: 'YouTube Intro', platform: 'youtube', order: 1 }, +] + +describe('CdnDocumentQuery', () => { + it('returns index entries from all()', async () => { + const q = new CdnDocumentQuery(sourceOf(INDEX), 'tr') + const items = await q.all() + expect(items).toHaveLength(3) + expect(items[0]).toMatchObject({ slug: 'tiktok-1', title: 'TikTok Basics' }) + }) + + it('filters and sorts index entries', async () => { + const q = new CdnDocumentQuery(sourceOf(INDEX), 'tr') + const items = await q.where('platform', 'eq', 'tiktok').sort('order', 'asc').all() + expect(items.map(i => i.slug)).toEqual(['tiktok-2', 'tiktok-1']) + }) + + it('returns the body only through bySlug()', async () => { + const q = new CdnDocumentQuery(sourceOf(INDEX, { 'tiktok-1': 'Prose here.' }), 'tr') + const doc = await q.bySlug('tiktok-1') + expect(doc?.body).toBe('Prose here.') + expect(doc?.html).toBe('

Prose here.

') + }) + + it('returns null from bySlug() for an unknown slug', async () => { + const q = new CdnDocumentQuery(sourceOf(INDEX), 'tr') + expect(await q.bySlug('nope')).toBeNull() + }) + + it('counts and firsts off the index', async () => { + const q = new CdnDocumentQuery(sourceOf(INDEX), 'tr') + expect(await q.count()).toBe(3) + expect((await q.sort('order', 'asc').first())?.slug).toBe('tiktok-2') + }) + + // The runtime half of the trap: the body really is absent from all() results. + // The type-level half — that reading it fails to compile — lives in + // document-query.test-d.ts, since tsconfig.json does not cover tests. + it('really has no body on all() results at runtime', async () => { + const q = new CdnDocumentQuery(sourceOf(INDEX, { 'tiktok-1': 'Prose here.' }), 'tr') + const items = await q.all() + expect((items[0] as Record)['body']).toBeUndefined() + }) +}) diff --git a/packages/sdk/js/tests/runtime/singleton.test.ts b/packages/sdk/js/tests/runtime/singleton.test.ts index 5ffee82..fe6dab9 100644 --- a/packages/sdk/js/tests/runtime/singleton.test.ts +++ b/packages/sdk/js/tests/runtime/singleton.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect } from 'vitest' import { SingletonAccessor } from '../../src/runtime/singleton.js' -interface Hero { +// SingletonAccessor is generic over Record, so the fixture +// type needs an index signature — an interface does not have one implicitly. +type Hero = { title: string subtitle: string cta_text: string diff --git a/packages/sdk/js/tsconfig.test.json b/packages/sdk/js/tsconfig.test.json new file mode 100644 index 0000000..53d5cf3 --- /dev/null +++ b/packages/sdk/js/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src", "tests"] +} diff --git a/packages/sdk/js/vitest.config.ts b/packages/sdk/js/vitest.config.ts index e00e9c3..660dfc1 100644 --- a/packages/sdk/js/vitest.config.ts +++ b/packages/sdk/js/vitest.config.ts @@ -3,5 +3,13 @@ import { defineConfig } from 'vitest/config' export default defineConfig({ test: { root: '.', + typecheck: { + // `tsconfig.json` only includes `src`, so tests are otherwise never + // type-checked. Type-level contracts (e.g. "reading .body off an index + // entry must not compile") live in *.test-d.ts and are asserted here. + enabled: true, + include: ['tests/**/*.test-d.ts'], + tsconfig: './tsconfig.test.json', + }, }, }) diff --git a/packages/skills/skills/contentrain-bulk/SKILL.md b/packages/skills/skills/contentrain-bulk/SKILL.md index 24d1fe2..f7b0deb 100644 --- a/packages/skills/skills/contentrain-bulk/SKILL.md +++ b/packages/skills/skills/contentrain-bulk/SKILL.md @@ -39,13 +39,15 @@ Confirm: ### 2. Pick the Correct Bulk Operation - `copy_locale`: clone one locale to another for i18n-enabled `collection`, `singleton`, or `dictionary` models -- `update_status`: update metadata state for many collection entries +- `update_status`: update metadata state — many entries at once for `collection`, or the single record of a `singleton`/`dictionary` - `delete_entries`: remove many collection entries at once ### 3. Apply Safety Rules - never use `copy_locale` on non-i18n models -- `update_status` and `delete_entries` are collection-only +- `update_status` takes `entry_ids` for `collection` models and **rejects them** for `singleton`/`dictionary` — those have one meta record per locale, so omit `entry_ids` entirely. Document models are not supported (their meta is keyed by slug). +- `delete_entries` is collection-only +- `update_status` rewrites every supported locale unless you pass `locale`. Pass it when restoring one locale's status, or you will change the other's too. - confirm entry IDs before delete operations - batch only related entries together @@ -71,6 +73,28 @@ Examples: } ``` +Scoped to one locale — leaves every other locale's status untouched: + +```json +{ + "operation": "update_status", + "model": "blog-post", + "entry_ids": ["post_001"], + "status": "published", + "locale": "tr" +} +``` + +A singleton or dictionary — one meta record, so no `entry_ids`: + +```json +{ + "operation": "update_status", + "model": "site-settings", + "status": "published" +} +``` + ```json { "operation": "delete_entries", diff --git a/packages/skills/skills/contentrain-sdk/SKILL.md b/packages/skills/skills/contentrain-sdk/SKILL.md index 5e36c0c..bc9e71b 100644 --- a/packages/skills/skills/contentrain-sdk/SKILL.md +++ b/packages/skills/skills/contentrain-sdk/SKILL.md @@ -114,6 +114,22 @@ const t = await client.dictionary('ui').locale('en').get() const doc = await client.document('docs').locale('en').bySlug('intro') ``` +### CDN `document()` — `all()` returns no bodies + +The CDN splits documents in two: `all()`/`first()` read the model's `_index` +(frontmatter only), `bySlug()` reads the per-slug document (`{ frontmatter, body, +html }`). Reading `.body` off an `all()` result is a compile error — fetch it +with `bySlug()`. + +```ts +const q = client.document('guide-sections').locale('tr') +const index = await q.sort('order', 'asc').all() // titles, slugs, order — no body +const full = await Promise.all(index.map(s => q.bySlug(s.slug))) // bodies +``` + +The bundled runtime's `all()` *does* include bodies. Do not assume code that +works bundled behaves the same on CDN. + ### CDN Collection Query Operators | Operator | Example | Description |