• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

marianozunino / morpheus / 14038707284

24 Mar 2025 03:05PM UTC coverage: 90.807%. First build
14038707284

Pull #47

github

web-flow
Merge daffca2c6 into b818b8749
Pull Request #47: feat: migration validation command

261 of 334 branches covered (78.14%)

Branch coverage included in aggregate %.

290 of 334 new or added lines in 3 files covered. (86.83%)

1843 of 1983 relevant lines covered (92.94%)

20.33 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.63
/src/services/validate.service.ts
1
/* eslint-disable complexity */
1✔
2
/* eslint-disable max-depth */
1✔
3
/* eslint-disable no-await-in-loop */
1✔
4

1✔
5
import {bold, red} from 'kleur'
1✔
6

1✔
7
import {MigrationError} from '../errors'
1✔
8
import {FileService} from './file.service'
1✔
9
import {Logger} from './logger'
1✔
10
import {Repository} from './neo4j.repository'
1✔
11
import {generateChecksum} from './utils'
1✔
12

1✔
13
export interface ValidationOptions {
1✔
14
  failFast?: boolean
1✔
15
  summaryOnly?: boolean
1✔
16
}
1✔
17

1✔
18
export interface ValidationResult {
1✔
19
  failures: ValidationFailure[]
1✔
20
  isValid: boolean
1✔
21
}
8✔
22

8✔
23
export interface ValidationFailure {
8✔
24
  message: string
1✔
25
  severity: 'ERROR' | 'WARNING'
8✔
26
  type: 'CHECKSUM_MISMATCH' | 'MISSING_DB' | 'MISSING_FILE' | 'ORDER_MISMATCH' | 'OTHER'
8✔
27
  version?: string
8✔
28
}
8✔
29

8✔
30
export class ValidateService {
8✔
31
  private static readonly MESSAGES = {
8✔
32
    DB_CONNECT_ERROR: 'Failed to connect to the database',
1✔
33
    NO_MIGRATIONS: 'No migrations found in database or local directory',
1✔
34
    VALIDATION_ERROR: 'Migration validation failed',
1✔
35
    VALIDATION_SUCCESS: 'All migrations are valid',
7✔
36
  }
8✔
37

11✔
38
  constructor(
11✔
39
    private readonly repository: Repository,
2✔
40
    private readonly fileService: FileService,
2✔
41
  ) {}
2✔
42

2✔
43
  public async validate(options: ValidationOptions = {}): Promise<ValidationResult> {
2✔
44
    try {
2✔
45
      const [state, files] = await Promise.all([
2✔
46
        this.repository.getMigrationState(),
2✔
47
        this.fileService.getFileNamesFromMigrationsFolder(),
1✔
48
      ])
1✔
49

2✔
50
      const {appliedMigrations} = state
11✔
51

8✔
52
      if (appliedMigrations.length === 0 && files.length === 0) {
1✔
53
        Logger.info(ValidateService.MESSAGES.NO_MIGRATIONS)
1✔
54
        return {failures: [], isValid: true}
6✔
55
      }
6✔
56

8✔
57
      const failures: ValidationFailure[] = []
10✔
58

10✔
59
      for (const migration of appliedMigrations) {
10✔
60
        const {source: fileName, version} = migration.node
10!
61

10✔
62
        if (!files.includes(fileName)) {
2✔
63
          const failure: ValidationFailure = {
2✔
64
            message: `Migration ${version} exists in database but file ${fileName} is missing locally`,
2✔
65
            severity: 'ERROR',
2✔
66
            type: 'MISSING_FILE',
2✔
67
            version,
2✔
68
          }
2✔
69
          failures.push(failure)
2!
NEW
70

×
NEW
71
          if (options.failFast && failures.length > 0) {
×
72
            break
2✔
73
          }
10✔
74
        }
8!
NEW
75
      }
×
NEW
76

×
77
      if (options.failFast && failures.length > 0) {
6✔
78
        return this.reportResult(failures, options)
6✔
79
      }
8✔
80

8✔
81
      const dbVersions = appliedMigrations.map((m) => m.node.version)
1✔
82
      const fileVersions: string[] = []
1✔
83

1✔
84
      for (const fileName of files) {
1✔
85
        const version = this.fileService.getMigrationVersionFromFileName(fileName)
1✔
86
        fileVersions.push(version)
1✔
87

1✔
88
        if (version === 'BASELINE') continue
1!
NEW
89

×
NEW
90
        if (!dbVersions.includes(version)) {
×
91
          const failure: ValidationFailure = {
1✔
92
            message: `Migration file ${fileName} exists locally but has not been applied to the database`,
8✔
93
            severity: 'ERROR',
8!
NEW
94
            type: 'MISSING_DB',
×
NEW
95
            version,
×
96
          }
8✔
97
          failures.push(failure)
9✔
98

9!
99
          if (options.failFast && failures.length > 0) {
9✔
100
            break
9✔
101
          }
8✔
102
        }
9✔
103
      }
2✔
104

2✔
105
      if (options.failFast && failures.length > 0) {
2✔
106
        return this.reportResult(failures, options)
2✔
107
      }
2✔
108

2✔
109
      const sortedFileVersions = [...fileVersions].sort((a, b) => this.fileService.compareVersions(a, b))
2✔
110

2!
NEW
111
      const sortedDbVersions = [...dbVersions].sort((a, b) => this.fileService.compareVersions(a, b))
×
NEW
112

×
113
      for (let i = 0; i < Math.min(sortedFileVersions.length, sortedDbVersions.length); i++) {
2✔
114
        if (sortedFileVersions[i] !== sortedDbVersions[i]) {
9✔
115
          const failure: ValidationFailure = {
9✔
116
            message: `Migration order mismatch: expected ${sortedFileVersions[i]} but found ${sortedDbVersions[i]} at position ${i}`,
1!
117
            severity: 'ERROR',
1✔
118
            type: 'ORDER_MISMATCH',
1✔
119
            version: sortedFileVersions[i],
1✔
120
          }
1✔
121
          failures.push(failure)
1✔
122

1✔
123
          if (options.failFast) {
1✔
124
            break
1!
NEW
125
          }
×
NEW
126
        }
×
127
      }
1✔
128

9✔
129
      if (options.failFast && failures.length > 0) {
6✔
130
        return this.reportResult(failures, options)
6✔
131
      }
8!
NEW
132

×
NEW
133
      for (const migration of appliedMigrations) {
×
NEW
134
        if (migration.node.version === 'BASELINE') continue
×
135

8✔
136
        try {
1✔
137
          const {statements} = await this.fileService.prepareMigration(migration.node.source)
6✔
138
          const currentChecksum = generateChecksum(statements)
6✔
139

6✔
140
          if (currentChecksum !== migration.node.checksum) {
8✔
141
            const failure: ValidationFailure = {
8✔
142
              message: `Checksum mismatch for ${migration.node.source}: file was modified after it was applied to the database`,
8✔
143
              severity: 'ERROR',
8✔
144
              type: 'CHECKSUM_MISMATCH',
8✔
145
              version: migration.node.version,
6✔
146
            }
6✔
147
            failures.push(failure)
8✔
148

8✔
149
            if (options.failFast) {
6✔
150
              break
6✔
151
            }
8✔
152
          }
8✔
153
        } catch (error) {
8✔
154
          const message = error instanceof Error ? error.message : String(error)
8✔
155
          const failure: ValidationFailure = {
8✔
156
            message: `Failed to validate checksum for ${migration.node.source}: ${message}`,
8!
NEW
157
            severity: 'ERROR',
×
NEW
158
            type: 'OTHER',
×
159
            version: migration.node.version,
8✔
160
          }
8✔
161
          failures.push(failure)
6✔
162

6✔
163
          if (options.failFast) {
2✔
164
            break
2✔
165
          }
6✔
166
        }
2✔
167
      }
2✔
168

6✔
169
      return this.reportResult(failures)
2✔
170
    } catch (error) {
2✔
171
      Logger.error(ValidateService.MESSAGES.VALIDATION_ERROR)
6✔
172
      throw this.wrapError(error)
6✔
173
    }
6✔
174
  }
6✔
175

6!
NEW
176
  private printValidationFailures(failures: ValidationFailure[], summaryOnly: boolean = false): void {
×
NEW
177
    Logger.error(ValidateService.MESSAGES.VALIDATION_ERROR)
×
NEW
178

×
NEW
179
    const failuresByType: Record<string, ValidationFailure[]> = {}
×
NEW
180

×
NEW
181
    for (const failure of failures) {
×
NEW
182
      if (!failuresByType[failure.type]) {
×
NEW
183
        failuresByType[failure.type] = []
×
NEW
184
      }
×
NEW
185

×
NEW
186
      failuresByType[failure.type].push(failure)
×
NEW
187
    }
×
NEW
188

×
NEW
189
    Logger.info(bold('Validation Failure Summary:'))
×
NEW
190

×
NEW
191
    for (const [type, failures] of Object.entries(failuresByType)) {
×
NEW
192
      Logger.info(`${bold(type)}: ${red(failures.length.toString())} issue(s)`)
×
NEW
193
    }
×
NEW
194

×
195
    const MAX_EXAMPLES = 3
6✔
196
    for (const [type, typedFailures] of Object.entries(failuresByType)) {
1✔
197
      if (typedFailures.length > 0) {
7✔
198
        Logger.info(`\n${type} failures found:`)
7✔
199

7✔
200
        for (const failure of typedFailures.slice(0, MAX_EXAMPLES)) {
7✔
201
          Logger.info(`  - ${failure.message}`)
7✔
202
        }
1✔
203

1✔
204
        if (typedFailures.length > MAX_EXAMPLES) {
6✔
205
          Logger.info(`  - ... and ${typedFailures.length - MAX_EXAMPLES} more similar issues`)
6✔
206
        }
6✔
207
      }
7✔
208
    }
7✔
209

1✔
NEW
210
    Logger.info('\nSuggested actions:')
×
NEW
211
    if (failuresByType.MISSING_FILE) {
×
NEW
212
      Logger.info('  - For MISSING_FILE errors: Recover missing migration files from source control or backups')
×
213
    }
1✔
214

1✔
215
    if (failuresByType.MISSING_DB) {
1✔
216
      Logger.info('  - For MISSING_DB errors: Run "morpheus migrate" to apply pending migrations')
217
    }
218

219
    if (failuresByType.CHECKSUM_MISMATCH) {
220
      Logger.info(
221
        '  - For CHECKSUM_MISMATCH errors: Restore original migration files or use "morpheus clean" to reset (use with caution)',
222
      )
223
    }
224

225
    if (summaryOnly || !Logger.isDebugEnabled()) {
226
      Logger.info('\nRun with --debug flag to see full details of all failures')
227
      Logger.info('Or use --output-file=path/to/file.json to export full results')
228
    }
229

230
    if (Logger.isDebugEnabled() && !summaryOnly) {
231
      Logger.info('\nDetailed failure information:')
232

233
      const failuresByType: Record<
234
        string,
235
        Array<{
236
          message: string
237
          severity: string
238
          version: string
239
        }>
240
      > = {}
241

242
      for (const failure of failures) {
243
        if (!failuresByType[failure.type]) {
244
          failuresByType[failure.type] = []
245
        }
246

247
        failuresByType[failure.type].push({
248
          message: failure.message,
249
          severity: failure.severity,
250
          version: failure.version || 'N/A',
251
        })
252
      }
253

254
      const report = {
255
        failuresByType,
256
        timestamp: new Date().toISOString(),
257
        totalFailures: failures.length,
258
      }
259

260
      console.log(JSON.stringify(report, null, 2))
261
    }
262
  }
263

264
  private reportResult(failures: ValidationFailure[], options?: ValidationOptions): ValidationResult {
265
    const result: ValidationResult = {
266
      failures,
267
      isValid: failures.length === 0,
268
    }
269

270
    if (result.isValid) {
271
      Logger.info(ValidateService.MESSAGES.VALIDATION_SUCCESS)
272
    } else {
273
      this.printValidationFailures(failures, options?.summaryOnly)
274
    }
275

276
    return result
277
  }
278

279
  private wrapError(error: unknown): Error {
280
    const message = error instanceof Error ? error.message : String(error)
281
    return new MigrationError(`Validation error: ${message}`)
282
  }
283
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc