-
Notifications
You must be signed in to change notification settings - Fork 18
fix: raw SQL from db export -o {data} envelope; warn when create silently relinks directory #211
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| import { readFileSync, rmSync, mkdtempSync } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { join } from 'node:path'; | ||
| import { Command } from 'commander'; | ||
| import { registerDbExportCommand, extractExportContent } from './export.js'; | ||
|
|
||
| vi.mock('../../lib/api/oss.js', () => ({ | ||
| ossFetch: vi.fn(), | ||
| })); | ||
| vi.mock('../../lib/credentials.js', () => ({ | ||
| requireAuth: vi.fn(async () => {}), | ||
| })); | ||
| vi.mock('../../lib/command-telemetry.js', () => ({ | ||
| trackCommandUsage: vi.fn(async () => {}), | ||
| })); | ||
|
|
||
| import { ossFetch } from '../../lib/api/oss.js'; | ||
|
|
||
| function makeProgram() { | ||
| const program = new Command().exitOverride(); | ||
| program.option('--json').option('--api-url <url>'); | ||
| const dbCmd = program.command('db'); | ||
| registerDbExportCommand(dbCmd); | ||
| return program; | ||
| } | ||
|
|
||
| function mockExportResponse(body: string) { | ||
| (ossFetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({ | ||
| text: async () => body, | ||
| }); | ||
| } | ||
|
|
||
| describe('extractExportContent', () => { | ||
| it('unwraps a { content } envelope', () => { | ||
| expect(extractExportContent({ format: 'sql', content: 'CREATE TABLE a ();' })).toBe( | ||
| 'CREATE TABLE a ();', | ||
| ); | ||
| }); | ||
|
|
||
| it('unwraps a { data } envelope', () => { | ||
| expect(extractExportContent({ format: 'sql', data: 'CREATE TABLE a ();' })).toBe( | ||
| 'CREATE TABLE a ();', | ||
| ); | ||
| }); | ||
|
|
||
| it('prefers content over data when both are present', () => { | ||
| expect(extractExportContent({ content: 'from-content', data: 'from-data' })).toBe( | ||
| 'from-content', | ||
| ); | ||
| }); | ||
|
|
||
| it('returns null for non-string content/data', () => { | ||
| expect(extractExportContent({ format: 'json', data: { tables: [] } })).toBeNull(); | ||
| expect(extractExportContent({ rows: [1, 2, 3] })).toBeNull(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('db export -o', () => { | ||
| let dir: string; | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| dir = mkdtempSync(join(tmpdir(), 'insforge-export-test-')); | ||
| return () => rmSync(dir, { recursive: true, force: true }); | ||
| }); | ||
|
|
||
| it('writes raw SQL when the backend returns a { format, data } envelope', async () => { | ||
| const sql = 'CREATE TABLE users (id uuid PRIMARY KEY);'; | ||
| mockExportResponse(JSON.stringify({ format: 'sql', data: sql, tables: ['users'] })); | ||
| const outFile = join(dir, 'dump.sql'); | ||
|
|
||
| await makeProgram().parseAsync( | ||
| ['db', 'export', '--format', 'sql', '-o', outFile], | ||
| { from: 'user' }, | ||
| ); | ||
|
|
||
| expect(readFileSync(outFile, 'utf-8')).toBe(sql); | ||
| }); | ||
|
|
||
| it('writes raw SQL when the backend returns a { format, content } envelope', async () => { | ||
| const sql = 'CREATE TABLE posts (id serial);'; | ||
| mockExportResponse(JSON.stringify({ format: 'sql', content: sql, tables: ['posts'] })); | ||
| const outFile = join(dir, 'dump.sql'); | ||
|
|
||
| await makeProgram().parseAsync( | ||
| ['db', 'export', '--format', 'sql', '-o', outFile], | ||
| { from: 'user' }, | ||
| ); | ||
|
|
||
| expect(readFileSync(outFile, 'utf-8')).toBe(sql); | ||
| }); | ||
|
|
||
| it('writes the response verbatim when it is not an envelope', async () => { | ||
| const raw = '-- raw sql dump\nCREATE TABLE t ();'; | ||
| mockExportResponse(raw); | ||
| const outFile = join(dir, 'dump.sql'); | ||
|
|
||
| await makeProgram().parseAsync( | ||
| ['db', 'export', '--format', 'sql', '-o', outFile], | ||
| { from: 'user' }, | ||
| ); | ||
|
|
||
| expect(readFileSync(outFile, 'utf-8')).toBe(raw); | ||
| }); | ||
| }); | ||
|
|
||
| describe('db export --json', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| it('includes SQL content when the backend returns a { format, data } envelope', async () => { | ||
| const sql = 'CREATE TABLE users (id uuid PRIMARY KEY);'; | ||
| mockExportResponse(JSON.stringify({ format: 'sql', data: sql, timestamp: '2026-07-28' })); | ||
| const log = vi.spyOn(console, 'log').mockImplementation(() => {}); | ||
|
|
||
| await makeProgram().parseAsync( | ||
| ['--json', 'db', 'export', '--format', 'sql'], | ||
| { from: 'user' }, | ||
| ); | ||
|
|
||
| expect(log).toHaveBeenCalledOnce(); | ||
| expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({ format: 'sql', content: sql }); | ||
| log.mockRestore(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,17 @@ import { handleError, getRootOpts } from '../../lib/errors.js'; | |
| import { outputJson, outputSuccess } from '../../lib/output.js'; | ||
| import { trackCommandUsage } from '../../lib/command-telemetry.js'; | ||
|
|
||
| /** | ||
| * The backend may wrap the export in a JSON envelope keyed `content` or | ||
| * `data` — unwrap either so `-o file.sql` writes raw SQL/JSON rather than | ||
| * the envelope itself. Returns null when the payload isn't a wrapper. | ||
| */ | ||
| export function extractExportContent(parsed: Record<string, unknown>): string | null { | ||
| if (typeof parsed.content === 'string') return parsed.content; | ||
| if (typeof parsed.data === 'string') return parsed.data; | ||
|
Comment on lines
+15
to
+16
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
A raw JSON export with a top-level string-valued Knowledge Base Used: Database commands ( Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
| return null; | ||
| } | ||
|
|
||
| export function registerDbExportCommand(dbCmd: Command): void { | ||
| dbCmd | ||
| .command('export') | ||
|
|
@@ -43,13 +54,14 @@ export function registerDbExportCommand(dbCmd: Command): void { | |
|
|
||
| const raw = await res.text(); | ||
|
|
||
| // API may return JSON wrapper { format, content, tables } or raw SQL/JSON text | ||
| // API may return JSON wrapper { format, content|data, tables } or raw SQL/JSON text | ||
| let content: string; | ||
| let meta: { format?: string; tables?: string[] } | null = null; | ||
| try { | ||
| const parsed = JSON.parse(raw) as Record<string, unknown>; | ||
| if (typeof parsed.content === 'string') { | ||
| content = parsed.content; | ||
| const wrapped = extractExportContent(parsed); | ||
| if (wrapped !== null) { | ||
| content = wrapped; | ||
| meta = { format: parsed.format as string, tables: parsed.tables as string[] }; | ||
| } else { | ||
| content = raw; | ||
|
|
@@ -59,7 +71,7 @@ export function registerDbExportCommand(dbCmd: Command): void { | |
| } | ||
|
|
||
| if (json) { | ||
| outputJson(meta ?? { content }); | ||
| outputJson(meta ? { ...meta, content } : { content }); | ||
| await trackCommandUsage('db', 'export', true); | ||
| return; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an existing
.insforge/project.jsoncontains malformed JSON, the newgetProjectConfig()call throws after the remote project has been created but beforesaveProjectConfig()replaces the file, causing the command to exit with the remote project left unlinked from the working directory.Knowledge Base Used: Project Lifecycle: Create, Link, List, and Branch