From e94cb98aff8637e12345a77b824a591061b74da8 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:26:11 +0300 Subject: [PATCH 1/8] fix(mcp): preserve entry publish status on content_save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit content_save rebuilt an entry's meta from scratch on every write, so editing a single field silently moved a `published` entry to `draft` — and the next CDN build served the whole collection as `{}`. Found on a live project: five published guides became two bytes of JSON, and contentrain_validate reported zero errors throughout. The sharpest tell was that the code already knew: content-save.ts computed `action: 'updated'` for a pre-existing entry, then overwrote its meta with defaultMeta() anyway — the distinction only ever reached the response payload. Resetting status is 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. - New `mergeEntryMeta(existing, data)` in meta-manager.ts. An existing entry keeps `status`, `approved_by` and `version`; only an entry with no prior meta starts at `draft`. `source`/`updated_by` describe this write, so they are still stamped. - All four kinds now read prior meta. Only the collection branch did before — and even it discarded the entry's own record while preserving its siblings. - The same reset lived in a byte-equivalent second copy in content-manager.ts, reached via contentrain_apply and scaffolding. Both call sites now share the one helper, so a fix here cannot drift. Tests: tests/core/content-save-meta.test.ts (10, plan layer, no git) and 5 new cases in tests/core/content.test.ts covering all four kinds, sibling isolation, approved_by/version survival, and publish_at still applying. Reverting mergeEntryMeta to a hardcoded draft turns 5 of them red. Existing create-path assertions are untouched — they always asserted draft on create, which stays correct. --- packages/mcp/src/core/content-manager.ts | 41 ++-- packages/mcp/src/core/meta-manager.ts | 65 +++++- packages/mcp/src/core/ops/content-save.ts | 43 ++-- .../mcp/tests/core/content-save-meta.test.ts | 216 ++++++++++++++++++ packages/mcp/tests/core/content.test.ts | 119 +++++++++- 5 files changed, 436 insertions(+), 48 deletions(-) create mode 100644 packages/mcp/tests/core/content-save-meta.test.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/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-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/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 ─── From 11340372a6e6d5d60572a00ce7178e3515ed8153 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:26:29 +0300 Subject: [PATCH 2/8] fix(mcp): stop bulk update_status/copy_locale losing all but one write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update_status reported `updated: 10` while committing a two-line diff. Five entry_ids across two locales persisted one status change per locale; the other four were silently dropped, and the tool still returned `status: "committed"`. copy_locale had the identical defect, writing one meta record instead of N while reporting `copied: N`. Root cause was `await Promise.all(updateOps)` over per-entry writeMeta calls. metaPath resolves to the same `{locale}.json` for every entry ID, and writeMeta is itself a read-modify-write of that whole file — so all N calls read the same snapshot and rewrote the file wholesale. Last to settle won. A plain sequential await loop would have been correct; the Promise.all is precisely what broke it. That matters beyond this call site: CLAUDE.md's quality gate says "no-await-in-loop -> use Promise.all() for parallel I/O", and this is that rule applied mechanically to shared-file writes. The rule is now qualified — reads and distinct paths only, never a read-modify-write over shared state — because otherwise the next author reintroduces this. - New `writeMetaEntries()` does one read-modify-write per locale file and returns the IDs actually written, so the race cannot be reintroduced by looping writeMeta. Both operations use it. - `updated` is now counted from what persisted, never from entry_ids.length, and a run that matches nothing fails loudly instead of committing empty. - Guard order fixed. The entry_ids check ran before the model-kind check, so a singleton had no reachable path: omitting entry_ids failed with "requires entry_ids", supplying them failed with "only supported for collection models". Singletons and dictionaries are now supported (omit entry_ids); documents are rejected with a reason. - New optional `locale` scopes the change; previously every supported locale was rewritten, so restoring one locale's status changed the other's. Tests: tests/tools/bulk.test.ts (7) — the tool had no test file at all, which is why both races shipped. Reintroducing the Promise.all turns "persists every entry_id" red with the exact production symptom (expected 'draft' to be 'published'). --- CLAUDE.md | 2 +- packages/mcp/src/tools/bulk.ts | 136 ++++++++++----- packages/mcp/tests/tools/bulk.test.ts | 235 ++++++++++++++++++++++++++ 3 files changed, 334 insertions(+), 39 deletions(-) create mode 100644 packages/mcp/tests/tools/bulk.test.ts 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/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/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') + } + }) +}) From de5442ff6caece212d77e4d54be2d85212095276 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:26:42 +0300 Subject: [PATCH 3/8] fix(mcp): pin non-i18n meta to the default locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A model with i18n:false keeps all its content in one data.json, so it has exactly one meta record. But the meta path was derived from the caller's locale, so saving the same entry under two locales produced meta/{id}/tr.json AND meta/{id}/en.json for one content file — and every reader was left to guess which was authoritative. paths.ts held the evidence 26 lines apart: contentFilePath's Pick includes 'i18n' and collapses to data.json; metaFilePath's Pick was 'id' | 'kind', so i18n was not even in the type and the function structurally could not branch on it. content-save then handed the same `locale` to both. - metaFilePath now takes 'i18n' in the Pick plus a required defaultLocale, making the mistake unrepresentable rather than merely fixed. tsc located all eight call sites. - meta-manager's metaPath gets the same treatment, which also removes its hardcoded `'en'` fallback — that silently wrote to the wrong file on any project whose default locale was not en. - Fixes a second bug this asymmetry caused: non-i18n collection deletes resolved locales from data.json, yielding the pseudo-locale "data", and looked for meta/{id}/data.json — a file that never existed. The read returned null, the loop continued, and the meta entry was orphaned on every delete. metaFilePath now maps "data" onto the default locale. - describe_format claimed "locale is ignored in file paths", which was false for meta and left agents to infer the layout. It now states the meta rule and carries a meta_pattern. The validator already resolved non-i18n models against the default locale only, so it agreed with the new convention and needed no behaviour change. Tests: 3 cases in tests/core/content-save-meta.test.ts under a config whose default (tr) is deliberately not supported[0] (en) — the exact shape that made a real project's CDN serve non-i18n collections empty. --- packages/mcp/src/core/ops/content-delete.ts | 15 ++++++++++++--- packages/mcp/src/core/ops/paths.ts | 18 +++++++++++++++--- packages/mcp/src/tools/content.ts | 1 + packages/mcp/src/tools/context.ts | 6 +++++- 4 files changed, 33 insertions(+), 7 deletions(-) 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/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/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.', From c882b0f9318fac450fdb3c81fd314891da6e2384 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:26:59 +0300 Subject: [PATCH 4/8] fix(mcp): compare file mtimes in doctor's SDK freshness check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doctor reported "SDK client: Stale — run `contentrain generate`" permanently. Running generate did not clear it, which trains people to ignore a health check. It stat'd the two directories. A directory's mtime only moves when an entry is added, removed or renamed inside it — and generate rewrites index.mjs/index.d.ts/index.cjs in place, so .contentrain/client stops moving after the very first run. Meanwhile a selective sync recreates model files via `git checkout HEAD -- `, which unlinks and recreates them and does bump .contentrain/models. So after any model save the comparison could never pass again. newestFileMtime() now walks each directory and compares the newest file mtime, which existing-file rewrites do update. The recursion covers client/data/*, where per-model modules land. The existing test only passed because it created a brand-new model file — the one case that bumps a directory mtime by design — so it never exercised the re-generate path where the bug lives. Tests: 2 new cases in tests/core/doctor.test.ts — one regenerating in place after a model save (red against the old directory-stat code), one where only a nested data file is newer. --- packages/mcp/src/core/doctor.ts | 47 +++++++++++++++++++++----- packages/mcp/tests/core/doctor.test.ts | 43 +++++++++++++++++++++++ 2 files changed, 81 insertions(+), 9 deletions(-) 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/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', () => { From 4ec6418d63aab5b7966aee2bae342c37d60f3aaa Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:26:59 +0300 Subject: [PATCH 5/8] feat(mcp): flag publish-state drift and non-i18n meta layout mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit contentrain_validate reported zero errors while a project's collections were being served empty from the CDN. It only ever checked whether meta existed, never what it said — so the entire class of failure behind the preceding three fixes was invisible to it. - Notice: drafts sitting alongside published entries in one collection. That is the fingerprint of a write that reset status. Advisory only — a genuine work-in-progress entry is indistinguishable, so this is a call for the agent to make. Never auto-fixed: publishing is a content decision, and MCP does not make those. - Warning: a non-i18n model with meta files outside the default locale. Reported, not auto-removed — a stray may hold the only `published` status in the project, so deleting it could unpublish content. The message names the file to merge into and the ones to drop. Docs: essentials now state that content_save preserves status and that the CDN only delivers `published` collection entries; the bulk skill documents the singleton/dictionary form and the locale scope; the MCP docs page gains a publish-status section. Tests: tests/core/validator/status-drift.test.ts (5), reader-backed and git-free, including the quiet cases so the checks cannot start shouting at healthy projects. --- docs/packages/mcp.md | 23 +++ packages/mcp/src/core/validator/project.ts | 71 ++++++++- .../tests/core/validator/status-drift.test.ts | 135 ++++++++++++++++++ .../rules/essential/contentrain-essentials.md | 1 + .../skills/skills/contentrain-bulk/SKILL.md | 28 +++- 5 files changed, 253 insertions(+), 5 deletions(-) create mode 100644 packages/mcp/tests/core/validator/status-drift.test.ts 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/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/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/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/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", From ead66ce7364122289482dd814abdadc99a2433f0 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:27:14 +0300 Subject: [PATCH 6/8] feat(sdk)!: return index entries from CDN document().all() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the CDN, document(model).all() reads the model's _index, which carries frontmatter only — the body lives in the per-slug document. But all() was typed Promise, and the generated document type declares `body: string`. So `entry.body` type-checked, was undefined at runtime, and pages rendered their headings and silently dropped their prose. No error, no warning, a passing typecheck, and a "verified live on CDN" check that passed because it only looked at anchors and the table of contents — both frontmatter. The trap is that the bundled runtime's all() does carry bodies: it reads the content files directly. Code written and tested against bundled delivery compiled and passed, then rendered empty after a CDN switch. The two modes genuinely differ in shape, so they no longer share a return type. BREAKING CHANGE: CdnDocumentQuery.all() returns DocumentIndexEntry[] (Omit) and first() returns DocumentIndexEntry | undefined. Reading .body off either is now a compile error; use bySlug(slug), which returns { frontmatter, body, html }. Runtime behaviour is unchanged — this only makes the type tell the truth about what was already returned, so the failure moves from a silently blank page to a build error. Also fixes the unchecked `fetch` assertion in http-transport that let the lie through in the first place. Tests: tests/cdn/document-query.test.ts (6) — the CDN document query had no tests — plus tests/cdn/document-query.test-d.ts (4) pinning the type contract. That needed real infrastructure: tsconfig.json only includes `src`, so the tests were never type-checked and a @ts-expect-error in them would have been decorative. vitest typecheck is now enabled against a tsconfig.test.json, runs under the existing `pnpm test`, and reverting DocumentIndexEntry to T fails with "Unused '@ts-expect-error' directive". --- docs/packages/sdk.md | 28 +++++++ packages/sdk/js/README.md | 29 +++++++ packages/sdk/js/src/cdn/data-source.ts | 17 +++- packages/sdk/js/src/cdn/document-query.ts | 13 ++- packages/sdk/js/src/cdn/http-transport.ts | 5 +- packages/sdk/js/src/cdn/index.ts | 2 +- packages/sdk/js/src/index.ts | 1 + .../sdk/js/tests/cdn/document-query.test-d.ts | 44 ++++++++++ .../sdk/js/tests/cdn/document-query.test.ts | 83 +++++++++++++++++++ packages/sdk/js/tsconfig.test.json | 8 ++ packages/sdk/js/vitest.config.ts | 8 ++ .../skills/skills/contentrain-sdk/SKILL.md | 16 ++++ 12 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 packages/sdk/js/tests/cdn/document-query.test-d.ts create mode 100644 packages/sdk/js/tests/cdn/document-query.test.ts create mode 100644 packages/sdk/js/tsconfig.test.json 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/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/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/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-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 | From d617dabd132a85fb427edae10e55676eaa850c45 Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:27:14 +0300 Subject: [PATCH 7/8] chore: changesets for publish-status fixes and the document index type --- .changeset/document-index-entry.md | 38 +++++++++++++++++++ .changeset/publish-status-fixes.md | 61 ++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 .changeset/document-index-entry.md create mode 100644 .changeset/publish-status-fixes.md 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. From 85be50e48bdfcd29e41036a5e30417572baa224f Mon Sep 17 00:00:00 2001 From: AHMET BAYHAN BAYRAMOGLU <49499275+ABB65@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:58:29 +0300 Subject: [PATCH 8/8] fix(sdk): fix two latent type errors in tests surfaced by typecheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enabling vitest typecheck for the document index-entry contract made the typechecker read the whole tsconfig.test.json project — src plus tests — for the first time. tsconfig.json only includes `src`, so these two test files had never been type-checked and had drifted: - tests/runtime/singleton.test.ts — `interface Hero` has no implicit index signature, so Map is not assignable to the accessor's Map>. A type alias does have one. - tests/cdn/bundle-preload.test.ts — mock.calls[1] is possibly undefined under noUncheckedIndexedAccess. Both are test-only; no source behaviour changes. They surfaced as "Unhandled Source Error" and failed the run while the 260 tests passed, which is exactly the point of turning the check on. --- packages/sdk/js/tests/cdn/bundle-preload.test.ts | 2 +- packages/sdk/js/tests/runtime/singleton.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) 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/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