feat(content): read the title from the field the model declares - #192
Merged
Conversation
added 2 commits
August 14, 2026 00:37
`@contentrain/types@1.0.0` makes `ModelDefinition.title_field` required.
Every consumer used to guess which field was an entry's title, and the
guess was wrong in ways that made listings unreadable — a hero slide
titled by a relation ID, an article titled by its slug.
This is the migration, so the bump has something valid to land on. It
has to come first: without it, a bumped Studio would fail validation on
its own dictionary.
Produced by `contentrain@0.9.2 validate --fix`, not by hand — the point
of the tool is that the choice and the rule behind it are the CLI's, and
a hand-written `title_field` would be exactly the guess this replaces.
4 dictionaries → key (the entry key is the title; the only legal value)
3 collections → name over slug / subject / description
1 singleton → headline
1 collection → name (starters)
Errors go 10 → 1. The one left is a false positive: the plan feature
whose id is `cdn-api-keys` has `key: "cdn.api_keys"`, and the secret
detector reads the name as a credential. Worth reporting upstream now
that `contentrain validate` exits non-zero — a CI step running it would
fail on a correctly-named feature flag.
Carried along, because the same command writes them:
- model files pick up canonical key order from `MODEL_FIELD_ORDER`,
which moved into `@contentrain/types`. `plans.json` had its top-level
keys scrambled; fields sort alphabetically now.
- two `error-messages` keys swap into canonical order.
- orphan meta records for `plans/community` and nine plan features.
They land as `source: import`, `status: draft` — the same shape every
neighbouring record already has, so nothing changes what the app
renders.
- `context.json` regenerates.
Run in an isolated copy rather than here: `--fix` works through the
`contentrain` branch, and this repo's is 44 commits behind `main` — the
CLI refused rather than writing onto stale content, which is the right
refusal and a separate thing to fix. Studio's own dictionary lives on
`main` and is edited by PR; the resulting files were brought over and
re-validated in place.
An integration group listed as `i-lucide-bot`, an article as its slug, a hero slide as `f3a81c09d24e`. Each is the same bug: Studio guessed which field was an entry's title, and the guess ranked `slug` alongside `string` and had no opinion about `icon`. `@contentrain/types@1.0.0` replaces the guess with a declaration. Bumps types 0.9.0 → 1.0.2, mcp 2.3.0 → 3.0.2, query 7.0.1 → 7.0.4. The resolution lives in one place, `app/utils/entry-title.ts`, because two call sites need the same answer — the entry list and a relation's label — and a hero slide naming an article one way while the article list names it another is how this started. The old inference survives one step down, minus its two bad rankings: hand-edited models, the migration window and older CLI output all still exist, and Studio has to render them rather than fail. Relation labels take the target model, so `ARTICLE: f3a81c09d24e` becomes the article's headline. ## What the bump moved, beyond `title_field` The rule after an MCP major is to diff its git internals rather than trust a green suite. Doing that: - `GitHubProvider` — byte-identical bar import hashes. Studio's write path is unchanged. - "a document save no longer replaces what it did not send" is neutral here: Studio already merges frontmatter and body before handing the entry to `planContentSave`, for both documents and collections. The two merges compose. - `planVocabularySave` / `planVocabularyDelete` are new, and Studio's vocabulary PATCH still does its own read-modify-write. Not switched in this PR — the retry loop from #186 guards a race the plan helper does not — but worth revisiting. ## `updated_at` had to be written, not just read The brief asked only that Studio *read* the new field. Reading it would have returned nothing forever: `applyStudioMetaOverrides` replaces MCP's meta wholesale to keep Studio's autoPublish / updated_by semantics, and `updateEntryStatus` builds meta by hand. MCP mints the stamp; both of those threw it away. Both stamp it now, one timestamp per call, so a bulk status change reads as the single operation it was. Without this, T06's "sort by recently edited" would have had no data for precisely the entries Studio users create. ## The picker writes directly, not through chat Every other action in that header goes through `sendChatPrompt`. This one does not: an MCP write takes ~17-18s, and a radio button that stays unconfirmed that long reads as broken. `[modelId].patch.ts` gains a `titleField` branch, deliberately OUTSIDE the `forms.enabled` gate — which field titles an entry is part of the model contract, not a forms feature, and gating it would leave listings unreadable on every plan without forms. Owner/admin is kept: it writes a model definition and produces a git commit. The chat path needs nothing; the agent already calls `contentrain_model_save` with the full definition.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes T07b. Stacked on #191 (the migration) — merge that first; without it a bumped Studio fails validation on its own dictionary.
An integration group listed as
i-lucide-bot, an article as its slug, a hero slide asf3a81c09d24e. Each is the same bug: Studio guessed which field was an entry's title, and the guess rankedslugalongsidestringand had no opinion abouticon.@contentrain/types@1.0.0replaces the guess with a declaration.@contentrain/types@contentrain/mcp@contentrain/queryOne resolver, two call sites
app/utils/entry-title.ts. The entry list and a relation's label need the same answer — a hero slide naming an article one way while the article list names it another is how this started.The old inference survives one step down, minus its two bad rankings (
slugdemoted,icon/colorexcluded). Hand-edited models, the migration window and older CLI output all still exist, and Studio has to render them rather than 500.updated_athad to be written, not just readThe brief asked only that Studio read the new field. Reading it would have returned nothing forever:
applyStudioMetaOverridesreplaces MCP's meta wholesale to keep Studio'sautoPublish/updated_bysemanticsupdateEntryStatusbuilds meta by handMCP mints the stamp; both of those threw it away. Both stamp it now — one timestamp per call, so a bulk status change reads as the single operation it was. Without this, T06's "sort by recently edited" would have had no data for precisely the entries Studio users create.
The picker writes directly, not through chat
Every other action in that header goes through
sendChatPrompt. This one does not: an MCP write takes ~17-18s, and a radio button that stays unconfirmed that long reads as broken.[modelId].patch.tsgains atitleFieldbranch, deliberately outside theforms.enabledgate — which field titles an entry is part of the model contract, not a forms feature, and gating it would leave listings unreadable on every plan without forms. There is a test that fails if someone moves it back inside. Owner/admin is kept: it writes a model definition and produces a git commit.The chat path needs nothing — the agent already calls
contentrain_model_savewith the full definition.What the bump moved, beyond
title_fieldThe standing rule after an MCP major is to diff its git internals rather than trust a green suite (1.8.0's
mergeBranchsilently started deleting the source branch). Doing that:GitHubProvideris byte-identical bar import hashes. Studio's write path is unchanged.planContentSave, for both documents and collections — the two merges compose.planVocabularySave/planVocabularyDeleteare new. Studio's vocabulary PATCH still does its own read-modify-write; not switched in this PR, because the retry loop from fix(vocabulary): stop concurrent saves from dropping each other's terms #186 guards a lost-update race the plan helper does not. Worth revisiting.Verified
pnpm lint— 0 errors (7 pre-existing warnings)pnpm typecheck— clean, including against the now-requiredModelDefinition.title_fieldpnpm test— 143 files / 1225 testsupdated_atstampsNot verified — needs staging
The acceptance criterion the brief calls non-negotiable for an MCP major:
title" through chat, and the list refreshing