Skip to content
38 changes: 38 additions & 0 deletions .changeset/document-index-entry.md
Original file line number Diff line number Diff line change
@@ -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<T[]>`, 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<T>[]`
(`Omit<T, 'body'>`), and `first()` returns `DocumentIndexEntry<T> | 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<GuideSections>('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<GuideSections>('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.
61 changes: 61 additions & 0 deletions .changeset/publish-status-fixes.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ When working with Contentrain content operations (models, content, normalize, va
npx oxlint packages/<package>/src/ packages/<package>/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

Expand Down
23 changes: 23 additions & 0 deletions docs/packages/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
28 changes: 28 additions & 0 deletions docs/packages/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` — frontmatter only, **no `body`** |
| `bySlug(slug)` | `documents/{model}/{slug}/{locale}.json` | `{ frontmatter: T, body, html }` |

```ts
const q = client.document<GuideSection>('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
Expand Down
41 changes: 15 additions & 26 deletions packages/mcp/src/core/content-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 {
Expand All @@ -85,23 +87,6 @@ export interface ListOpts {
offset?: number
}

// ─── Default meta ───

function defaultMeta(data?: Record<string, unknown>): 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(
Expand Down Expand Up @@ -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
}
Expand All @@ -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<string, EntryMeta> | null
await writeMeta(projectRoot, model, { locale, entryId: id, defaultLocale }, mergeEntryMeta(prevMetaMap?.[id], entry.data))
results.push({ action, id, locale })
break
}
Expand All @@ -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
}
Expand Down Expand Up @@ -235,7 +223,8 @@ export async function writeContent(

const merged = { ...existing, ...entry.data as Record<string, string> }
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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}

Expand All @@ -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
}

Expand Down Expand Up @@ -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
}
Expand Down
47 changes: 38 additions & 9 deletions packages/mcp/src/core/doctor.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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' })
}
Expand Down Expand Up @@ -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<number | null> {
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<string[]> {
const crDir = contentrainDir(projectRoot)
const models = await listModels(projectRoot)
Expand Down
Loading
Loading