diff --git a/src/commands/apply-migrations.ts b/src/commands/apply-migrations.ts index 259409485..ca47ae673 100644 --- a/src/commands/apply-migrations.ts +++ b/src/commands/apply-migrations.ts @@ -133,14 +133,15 @@ function indexCompleted(entries: CompletedMigrationEntry[]): CompletedIndex { * Returns the resolved status for a migration based on its entries. * * Semantics (Bug 3 — keep "complete wins" safety): - * - If any entry is `complete`, the version is complete. Terminal state. - * - Otherwise, if the latest entry is `retry`, the version is pending - * (user requested a fresh attempt). + * - If the latest entry is `retry`, the version is pending. This is the + * explicit escape hatch written by `--force-retry`, and it overrides an + * earlier `complete` entry without hand-editing the ledger. + * - Otherwise, if any entry is `complete`, the version is complete. * - Otherwise, if any entry is `partial`, the version is partial. * - Otherwise, pending. * - * `complete` never regresses. A later accidental `partial` append cannot - * undo a completed migration. + * `complete` never regresses accidentally. A later `partial` append cannot + * undo a completed migration; only a trailing, explicit `retry` marker can. */ function statusForVersion( version: string, @@ -148,9 +149,9 @@ function statusForVersion( ): 'complete' | 'partial' | 'pending' | 'wedged' { const entries = idx.byVersion.get(version) ?? []; if (entries.length === 0) return 'pending'; - if (entries.some(e => e.status === 'complete')) return 'complete'; const latest = entries[entries.length - 1]; if (latest.status === 'retry') return 'pending'; + if (entries.some(e => e.status === 'complete')) return 'complete'; // Bug 3 attempt cap — count consecutive partials from the end (stopping // at any 'retry' or 'complete'). If we hit MAX_CONSECUTIVE_PARTIALS, // the migration is wedged and needs explicit --force-retry to try again. diff --git a/test/apply-migrations.test.ts b/test/apply-migrations.test.ts index 223c28d50..87e8a8b91 100644 --- a/test/apply-migrations.test.ts +++ b/test/apply-migrations.test.ts @@ -167,6 +167,41 @@ describe('buildPlan — diff against completed + installed VERSION', () => { }); }); +describe('force-retry escape hatch', () => { + test("complete then retry-latest → pending and buildPlan lists the version as pending", () => { + const idx = indexCompleted([ + { version: '0.11.0', status: 'complete' }, + { version: '0.11.0', status: 'retry' }, + ]); + + expect(statusForVersion('0.11.0', idx)).toBe('pending'); + const plan = buildPlan(idx, '0.11.1', '0.11.0'); + expect(plan.pending.map(m => m.version)).toEqual(['0.11.0']); + expect(plan.applied).toEqual([]); + expect(plan.partial).toEqual([]); + expect(plan.wedged).toEqual([]); + }); + + test('complete then stray partial without retry → still complete', () => { + const idx = indexCompleted([ + { version: '0.11.0', status: 'complete' }, + { version: '0.11.0', status: 'partial' }, + ]); + + expect(statusForVersion('0.11.0', idx)).toBe('complete'); + }); + + test('retry followed by a newer complete → complete', () => { + const idx = indexCompleted([ + { version: '0.11.0', status: 'complete' }, + { version: '0.11.0', status: 'retry' }, + { version: '0.11.0', status: 'complete' }, + ]); + + expect(statusForVersion('0.11.0', idx)).toBe('complete'); + }); +}); + // v0.36.1.x (cherry-pick #1062): list, dry-run, and "all migrations up to // date" paths must exit 0 so shell scripts gating on the exit code work. // Pre-fix, these `return` statements left the CLI dispatcher's implicit