Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ Commands:
connector delete <name> Delete a connector and its snapshot
connector refresh <name> Re-create snapshot with fresh data
analyze <url> Analyze database schema, stats, and PII
doctor Check setup and diagnose issues
doctor [connector] Check setup; pass a connector name for details
mcp Configure MCP server for coding agents

Examples:
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/commands/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,11 @@ export async function runConnect(
console.log(` ℹ ${result.piiColumnsDetected} PII columns detected (sanitization disabled)`);
}
}
if (result.integrityWarningsCount > 0) {
console.log(
` ⚠ ${result.integrityWarningsCount} referential integrity warning(s) — run \`sow doctor ${result.name}\` for details`,
);
}
console.log();
console.log(" Next step — create an isolated branch to work with:");
console.log(` $ sow branch create dev`);
Expand Down
55 changes: 55 additions & 0 deletions packages/cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import {
listConnectors,
listBranches,
getSowDir,
getConnectorMetadata,
type ConnectorInfo,
type Branch,
type IntegrityWarning,
} from "@sowdb/core";
import { execSync } from "node:child_process";
import { existsSync, accessSync, constants, statSync, readdirSync, readFileSync } from "node:fs";
Expand Down Expand Up @@ -130,6 +132,21 @@ function checkConnectors(connectors: ConnectorInfo[]): CheckResult[] {
};
}
const size = statSync(initSql).size;

// If the connector has integrity warnings from sampling, surface that
// so users know to run `sow doctor <name>` for the full list rather
// than discovering it the hard way via a dangling FK in the sandbox.
const meta = getConnectorMetadata(c.name);
const warningCount = meta?.integrityWarnings?.length ?? 0;
if (warningCount > 0) {
return {
name: `Connector "${c.name}" has ${warningCount} integrity warning(s)`,
status: "warn" as const,
detail: `snapshot: ${formatBytes(size)}, created ${c.createdAt ? timeAgo(c.createdAt) : "unknown"}`,
hint: `Run: sow doctor ${c.name}`,
};
}

return {
name: `Connector "${c.name}" healthy`,
status: "pass" as const,
Expand Down Expand Up @@ -241,6 +258,44 @@ function getDiskUsage(): CheckResult {
return { name: "Disk usage", status: "pass", detail: formatBytes(total) };
}

export interface ConnectorWarningsReport {
found: boolean;
name: string;
tables: number;
rows: number;
snapshotSize: string;
warnings: IntegrityWarning[];
}

/**
* Fetch a per-connector report of referential-integrity warnings.
* Used by `sow doctor <connector-name>` to drill into the warnings
* that `sow connect` summarized as a count on the result line.
*/
export function describeConnectorWarnings(
connectorName: string,
): ConnectorWarningsReport {
const meta = getConnectorMetadata(connectorName);
if (!meta) {
return {
found: false,
name: connectorName,
tables: 0,
rows: 0,
snapshotSize: "0 B",
warnings: [],
};
}
return {
found: true,
name: meta.name,
tables: meta.tables,
rows: meta.rows,
snapshotSize: formatBytes(meta.sizeBytes),
warnings: meta.integrityWarnings ?? [],
};
}

export async function runDoctorChecks(): Promise<CheckResult[]> {
const connectors = listConnectors();
const branches = await listBranches();
Expand Down
48 changes: 45 additions & 3 deletions packages/cli/src/commands/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export async function runCommand(
await runAnalyze(resolvedConn, flags, log, merged);
break;
case "doctor":
await runDoctor(flags);
await runDoctor(flags, connectionString);
break;
case "mcp":
await runMcp(flags);
Expand Down Expand Up @@ -213,8 +213,50 @@ async function runAnalyze(
}
}

async function runDoctor(flags: Record<string, unknown>): Promise<void> {
const { runDoctorChecks } = await import("./doctor.js");
async function runDoctor(
flags: Record<string, unknown>,
connectorName?: string,
): Promise<void> {
const { runDoctorChecks, describeConnectorWarnings } = await import("./doctor.js");

// If a connector name was passed, print a detailed warnings report
// for that connector instead of the generic system checks.
if (connectorName) {
const report = describeConnectorWarnings(connectorName);
if (flags.json) {
console.log(JSON.stringify(report));
return;
}
if (!report.found) {
console.error(` ✗ Connector "${connectorName}" not found.`);
process.exit(1);
}
console.log();
console.log(` Connector "${connectorName}"`);
console.log(` ${report.tables} tables, ${report.rows.toLocaleString()} rows, snapshot ${report.snapshotSize}`);
console.log();
if (report.warnings.length === 0) {
console.log(" ✓ No referential integrity warnings — every FK resolved cleanly.");
return;
}
console.log(` ⚠ ${report.warnings.length} referential integrity warning(s):`);
console.log();
for (const w of report.warnings) {
const src = w.sourceTable
? `${w.sourceTable}.${(w.sourceColumns ?? []).join(",")}`
: "(implicit)";
const tgt = `${w.targetTable}${w.targetColumns ? "." + w.targetColumns.join(",") : ""}`;
console.log(` • [${w.kind}] ${src} → ${tgt}`);
console.log(` ${w.reason}`);
}
console.log();
console.log(" These FKs may be dangling in the sandbox. Root causes vary:");
console.log(" - `parent_not_found`: the source DB itself has orphaned rows");
console.log(" - `*_fetch_failed`: a transient read error hit the sampler");
console.log(" Run `sow connector refresh <name>` to retry sampling.");
return;
}

const checks = await runDoctorChecks();

if (flags.json) {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/branching/connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ export async function createConnector(
sanitizationConfig,
analysis,
authUsers: providerAuthUsers,
integrityWarnings:
sampled.integrityWarnings && sampled.integrityWarnings.length > 0
? sampled.integrityWarnings
: undefined,
};

writeConnectorMetadata(name, metadata);
Expand All @@ -143,6 +147,7 @@ export async function createConnector(
piiColumnsDetected: metadata.piiColumnsDetected,
sizeBytes: result.totalSize,
snapshotPath: snapshotDir,
integrityWarningsCount: sampled.integrityWarnings?.length ?? 0,
};
} finally {
await adapter.disconnect();
Expand Down
21 changes: 20 additions & 1 deletion packages/core/src/branching/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { AnalysisResult, SamplingConfig, SanitizationConfig } from "../types.js";
import type {
AnalysisResult,
IntegrityWarning,
SamplingConfig,
SanitizationConfig,
} from "../types.js";

// ---------------------------------------------------------------------------
// Branch — an isolated database managed by a provider (Docker, Supabase, etc.)
Expand Down Expand Up @@ -50,6 +55,14 @@ export interface ConnectorMetadata {
analysis: AnalysisResult;
/** Auth user mappings for Supabase projects (original UUID -> sanitized email). */
authUsers?: AuthUserMapping[];
/**
* Non-fatal referential-integrity warnings captured during the sample.
* These are surfaced by `sow doctor <connector>` so users know which
* FK relationships couldn't be fully resolved without having to re-run
* the whole connect flow. Optional for backwards compat with metadata
* written by earlier sow versions.
*/
integrityWarnings?: IntegrityWarning[];
}

export interface ConnectorInfo {
Expand Down Expand Up @@ -77,6 +90,12 @@ export interface ConnectorCreateResult {
piiColumnsDetected: number;
sizeBytes: number;
snapshotPath: string;
/**
* Count of non-fatal referential-integrity warnings from the sampler.
* Zero when everything resolved cleanly. When non-zero, the CLI prints
* a summary and the user can run `sow doctor <name>` for the full list.
*/
integrityWarningsCount: number;
}

// ---------------------------------------------------------------------------
Expand Down
11 changes: 8 additions & 3 deletions packages/core/src/sampler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,13 +148,14 @@ export function createSampler(options: SamplerOptions) {
if (rows) sampledMap.set(tableName, rows);
}

const integrityFixed = isFullCopy
? sampledMap
const integrityResult = isFullCopy
? { tables: sampledMap, warnings: [] }
: await ensureReferentialIntegrity(
adapter,
sampledMap,
analysis.schema.relationships,
);
const integrityFixed = integrityResult.tables;

const sampledTables: SampledTable[] = [];
for (const tableName of tablesToSample) {
Expand All @@ -173,7 +174,11 @@ export function createSampler(options: SamplerOptions) {
});
}

return { tables: sampledTables, config };
return {
tables: sampledTables,
config,
integrityWarnings: integrityResult.warnings,
};
}

return { sample };
Expand Down
Loading
Loading