Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/status-applied-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chkit": patch
---

`status` now reports `Applied` scoped to migrations present in your project's migrations directory, rather than a global count from the journal table. On a shared ObsessionDB journal this previously counted other tenants' rows and could show `Applied` greater than `Total`.
11 changes: 8 additions & 3 deletions packages/cli/src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
name: 'status',
description: 'Show migration status and checksum mismatch information',
flags: [],
async run(context): Promise<undefined | number> {

Check failure on line 14 in packages/cli/src/commands/status.ts

View workflow job for this annotation

GitHub Actions / verify

High CRAP score (critical)

Function 'run' has a CRAP score of 132.0 (threshold: 30.0). • Severity: critical • Cyclomatic: 11 • Cognitive: 11 • CRAP: 132.0 (threshold: 30.0) • Lines: 63 CRAP combines complexity with coverage: high CRAP means changes here carry high risk. Consider adding tests, simplifying the function, or both.
const { flags, config, pluginContext } = context
const jsonMode = flags['--json'] === true
const { migrationsDir } = resolveDirs(config)
Expand All @@ -21,22 +21,27 @@
}
const db = pluginContext.executor
const database = config.clickhouse?.database
const journalStore = createJournalStore(db)

await mkdir(migrationsDir, { recursive: true })
const files = await listMigrations(migrationsDir)
const journal = await journalStore.readJournal()
const appliedNames = new Set(journal.applied.map((entry) => entry.name))
// Scope "Applied" to migrations that exist in this project's migrations dir
// (#31). The journal table can be shared across tenants/services on
// ObsessionDB, so journal.applied.length counts other projects' rows too and
// can exceed this project's total. The intersection is always <= total.
const applied = files.filter((f) => appliedNames.has(f))

Check warning on line 34 in packages/cli/src/commands/status.ts

View workflow job for this annotation

GitHub Actions / verify

Code duplication

11 duplicated lines (63 tokens) 2 instances found. Also in: → commands/migrate/command.ts:73-79 Extract a shared function to eliminate this duplication.
const pending = files.filter((f) => !appliedNames.has(f))
const checksumMismatches = await findChecksumMismatches(migrationsDir, journal)

debug('status', `files=${files.length}, applied=${journal.applied.length}, pending=${pending.length}, checksumMismatches=${checksumMismatches.length}`)
debug('status', `files=${files.length}, applied=${applied.length}, pending=${pending.length}, checksumMismatches=${checksumMismatches.length}`)

const databaseMissing = journalStore.databaseMissing
const payload = {
migrationsDir,
total: files.length,
applied: journal.applied.length,
applied: applied.length,
pending: pending.length,
pendingMigrations: pending,
checksumMismatchCount: checksumMismatches.length,
Expand All @@ -56,7 +61,7 @@

console.log(`Migrations directory: ${migrationsDir}`)
console.log(`Total migrations: ${files.length}`)
console.log(`Applied: ${journal.applied.length}`)
console.log(`Applied: ${applied.length}`)
console.log(`Pending: ${pending.length}`)

if (pending.length > 0) {
Expand Down
120 changes: 120 additions & 0 deletions packages/cli/src/test/status-applied-scope.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'

import { describe, expect, test } from 'bun:test'

import {
CORE_ENTRY,
createJournalTableName,
createLiveExecutor,
createPrefix,
getRequiredEnv,
quoteIdent,
runCli,
} from './e2e-testkit.js'

/**
* #31: on a shared ObsessionDB journal the `Applied` count must reflect only
* the migrations present in THIS project's migrations dir, not every tenant's
* rows in the shared table. The bug let `Applied` exceed `Total`.
*/
describe('@chkit/cli status applied scope e2e (#31)', () => {
test('Applied counts only this project\'s migrations, not foreign rows in a shared journal', async () => {
const liveEnv = getRequiredEnv()
const executor = createLiveExecutor(liveEnv)
const database = liveEnv.clickhouseDatabase
// One journal table shared by two independent projects — the multi-tenant
// scenario that previously inflated the global Applied count.
const journalTable = createJournalTableName('status_scope')
const prefix = createPrefix('status_scope')
const tableA = `${prefix}_a`
const tableB = `${prefix}_b`
const cliEnv = { CHKIT_JOURNAL_TABLE: journalTable, CI: '1' }

const makeProject = async (label: string, tableName: string): Promise<string> => {
const dir = await mkdtemp(join(tmpdir(), `chkit-status-${label}-`))
const schemaPath = join(dir, 'schema.ts')
await writeFile(
schemaPath,
`import { schema, table } from '${CORE_ENTRY}'\n\n` +
`export default schema(table({\n` +
` database: '${database}',\n` +
` name: '${tableName}',\n` +
` columns: [{ name: 'id', type: 'UInt64' }],\n` +
` engine: 'MergeTree()',\n` +
` primaryKey: ['id'],\n` +
` orderBy: ['id'],\n` +
`}))\n`,
'utf8',
)
await writeFile(
join(dir, 'clickhouse.config.ts'),
`export default {\n` +
` schema: '${schemaPath}',\n` +
` outDir: '${join(dir, 'chkit')}',\n` +
` migrationsDir: '${join(dir, 'chkit/migrations')}',\n` +
` metaDir: '${join(dir, 'chkit/meta')}',\n` +
` clickhouse: {\n` +
` url: '${liveEnv.clickhouseUrl}',\n` +
` username: '${liveEnv.clickhouseUser}',\n` +
` password: '${liveEnv.clickhousePassword}',\n` +
` database: '${database}',\n` +
` },\n}\n`,
'utf8',
)
return dir
}

const applyProject = (dir: string, name: string, migrationId: string): void => {
const config = join(dir, 'clickhouse.config.ts')
const gen = runCli(dir, ['generate', '--config', config, '--name', name, '--migration-id', migrationId, '--json'], cliEnv)
expect(gen.exitCode).toBe(0)
const migrate = runCli(dir, ['migrate', '--config', config, '--execute', '--json'], cliEnv)
expect(migrate.exitCode).toBe(0)
}

const journalCompletedCount = async (): Promise<number> => {
const rows = await executor.query<{ n: number }>(
`SELECT count() AS n FROM ${quoteIdent(database)}.${quoteIdent(journalTable)} FINAL ` +
`WHERE migration_completed = true SETTINGS select_sequential_consistency = 1`,
)
return Number(rows[0]?.n ?? 0)
}

const dirA = await makeProject('a', tableA)
const dirB = await makeProject('b', tableB)

try {
// Two independent projects, distinct migration names, one shared journal.
applyProject(dirA, 'init_a', '20990101000000')
applyProject(dirB, 'init_b', '20990101000001')

// Make the foreign row's visibility deterministic: assert the shared
// journal really holds BOTH rows before checking status, so the scoping
// (not propagation lag) is what keeps Applied at 1.
let total = 0
for (let attempt = 0; attempt < 20 && total < 2; attempt++) {
total = await journalCompletedCount()
if (total < 2) await new Promise((r) => setTimeout(r, 1000))
}
expect(total).toBe(2)

const status = runCli(dirB, ['status', '--config', join(dirB, 'clickhouse.config.ts'), '--json'], cliEnv)
expect(status.exitCode).toBe(0)
const payload = JSON.parse(status.stdout) as { total: number; applied: number; pending: number }
expect(payload.total).toBe(1)
expect(payload.applied).toBe(1)
expect(payload.pending).toBe(0)
// The invariant the bug violated: Applied never exceeds Total.
expect(payload.applied).toBeLessThanOrEqual(payload.total)
} finally {
await rm(dirA, { recursive: true, force: true })
await rm(dirB, { recursive: true, force: true })
await executor.command(`DROP TABLE IF EXISTS ${quoteIdent(database)}.${quoteIdent(tableA)}`)
await executor.command(`DROP TABLE IF EXISTS ${quoteIdent(database)}.${quoteIdent(tableB)}`)
await executor.command(`DROP TABLE IF EXISTS ${quoteIdent(database)}.${quoteIdent(journalTable)}`)
await executor.close()
}
}, 180_000)
})
Loading