diff --git a/scripts/v18-to-v19/V18MigrationApp.ts b/scripts/v18-to-v19/V18MigrationApp.ts index a949b935..f3a4f85c 100644 --- a/scripts/v18-to-v19/V18MigrationApp.ts +++ b/scripts/v18-to-v19/V18MigrationApp.ts @@ -14,6 +14,7 @@ import { runV18ToV19Migration, type V18MigrationCommandReport } from './V18Migra import type V18MigrationExecutionMode from './V18MigrationExecutionMode.ts'; import type V18MigrationGraph from './V18MigrationGraph.ts'; import type { V18MigrationProgress } from './V18MigrationProgress.ts'; +import V18MigrationProgressCoalescer from './V18MigrationProgressCoalescer.ts'; import type { V18MigrationPreflight } from './V18MigrationPreflight.ts'; import { renderV18MigrationApp, @@ -50,19 +51,24 @@ export async function runV18MigrationApp( ): Promise { let result: V18MigrationAppResult = Object.freeze({ status: 'cancelled' }); const execute: Cmd = async (emit) => { + const progress = new V18MigrationProgressCoalescer((update) => { + emit(Object.freeze({ progress: update, type: 'progress' })); + }); try { const report = await runV18ToV19Migration({ graph: options.graph.name, mode: options.mode, - progress: (progress) => emit(Object.freeze({ progress, type: 'progress' })), + progress: (update) => progress.report(update), repositoryPath: options.repositoryPath, ...(options.passphrase === undefined ? {} : { passphrase: options.passphrase }), ...(options.recoveryId === undefined ? {} : { recoveryId: options.recoveryId }), ...(options.scratchRoot === undefined ? {} : { scratchRoot: options.scratchRoot }), }); + progress.flushBestEffort(); result = Object.freeze({ report, status: 'completed' }); return Object.freeze({ report, type: 'succeeded' }); } catch (error: unknown) { + progress.flushBestEffort(); result = Object.freeze({ error, status: 'failed' }); return Object.freeze({ error, diff --git a/scripts/v18-to-v19/V18MigrationAppSurface.ts b/scripts/v18-to-v19/V18MigrationAppSurface.ts index 4a870cd3..a046fc99 100644 --- a/scripts/v18-to-v19/V18MigrationAppSurface.ts +++ b/scripts/v18-to-v19/V18MigrationAppSurface.ts @@ -175,11 +175,16 @@ function migrationLines( if (model.phase === 'succeeded' && model.report !== undefined) { const report = model.report; lines.push( - { text: `Migration ${report.status}.`, token: success }, + { text: 'Migration completed successfully.', token: success }, + { text: `Status: ${report.status}`, token: success }, { text: `Writers: ${String(report.plan.writers.length)}`, token: body }, { text: `Scratch verified: ${report.scratchVerified ? 'yes' : 'no'}`, token: body, + }, + { + text: `Authoritative refs changed: ${report.finalization === null ? 'no' : 'yes'}`, + token: body, } ); if (report.finalization !== null) { diff --git a/scripts/v18-to-v19/V18MigrationPlan.ts b/scripts/v18-to-v19/V18MigrationPlan.ts index a496d4ff..e72a5263 100644 --- a/scripts/v18-to-v19/V18MigrationPlan.ts +++ b/scripts/v18-to-v19/V18MigrationPlan.ts @@ -21,7 +21,6 @@ import { import { V18MigrationGitObjectReader } from './V18MigrationGitObjectReader.ts'; import { reportV18MigrationProgress, - shouldReportV18CommitProgress, type V18MigrationProgressReporter, } from './V18MigrationProgress.ts'; @@ -192,15 +191,13 @@ async function planWriter( } previous = sha; completed += 1; - if (shouldReportV18CommitProgress(completed, commits.length)) { - reportV18MigrationProgress(options.progress, { - completed, - message: 'validating writer chain', - phase: 'inventory', - total: commits.length, - writer, - }); - } + reportV18MigrationProgress(options.progress, { + completed, + message: 'validating writer chain', + phase: 'inventory', + total: commits.length, + writer, + }); } if (previous !== head) { throw new Error(`writer chain ${options.refName} did not inventory to its head`); diff --git a/scripts/v18-to-v19/V18MigrationProgress.ts b/scripts/v18-to-v19/V18MigrationProgress.ts index 0100f0e0..3ac676bb 100644 --- a/scripts/v18-to-v19/V18MigrationProgress.ts +++ b/scripts/v18-to-v19/V18MigrationProgress.ts @@ -1,5 +1,3 @@ -const COMMIT_PROGRESS_INTERVAL = 250; - export type V18MigrationPhase = 'finalize' | 'inventory' | 'rewrite' | 'scratch' | 'verify'; export type V18MigrationProgress = Readonly<{ @@ -19,10 +17,6 @@ export function reportV18MigrationProgress( reporter?.(Object.freeze({ ...progress })); } -export function shouldReportV18CommitProgress(completed: number, total: number): boolean { - return completed === total || completed % COMMIT_PROGRESS_INTERVAL === 0; -} - /** Convert bounded work counts into the percentage expected by Bijou. */ export function v18MigrationProgressPercent(completed: number, total: number): number { return total === 0 ? 100 : Math.min(100, Math.max(0, (completed / total) * 100)); diff --git a/scripts/v18-to-v19/V18MigrationProgressCoalescer.ts b/scripts/v18-to-v19/V18MigrationProgressCoalescer.ts new file mode 100644 index 00000000..9c99bb93 --- /dev/null +++ b/scripts/v18-to-v19/V18MigrationProgressCoalescer.ts @@ -0,0 +1,103 @@ +import type { V18MigrationProgress, V18MigrationProgressReporter } from './V18MigrationProgress.ts'; + +export const V18_MIGRATION_PROGRESS_RENDER_INTERVAL_MS = 100; + +/** + * Preserves per-item progress truth while bounding host rendering work. + * + * The first event is delivered immediately. During each render window, the + * newest event replaces older pending events. Phase, writer, and message + * transitions flush the prior stream so named migration steps are never + * hidden. Flush delivers the final pending event synchronously and cancels + * scheduled work before a terminal result. + */ +export default class V18MigrationProgressCoalescer { + readonly #intervalMs: number; + readonly #reporter: V18MigrationProgressReporter; + #hasStream = false; + #message: string | null = null; + #phase: V18MigrationProgress['phase'] | null = null; + #pending: V18MigrationProgress | null = null; + #timer: ReturnType | null = null; + #writer: string | undefined; + + constructor( + reporter: V18MigrationProgressReporter, + intervalMs: number = V18_MIGRATION_PROGRESS_RENDER_INTERVAL_MS + ) { + if (!Number.isSafeInteger(intervalMs) || intervalMs <= 0) { + throw new RangeError('migration progress render interval must be a positive integer'); + } + this.#intervalMs = intervalMs; + this.#reporter = reporter; + } + + report(progress: V18MigrationProgress): void { + if (this.#changesStream(progress)) { + this.#finishWindow(); + } + this.#hasStream = true; + this.#message = progress.message; + this.#phase = progress.phase; + this.#writer = progress.writer; + this.#pending = progress; + if (this.#timer === null) { + this.#deliverPending(); + this.#scheduleWindow(); + } + } + + flush(): void { + this.#finishWindow(); + } + + flushBestEffort(): void { + try { + this.flush(); + } catch { + // Terminal progress rendering must not replace the migration outcome. + } + } + + #changesStream(progress: V18MigrationProgress): boolean { + return ( + this.#hasStream + && ( + progress.message !== this.#message + || progress.phase !== this.#phase + || progress.writer !== this.#writer + ) + ); + } + + #finishWindow(): void { + if (this.#timer !== null) { + clearTimeout(this.#timer); + this.#timer = null; + } + this.#deliverPending(); + } + + #deliverPending(): void { + const progress = this.#pending; + if (progress === null) { + return; + } + this.#pending = null; + this.#reporter(progress); + } + + #scheduleWindow(): void { + this.#timer = setTimeout(() => { + this.#timer = null; + if (this.#pending !== null) { + try { + this.#deliverPending(); + } catch { + // Scheduled progress rendering must not terminate the migration. + } + this.#scheduleWindow(); + } + }, this.#intervalMs); + } +} diff --git a/scripts/v18-to-v19/V18MigrationReport.ts b/scripts/v18-to-v19/V18MigrationReport.ts new file mode 100644 index 00000000..5ec8c027 --- /dev/null +++ b/scripts/v18-to-v19/V18MigrationReport.ts @@ -0,0 +1,21 @@ +import type { V18MigrationCommandReport } from './V18MigrationCommand.ts'; + +/** Format the durable operator report printed after terminal UI teardown. */ +export function formatV18MigrationReport(report: V18MigrationCommandReport): string { + const lines = [ + 'git-warp v18-to-v19 migration completed successfully', + `status: ${report.status}`, + `repository: ${report.plan.repositoryPath}`, + `graph: ${report.plan.graph}`, + `writers: ${String(report.plan.writers.length)}`, + `scratch verified: ${report.scratchVerified ? 'yes' : 'no'}`, + `authoritative refs changed: ${report.finalization === null ? 'no' : 'yes'}`, + ]; + if (report.finalization !== null) { + lines.push(`recovery refs: ${report.finalization.recoveryPrefix}`); + } + if (report.status === 'verified-dry-run') { + lines.push('no authoritative refs changed; a later promotion reruns the migration'); + } + return `${lines.join('\n')}\n`; +} diff --git a/scripts/v18-to-v19/V18WriterChainRewriter.ts b/scripts/v18-to-v19/V18WriterChainRewriter.ts index bdf13aed..5314fee5 100644 --- a/scripts/v18-to-v19/V18WriterChainRewriter.ts +++ b/scripts/v18-to-v19/V18WriterChainRewriter.ts @@ -1,15 +1,11 @@ import type { V18CommitIdentity, V18PatchCommit } from './V18PatchCommit.ts'; import { readV18PatchCommit } from './V18PatchCommit.ts'; -import { - readV18MigrationRef, - v18MigrationGitText, -} from './V18MigrationGit.ts'; +import { readV18MigrationRef, v18MigrationGitText } from './V18MigrationGit.ts'; import type { V18MigrationGitCommitWriter } from './V18MigrationGitCommitWriter.ts'; import type { V18MigrationGitObjectReader } from './V18MigrationGitObjectReader.ts'; import V18PatchTranslator from './V18PatchTranslator.ts'; import { reportV18MigrationProgress, - shouldReportV18CommitProgress, type V18MigrationProgressReporter, } from './V18MigrationProgress.ts'; @@ -23,17 +19,19 @@ export type V18WriterChainRewrite = Readonly<{ }>; /** Recreates one writer chain in order, translating only legacy patch payloads. */ -export async function rewriteV18WriterChain(options: Readonly<{ - commitWriter?: V18MigrationGitCommitWriter; - commitMap?: Map; - graph: string; - objectReader?: V18MigrationGitObjectReader; - progress?: V18MigrationProgressReporter; - refName: string; - repositoryPath: string; - translator: V18PatchTranslator; - writer: string; -}>): Promise { +export async function rewriteV18WriterChain( + options: Readonly<{ + commitWriter?: V18MigrationGitCommitWriter; + commitMap?: Map; + graph: string; + objectReader?: V18MigrationGitObjectReader; + progress?: V18MigrationProgressReporter; + refName: string; + repositoryPath: string; + translator: V18PatchTranslator; + writer: string; + }> +): Promise { const oldHead = await readV18MigrationRef(options.repositoryPath, options.refName); if (oldHead === null) { throw new Error(`writer ref disappeared: ${options.refName}`); @@ -51,16 +49,13 @@ export async function rewriteV18WriterChain(options: Readonly<{ writer: options.writer, }); for (const sha of commits) { - const patch = await readV18PatchCommit( - options.repositoryPath, - sha, - options.objectReader, - ); + const patch = await readV18PatchCommit(options.repositoryPath, sha, options.objectReader); requirePatchIdentity(patch, options.graph, options.writer); requireLinearParent(patch, previousOld); - const translated = patch.storage.kind === 'current' - ? { message: patch.commit.message, tree: patch.commit.tree } - : await options.translator.translate(patch); + const translated = + patch.storage.kind === 'current' + ? { message: patch.commit.message, tree: patch.commit.tree } + : await options.translator.translate(patch); if (patch.storage.kind !== 'current') { translatedCount += 1; } @@ -70,7 +65,7 @@ export async function rewriteV18WriterChain(options: Readonly<{ translated.tree, translated.message, previousNew, - options.commitWriter, + options.commitWriter ); if (patch.storage.kind === 'current' && previousNew === previousOld && newSha !== sha) { throw new Error(`current commit ${sha} was not recreated byte-identically`); @@ -79,15 +74,13 @@ export async function rewriteV18WriterChain(options: Readonly<{ previousNew = newSha; options.commitMap?.set(sha, newSha); completed += 1; - if (shouldReportV18CommitProgress(completed, commits.length)) { - reportV18MigrationProgress(options.progress, { - completed, - message: 'translating writer chain', - phase: 'rewrite', - total: commits.length, - writer: options.writer, - }); - } + reportV18MigrationProgress(options.progress, { + completed, + message: 'translating writer chain', + phase: 'rewrite', + total: commits.length, + writer: options.writer, + }); } if (previousNew === null) { throw new Error(`writer ref has no commits: ${options.refName}`); @@ -110,34 +103,22 @@ export async function rewriteV18WriterChain(options: Readonly<{ async function listWriterCommits( repositoryPath: string, - refName: string, + refName: string ): Promise { - const output = await v18MigrationGitText(repositoryPath, [ - 'rev-list', - '--reverse', - refName, - ]); + const output = await v18MigrationGitText(repositoryPath, ['rev-list', '--reverse', refName]); return Object.freeze(output === '' ? [] : output.split('\n').filter(Boolean)); } -function requirePatchIdentity( - patch: V18PatchCommit, - graph: string, - writer: string, -): void { +function requirePatchIdentity(patch: V18PatchCommit, graph: string, writer: string): void { if (patch.graph !== graph || patch.writer !== writer) { - throw new Error( - `commit ${patch.commit.sha} identity does not match ${graph}/${writer}`, - ); + throw new Error(`commit ${patch.commit.sha} identity does not match ${graph}/${writer}`); } } function requireLinearParent(patch: V18PatchCommit, previousOld: string | null): void { const parent = patch.commit.parents[0] ?? null; if (parent !== previousOld) { - throw new Error( - `writer commit ${patch.commit.sha} does not form one complete linear chain`, - ); + throw new Error(`writer commit ${patch.commit.sha} does not form one complete linear chain`); } } @@ -147,7 +128,7 @@ async function createCommit( tree: string, message: string, parent: string | null, - commitWriter?: V18MigrationGitCommitWriter, + commitWriter?: V18MigrationGitCommitWriter ): Promise { if (commitWriter !== undefined) { return await commitWriter.writeCommit({ @@ -170,7 +151,7 @@ async function createCommit( function commitEnvironment( author: V18CommitIdentity, - committer: V18CommitIdentity, + committer: V18CommitIdentity ): Readonly> { return Object.freeze({ GIT_AUTHOR_NAME: author.name, diff --git a/scripts/v18-to-v19/migrate.ts b/scripts/v18-to-v19/migrate.ts index 1378b2f9..6ae2effd 100644 --- a/scripts/v18-to-v19/migrate.ts +++ b/scripts/v18-to-v19/migrate.ts @@ -16,6 +16,8 @@ import { inspectV18MigrationPreflight, } from './V18MigrationPreflight.ts'; import { type V18MigrationProgress, v18MigrationProgressPercent } from './V18MigrationProgress.ts'; +import V18MigrationProgressCoalescer from './V18MigrationProgressCoalescer.ts'; +import { formatV18MigrationReport } from './V18MigrationReport.ts'; import { createV18MigrationTheme } from './V18MigrationTheme.ts'; export type V18MigrationCliOptions = Readonly<{ @@ -51,8 +53,10 @@ async function main(): Promise { ...(options.scratchRoot === undefined ? {} : { scratchRoot: options.scratchRoot }), }; let report: V18MigrationCommandReport; + let usedInteractiveApp = false; if (!options.assumeYes) { requireInteractiveConfirmation(catalog.summary()); + usedInteractiveApp = true; const result = await runV18MigrationApp({ context: ctx, graph, @@ -69,19 +73,29 @@ async function main(): Promise { report = result.report; } else { process.stderr.write(`${catalog.summary()}\n${formatV18MigrationPreflight(preflight)}\n`); - report = await runV18ToV19Migration({ - ...sharedOptions, - graph: options.graph, - progress: (progress) => { - process.stderr.write(formatProgress(progress, ctx)); - }, + const progress = new V18MigrationProgressCoalescer((update) => { + process.stderr.write(formatProgress(update, ctx)); }); + try { + report = await runV18ToV19Migration({ + ...sharedOptions, + graph: options.graph, + progress: (update) => progress.report(update), + }); + } finally { + progress.flushBestEffort(); + } + } + if (usedInteractiveApp) { + process.stderr.write(formatV18MigrationReport(report)); } if (options.json) { process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); return; } - process.stdout.write(formatReport(report)); + if (!usedInteractiveApp) { + process.stdout.write(formatV18MigrationReport(report)); + } } function migrationContext(): BijouContext { @@ -177,23 +191,6 @@ function requireValue(args: readonly string[], index: number, option: string): s return value; } -function formatReport(report: Awaited>): string { - const lines = [ - `v18-to-v19 retained-substrate migration: ${report.status}`, - `repository: ${report.plan.repositoryPath}`, - `graph: ${report.plan.graph}`, - `writers: ${String(report.plan.writers.length)}`, - `scratch verified: ${report.scratchVerified ? 'yes' : 'no'}`, - ]; - if (report.finalization !== null) { - lines.push(`recovery refs: ${report.finalization.recoveryPrefix}`); - } - if (report.status === 'verified-dry-run') { - lines.push('no authoritative refs changed; a later promotion reruns the migration'); - } - return `${lines.join('\n')}\n`; -} - function usage(): string { return [ 'Usage: git-warp-v18-to-v19 --repo --graph [options]', diff --git a/test/unit/scripts/v18-migration-app.test.ts b/test/unit/scripts/v18-migration-app.test.ts index 778a0a6a..4666dafe 100644 --- a/test/unit/scripts/v18-migration-app.test.ts +++ b/test/unit/scripts/v18-migration-app.test.ts @@ -2,6 +2,7 @@ import { themeContrastRatio } from '@flyingrobots/bijou'; import { createTestContext } from '@flyingrobots/bijou/adapters/test'; import { describe, expect, it } from 'vitest'; +import type { V18MigrationCommandReport } from '../../../scripts/v18-to-v19/V18MigrationCommand.ts'; import { renderV18MigrationApp } from '../../../scripts/v18-to-v19/V18MigrationAppSurface.ts'; import V18MigrationExecutionMode from '../../../scripts/v18-to-v19/V18MigrationExecutionMode.ts'; import V18MigrationGraph from '../../../scripts/v18-to-v19/V18MigrationGraph.ts'; @@ -26,6 +27,24 @@ const CONTEXT = createTestContext({ noColor: true, theme: createV18MigrationTheme(), }); +const SUCCESS_REPORT = Object.freeze({ + finalization: Object.freeze({ + promotedRefs: Object.freeze({ 'refs/warp/think/writers/local': 'b'.repeat(40) }), + recoveryPrefix: 'refs/warp/think/recovery/v18-to-v19/manual', + recoveryRefs: Object.freeze({ 'refs/warp/think/recovery/source': 'a'.repeat(40) }), + }), + plan: Object.freeze({ + derivedRefs: Object.freeze({}), + graph: 'think', + markerRef: 'refs/warp/think/substrate-version', + preservedRefs: Object.freeze({}), + repositoryPath: '/tmp/think', + status: 'migration-required', + writers: Object.freeze([]), + }), + scratchVerified: true, + status: 'migrated', +}) satisfies V18MigrationCommandReport; describe('v18 migration framed app', () => { it('keeps every rendered foreground above WCAG AAA on the primary surface', () => { @@ -172,6 +191,33 @@ describe('v18 migration framed app', () => { expect(text).toContain('███████████'); }); + it('states successful completion without requiring status interpretation', () => { + const surface = renderV18MigrationApp( + { + context: CONTEXT, + graph: new V18MigrationGraph({ + name: 'think', + refCount: 4, + version: 'upgrade required (legacy unmarked substrate)', + writerCount: 2, + }), + mode: V18MigrationExecutionMode.promote(), + preflight: PREFLIGHT, + repositoryPath: '/tmp/think', + }, + { phase: 'succeeded', report: SUCCESS_REPORT }, + 100, + 24 + ); + + const text = surfaceText(surface); + expect(text).toContain('Migration completed successfully.'); + expect(text).toContain('Status: migrated'); + expect(text).toContain('Scratch verified: yes'); + expect(text).toContain('Authoritative refs changed: yes'); + expect(text).toContain('Recovery refs: refs/warp/think/recovery/v18-to-v19/manual'); + }); + it('does not describe an incomplete success model as a migration failure', () => { const surface = renderV18MigrationApp( { diff --git a/test/unit/scripts/v18-migration-cli.test.ts b/test/unit/scripts/v18-migration-cli.test.ts index 1e1f61d3..0727c603 100644 --- a/test/unit/scripts/v18-migration-cli.test.ts +++ b/test/unit/scripts/v18-migration-cli.test.ts @@ -2,8 +2,10 @@ import { spawn } from 'node:child_process'; import { afterEach, describe, expect, it } from 'vitest'; +import type { V18MigrationCommandReport } from '../../../scripts/v18-to-v19/V18MigrationCommand.ts'; import V18MigrationExecutionMode from '../../../scripts/v18-to-v19/V18MigrationExecutionMode.ts'; import { parseV18MigrationCliOptions } from '../../../scripts/v18-to-v19/migrate.ts'; +import { formatV18MigrationReport } from '../../../scripts/v18-to-v19/V18MigrationReport.ts'; import { gitOk, MigrationTestDirectories } from './migrationTestEnvironment.ts'; describe('v18 migration CLI', () => { @@ -55,6 +57,41 @@ describe('v18 migration CLI', () => { expect(execution.stderr).not.toContain('[inventory]'); expect(await gitOk(repositoryPath, ['rev-parse', writerRef])).toBe(before); }); + + it('formats a durable success report with authority and recovery outcomes', () => { + const report = Object.freeze({ + finalization: Object.freeze({ + promotedRefs: Object.freeze({ 'refs/warp/think/writers/local': 'b'.repeat(40) }), + recoveryPrefix: 'refs/warp/think/recovery/v18-to-v19/manual', + recoveryRefs: Object.freeze({ 'refs/warp/think/recovery/source': 'a'.repeat(40) }), + }), + plan: Object.freeze({ + derivedRefs: Object.freeze({}), + graph: 'think', + markerRef: 'refs/warp/think/substrate-version', + preservedRefs: Object.freeze({}), + repositoryPath: '/tmp/think-copy', + status: 'migration-required', + writers: Object.freeze([]), + }), + scratchVerified: true, + status: 'migrated', + }) satisfies V18MigrationCommandReport; + + expect(formatV18MigrationReport(report)).toBe( + [ + 'git-warp v18-to-v19 migration completed successfully', + 'status: migrated', + 'repository: /tmp/think-copy', + 'graph: think', + 'writers: 0', + 'scratch verified: yes', + 'authoritative refs changed: yes', + 'recovery refs: refs/warp/think/recovery/v18-to-v19/manual', + '', + ].join('\n') + ); + }); }); async function repositoryWithGraph( diff --git a/test/unit/scripts/v18-migration-progress.test.ts b/test/unit/scripts/v18-migration-progress.test.ts index 4ed8c9c8..3a94fd5b 100644 --- a/test/unit/scripts/v18-migration-progress.test.ts +++ b/test/unit/scripts/v18-migration-progress.test.ts @@ -1,13 +1,124 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { shouldReportV18CommitProgress } from '../../../scripts/v18-to-v19/V18MigrationProgress.ts'; +import type { V18MigrationProgress } from '../../../scripts/v18-to-v19/V18MigrationProgress.ts'; +import V18MigrationProgressCoalescer from '../../../scripts/v18-to-v19/V18MigrationProgressCoalescer.ts'; describe('v18 migration progress', () => { - it('reports every 250 commits and at writer completion', () => { - expect(shouldReportV18CommitProgress(249, 501)).toBe(false); - expect(shouldReportV18CommitProgress(250, 501)).toBe(true); - expect(shouldReportV18CommitProgress(251, 501)).toBe(false); - expect(shouldReportV18CommitProgress(500, 501)).toBe(true); - expect(shouldReportV18CommitProgress(501, 501)).toBe(true); + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders the first item immediately and the newest item in each window', () => { + vi.useFakeTimers(); + const rendered: V18MigrationProgress[] = []; + const coalescer = new V18MigrationProgressCoalescer((progress) => rendered.push(progress), 100); + + coalescer.report(progress(0)); + coalescer.report(progress(1)); + coalescer.report(progress(2)); + + expect(rendered.map((event) => event.completed)).toEqual([0]); + vi.advanceTimersByTime(100); + expect(rendered.map((event) => event.completed)).toEqual([0, 2]); + }); + + it('flushes the newest item and cancels scheduled rendering', () => { + vi.useFakeTimers(); + const rendered: V18MigrationProgress[] = []; + const coalescer = new V18MigrationProgressCoalescer((progress) => rendered.push(progress), 100); + + coalescer.report(progress(0)); + coalescer.report(progress(1)); + coalescer.flush(); + vi.advanceTimersByTime(100); + + expect(rendered.map((event) => event.completed)).toEqual([0, 1]); + }); + + it('does not let terminal rendering failure replace the migration outcome', () => { + vi.useFakeTimers(); + const reporter = vi.fn((update: V18MigrationProgress) => { + if (update.completed === 1) { + throw new Error('terminal renderer failed'); + } + }); + const coalescer = new V18MigrationProgressCoalescer(reporter, 100); + + coalescer.report(progress(0)); + coalescer.report(progress(1)); + + expect(() => coalescer.flushBestEffort()).not.toThrow(); + vi.advanceTimersByTime(100); + expect(reporter).toHaveBeenCalledTimes(2); + }); + + it('contains rendering failures from scheduled progress delivery', () => { + vi.useFakeTimers(); + const reporter = vi.fn((update: V18MigrationProgress) => { + if (update.completed === 1) { + throw new Error('scheduled renderer failed'); + } + }); + const coalescer = new V18MigrationProgressCoalescer(reporter, 100); + + coalescer.report(progress(0)); + coalescer.report(progress(1)); + + expect(() => vi.advanceTimersByTime(100)).not.toThrow(); + vi.advanceTimersByTime(100); + expect(reporter).toHaveBeenCalledTimes(2); + }); + + it('flushes the previous writer before rendering the next writer', () => { + vi.useFakeTimers(); + const rendered: V18MigrationProgress[] = []; + const coalescer = new V18MigrationProgressCoalescer((progress) => rendered.push(progress), 100); + + coalescer.report(progress(0)); + coalescer.report(progress(1)); + coalescer.report(Object.freeze({ ...progress(0), writer: 'remote' })); + + expect(rendered.map((event) => [event.writer, event.completed])).toEqual([ + ['local', 0], + ['local', 1], + ['remote', 0], + ]); + }); + + it('renders every named step while coalescing repeated item updates', () => { + vi.useFakeTimers(); + const rendered: V18MigrationProgress[] = []; + const coalescer = new V18MigrationProgressCoalescer((update) => rendered.push(update), 100); + + coalescer.report(progress(0)); + coalescer.report(progress(1)); + coalescer.report(Object.freeze({ + completed: 0, + message: 'building current bounded checkpoint indexes', + phase: 'scratch', + total: 2, + })); + + expect(rendered.map((event) => [event.message, event.completed])).toEqual([ + ['translating writer chain', 0], + ['translating writer chain', 1], + ['building current bounded checkpoint indexes', 0], + ]); + }); + + it('rejects a non-positive render interval', () => { + expect(() => new V18MigrationProgressCoalescer(() => undefined, 0)).toThrow( + 'migration progress render interval must be a positive integer' + ); }); }); + +function progress(completed: number): V18MigrationProgress { + return Object.freeze({ + completed, + message: 'translating writer chain', + phase: 'rewrite', + total: 2, + writer: 'local', + }); +} diff --git a/test/unit/scripts/v18-to-v19-scratch.test.ts b/test/unit/scripts/v18-to-v19-scratch.test.ts index c6d90397..c70265c6 100644 --- a/test/unit/scripts/v18-to-v19-scratch.test.ts +++ b/test/unit/scripts/v18-to-v19-scratch.test.ts @@ -15,6 +15,7 @@ import type { SnapshotPropValue } from '../../../src/domain/services/snapshot/Sn import { buildCheckpointRef } from '../../../src/domain/utils/RefLayout.ts'; import { restoreV18RetainedSubstrateFixture } from '../../../scripts/v18-to-v19/V18RetainedSubstrateFixtureRestore.ts'; import { planV18ToV19Migration } from '../../../scripts/v18-to-v19/V18MigrationPlan.ts'; +import type { V18MigrationProgress } from '../../../scripts/v18-to-v19/V18MigrationProgress.ts'; import { prepareV18MigrationScratch, type V18PreparedMigration, @@ -23,12 +24,8 @@ import { v18MigrationGitText } from '../../../scripts/v18-to-v19/V18MigrationGit import { collectAsyncBytes } from '../../helpers/collectAsyncBytes.ts'; import { readRequiredV18MigrationRefMap } from '../../helpers/V18MigrationRefMap.ts'; -const MANIFEST_PATH = resolve( - 'fixtures/v18/retained-substrate-golden/manifest.json', -); -const MEDIUM_MANIFEST_PATH = resolve( - 'fixtures/v18/retained-substrate-medium/manifest.json', -); +const MANIFEST_PATH = resolve('fixtures/v18/retained-substrate-golden/manifest.json'); +const MEDIUM_MANIFEST_PATH = resolve('fixtures/v18/retained-substrate-medium/manifest.json'); describe('v18-to-v19 scratch migration', () => { const temporaryDirectories: string[] = []; @@ -36,12 +33,12 @@ describe('v18-to-v19 scratch migration', () => { afterEach(async () => { await Promise.all( - preparedMigrations.splice(0).map(async (prepared) => await prepared.cleanup()), + preparedMigrations.splice(0).map(async (prepared) => await prepared.cleanup()) ); await Promise.all( temporaryDirectories.splice(0).map(async (directory) => { await rm(directory, { recursive: true, force: true }); - }), + }) ); }); @@ -57,14 +54,11 @@ describe('v18-to-v19 scratch migration', () => { if (preservedHead === undefined) { throw new Error('authentic fixture has no writer head'); } - await v18MigrationGitText( - restored.repositoryPath, - ['update-ref', preservedRef, preservedHead], - ); - const sourceHeads = await readRequiredV18MigrationRefMap( - restored.repositoryPath, - [...restored.manifest.refs.map((ref) => ref.refName), preservedRef], - ); + await v18MigrationGitText(restored.repositoryPath, ['update-ref', preservedRef, preservedHead]); + const sourceHeads = await readRequiredV18MigrationRefMap(restored.repositoryPath, [ + ...restored.manifest.refs.map((ref) => ref.refName), + preservedRef, + ]); const plan = await planV18ToV19Migration({ graph: restored.manifest.graphId, passphraseAvailable: false, @@ -80,31 +74,31 @@ describe('v18-to-v19 scratch migration', () => { preparedMigrations.push(prepared); expect(dirname(prepared.scratchPath)).toBe(scratchRoot); - expect(prepared.desiredRefs).toHaveProperty( - buildCheckpointRef(restored.manifest.graphId), - ); + expect(prepared.desiredRefs).toHaveProperty(buildCheckpointRef(restored.manifest.graphId)); expect(prepared.desiredRefs).toHaveProperty(plan.markerRef); expect(prepared.desiredRefs).toHaveProperty(preservedRef, preservedHead); expect(Object.keys(prepared.desiredRefs)).not.toContain( - restored.manifest.retainedState.refName, + restored.manifest.retainedState.refName ); - expect(await readRequiredV18MigrationRefMap( - restored.repositoryPath, - [...restored.manifest.refs.map((ref) => ref.refName), preservedRef], - )).toEqual(sourceHeads); + expect( + await readRequiredV18MigrationRefMap(restored.repositoryPath, [ + ...restored.manifest.refs.map((ref) => ref.refName), + preservedRef, + ]) + ).toEqual(sourceHeads); const title = await observeProperty( prepared.scratchPath, restored.manifest.graphId, 'doc:fixture', - 'title', + 'title' ); expect(title).toBe('Authentic v18 retained state'); const contentHandle = await observeProperty( prepared.scratchPath, restored.manifest.graphId, 'doc:fixture', - '_content', + '_content' ); expect(typeof contentHandle).toBe('string'); if (typeof contentHandle !== 'string') { @@ -115,11 +109,13 @@ describe('v18-to-v19 scratch migration', () => { codec: new GitCasCborCodec(), }); try { - const content = await collectAsyncBytes(cas.assets.open({ - handle: GitCasAssetHandle.parse(contentHandle), - })); + const content = await collectAsyncBytes( + cas.assets.open({ + handle: GitCasAssetHandle.parse(contentHandle), + }) + ); expect(Buffer.from(content).toString('utf8')).toBe( - 'v18 blob-backed content retained for v19 migration proof\n', + 'v18 blob-backed content retained for v19 migration proof\n' ); } finally { await cas.close(); @@ -136,20 +132,19 @@ describe('v18-to-v19 scratch migration', () => { const retiredOid = await v18MigrationGitText( restored.repositoryPath, ['hash-object', '-w', '--stdin'], - { input: 'retired audit receipt' }, + { input: 'retired audit receipt' } ); const retiredRef = `refs/warp/${restored.manifest.graphId}/audit/retired`; - await v18MigrationGitText( - restored.repositoryPath, - ['update-ref', retiredRef, retiredOid], - ); + await v18MigrationGitText(restored.repositoryPath, ['update-ref', retiredRef, retiredOid]); - await expect(planV18ToV19Migration({ - graph: restored.manifest.graphId, - passphraseAvailable: false, - repositoryPath: restored.repositoryPath, - })).rejects.toThrow( - `retained ref requires a pre-v18 migration before v19: ${retiredRef} targets blob`, + await expect( + planV18ToV19Migration({ + graph: restored.manifest.graphId, + passphraseAvailable: false, + repositoryPath: restored.repositoryPath, + }) + ).rejects.toThrow( + `retained ref requires a pre-v18 migration before v19: ${retiredRef} targets blob` ); }); @@ -162,33 +157,43 @@ describe('v18-to-v19 scratch migration', () => { }); const sourceHeads = await readRequiredV18MigrationRefMap( restored.repositoryPath, - restored.manifest.refs.map((ref) => ref.refName), + restored.manifest.refs.map((ref) => ref.refName) ); + const progress: V18MigrationProgress[] = []; const plan = await planV18ToV19Migration({ graph: restored.manifest.graphId, passphraseAvailable: false, + progress: (event) => progress.push(event), repositoryPath: restored.repositoryPath, }); + expect(inventoryCounts(progress, 'medium-alice')).toEqual( + Array.from({ length: 17 }, (_, completed) => completed) + ); + expect(inventoryCounts(progress, 'medium-bob')).toEqual([0, 1, 2]); const prepared = await prepareV18MigrationScratch({ plan }); preparedMigrations.push(prepared); - await expect(observeProperty( - prepared.scratchPath, - restored.manifest.graphId, - 'medium:document:015', - 'ordinal', - )).resolves.toBe(15); - await expect(observeProperty( - prepared.scratchPath, - restored.manifest.graphId, - 'medium:review:01', - 'reviewed', - )).resolves.toBe(true); + await expect( + observeProperty( + prepared.scratchPath, + restored.manifest.graphId, + 'medium:document:015', + 'ordinal' + ) + ).resolves.toBe(15); + await expect( + observeProperty( + prepared.scratchPath, + restored.manifest.graphId, + 'medium:review:01', + 'reviewed' + ) + ).resolves.toBe(true); const contentHandle = await observeProperty( prepared.scratchPath, restored.manifest.graphId, 'medium:document:015', - '_content', + '_content' ); expect(typeof contentHandle).toBe('string'); if (typeof contentHandle !== 'string') { @@ -199,34 +204,46 @@ describe('v18-to-v19 scratch migration', () => { codec: new GitCasCborCodec(), }); try { - const content = await collectAsyncBytes(cas.assets.open({ - handle: GitCasAssetHandle.parse(contentHandle), - })); + const content = await collectAsyncBytes( + cas.assets.open({ + handle: GitCasAssetHandle.parse(contentHandle), + }) + ); expect(content).toHaveLength(128 * 1024); } finally { await cas.close(); } - expect(await readRequiredV18MigrationRefMap( - restored.repositoryPath, - restored.manifest.refs.map((ref) => ref.refName), - )).toEqual(sourceHeads); + expect( + await readRequiredV18MigrationRefMap( + restored.repositoryPath, + restored.manifest.refs.map((ref) => ref.refName) + ) + ).toEqual(sourceHeads); }, 120_000); }); +function inventoryCounts(progress: readonly V18MigrationProgress[], writer: string): number[] { + return progress + .filter((event) => event.phase === 'inventory' && event.writer === writer) + .flatMap((event) => (event.completed === undefined ? [] : [event.completed])); +} + async function observeProperty( repositoryPath: string, graph: string, subject: string, - key: string, + key: string ): Promise { const runtime = await Runtime.open({ at: repositoryPath, writer: 'fixture-reader' }); try { const lane = await runtime.lane(graph); - const observation = lane.observe(createObserver( - `fixture.${key}`, - Reading.property({ subject, key }), - (value) => value, - )); + const observation = lane.observe( + createObserver( + `fixture.${key}`, + Reading.property({ subject, key }), + (value) => value + ) + ); return (await observation.one()).value; } finally { await runtime.close(); diff --git a/test/unit/scripts/v18-to-v19-writer-chain.test.ts b/test/unit/scripts/v18-to-v19-writer-chain.test.ts index f3edad21..77e90bbe 100644 --- a/test/unit/scripts/v18-to-v19-writer-chain.test.ts +++ b/test/unit/scripts/v18-to-v19-writer-chain.test.ts @@ -13,6 +13,7 @@ import { restoreV18RetainedSubstrateFixture } from '../../../scripts/v18-to-v19/ import { V18MigrationGitObjectReader } from '../../../scripts/v18-to-v19/V18MigrationGitObjectReader.ts'; import { readV18PatchCommit } from '../../../scripts/v18-to-v19/V18PatchCommit.ts'; import V18PatchTranslator from '../../../scripts/v18-to-v19/V18PatchTranslator.ts'; +import type { V18MigrationProgress } from '../../../scripts/v18-to-v19/V18MigrationProgress.ts'; import { rewriteV18WriterChain, type V18WriterChainRewrite, @@ -20,9 +21,7 @@ import { import { v18MigrationGitText } from '../../../scripts/v18-to-v19/V18MigrationGit.ts'; import { collectAsyncBytes } from '../../helpers/collectAsyncBytes.ts'; -const MANIFEST_PATH = resolve( - 'fixtures/v18/retained-substrate-golden/manifest.json', -); +const MANIFEST_PATH = resolve('fixtures/v18/retained-substrate-golden/manifest.json'); describe('v18-to-v19 writer chain migration', () => { const temporaryDirectories: string[] = []; @@ -31,7 +30,7 @@ describe('v18-to-v19 writer chain migration', () => { await Promise.all( temporaryDirectories.splice(0).map(async (directory) => { await rm(directory, { recursive: true, force: true }); - }), + }) ); }); @@ -49,17 +48,21 @@ describe('v18-to-v19 writer chain migration', () => { }); const rewrites: V18WriterChainRewrite[] = []; const commitMap = new Map(); + const progress: V18MigrationProgress[] = []; try { for (const ref of restored.manifest.refs) { if (ref.kind === 'writer') { - rewrites.push(await rewriteV18WriterChain({ - commitMap, - graph: restored.manifest.graphId, - refName: ref.refName, - repositoryPath: restored.repositoryPath, - translator, - writer: ref.writerId, - })); + rewrites.push( + await rewriteV18WriterChain({ + commitMap, + graph: restored.manifest.graphId, + progress: (event) => progress.push(event), + refName: ref.refName, + repositoryPath: restored.repositoryPath, + translator, + writer: ref.writerId, + }) + ); } } } finally { @@ -67,6 +70,8 @@ describe('v18-to-v19 writer chain migration', () => { } expect(rewrites.map((rewrite) => rewrite.translatedCount)).toEqual([2, 1]); + expect(rewriteCounts(progress, 'alice')).toEqual([0, 1, 2]); + expect(rewriteCounts(progress, 'bob')).toEqual([0, 1]); for (const rewrite of rewrites) { expect(commitMap.get(rewrite.oldHead)).toBe(rewrite.newHead); } @@ -76,11 +81,7 @@ describe('v18-to-v19 writer chain migration', () => { throw new Error('missing rewritten alice head'); } const firstSha = ( - await v18MigrationGitText(restored.repositoryPath, [ - 'rev-list', - '--reverse', - aliceHead, - ]) + await v18MigrationGitText(restored.repositoryPath, ['rev-list', '--reverse', aliceHead]) ).split('\n')[0]; expect(firstSha).toBeDefined(); if (firstSha === undefined) { @@ -102,7 +103,7 @@ describe('v18-to-v19 writer chain migration', () => { const parsed = GitCasAssetHandle.parse(contentHandle); const content = await collectAsyncBytes(cas.assets.open({ handle: parsed })); expect(Buffer.from(content).toString('utf8')).toBe( - 'v18 blob-backed content retained for v19 migration proof\n', + 'v18 blob-backed content retained for v19 migration proof\n' ); } finally { await cas.close(); @@ -110,6 +111,12 @@ describe('v18-to-v19 writer chain migration', () => { }); }); +function rewriteCounts(progress: readonly V18MigrationProgress[], writer: string): number[] { + return progress + .filter((event) => event.phase === 'rewrite' && event.writer === writer) + .flatMap((event) => (event.completed === undefined ? [] : [event.completed])); +} + function findContentHandle(decoded: unknown): string { if (decoded === null || typeof decoded !== 'object' || !('ops' in decoded)) { throw new Error('decoded patch has no ops'); @@ -120,12 +127,12 @@ function findContentHandle(decoded: unknown): string { } for (const op of ops) { if ( - op !== null - && typeof op === 'object' - && 'key' in op - && op.key === '_content' - && 'value' in op - && typeof op.value === 'string' + op !== null && + typeof op === 'object' && + 'key' in op && + op.key === '_content' && + 'value' in op && + typeof op.value === 'string' ) { return op.value; }