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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
"@aws-sdk/client-s3": "^3.1076.0",
"@contentrain/mcp": "3.9.0",
"@contentrain/query": "7.4.0",
"@contentrain/types": "1.35.0",
"@contentrain/types": "1.40.0",
"@gitbeaker/rest": "^43.8.0",
"@nuxt/eslint": "1.16.0",
"@nuxt/image": "2.0.0",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions server/utils/migration-handoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
*/

import type { CommentsExport, HandoffComments, MigrationHandoff } from '@contentrain/types'
import { CONTENTRAIN_BRANCH } from '@contentrain/types'
import { CONTENTRAIN_BRANCH, MIGRATION_CONTRACT_VERSION } from '@contentrain/types'
import type { GitProvider } from '~~/server/providers/git'
import type { CommentsImportReport } from './comment-import'
import { runCommentsImportChunked } from './comment-import'
Expand Down Expand Up @@ -59,7 +59,9 @@ export function validateMigrationHandoff(input: unknown): HandoffValidationError
if (!input || typeof input !== 'object' || Array.isArray(input)) return { code: 'invalid_payload' }
const h = input as Partial<MigrationHandoff>
if (typeof h.version !== 'number' || !Number.isFinite(h.version)) return { code: 'invalid_payload', detail: 'version' }
if (h.version !== 1) return { code: 'unsupported_version', detail: String(h.version) }
// A compatible range, not one number: Studio reads every contract version up to the one its pinned
// `@contentrain/types` knows, so a Migrate bump that stays within the types Studio ships keeps working.
if (!Number.isInteger(h.version) || h.version < 1 || h.version > MIGRATION_CONTRACT_VERSION) return { code: 'unsupported_version', detail: String(h.version) }
if (typeof h.site_url !== 'string' || !h.site_url) return { code: 'invalid_payload', detail: 'site_url' }
if (typeof h.generated_at !== 'string' || Number.isNaN(Date.parse(h.generated_at))) return { code: 'invalid_payload', detail: 'generated_at' }
if (!Array.isArray(h.capabilities)) return { code: 'invalid_capabilities' }
Expand Down
114 changes: 114 additions & 0 deletions tests/fixtures/migrate-handoff-golden.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
{
"version": 1,
"site_url": "https://site.test",
"generated_at": "2026-09-09T10:00:00.000Z",
"content_summary": {
"models": 4,
"entries": 131,
"locales": [
"tr"
]
},
"capabilities": [
{
"key": "forms",
"disposition": "needs_runtime",
"detail": "Studio'ya bağlanınca yeni sitede çalışır; bağlanana dek yeni sitede form görünmez (contact-form-7)"
},
{
"key": "comments",
"disposition": "needs_runtime",
"detail": "Studio'ya bağlanınca yeni sitede çalışır; bağlanana dek yeni sitede yorum görünmez",
"counts": {
"total": 2,
"mapped": 1,
"unresolved": 1
}
},
{
"key": "i18n",
"disposition": "migrated_static",
"detail": "dil başına aile, route'larda locale (polylang)",
"counts": {
"locales": 1
}
},
{
"key": "ecommerce",
"disposition": "needs_runtime",
"detail": "mağaza taşımaya dahil değil; WordPress sunucusunda kalır (woocommerce)"
}
],
"comments": {
"total": 2,
"by_status": {
"0": 1,
"1": 1
},
"types": {
"comment": 2
},
"export": {
"format": "contentrain-comments@1"
},
"threads_closed": [
11
],
"unresolved": [
{
"comment_id": 2,
"post": 99,
"reason": "post has no entry mapping"
}
]
},
"offers": [
{
"capability": "comments",
"provider": "studio_managed"
},
{
"capability": "comments",
"provider": "keep_wordpress",
"warning": "yorumlar WordPress'te kalırsa eski sunucu canlı kalır — bakım, güvenlik ve barındırma maliyeti sürer"
},
{
"capability": "forms",
"provider": "studio_managed"
},
{
"capability": "forms",
"provider": "keep_wordpress",
"warning": "formlar WordPress'te kalırsa gönderimler eski sunucuya gider; sunucu ve form eklentisi canlı kalır"
},
{
"capability": "ecommerce",
"provider": "keep_wordpress",
"warning": "mağaza WordPress'te kalırsa WooCommerce ve ödeme altyapısı eski sunucuda çalışmaya devam eder"
}
],
"runtime": {
"base_url": "https://studio.test",
"project_id": "p1"
},
"notes": [
"derleme başarılı · kalite kapısı geçti",
"görsel sadakat: yazı medyanı 97.6 (1280 px)",
"6 aile · 7 route · 120 yazı · 9 sayfa",
"içerik mağazası: 4 model · 131 kayıt (.contentrain, @contentrain/wp-import) · KESİLDİ: media 3/12",
"çalışma zamanı adresleri: 128 route bağlandı · 1 BAĞLANAMADI",
"mağaza medyası: 12 URL yerel yola çevrildi · 300 eski-origin URL kaldı (medya taşıma: Studio)",
"form modeli: contact (4 alan, cf7) — Studio'da form bloğu açılınca gönderim alır",
"düzenlenebilirlik: 5/7 route içeriği veriden · 2 route DÜZENLENEMEZ (içerik chrome'a pişmiş) · 2 alan veride taşınıyor ama basılmıyor",
"aile genelliği: 3/5 aile canlıya karşı kanıtlandı · kanıtlanmayan: category (weak), page-solo-0 (single-sample)",
"bileşen mount noktaları: c-comments (comments, 1 aile) · c-contact (form, 1 aile) · Studio'ya bağlı: https://studio.test / p1",
"yorumlar: 2 (onaylı 1 · bekleyen 1) · dışa aktarım satır içi · 1 kapalı thread · 1 eşlenmeyen",
"yorum dışa aktarımı Git'e girmez — yorumlar teslim paketinde (makbuz), Studio'ya Migrate üzerinden aktarılır"
],
"repository": {
"provider": "github",
"owner": "acme",
"name": "site",
"default_branch": "main"
}
}
55 changes: 55 additions & 0 deletions tests/unit/migration-handoff-migrate-golden.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* The handoff Contentrain Migrate actually writes (E2 contract test).
*
* `tests/fixtures/migrate-handoff-golden.json` is the document a Migrate run builds and stamps on delivery
* (copied byte for byte from Contentrain/migrate `packages/delivery/tests/fixtures/studio-golden-handoff.json`,
* regenerated there with `UPDATE_GOLDEN=1`). Studio's own tests used hand-built handoffs; this one runs the
* producer's output through the consumer's validate, split and summary, in CI (no env vars needed).
*/
import { CAPABILITY_KEYS, MIGRATION_CONTRACT_VERSION, type MigrationHandoff } from '@contentrain/types'
import { describe, expect, it } from 'vitest'
import golden from '../fixtures/migrate-handoff-golden.json'
import {
enrichMigrationHandoff,
splitMigrationHandoff,
summarizeMigrationHandoff,
validateMigrationHandoff,
} from '../../server/utils/migration-handoff'

const handoff = golden as unknown as MigrationHandoff

describe('the handoff Migrate writes, through Studio', () => {
it('passes Studio validation', () => {
expect(validateMigrationHandoff(handoff)).toBeNull()
})

it('every capability and offer key is one Studio\'s pinned types know', () => {
const known = new Set<string>(CAPABILITY_KEYS)
expect(handoff.capabilities.length).toBeGreaterThan(0)
for (const capability of handoff.capabilities) expect(known.has(capability.key), capability.key).toBe(true)
for (const offer of handoff.offers ?? []) expect(known.has(offer.capability), offer.capability).toBe(true)
})

it('splits into a manifest and no inline comments export (Migrate keeps it out of the repository)', () => {
const { manifest } = splitMigrationHandoff(handoff, { path: '.contentrain/migrate/handoff.json', ref: 'contentrain' }, JSON.stringify(handoff).length)
expect(manifest.version).toBe(1)
expect(manifest.studio_intake?.comments).toEqual({ kind: 'none' })
})

it('summarises: the forms and comments capabilities stay runtime work until Studio is bound', () => {
const summary = summarizeMigrationHandoff({ ...handoff })
expect(summary.siteUrl).toBe('https://site.test')
expect(JSON.stringify(summary)).toContain('forms')
})

it('a stamped repository is kept; Studio only fills it when missing', () => {
expect(enrichMigrationHandoff(handoff, { repo_full_name: 'other/repo' }).repository).toEqual({ provider: 'github', owner: 'acme', name: 'site', default_branch: 'main' })
})

it('the contract version is a range: the pinned version and below are read, above is refused', () => {
expect(validateMigrationHandoff({ ...handoff, version: MIGRATION_CONTRACT_VERSION })).toBeNull()
expect(validateMigrationHandoff({ ...handoff, version: MIGRATION_CONTRACT_VERSION + 1 })).toEqual({ code: 'unsupported_version', detail: String(MIGRATION_CONTRACT_VERSION + 1) })
expect(validateMigrationHandoff({ ...handoff, version: 0 })).toEqual({ code: 'unsupported_version', detail: '0' })
expect(validateMigrationHandoff({ ...handoff, version: 1.5 })).toMatchObject({ code: 'unsupported_version' })
})
})
Loading