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.47.0",
"@contentrain/types": "1.49.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.

22 changes: 22 additions & 0 deletions server/utils/migration-handoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,9 @@ export type MigrationHandoffIssue
export interface MigrationHandoffSummary {
siteUrl: string
generatedAt: string
/** The producing run and the order it was delivered for, when the document carries them (older ones do not: unknown, never stale). */
planHash?: string
orderId?: string
content?: { models: number, entries: number, locales: string[] }
capabilities: Array<{ key: string, disposition: string, detail?: string }>
/** Capabilities that need a live service — the ones Studio can take over. */
Expand All @@ -216,6 +219,18 @@ function commentsSourceOf(handoff: StoredMigrationHandoff): HandoffCommentsSourc
return { kind: 'none' }
}

/**
* The run and order stamps come from the customer's repository, so they are shape-checked (the same shapes Migrate's
* `validateHandoff` accepts) before they reach the summary or the log: anything else reads as unknown.
*/
export function readPlanHash(value: unknown): string | undefined {
return typeof value === 'string' && /^[0-9a-f]{16}$/.test(value) ? value : undefined
}

export function readOrderId(value: unknown): string | undefined {
return typeof value === 'string' && /^ord_[0-9a-f]{24}$/.test(value) ? value : undefined
}

export function summarizeMigrationHandoff(handoff: StoredMigrationHandoff): MigrationHandoffSummary {
const capabilities = (handoff.capabilities ?? []).map(c => ({ key: String(c.key), disposition: String(c.disposition), ...(c.detail ? { detail: c.detail } : {}) }))
const needsRuntime = capabilities.filter(c => c.disposition === 'needs_runtime').map(c => c.key)
Expand All @@ -240,6 +255,8 @@ export function summarizeMigrationHandoff(handoff: StoredMigrationHandoff): Migr
return {
siteUrl: handoff.site_url,
generatedAt: handoff.generated_at,
...(readPlanHash(handoff.plan_hash) ? { planHash: readPlanHash(handoff.plan_hash) } : {}),
...(readOrderId(handoff.order_id) ? { orderId: readOrderId(handoff.order_id) } : {}),
content: handoff.content_summary
? { models: handoff.content_summary.models, entries: handoff.content_summary.entries, locales: handoff.content_summary.locales ?? [] }
: undefined,
Expand Down Expand Up @@ -373,6 +390,11 @@ export async function syncMigrationHandoff(input: SyncMigrationHandoffInput): Pr

const handoff = enrichMigrationHandoff(manifest, input.project)
await useDatabaseProvider().setProjectMigrationHandoff(input.projectId, handoff as unknown as Record<string, unknown>)
// Which run and order this handoff belongs to, so a stale one (left by an earlier delivery) can be traced from the log.
const planHash = readPlanHash(handoff.plan_hash)
const orderId = readOrderId(handoff.order_id)
// eslint-disable-next-line no-console
if (planHash || orderId) console.info(`[migration-handoff] synced project ${input.projectId} plan_hash=${planHash ?? '-'} order_id=${orderId ?? '-'} generated_at=${handoff.generated_at}`)
return { found: true, handoff, summary: summarizeMigrationHandoff(handoff), source }
}

Expand Down
114 changes: 0 additions & 114 deletions tests/fixtures/migrate-handoff-golden.json

This file was deleted.

36 changes: 29 additions & 7 deletions tests/unit/migration-handoff-migrate-golden.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
/**
* 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).
* `MIGRATION_HANDOFF_GOLDEN` (`@contentrain/types`) is the document a Migrate run builds and stamps on delivery,
* published with the contract itself so the producer and this consumer test the same bytes. Studio's own tests used
* hand-built handoffs; this one runs the producer's output through the consumer's validate, split and summary.
*/
import { CAPABILITY_KEYS, MIGRATION_CONTRACT_VERSION, type MigrationHandoff } from '@contentrain/types'
import { CAPABILITY_KEYS, MIGRATION_CONTRACT_VERSION, MIGRATION_HANDOFF_GOLDEN } 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
const handoff = MIGRATION_HANDOFF_GOLDEN

describe('the handoff Migrate writes, through Studio', () => {
it('passes Studio validation', () => {
Expand Down Expand Up @@ -46,6 +44,30 @@ describe('the handoff Migrate writes, through Studio', () => {
expect(enrichMigrationHandoff(handoff, { repo_full_name: 'other/repo' }).repository).toEqual({ provider: 'github', owner: 'acme', name: 'site', default_branch: 'main' })
})

it('the run and order the handoff was delivered for are read when present, and absent on an older document (unknown, not stale)', () => {
const summary = summarizeMigrationHandoff({ ...handoff })
expect(handoff.plan_hash).toMatch(/^[0-9a-f]{16}$/)
expect(handoff.order_id).toMatch(/^ord_[0-9a-f]{24}$/)
expect(summary.planHash).toBe(handoff.plan_hash)
expect(summary.orderId).toBe(handoff.order_id)
const { plan_hash: _p, order_id: _o, ...older } = handoff
expect(validateMigrationHandoff(older)).toBeNull()
expect(summarizeMigrationHandoff({ ...older })).not.toHaveProperty('planHash')
expect(summarizeMigrationHandoff({ ...older })).not.toHaveProperty('orderId')
const stamped = { ...handoff, plan_hash: '0123456789abcdef', order_id: `ord_${'a'.repeat(24)}` }
expect(validateMigrationHandoff(stamped)).toBeNull()
expect(summarizeMigrationHandoff({ ...stamped })).toMatchObject({ planHash: '0123456789abcdef', orderId: `ord_${'a'.repeat(24)}` })
})

it('a stamp that is not the shape Migrate writes (non-string, newline, wrong form) is unknown, never passed to the summary or the log', () => {
for (const bad of [42, ['0123456789abcdef'], '0123456789abcdef\nforged=1', 'ZZZZ', '']) {
const summary = summarizeMigrationHandoff({ ...handoff, plan_hash: bad, order_id: bad } as never)
expect(summary).not.toHaveProperty('planHash')
expect(summary).not.toHaveProperty('orderId')
}
expect(summarizeMigrationHandoff({ ...handoff, order_id: `ord_${'a'.repeat(24)}\nx` } as never)).not.toHaveProperty('orderId')
})

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) })
Expand Down
Loading