From 4387710478097d9200d5d2d6c20fb7515eaab93e Mon Sep 17 00:00:00 2001 From: shulaoda <165626830+shulaoda@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:58:31 +0800 Subject: [PATCH] fix(cli): keep db export stdout clean by routing the banner to stderr --- packages/void/src/cli/db.ts | 17 ++-- .../void/test/unit/db-export-stdout.test.ts | 83 +++++++++++++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 packages/void/test/unit/db-export-stdout.test.ts diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index 6a601280c..8d824c780 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -139,8 +139,14 @@ function validatePendingMigrations( } export async function runDbCommand(root: string, args: DbArgs): Promise { - console.log(); - intro(cliTitle('db')); + // `db export` streams raw SQL to stdout for piping — keep the banner off it. + const exportToStdout = args.action === 'export' && !args.output; + if (exportToStdout) { + process.stderr.write('\n'); + } else { + console.log(); + } + intro(cliTitle('db'), exportToStdout ? { output: process.stderr } : undefined); const dialect = getDatabaseDialect(readConfig(root)); @@ -1374,6 +1380,7 @@ async function runExport( args: { output?: string; noData?: boolean; noSchema?: boolean; table?: string }, dialect: DatabaseDialect, ): Promise { + const errOut = { output: process.stderr }; if (dialect === 'postgresql') { const parts: Array = []; await withPgClient(root, async (client) => { @@ -1384,7 +1391,7 @@ async function runExport( [args.table], ); if (check.rows.length === 0) { - log.error(`db: Table '${args.table}' not found.`); + log.error(`db: Table '${args.table}' not found.`, errOut); process.exit(1); } tables = [args.table]; @@ -1452,7 +1459,7 @@ async function runExport( const dbPath = getMiniflareDatabasePath(persistRoot, 'local'); if (!existsSync(dbPath)) { - log.error('No local database found. Run `void dev` first.'); + log.error('No local database found. Run `void dev` first.', errOut); process.exit(1); } @@ -1464,7 +1471,7 @@ async function runExport( .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?") .get(args.table); if (!exists) { - log.error(`db: Table '${args.table}' not found.`); + log.error(`db: Table '${args.table}' not found.`, errOut); process.exit(1); } tables = [args.table]; diff --git a/packages/void/test/unit/db-export-stdout.test.ts b/packages/void/test/unit/db-export-stdout.test.ts new file mode 100644 index 000000000..e9fe85d37 --- /dev/null +++ b/packages/void/test/unit/db-export-stdout.test.ts @@ -0,0 +1,83 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'pathe'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; + +const shared = vi.hoisted(() => ({ dbPath: '' })); + +// Mock the SQLite layer directly (no `importActual`) so the test never loads +// better-sqlite3's native binding — it feeds the export path a canned schema. +vi.mock('../../src/migrations/runner', () => { + const fakeDb = { + prepare(sql: string) { + return { + get: () => + sql.includes('SELECT sql FROM sqlite_master') + ? { sql: 'CREATE TABLE notes (id INTEGER PRIMARY KEY, title TEXT NOT NULL)' } + : undefined, + all: () => { + if (sql.includes('NOT LIKE')) { + return [{ name: 'notes' }]; + } + if (sql.startsWith('SELECT * FROM')) { + return [{ id: 1, title: 'hi' }]; + } + return []; + }, + }; + }, + close() {}, + }; + return { + getMiniflareDatabasePath: () => shared.dbPath, + openSqliteDatabase: () => fakeDb, + }; +}); + +const EXPECTED_SQL = + 'CREATE TABLE notes (id INTEGER PRIMARY KEY, title TEXT NOT NULL);\n' + + 'INSERT INTO "notes" (id, title) VALUES (1, \'hi\');\n'; + +describe('void db export → stdout', () => { + const roots: Array = []; + + afterEach(() => { + vi.restoreAllMocks(); + for (const root of roots.splice(0, roots.length)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('streams pure SQL to stdout and routes the banner to stderr', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-db-export-')); + roots.push(root); + writeFileSync(join(root, 'package.json'), JSON.stringify({ dependencies: { void: '*' } })); + writeFileSync(join(root, 'void.json'), JSON.stringify({})); + const dbFile = join(root, 'local.sqlite'); + writeFileSync(dbFile, ''); + shared.dbPath = dbFile; + + const { runDbCommand } = await import('../../src/cli/db.ts'); + + let stdout = ''; + let stderr = ''; + vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => { + stdout += chunk.toString(); + return true; + }); + vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => { + stderr += chunk.toString(); + return true; + }); + vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code ?? 0}`); + }) as never); + + await runDbCommand(root, { action: 'export' }); + + expect(stdout).toBe(EXPECTED_SQL); + expect(stdout.startsWith('CREATE TABLE')).toBe(true); + expect(stdout).not.toContain('void db'); + expect(stderr).toContain('void db'); + }); +});