Skip to content

Commit 8522299

Browse files
authored
fix(cli): keep db export stdout clean by routing the banner to stderr (#253)
## What `runDbCommand` now detects the `export` action without `--output` and sends the intro banner (and its leading blank line) to stderr for that mode. `runExport` sends its three error messages to stderr as well. The SQL dump still goes to stdout via `process.stdout.write`. Every other db subcommand is unchanged. ## Why `void db export` without `--output` streams raw SQL to stdout so it can be piped or redirected, for example `void db export > dump.sql` or `void db export | sqlite3 new.db`. But the command printed the clack intro banner first, and clack writes to stdout by default, so the dump began with a blank line and a `void db` banner before the first `CREATE TABLE`. Feeding that file into sqlite3 fails with a parse error on the banner line and the restore never runs. Errors raised during a piped export were also written into the dump and hidden from the user, so routing them to stderr both keeps the dump clean and makes the error visible on the terminal. ## Safety The change is scoped to the export to stdout path. Other db subcommands keep their banner and status on stdout exactly as before, and `void db export --output file.sql` is unaffected because that path does not stream to stdout. The banner still appears on the terminal in every case, since stderr is not redirected by a stdout pipe. ## Verification A new regression test at `packages/void/test/unit/db-export-stdout.test.ts` drives `runDbCommand` with the real clack library and a mocked SQLite layer, then asserts that stdout is byte for byte the SQL dump and that the banner lands on stderr. It fails on the old code, where stdout starts with the banner, and passes on the fix. The `db-migrate`, `db-seed`, and `db-commands` suites that assert exact clack arguments stay green (33 tests total), and the void package typechecks with tsgo and passes oxlint.
1 parent ab73bf7 commit 8522299

2 files changed

Lines changed: 95 additions & 5 deletions

File tree

‎packages/void/src/cli/db.ts‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,14 @@ function validatePendingMigrations(
139139
}
140140

141141
export async function runDbCommand(root: string, args: DbArgs): Promise<void> {
142-
console.log();
143-
intro(cliTitle('db'));
142+
// `db export` streams raw SQL to stdout for piping — keep the banner off it.
143+
const exportToStdout = args.action === 'export' && !args.output;
144+
if (exportToStdout) {
145+
process.stderr.write('\n');
146+
} else {
147+
console.log();
148+
}
149+
intro(cliTitle('db'), exportToStdout ? { output: process.stderr } : undefined);
144150

145151
const dialect = getDatabaseDialect(readConfig(root));
146152

@@ -1374,6 +1380,7 @@ async function runExport(
13741380
args: { output?: string; noData?: boolean; noSchema?: boolean; table?: string },
13751381
dialect: DatabaseDialect,
13761382
): Promise<void> {
1383+
const errOut = { output: process.stderr };
13771384
if (dialect === 'postgresql') {
13781385
const parts: Array<string> = [];
13791386
await withPgClient(root, async (client) => {
@@ -1384,7 +1391,7 @@ async function runExport(
13841391
[args.table],
13851392
);
13861393
if (check.rows.length === 0) {
1387-
log.error(`db: Table '${args.table}' not found.`);
1394+
log.error(`db: Table '${args.table}' not found.`, errOut);
13881395
process.exit(1);
13891396
}
13901397
tables = [args.table];
@@ -1452,7 +1459,7 @@ async function runExport(
14521459
const dbPath = getMiniflareDatabasePath(persistRoot, 'local');
14531460

14541461
if (!existsSync(dbPath)) {
1455-
log.error('No local database found. Run `void dev` first.');
1462+
log.error('No local database found. Run `void dev` first.', errOut);
14561463
process.exit(1);
14571464
}
14581465

@@ -1464,7 +1471,7 @@ async function runExport(
14641471
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
14651472
.get(args.table);
14661473
if (!exists) {
1467-
log.error(`db: Table '${args.table}' not found.`);
1474+
log.error(`db: Table '${args.table}' not found.`, errOut);
14681475
process.exit(1);
14691476
}
14701477
tables = [args.table];
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'pathe';
4+
import { afterEach, describe, expect, it, vi } from 'vite-plus/test';
5+
6+
const shared = vi.hoisted(() => ({ dbPath: '' }));
7+
8+
// Mock the SQLite layer directly (no `importActual`) so the test never loads
9+
// better-sqlite3's native binding — it feeds the export path a canned schema.
10+
vi.mock('../../src/migrations/runner', () => {
11+
const fakeDb = {
12+
prepare(sql: string) {
13+
return {
14+
get: () =>
15+
sql.includes('SELECT sql FROM sqlite_master')
16+
? { sql: 'CREATE TABLE notes (id INTEGER PRIMARY KEY, title TEXT NOT NULL)' }
17+
: undefined,
18+
all: () => {
19+
if (sql.includes('NOT LIKE')) {
20+
return [{ name: 'notes' }];
21+
}
22+
if (sql.startsWith('SELECT * FROM')) {
23+
return [{ id: 1, title: 'hi' }];
24+
}
25+
return [];
26+
},
27+
};
28+
},
29+
close() {},
30+
};
31+
return {
32+
getMiniflareDatabasePath: () => shared.dbPath,
33+
openSqliteDatabase: () => fakeDb,
34+
};
35+
});
36+
37+
const EXPECTED_SQL =
38+
'CREATE TABLE notes (id INTEGER PRIMARY KEY, title TEXT NOT NULL);\n' +
39+
'INSERT INTO "notes" (id, title) VALUES (1, \'hi\');\n';
40+
41+
describe('void db export → stdout', () => {
42+
const roots: Array<string> = [];
43+
44+
afterEach(() => {
45+
vi.restoreAllMocks();
46+
for (const root of roots.splice(0, roots.length)) {
47+
rmSync(root, { recursive: true, force: true });
48+
}
49+
});
50+
51+
it('streams pure SQL to stdout and routes the banner to stderr', async () => {
52+
const root = mkdtempSync(join(tmpdir(), 'void-db-export-'));
53+
roots.push(root);
54+
writeFileSync(join(root, 'package.json'), JSON.stringify({ dependencies: { void: '*' } }));
55+
writeFileSync(join(root, 'void.json'), JSON.stringify({}));
56+
const dbFile = join(root, 'local.sqlite');
57+
writeFileSync(dbFile, '');
58+
shared.dbPath = dbFile;
59+
60+
const { runDbCommand } = await import('../../src/cli/db.ts');
61+
62+
let stdout = '';
63+
let stderr = '';
64+
vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => {
65+
stdout += chunk.toString();
66+
return true;
67+
});
68+
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => {
69+
stderr += chunk.toString();
70+
return true;
71+
});
72+
vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
73+
throw new Error(`process.exit:${code ?? 0}`);
74+
}) as never);
75+
76+
await runDbCommand(root, { action: 'export' });
77+
78+
expect(stdout).toBe(EXPECTED_SQL);
79+
expect(stdout.startsWith('CREATE TABLE')).toBe(true);
80+
expect(stdout).not.toContain('void db');
81+
expect(stderr).toContain('void db');
82+
});
83+
});

0 commit comments

Comments
 (0)