Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions packages/mcp/src/core/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,15 @@ export async function writeContext(

/**
* Build a FileChange for `.contentrain/context.json` that remote providers
* (GitHubProvider over HTTP, for example) can slot into their plan — the
* (GitHubProvider over HTTP, for example) can slot into their plan. The
* local write path still uses {@link writeContext} directly because its
* transaction layer computes entry counts against the post-apply worktree,
* which is cheaper than re-walking the repo via the Git Data API.
* transaction layer writes into the post-apply worktree with real stats.
*
* Entry count is left as the reader cannot enumerate directories cheaply
* across every provider; the legacy writeContext path continues to supply
* real counts for local flows.
* Remote providers (Phase 5.5+) now also get accurate entry counts: the
* reader-based {@link countEntries} walks each model over the provider's
* read surface. GitHubProvider pays an extra round trip per model; the
* payoff is a context.json that matches what the local write path emits,
* so cross-provider merges stay deterministic.
*/
export async function buildContextChange(
reader: RepoReader,
Expand All @@ -83,6 +84,19 @@ export async function buildContextChange(
const models = await listModels(reader)
const locales = config?.locales.supported ?? ['en']

let totalEntries: number | null = null
try {
const fullModels = await Promise.all(models.map(m => readModel(reader, m.id)))
const counts = await Promise.all(
fullModels
.filter((m): m is NonNullable<typeof m> => m !== null)
.map(m => countEntries(reader, m)),
)
totalEntries = counts.reduce((acc, c) => acc + c.total, 0)
} catch {
totalEntries = null
}

const context: ContextJson = {
version: '1',
lastOperation: {
Expand All @@ -95,7 +109,7 @@ export async function buildContextChange(
},
stats: {
models: models.length,
entries: null as unknown as number,
entries: totalEntries as number,
locales,
lastSync: new Date().toISOString(),
},
Expand Down
95 changes: 65 additions & 30 deletions packages/mcp/src/core/model-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { rm } from 'node:fs/promises'
import { z } from 'zod'
import { contentrainDir, ensureDir, readDir, readJson, writeJson } from '../util/fs.js'
import type { RepoReader } from './contracts/index.js'
import { LocalReader } from '../providers/local/reader.js'
import { resolveContentDir, resolveLocaleStrategy } from './content-manager.js'
import { contentDirPath } from './ops/paths.js'

export type { ModelSummary } from '@contentrain/types'

Expand Down Expand Up @@ -69,15 +71,16 @@ export async function readModel(input: string | RepoReader, modelId: string): Pr
}

async function countDocumentFileStrategy(
reader: RepoReader,
contentDir: string,
entries: string[],
): Promise<{ total: number; locales: Record<string, number> }> {
): Promise<{ total: number, locales: Record<string, number> }> {
const locales: Record<string, number> = {}
let total = 0

const results = await Promise.all(
entries.map(async (entry) => {
const localeFiles = await readDir(join(contentDir, entry))
const localeFiles = await reader.listDirectory(`${contentDir}/${entry}`)
return localeFiles
.map(lf => lf.replace(/\.(json|md|mdx)$/, ''))
.filter((locale, i) => locale !== localeFiles[i])
Expand All @@ -95,9 +98,10 @@ async function countDocumentFileStrategy(
}

async function countDocumentSuffixStrategy(
_reader: RepoReader,
_contentDir: string,
files: string[],
): Promise<{ total: number; locales: Record<string, number> }> {
): Promise<{ total: number, locales: Record<string, number> }> {
const locales: Record<string, number> = {}
const slugsByLocale: Record<string, Set<string>> = {}

Expand All @@ -121,15 +125,16 @@ async function countDocumentSuffixStrategy(
}

async function countDocumentDirectoryStrategy(
reader: RepoReader,
contentDir: string,
localeDirs: string[],
): Promise<{ total: number; locales: Record<string, number> }> {
): Promise<{ total: number, locales: Record<string, number> }> {
const locales: Record<string, number> = {}
let total = 0

const results = await Promise.all(
localeDirs.map(async (localeDir) => {
const files = await readDir(join(contentDir, localeDir))
const files = await reader.listDirectory(`${contentDir}/${localeDir}`)
const mdFiles = files.filter(f => f.endsWith('.md'))
return { locale: localeDir, count: mdFiles.length }
}),
Expand All @@ -144,11 +149,11 @@ async function countDocumentDirectoryStrategy(
}

async function countDocumentNoneStrategy(
projectRoot: string,
reader: RepoReader,
modelId: string,
files: string[],
i18n: boolean,
): Promise<{ total: number; locales: Record<string, number> }> {
): Promise<{ total: number, locales: Record<string, number> }> {
const mdFiles = files.filter(f => f.endsWith('.md'))

if (!i18n) {
Expand All @@ -157,14 +162,14 @@ async function countDocumentNoneStrategy(

// With i18n + none strategy, content files have no locale info.
// Use meta/{modelId}/{slug}/ directories to determine locale counts.
const metaDir = join(contentrainDir(projectRoot), 'meta', modelId)
const metaDir = `.contentrain/meta/${modelId}`
const locales: Record<string, number> = {}
let total = 0

const slugs = mdFiles.map(f => f.replace('.md', ''))
const results = await Promise.all(
slugs.map(async (slug) => {
const metaFiles = await readDir(join(metaDir, slug))
const metaFiles = await reader.listDirectory(`${metaDir}/${slug}`)
return metaFiles
.filter(f => f.endsWith('.json'))
.map(f => f.replace('.json', ''))
Expand All @@ -182,16 +187,17 @@ async function countDocumentNoneStrategy(
}

async function countCollectionEntries(
reader: RepoReader,
contentDir: string,
jsonFiles: string[],
): Promise<{ total: number; locales: Record<string, number> }> {
): Promise<{ total: number, locales: Record<string, number> }> {
const locales: Record<string, number> = {}
let total = 0

const results = await Promise.all(
jsonFiles.map(async (file) => {
const locale = file.replace(/\.json$/, '')
const data = await readJson<Record<string, unknown>>(join(contentDir, file))
const data = await tryReadJsonViaReader<Record<string, unknown>>(reader, `${contentDir}/${file}`)
return { locale, count: data ? Object.keys(data).length : 0 }
}),
)
Expand All @@ -204,37 +210,53 @@ async function countCollectionEntries(
return { total, locales }
}

export async function countEntries(
/**
* Count content entries across locales for a model.
*
* Accepts either a legacy `projectRoot` string (wraps LocalReader internally)
* or any `RepoReader` — so remote providers (GitHubProvider) feed the same
* counting logic via the Git Data API.
*/
export function countEntries(
projectRoot: string,
model: ModelDefinition,
): Promise<{ total: number; locales: Record<string, number> }> {
const cDir = resolveContentDir(projectRoot, model)
): Promise<{ total: number, locales: Record<string, number> }>
export function countEntries(
reader: RepoReader,
model: ModelDefinition,
): Promise<{ total: number, locales: Record<string, number> }>
export async function countEntries(
input: string | RepoReader,
model: ModelDefinition,
): Promise<{ total: number, locales: Record<string, number> }> {
const reader: RepoReader = typeof input === 'string' ? new LocalReader(input) : input
const cDir = contentDirPath(model)
const strategy = resolveLocaleStrategy(model)
const files = await readDir(cDir)
const files = await reader.listDirectory(cDir)

if (model.kind === 'document') {
if (!model.i18n) {
// No i18n: flat {slug}.md files
return countDocumentNoneStrategy(projectRoot, model.id, files, false)
return countDocumentNoneStrategy(reader, model.id, files, false)
}

switch (strategy) {
case 'file':
return countDocumentFileStrategy(cDir, files)
return countDocumentFileStrategy(reader, cDir, files)
case 'suffix':
return countDocumentSuffixStrategy(cDir, files)
return countDocumentSuffixStrategy(reader, cDir, files)
case 'directory':
return countDocumentDirectoryStrategy(cDir, files)
return countDocumentDirectoryStrategy(reader, cDir, files)
case 'none':
return countDocumentNoneStrategy(projectRoot, model.id, files, true)
return countDocumentNoneStrategy(reader, model.id, files, true)
}
}

if (model.kind === 'collection') {
if (!model.i18n) {
// Non-i18n: single data.json
const jsonFiles = files.filter(f => f.endsWith('.json'))
return countCollectionEntries(cDir, jsonFiles)
return countCollectionEntries(reader, cDir, jsonFiles)
}
switch (strategy) {
case 'suffix': {
Expand All @@ -244,7 +266,7 @@ export async function countEntries(
for (const f of jsonFiles) {
const match = f.match(/^.+\.([a-z]{2}(?:-[A-Z]{2})?)\.json$/)
if (match) {
const data = await readJson<Record<string, unknown>>(join(cDir, f))
const data = await tryReadJsonViaReader<Record<string, unknown>>(reader, `${cDir}/${f}`)
locales[match[1]!] = data ? Object.keys(data).length : 0
}
}
Expand All @@ -256,10 +278,10 @@ export async function countEntries(
const locales: Record<string, number> = {}
let total = 0
for (const localeDir of files) {
const subFiles = await readDir(join(cDir, localeDir))
const subFiles = await reader.listDirectory(`${cDir}/${localeDir}`)
const jsonFile = subFiles.find(f => f.endsWith('.json'))
if (jsonFile) {
const data = await readJson<Record<string, unknown>>(join(cDir, localeDir, jsonFile))
const data = await tryReadJsonViaReader<Record<string, unknown>>(reader, `${cDir}/${localeDir}/${jsonFile}`)
const count = data ? Object.keys(data).length : 0
locales[localeDir] = count
total += count
Expand All @@ -271,14 +293,14 @@ export async function countEntries(
// Single file: {model}.json — count entries inside
const noneFile = files.find(f => f === `${model.id}.json`)
if (!noneFile) return { total: 0, locales: {} }
const data = await readJson<Record<string, unknown>>(join(cDir, noneFile))
const data = await tryReadJsonViaReader<Record<string, unknown>>(reader, `${cDir}/${noneFile}`)
const count = data ? Object.keys(data).length : 0
return { total: count, locales: { _: count } }
}
default: {
// 'file': {locale}.json
const jsonFiles = files.filter(f => f.endsWith('.json'))
return countCollectionEntries(cDir, jsonFiles)
return countCollectionEntries(reader, cDir, jsonFiles)
}
}
}
Expand All @@ -304,7 +326,7 @@ export async function countEntries(
// Dirs: {locale}/...
const locales: Record<string, number> = {}
for (const localeDir of files) {
const subFiles = await readDir(join(cDir, localeDir))
const subFiles = await reader.listDirectory(`${cDir}/${localeDir}`)
if (subFiles.some(f => f.endsWith('.json'))) {
locales[localeDir] = 1
}
Expand Down Expand Up @@ -376,12 +398,25 @@ export interface ModelReference {
type: 'relation' | 'relations'
}

export async function checkReferences(projectRoot: string, modelId: string): Promise<ModelReference[]> {
const summaries = await listModels(projectRoot)
/**
* Enumerate models that reference `modelId` through relation or relations
* fields. Used by `model_delete` to block destructive deletes when other
* models still depend on the target.
*
* Accepts either a legacy `projectRoot` string (LocalReader wraps internally)
* or any `RepoReader` — remote providers get the same pre-check.
*/
export function checkReferences(projectRoot: string, modelId: string): Promise<ModelReference[]>
export function checkReferences(reader: RepoReader, modelId: string): Promise<ModelReference[]>
export async function checkReferences(
input: string | RepoReader,
modelId: string,
): Promise<ModelReference[]> {
const summaries = typeof input === 'string' ? await listModels(input) : await listModels(input)
const others = summaries.filter(s => s.id !== modelId)

const models = await Promise.all(
others.map(s => readModel(projectRoot, s.id)),
others.map(s => typeof input === 'string' ? readModel(input, s.id) : readModel(input, s.id)),
)

const refs: ModelReference[] = []
Expand Down
13 changes: 12 additions & 1 deletion packages/mcp/src/core/ops/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,22 @@ import type { ModelDefinition } from '@contentrain/types'
* `core/meta-manager.ts` will call into these (or be replaced entirely).
*/

function contentDir(model: Pick<ModelDefinition, 'domain' | 'id' | 'content_path'>): string {
/**
* Content-root-relative content directory for a model (forward-slash paths).
* Mirrors `resolveContentDir` with an empty `projectRoot`, so reader-backed
* callers and plan-API consumers share the same path resolution.
*/
export function contentDirPath(
model: Pick<ModelDefinition, 'domain' | 'id' | 'content_path'>,
): string {
if (model.content_path) return model.content_path
return `.contentrain/content/${model.domain}/${model.id}`
}

function contentDir(model: Pick<ModelDefinition, 'domain' | 'id' | 'content_path'>): string {
return contentDirPath(model)
}

export function contentFilePath(
model: Pick<ModelDefinition, 'id' | 'kind' | 'domain' | 'i18n' | 'content_path' | 'locale_strategy'>,
locale: string,
Expand Down
Loading
Loading