Skip to content

Commit b3e5e9e

Browse files
committed
Fix: Skip completed check in dry-run mode and add reset endpoint
Dry-run mode now runs all discovered migrations regardless of completion status. New POST /api/migrations/reset endpoint allows clearing a migration's completion record so it will re-run on next start.
1 parent 006050b commit b3e5e9e

3 files changed

Lines changed: 109 additions & 1 deletion

File tree

lib/MigrationsModule.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ class MigrationsModule extends AbstractModule {
3535
async runMigrations (options = {}) {
3636
const { dryRun } = options
3737
const discovered = await this.discoverMigrations()
38-
const completed = await this.getCompletedMigrations()
38+
const completed = dryRun ? [] : await this.getCompletedMigrations()
3939
const pending = this.filterPending(discovered, completed)
4040

4141
if (!pending.length) {
@@ -99,6 +99,24 @@ class MigrationsModule extends AbstractModule {
9999
}
100100
}
101101

102+
async resetHandler (req, res, next) {
103+
try {
104+
const { module, version } = req.body
105+
if (!module || !version) {
106+
res.status(400).json({ error: 'module and version are required' })
107+
return
108+
}
109+
const { deletedCount } = await this.db.collection('migrations').deleteOne({ module, version })
110+
if (!deletedCount) {
111+
res.status(404).json({ error: `no completion record found for ${module}@${version}` })
112+
return
113+
}
114+
res.json({ message: `reset ${module}@${version}, migration will re-run on next start` })
115+
} catch (e) {
116+
next(e)
117+
}
118+
}
119+
102120
async getStatus () {
103121
const discovered = await this.discoverMigrations()
104122
const completed = await this.getCompletedMigrations()

routes.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,17 @@
1111
"description": "Returns all discovered migrations with their completion status."
1212
}
1313
}
14+
},
15+
{
16+
"route": "/reset",
17+
"handlers": { "post": "resetHandler" },
18+
"permissions": { "post": ["write:migrations"] },
19+
"meta": {
20+
"post": {
21+
"summary": "Reset a migration",
22+
"description": "Removes the completion record for a migration so it will re-run on next start."
23+
}
24+
}
1425
}
1526
]
1627
}

tests/MigrationsModule.spec.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,19 @@ describe('MigrationsModule', () => {
406406
await assert.rejects(() => inst.runMigrations(), { message: 'boom' })
407407
assert.equal(inst.executeMigration.mock.callCount(), 1)
408408
})
409+
410+
it('should skip completed check in dryRun mode', async () => {
411+
const inst = createInstance()
412+
inst.executeMigration = mock.fn()
413+
inst.discoverMigrations = mock.fn(async () => [createMigration()])
414+
inst.getCompletedMigrations = mock.fn(async () => [
415+
{ module: 'mod-a', version: '1.0.0' }
416+
])
417+
await inst.runMigrations({ dryRun: true })
418+
419+
assert.equal(inst.getCompletedMigrations.mock.callCount(), 0)
420+
assert.equal(inst.executeMigration.mock.callCount(), 1)
421+
})
409422
})
410423

411424
describe('executeMigration', () => {
@@ -650,4 +663,70 @@ describe('MigrationsModule', () => {
650663
assert.ok(doc.completedAt instanceof Date)
651664
})
652665
})
666+
667+
describe('resetHandler', () => {
668+
function createRes () {
669+
const res = {
670+
statusCode: 200,
671+
status: mock.fn(function (code) { res.statusCode = code; return res }),
672+
json: mock.fn()
673+
}
674+
return res
675+
}
676+
677+
it('should return 400 when module is missing', async () => {
678+
const inst = createInstance()
679+
inst.resetHandler = proto.resetHandler
680+
const res = createRes()
681+
await inst.resetHandler({ body: { version: '1.0.0' } }, res, mock.fn())
682+
683+
assert.equal(res.statusCode, 400)
684+
})
685+
686+
it('should return 400 when version is missing', async () => {
687+
const inst = createInstance()
688+
inst.resetHandler = proto.resetHandler
689+
const res = createRes()
690+
await inst.resetHandler({ body: { module: 'mod-a' } }, res, mock.fn())
691+
692+
assert.equal(res.statusCode, 400)
693+
})
694+
695+
it('should return 404 when no record found', async () => {
696+
const deleteOneMock = mock.fn(async () => ({ deletedCount: 0 }))
697+
const inst = createInstance({
698+
db: { collection: mock.fn(() => ({ deleteOne: deleteOneMock })) }
699+
})
700+
inst.resetHandler = proto.resetHandler
701+
const res = createRes()
702+
await inst.resetHandler({ body: { module: 'mod-a', version: '1.0.0' } }, res, mock.fn())
703+
704+
assert.equal(res.statusCode, 404)
705+
})
706+
707+
it('should delete the record and return success', async () => {
708+
const deleteOneMock = mock.fn(async () => ({ deletedCount: 1 }))
709+
const collectionMock = mock.fn(() => ({ deleteOne: deleteOneMock }))
710+
const inst = createInstance({ db: { collection: collectionMock } })
711+
inst.resetHandler = proto.resetHandler
712+
const res = createRes()
713+
await inst.resetHandler({ body: { module: 'mod-a', version: '1.0.0' } }, res, mock.fn())
714+
715+
assert.equal(collectionMock.mock.calls[0].arguments[0], 'migrations')
716+
assert.deepEqual(deleteOneMock.mock.calls[0].arguments[0], { module: 'mod-a', version: '1.0.0' })
717+
assert.equal(res.statusCode, 200)
718+
})
719+
720+
it('should call next on error', async () => {
721+
const inst = createInstance({
722+
db: { collection: mock.fn(() => { throw new Error('db error') }) }
723+
})
724+
inst.resetHandler = proto.resetHandler
725+
const next = mock.fn()
726+
await inst.resetHandler({ body: { module: 'mod-a', version: '1.0.0' } }, createRes(), next)
727+
728+
assert.equal(next.mock.callCount(), 1)
729+
assert.equal(next.mock.calls[0].arguments[0].message, 'db error')
730+
})
731+
})
653732
})

0 commit comments

Comments
 (0)