Skip to content

Commit b6e2b9b

Browse files
committed
feat: implement schema explorer data handling with offline support and live sync
1 parent 34d603a commit b6e2b9b

File tree

5 files changed

+321
-95
lines changed

5 files changed

+321
-95
lines changed

src/components/schema-explorer/SchemaExplorerPanel.tsx

Lines changed: 77 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { useState, useEffect } from "react";
22
import { toast } from "sonner";
3-
import { AlertCircle, RefreshCw } from "lucide-react";
3+
import { AlertCircle, RefreshCw, Download, Wifi, WifiOff } from "lucide-react";
44
import { Button } from "@/components/ui/button";
55
import { Spinner } from "@/components/ui/spinner";
6-
import { useFullSchema, useInvalidateCache } from "@/hooks/useDbQueries";
6+
import { useInvalidateCache } from "@/hooks/useDbQueries";
7+
import { useSchemaExplorerData } from "@/hooks/useSchemaExplorerData";
78
import { ColumnDetails, DatabaseSchemaDetails, SchemaGroup, TableSchemaDetails } from "@/types/database";
89
import TreeViewPanel from "@/components/schema-explorer/TreeViewPanel";
910
import SchemaExplorerHeader from "@/components/schema-explorer/SchemaExplorerHeader";
@@ -27,19 +28,23 @@ interface DatabaseSchema extends DatabaseSchemaDetails {
2728

2829
interface SchemaExplorerPanelProps {
2930
dbId: string;
31+
projectId?: string | null;
3032
}
3133

32-
export default function SchemaExplorerPanel({ dbId }: SchemaExplorerPanelProps) {
33-
// Use React Query for schema data (cached!)
34+
export default function SchemaExplorerPanel({ dbId, projectId }: SchemaExplorerPanelProps) {
35+
// Offline-first data source: prefers live DB, falls back to project files
3436
const {
35-
data: schemaData,
37+
schemaData,
3638
isLoading,
37-
error: queryError,
38-
refetch
39-
} = useFullSchema(dbId);
39+
dataSource,
40+
hasLiveSchema,
41+
syncFromDatabase,
42+
refetch,
43+
} = useSchemaExplorerData(dbId, projectId);
4044

45+
const [isSyncing, setIsSyncing] = useState(false);
4146
const { invalidateDatabase } = useInvalidateCache();
42-
const error = queryError ? (queryError as Error).message : null;
47+
const error = dataSource === "none" && !isLoading ? "No schema data available" : null;
4348

4449
const [expandedSchemas, setExpandedSchemas] = useState<Set<string>>(new Set());
4550
const [expandedTables, setExpandedTables] = useState<Set<string>>(new Set());
@@ -139,17 +144,34 @@ export default function SchemaExplorerPanel({ dbId }: SchemaExplorerPanelProps)
139144
<div className="h-full flex items-center justify-center bg-background">
140145
<div className="text-center p-8 border border-destructive/30 rounded-xl bg-destructive/10 text-destructive">
141146
<AlertCircle className="h-10 w-10 text-destructive mx-auto mb-4" />
142-
<h2 className="text-xl font-bold mb-2">Error</h2>
143-
<p className="text-sm text-muted-foreground">{error || "No schema data could be loaded."}</p>
144-
<Button
145-
onClick={() => {
146-
if (dbId) invalidateDatabase(dbId);
147-
refetch();
148-
}}
149-
className="mt-4 bg-primary hover:bg-primary/90 text-white shadow-md shadow-primary/30"
150-
>
151-
<RefreshCw className="h-4 w-4 mr-2" /> Retry Load
152-
</Button>
147+
<h2 className="text-xl font-bold mb-2">No Schema Available</h2>
148+
<p className="text-sm text-muted-foreground mb-1">{typeof error === "string" ? error : "No schema data could be loaded."}</p>
149+
<p className="text-xs text-muted-foreground mb-4">Connect to a database or sync schema to use offline.</p>
150+
<div className="flex gap-2 justify-center">
151+
<Button
152+
onClick={() => {
153+
if (dbId) invalidateDatabase(dbId);
154+
refetch();
155+
}}
156+
variant="outline"
157+
size="sm"
158+
>
159+
<RefreshCw className="h-4 w-4 mr-2" /> Retry
160+
</Button>
161+
{projectId && (
162+
<Button
163+
onClick={async () => {
164+
setIsSyncing(true);
165+
try { await syncFromDatabase(); } finally { setIsSyncing(false); }
166+
}}
167+
size="sm"
168+
disabled={isSyncing || !hasLiveSchema}
169+
>
170+
{isSyncing ? <Spinner className="h-4 w-4 mr-2" /> : <Download className="h-4 w-4 mr-2" />}
171+
Sync from Database
172+
</Button>
173+
)}
174+
</div>
153175
</div>
154176
</div>
155177
);
@@ -191,10 +213,41 @@ export default function SchemaExplorerPanel({ dbId }: SchemaExplorerPanelProps)
191213
{/* Footer */}
192214
<div className="border-t border-border bg-card px-4 py-2">
193215
<div className="container mx-auto flex items-center justify-between text-xs text-muted-foreground">
194-
<span>
195-
{schemaData?.schemas?.length} Schemas • {schemaData?.schemas?.flatMap(s => s.tables).length} Tables
196-
</span>
197-
<span>Click table to highlight • Drag to pan • Scroll to zoom</span>
216+
<div className="flex items-center gap-3">
217+
<span>
218+
{schemaData?.schemas?.length} Schemas • {schemaData?.schemas?.flatMap(s => s.tables).length} Tables
219+
</span>
220+
{/* Data source badge */}
221+
{dataSource === "live" ? (
222+
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20">
223+
<Wifi className="h-2.5 w-2.5" />
224+
Live
225+
</span>
226+
) : dataSource === "project" ? (
227+
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/20">
228+
<WifiOff className="h-2.5 w-2.5" />
229+
Offline
230+
</span>
231+
) : null}
232+
</div>
233+
<div className="flex items-center gap-2">
234+
{dataSource === "project" && hasLiveSchema && (
235+
<Button
236+
variant="ghost"
237+
size="sm"
238+
className="h-6 text-xs px-2"
239+
onClick={async () => {
240+
setIsSyncing(true);
241+
try { await syncFromDatabase(); } finally { setIsSyncing(false); }
242+
}}
243+
disabled={isSyncing}
244+
>
245+
{isSyncing ? <Spinner className="h-3 w-3 mr-1" /> : <Download className="h-3 w-3 mr-1" />}
246+
Sync
247+
</Button>
248+
)}
249+
<span>Click table to highlight • Drag to pan • Scroll to zoom</span>
250+
</div>
198251
</div>
199252
</div>
200253
</div>

src/hooks/useERDiagramData.ts

Lines changed: 1 addition & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,11 @@ import {
88
projectKeys,
99
} from "@/hooks/useProjectQueries";
1010
import { bridgeApi } from "@/services/bridgeApi";
11+
import { snapshotToSchemaDetails, schemaGroupsToSnapshots } from "@/lib/schemaConverters";
1112
import type {
1213
DatabaseSchemaDetails,
13-
SchemaGroup,
14-
TableSchemaDetails,
15-
ColumnDetails,
1614
} from "@/types/database";
1715
import type {
18-
SchemaSnapshot,
19-
TableSnapshot,
20-
ColumnSnapshot,
2116
ERDiagramFile,
2217
ERNode,
2318
} from "@/types/project";
@@ -43,70 +38,6 @@ import type {
4338
// and does NOT touch the ER layout (er-diagram.json).
4439
// ================================================================
4540

46-
/**
47-
* Convert project SchemaSnapshot[] to DatabaseSchemaDetails
48-
* (the format the ER diagram renderer expects)
49-
*/
50-
function snapshotToSchemaDetails(
51-
dbName: string,
52-
snapshots: SchemaSnapshot[]
53-
): DatabaseSchemaDetails {
54-
return {
55-
name: dbName,
56-
schemas: snapshots.map(
57-
(snap): SchemaGroup => ({
58-
name: snap.name,
59-
tables: snap.tables.map(
60-
(t): TableSchemaDetails => ({
61-
name: t.name,
62-
type: t.type || "BASE TABLE",
63-
columns: t.columns.map(
64-
(c): ColumnDetails => ({
65-
name: c.name,
66-
type: c.type,
67-
nullable: c.nullable,
68-
isPrimaryKey: c.isPrimaryKey,
69-
isForeignKey: c.isForeignKey,
70-
isUnique: c.isUnique,
71-
defaultValue: c.defaultValue,
72-
})
73-
),
74-
// Snapshots don't store FK/index details — they're
75-
// only available from live DB. This is fine for
76-
// offline rendering (columns still show FK badges).
77-
})
78-
),
79-
})
80-
),
81-
};
82-
}
83-
84-
/**
85-
* Convert live SchemaGroup[] → SchemaSnapshot[] for saving
86-
*/
87-
function schemaGroupsToSnapshots(groups: SchemaGroup[]): SchemaSnapshot[] {
88-
return groups.map((sg) => ({
89-
name: sg.name,
90-
tables: (sg.tables || []).map(
91-
(t): TableSnapshot => ({
92-
name: t.name,
93-
type: t.type || "BASE TABLE",
94-
columns: (t.columns || []).map(
95-
(c): ColumnSnapshot => ({
96-
name: c.name,
97-
type: c.type,
98-
nullable: c.nullable ?? true,
99-
isPrimaryKey: c.isPrimaryKey ?? false,
100-
isForeignKey: c.isForeignKey ?? false,
101-
defaultValue: c.defaultValue ?? null,
102-
isUnique: c.isUnique ?? false,
103-
})
104-
),
105-
})
106-
),
107-
}));
108-
}
109-
11041
// ================================================================
11142

11243
export interface UseERDiagramDataReturn {

src/hooks/useSchemaExplorerData.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { useMemo, useCallback } from "react";
2+
import { useQueryClient } from "@tanstack/react-query";
3+
import { toast } from "sonner";
4+
import { useFullSchema } from "@/hooks/useDbQueries";
5+
import {
6+
useProjectSchema,
7+
projectKeys,
8+
} from "@/hooks/useProjectQueries";
9+
import { bridgeApi } from "@/services/bridgeApi";
10+
import { snapshotToSchemaDetails, schemaGroupsToSnapshots } from "@/lib/schemaConverters";
11+
import type { DatabaseSchemaDetails } from "@/types/database";
12+
13+
// ================================================================
14+
// useSchemaExplorerData
15+
//
16+
// Smart data source for the Schema Explorer that:
17+
//
18+
// 1. OFFLINE-FIRST: Loads from project files (schema.json) even
19+
// when the database is not connected.
20+
//
21+
// 2. LIVE FALLBACK: Falls back to live DB schema via useFullSchema
22+
// when project files are empty or projectId is not provided.
23+
//
24+
// 3. SYNC: Provides a `syncFromDatabase` callback that pulls fresh
25+
// schema from the live DB and saves it to project files.
26+
// ================================================================
27+
28+
export interface UseSchemaExplorerDataReturn {
29+
/** The resolved schema data for the explorer to render */
30+
schemaData: DatabaseSchemaDetails | null;
31+
32+
/** True while initial data is loading */
33+
isLoading: boolean;
34+
35+
/** Data source: "live" = from DB, "project" = from project files */
36+
dataSource: "live" | "project" | "none";
37+
38+
/** Whether live DB schema is available (for sync button state) */
39+
hasLiveSchema: boolean;
40+
41+
/** Pull fresh schema from DB → save to project files → reload */
42+
syncFromDatabase: () => Promise<void>;
43+
44+
/** Refetch from existing source (not a DB sync — just re-queries) */
45+
refetch: () => void;
46+
}
47+
48+
export function useSchemaExplorerData(
49+
dbId: string | undefined,
50+
projectId: string | null | undefined
51+
): UseSchemaExplorerDataReturn {
52+
const queryClient = useQueryClient();
53+
54+
// ---- Live DB schema ----
55+
const {
56+
data: liveSchema,
57+
isLoading: liveLoading,
58+
error: liveError,
59+
refetch: refetchLive,
60+
} = useFullSchema(dbId);
61+
62+
// ---- Project files ----
63+
const {
64+
data: projectSchemaFile,
65+
isLoading: projectSchemaLoading,
66+
refetch: refetchProject,
67+
} = useProjectSchema(projectId ?? undefined);
68+
69+
// ---- Determine the best schema source ----
70+
const { schemaData, dataSource } = useMemo(() => {
71+
// Prefer live DB if available (most up-to-date, has FK/index data)
72+
if (liveSchema && liveSchema.schemas?.some((s) => s.tables?.length)) {
73+
return { schemaData: liveSchema, dataSource: "live" as const };
74+
}
75+
76+
// Fall back to project cached schema (offline mode)
77+
if (projectSchemaFile?.schemas?.length) {
78+
const converted = snapshotToSchemaDetails(
79+
projectSchemaFile.databaseId || "Database",
80+
projectSchemaFile.schemas
81+
);
82+
if (converted.schemas.some((s) => s.tables?.length)) {
83+
return { schemaData: converted, dataSource: "project" as const };
84+
}
85+
}
86+
87+
return { schemaData: null, dataSource: "none" as const };
88+
}, [liveSchema, projectSchemaFile]);
89+
90+
// ---- Loading state ----
91+
const isLoading = liveLoading || projectSchemaLoading;
92+
const hasLiveSchema = !!liveSchema && !liveError;
93+
94+
// ---- Refetch from current source ----
95+
const refetch = useCallback(() => {
96+
if (dataSource === "live") {
97+
refetchLive();
98+
} else if (dataSource === "project") {
99+
refetchProject();
100+
} else {
101+
refetchLive();
102+
refetchProject();
103+
}
104+
}, [dataSource, refetchLive, refetchProject]);
105+
106+
// ---- Sync from Database ----
107+
const syncFromDatabase = useCallback(async () => {
108+
if (!dbId) {
109+
toast.error("No database connected");
110+
return;
111+
}
112+
113+
if (!projectId) {
114+
toast.error("No project linked to this database");
115+
return;
116+
}
117+
118+
try {
119+
// 1. Fetch fresh schema from live database
120+
const freshSchema = await bridgeApi.getSchema(dbId);
121+
122+
if (!freshSchema?.schemas?.length) {
123+
toast.warning("Database returned no schemas");
124+
return;
125+
}
126+
127+
// 2. Convert to snapshots and save to project schema.json
128+
const snapshots = schemaGroupsToSnapshots(freshSchema.schemas);
129+
await bridgeApi.saveProjectSchema(projectId, snapshots);
130+
131+
// 3. Invalidate React Query caches to trigger re-render
132+
queryClient.invalidateQueries({
133+
queryKey: projectKeys.schema(projectId),
134+
});
135+
queryClient.invalidateQueries({
136+
queryKey: ["fullSchema", dbId],
137+
});
138+
139+
toast.success("Schema synced from database", {
140+
description: `${freshSchema.schemas.reduce(
141+
(acc, s) => acc + (s.tables?.length || 0),
142+
0
143+
)} tables across ${freshSchema.schemas.length} schemas`,
144+
});
145+
} catch (err: any) {
146+
console.error("[SchemaExplorerData] Sync failed:", err);
147+
toast.error("Schema sync failed", {
148+
description: err.message || "Could not pull schema from database",
149+
});
150+
}
151+
}, [dbId, projectId, queryClient]);
152+
153+
return {
154+
schemaData,
155+
isLoading,
156+
dataSource,
157+
hasLiveSchema,
158+
syncFromDatabase,
159+
refetch,
160+
};
161+
}

0 commit comments

Comments
 (0)