Skip to content
Draft
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
31 changes: 31 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,37 @@ confidence whoami --output plain # Force plain text
confidence events list | jq '.data[].name' # Pipe-friendly JSON
```

## SQL Preview and Job Status

These commands call the regional Metrics HTTP API directly. No MCP installation is required.

```bash
confidence data sql preview --from-file query.json --json
confidence data sql preview --from-file query.json --wait --timeout 60 --json
confidence data sql status --name sqlJobs/example --json
```

`query.json` contains the request body:

```json
{ "query": "SELECT 1" }
```

Preview starts a warehouse query and can incur costs. It is not an offline SQL check or warehouse-table discovery. By default it returns the job reference immediately. `--wait` polls pending, running, retry-pending and throttled states; `--timeout` is a positive number of seconds (maximum 3600, default 60) starting after preview creation. Waiting failures return a nonzero exit status and retain the job reference. A successful `status` inspection exits successfully even when the job itself failed.

The CLI never automatically retries preview or cancels the remote job when local waiting ends. Inspect the returned job reference before deciding what to do next. A lost preview response has an unknown outcome and may have started a job. `--profile` selects authentication; `--dry-run` validates and previews local input without authentication, HTTP calls, writes or telemetry. Input accepts only `query`; unsupported fields are rejected.

## SQL Schema (No Sample-Row Output)

```bash
confidence data sql schema --name sqlJobs/example --json
confidence data sql schema --name sqlJobs/example --dry-run --json
```

Reads the existing job's schema, column issues and table issues through `GetSampleRows`; it never starts or retries a query. **The backend endpoint can return sample rows over the wire.** The CLI immediately projects only schema and issue metadata: sample rows and unrelated result fields are never printed, logged, persisted or sent to telemetry or a model. This is output filtering, not a server-side no-row retrieval guarantee.

Completed results include `state: COMPLETED` and schema/issues arrays. Pending results use `state: PENDING, schema: null`; failed queries use `state: FAILED, schema: null` and their error/reason. A successful inspection exits zero even for pending/failed jobs; HTTP/authentication and malformed-response errors exit nonzero. This is schema inspection, not physical-table discovery or semantic certification. `--profile` selects authentication; `--dry-run` validates names without authentication, HTTP calls, writes or telemetry.

## License

[Apache License 2.0](../../LICENSE)
51 changes: 51 additions & 0 deletions packages/cli/__tests__/commands/data.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import yargs from 'yargs';
import { prepareAuthTokens } from '@spotify-confidence/testing/auth';
import { dataCommand } from '@commands/index.js';

function run(args: string[]) {
let output = '';
const sut = yargs(args).scriptName('confidence').command(dataCommand).help().exitProcess(false);
return sut
.parseAsync(args, (_error: Error | undefined, _argv: unknown, text: string) => {
output = text;
})
.then(() => output);
}

describe('data command group', () => {
it('is registered in the real root help alongside existing commands', () => {
using _auth = prepareAuthTokens('none');
const sut = fileURLToPath(new URL('../../bin/cli.ts', import.meta.url));

const output = execFileSync(process.execPath, ['--import', 'tsx', sut, '--help'], {
encoding: 'utf8',
env: { ...process.env, CONFIDENCE_TELEMETRY: 'false' },
});

expect(output).toContain('confidence data');
expect(output).toContain('confidence warehouse');
expect(output).toContain('confidence flags');
});

it('shows help without authentication or placeholder API subcommands', async () => {
using _auth = prepareAuthTokens('none');
const sut = run;

const output = await sut(['data', '--help']);

expect(output).toContain('confidence data <command>');
expect(output).not.toMatch(/onboard|discover|proposal|workflow/iu);
});

it('shows guidance when invoked without a subcommand', async () => {
const sut = run;
expect(await sut(['data'])).toContain('confidence data --help');
});

it('rejects unimplemented subcommands', async () => {
const sut = run;
expect(await sut(['data', 'invented'])).toContain('Unknown argument: invented');
});
});
218 changes: 218 additions & 0 deletions packages/cli/__tests__/commands/sql-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { http, HttpResponse, server } from '@spotify-confidence/testing';
import { prepareAuthTokens } from '@spotify-confidence/testing/auth';
import { sqlCommand } from '@commands/data/sql.js';
import { captureOutput } from '../helpers/capture.js';
import { createRunner } from '../helpers/run-command.js';

const sut = createRunner(sqlCommand);
const name = 'sqlJobs/preview-1';
const url = `https://metrics.eu.confidence.dev/v1/${name}/sampleRows`;
const secret = 'PRIVATE_SAMPLE_VALUE_DO_NOT_PRINT';

it.each([true, false])(
'projects schema and warnings without sample rows (JSON=%s)',
async (json) => {
// Arrange
using _auth = prepareAuthTokens('valid');
using output = captureOutput();
const schema = [
{ name: 'user_id', type: 'STRING', repeated: false },
{ name: 'count', type: 3 },
];
const issues = [{ name: 'count', message: 'Check nullable values.' }];
const tableIssues = [{ severity: 'WARNING', message: 'No time column.' }];
server.use(
http.get(url, () =>
HttpResponse.json({
name,
sampleRows: [{ values: { user_id: secret, count: secret } }],
unknown: secret,
successfulQueryResult: {
schema: schema.map((column) => ({ ...column, sampleRows: secret })),
issues: issues.map((issue) => ({ ...issue, unknown: secret })),
tableIssues: tableIssues.map((issue) => ({ ...issue, unknown: secret })),
jobStats: { unknown: secret },
},
}),
),
);

// Act
await sut(['sql', 'schema', '--name', name, ...(json ? ['--json'] : [])]);

// Assert
expect(output.stdout).not.toContain(secret);
expect(output.stderr).not.toContain(secret);
expect(output.stdout).not.toContain('sampleRows');
expect(process.exitCode ?? 0).toBe(0);
if (json)
expect(JSON.parse(output.stdout)).toEqual({
data: { name, state: 'COMPLETED', schema, issues, tableIssues },
});
else expect(output.stdout).toContain('user_id');
},
);

it('normalizes omitted protobuf repeated fields only after confirmed success', async () => {
// Arrange
using _auth = prepareAuthTokens('valid');
using output = captureOutput();
server.use(http.get(url, () => HttpResponse.json({ name, successfulQueryResult: {} })));

// Act
await sut(['sql', 'schema', '--name', name, '--json']);

// Assert
expect(JSON.parse(output.stdout).data).toEqual({
name,
state: 'COMPLETED',
schema: [],
issues: [],
tableIssues: [],
});
});

it('reports pending without inventing an empty completed schema', async () => {
// Arrange
using _auth = prepareAuthTokens('valid');
using output = captureOutput();
server.use(
http.get(url, () =>
HttpResponse.json({ name, pendingQueryResult: {}, sampleRows: [{ values: { secret } }] }),
),
);

// Act
await sut(['sql', 'schema', '--name', name, '--json']);

// Assert
expect(JSON.parse(output.stdout).data).toEqual({ name, state: 'PENDING', schema: null });
expect(process.exitCode ?? 0).toBe(0);
});

it('inspects a failed query successfully without exposing rows', async () => {
// Arrange
using _auth = prepareAuthTokens('valid');
using output = captureOutput();
server.use(
http.get(url, () =>
HttpResponse.json({
name,
failedQueryResult: { error: 'Permission denied', reason: 'ACCESS_DENIED' },
sampleRows: [{ values: { secret } }],
}),
),
);

// Act
await sut(['sql', 'schema', '--name', name, '--json']);

// Assert
expect(process.exitCode ?? 0).toBe(0);
expect(JSON.parse(output.stdout).data).toEqual({
name,
state: 'FAILED',
schema: null,
failedQueryResult: { error: 'Permission denied', reason: 'ACCESS_DENIED' },
});
expect(output.stderr).toBe('');
expect(output.stdout).not.toContain(secret);
});

it.each([
null,
[],
{},
{ name: 'sqlJobs/other', successfulQueryResult: {} },
{ name, successfulQueryResult: null },
{ name, pendingQueryResult: null },
{ name, successfulQueryResult: {}, pendingQueryResult: {} },
{ name, successfulQueryResult: { schema: null } },
{ name, successfulQueryResult: { schema: [null] } },
{ name, successfulQueryResult: { schema: [{ name: '' }] } },
{ name, successfulQueryResult: { schema: [{ name: 'id', type: {} }] } },
{ name, successfulQueryResult: { schema: [{ name: 'id', repeated: 'false' }] } },
{ name, successfulQueryResult: { issues: {} } },
{ name, successfulQueryResult: { issues: [{ message: 1 }] } },
{ name, successfulQueryResult: { tableIssues: [{ severity: {} }] } },
{ name, failedQueryResult: { error: {} } },
])('rejects malformed sample results without printing their content: %j', async (body) => {
// Arrange
using _auth = prepareAuthTokens('valid');
using output = captureOutput();
server.use(http.get(url, () => HttpResponse.json(body)));

// Act
await sut(['sql', 'schema', '--name', name, '--json']);

// Assert
expect(process.exitCode).toBe(1);
expect(output.stdout).toBe('');
expect(output.stderr).not.toContain(secret);
});

it.each([403, 404, 500])('reports HTTP %s as a failure', async (status) => {
// Arrange
using _auth = prepareAuthTokens('valid');
using output = captureOutput();
server.use(
http.get(url, () =>
HttpResponse.json({ code: status, message: 'Unavailable schema' }, { status }),
),
);

// Act
await sut(['sql', 'schema', '--name', name, '--json']);

// Assert
expect(process.exitCode).toBe(1);
expect(output.stderr).toContain('Unavailable schema');
expect(output.stdout).toBe('');
});

it('validates a dry-run locally without authentication or HTTP', async () => {
// Arrange
using _auth = prepareAuthTokens('none');
using output = captureOutput();
let requests = 0;
server.use(
http.get(url, () => {
requests += 1;
return HttpResponse.json({});
}),
);

// Act
await sut(['sql', 'schema', '--name', name, '--dry-run', '--json']);

// Assert
expect(requests).toBe(0);
expect(output.stderr).toBe('');
expect(JSON.parse(output.stdout).data).toEqual({
dryRun: true,
operation: 'sql.schema',
path: '/v1/sqlJobs/preview-1/sampleRows',
name,
});
});

it.each([
'preview-1',
'factTables/id',
'sqlJobs/../id',
'sqlJobs/id/rows',
'sqlJobs/id?token=x',
'sqlJobs/id%2Frows',
])('rejects unsafe names locally: %s', async (invalid) => {
// Arrange
using _auth = prepareAuthTokens('none');
using output = captureOutput();

// Act
await sut(['sql', 'schema', '--name', invalid, '--dry-run', '--json']);

// Assert
expect(process.exitCode).toBe(1);
expect(output.stdout).toBe('');
expect(output.stderr).not.toContain('Not logged in');
});
Loading
Loading