diff --git a/package.json b/package.json index 7b0c89a4..54fa3706 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3db5422d..89b9e83b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,8 +29,8 @@ importers: specifier: 7.4.0 version: 7.4.0 '@contentrain/types': - specifier: 1.35.0 - version: 1.35.0 + specifier: 1.40.0 + version: 1.40.0 '@gitbeaker/rest': specifier: ^43.8.0 version: 43.8.0 @@ -713,8 +713,8 @@ packages: '@contentrain/types@1.30.0': resolution: {integrity: sha512-inJhFqAY25wIw4NvpqDXOn24PRbVEh43s6GGFQSRyBbbmGiWu+xD67D2UEqaEllaFNLSVQ0j2DpOjDj+P6ezQA==} - '@contentrain/types@1.35.0': - resolution: {integrity: sha512-cwRRSmnfLJ0J1uHIz4I/6fCY6Tov3hZp8MY7+uVMU7iFw9mD4Uvq5Plyp2zzAx2EcntFKYIPCyMobSXd1apD6Q==} + '@contentrain/types@1.40.0': + resolution: {integrity: sha512-T/oUpkkVvBg6Ad9YcenvPAOh7IkcMmPWsmzoHimAcwUjCB799WNokwH3/kB6JGMPDCrtpXyS7yPZetdGzbIo7g==} '@conventional-changelog/git-client@3.1.2': resolution: {integrity: sha512-jZqwnJwf7nboIlAcw/mkOjVa6DexCcUOgT2oOQgkoi3z9vR8tGFkcMy2BFcYwjhL9sYcDDXkRQDayiDieCoW7A==} @@ -7938,7 +7938,7 @@ snapshots: '@contentrain/types@1.30.0': {} - '@contentrain/types@1.35.0': {} + '@contentrain/types@1.40.0': {} '@conventional-changelog/git-client@3.1.2(conventional-commits-parser@7.1.2)': dependencies: diff --git a/server/utils/migration-handoff.ts b/server/utils/migration-handoff.ts index 374c5189..5c6f2f15 100644 --- a/server/utils/migration-handoff.ts +++ b/server/utils/migration-handoff.ts @@ -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' @@ -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 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' } diff --git a/tests/fixtures/migrate-handoff-golden.json b/tests/fixtures/migrate-handoff-golden.json new file mode 100644 index 00000000..067c1b79 --- /dev/null +++ b/tests/fixtures/migrate-handoff-golden.json @@ -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" + } +} diff --git a/tests/unit/migration-handoff-migrate-golden.test.ts b/tests/unit/migration-handoff-migrate-golden.test.ts new file mode 100644 index 00000000..adafb52d --- /dev/null +++ b/tests/unit/migration-handoff-migrate-golden.test.ts @@ -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(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' }) + }) +})