dbdiff-app 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -0
- package/bin/cli.js +83 -0
- package/bin/install-local.js +57 -0
- package/electron/generate-icon.mjs +54 -0
- package/electron/icon.icns +0 -0
- package/electron/icon.png +0 -0
- package/electron/icon.svg +21 -0
- package/electron/main.js +169 -0
- package/electron/patch-dev-plist.js +31 -0
- package/electron/preload.cjs +18 -0
- package/electron/wait-for-vite.js +43 -0
- package/index.html +13 -0
- package/package.json +91 -0
- package/public/favicon.svg +15 -0
- package/public/vite.svg +1 -0
- package/server/export.ts +57 -0
- package/server/index.ts +392 -0
- package/src/App.css +1 -0
- package/src/App.tsx +543 -0
- package/src/assets/react.svg +1 -0
- package/src/components/CommandPalette.tsx +243 -0
- package/src/components/ConnectedView.tsx +78 -0
- package/src/components/ConnectionPicker.tsx +381 -0
- package/src/components/ConsoleView.tsx +360 -0
- package/src/components/CsvExportModal.tsx +144 -0
- package/src/components/DataGrid/DataGrid.tsx +262 -0
- package/src/components/DataGrid/DataGridCell.tsx +73 -0
- package/src/components/DataGrid/DataGridHeader.tsx +89 -0
- package/src/components/DataGrid/index.ts +20 -0
- package/src/components/DataGrid/types.ts +63 -0
- package/src/components/DataGrid/useColumnResize.ts +153 -0
- package/src/components/DataGrid/useDataGridSelection.ts +340 -0
- package/src/components/DataGrid/utils.ts +184 -0
- package/src/components/DatabaseMenu.tsx +93 -0
- package/src/components/DatabaseSwitcher.tsx +208 -0
- package/src/components/DiffView.tsx +215 -0
- package/src/components/EditConnectionModal.tsx +417 -0
- package/src/components/ErrorBoundary.tsx +69 -0
- package/src/components/GlobalShortcuts.tsx +201 -0
- package/src/components/InnerTabBar.tsx +129 -0
- package/src/components/JsonTreeViewer.tsx +387 -0
- package/src/components/MemberAccessEditor.tsx +443 -0
- package/src/components/MembersModal.tsx +446 -0
- package/src/components/NewConnectionModal.tsx +274 -0
- package/src/components/Resizer.tsx +66 -0
- package/src/components/ScanSuccessModal.tsx +113 -0
- package/src/components/ShortcutSettingsModal.tsx +318 -0
- package/src/components/Sidebar.tsx +532 -0
- package/src/components/TabBar.tsx +188 -0
- package/src/components/TableView.tsx +2147 -0
- package/src/components/ThemeToggle.tsx +44 -0
- package/src/components/index.ts +17 -0
- package/src/constants.ts +12 -0
- package/src/electron.d.ts +12 -0
- package/src/index.css +44 -0
- package/src/main.tsx +13 -0
- package/src/stores/hooks.ts +1146 -0
- package/src/stores/index.ts +12 -0
- package/src/stores/store.ts +1514 -0
- package/src/stores/useCloudSync.ts +274 -0
- package/src/stores/useSyncDatabase.ts +422 -0
- package/src/types.ts +277 -0
- package/src/utils/csv.ts +27 -0
- package/src/vite-env.d.ts +2 -0
- package/tsconfig.app.json +28 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/tsconfig.server.json +14 -0
- package/vite.config.ts +14 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from "react";
|
|
2
|
+
import { useStore, type CloudConnection } from "./store";
|
|
3
|
+
import type { DatabaseConfig } from "../types";
|
|
4
|
+
import { CLOUD_URL } from "../constants";
|
|
5
|
+
|
|
6
|
+
interface CloudConnectionsResponse {
|
|
7
|
+
connections: CloudConnection[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface CloudConnectionsErrorResponse {
|
|
11
|
+
error: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface CreateConnectionResponse {
|
|
15
|
+
connection: CloudConnection;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface UpdateConnectionResponse {
|
|
19
|
+
connection: CloudConnection;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface UpdateCloudConnectionResult {
|
|
23
|
+
success: boolean;
|
|
24
|
+
error?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface DeleteCloudConnectionResult {
|
|
28
|
+
success: boolean;
|
|
29
|
+
error?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Hook for syncing database connections from the cloud.
|
|
34
|
+
* Follows the pattern from useSyncDatabase.ts for race condition handling.
|
|
35
|
+
*/
|
|
36
|
+
export function useCloudSync() {
|
|
37
|
+
const cloudApiKey = useStore((state) => state.cloudApiKey);
|
|
38
|
+
const cloudSyncState = useStore((state) => state.cloudSyncState);
|
|
39
|
+
const setCloudSyncState = useStore((state) => state.setCloudSyncState);
|
|
40
|
+
const syncCloudConfigs = useStore((state) => state.syncCloudConfigs);
|
|
41
|
+
const convertToCloudConfig = useStore((state) => state.convertToCloudConfig);
|
|
42
|
+
|
|
43
|
+
const [transferringId, setTransferringId] = useState<string | null>(null);
|
|
44
|
+
const [transferError, setTransferError] = useState<string | null>(null);
|
|
45
|
+
const [isUpdating, setIsUpdating] = useState(false);
|
|
46
|
+
const [updateError, setUpdateError] = useState<string | null>(null);
|
|
47
|
+
const [isDeleting, setIsDeleting] = useState(false);
|
|
48
|
+
const [deleteError, setDeleteError] = useState<string | null>(null);
|
|
49
|
+
|
|
50
|
+
// Use ref to track current execution ID to handle race conditions
|
|
51
|
+
const currentExecutionRef = useRef<string | null>(null);
|
|
52
|
+
|
|
53
|
+
const sync = useCallback(async () => {
|
|
54
|
+
if (!cloudApiKey) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Generate unique execution ID for race condition handling
|
|
59
|
+
const executionId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
60
|
+
currentExecutionRef.current = executionId;
|
|
61
|
+
|
|
62
|
+
// Set syncing state
|
|
63
|
+
setCloudSyncState({
|
|
64
|
+
status: "syncing",
|
|
65
|
+
error: null,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetch(`${CLOUD_URL}/api/connections`, {
|
|
70
|
+
method: "GET",
|
|
71
|
+
headers: {
|
|
72
|
+
"x-api-key": cloudApiKey,
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Check if this execution is still current (race condition check)
|
|
77
|
+
if (currentExecutionRef.current !== executionId) {
|
|
78
|
+
return; // Stale response, discard
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
const data = (await response.json()) as CloudConnectionsErrorResponse;
|
|
83
|
+
throw new Error(data.error || `HTTP ${response.status}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const data = (await response.json()) as CloudConnectionsResponse;
|
|
87
|
+
|
|
88
|
+
// Double-check after async operation
|
|
89
|
+
if (currentExecutionRef.current !== executionId) {
|
|
90
|
+
return; // Stale response, discard
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Update the store with cloud connections
|
|
94
|
+
syncCloudConfigs(data.connections);
|
|
95
|
+
|
|
96
|
+
setCloudSyncState({
|
|
97
|
+
status: "completed",
|
|
98
|
+
lastSyncedAt: Date.now(),
|
|
99
|
+
error: null,
|
|
100
|
+
});
|
|
101
|
+
} catch (err) {
|
|
102
|
+
// Check if this execution is still current
|
|
103
|
+
if (currentExecutionRef.current !== executionId) {
|
|
104
|
+
return; // Stale response, discard
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
setCloudSyncState({
|
|
108
|
+
status: "error",
|
|
109
|
+
error: err instanceof Error ? err.message : "Unknown error",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}, [cloudApiKey, setCloudSyncState, syncCloudConfigs]);
|
|
113
|
+
|
|
114
|
+
const transferToCloud = useCallback(
|
|
115
|
+
async (config: DatabaseConfig) => {
|
|
116
|
+
if (!cloudApiKey) {
|
|
117
|
+
setTransferError("No cloud API key configured");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
setTransferringId(config.id);
|
|
122
|
+
setTransferError(null);
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetch(`${CLOUD_URL}/api/connections`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: {
|
|
128
|
+
"Content-Type": "application/json",
|
|
129
|
+
"x-api-key": cloudApiKey,
|
|
130
|
+
},
|
|
131
|
+
body: JSON.stringify({
|
|
132
|
+
name: config.display.name,
|
|
133
|
+
config: {
|
|
134
|
+
display: config.display,
|
|
135
|
+
connection: config.connection,
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
const data = (await response.json()) as CloudConnectionsErrorResponse;
|
|
142
|
+
throw new Error(data.error || `HTTP ${response.status}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const data = (await response.json()) as CreateConnectionResponse;
|
|
146
|
+
const conn = data.connection;
|
|
147
|
+
|
|
148
|
+
// Convert the local config to a cloud config
|
|
149
|
+
convertToCloudConfig(config.id, {
|
|
150
|
+
id: conn.id,
|
|
151
|
+
ownerId: conn.ownerId,
|
|
152
|
+
ownerEmail: conn.ownerEmail,
|
|
153
|
+
role: conn.role,
|
|
154
|
+
access: conn.access,
|
|
155
|
+
updatedAt: conn.updatedAt,
|
|
156
|
+
});
|
|
157
|
+
} catch (err) {
|
|
158
|
+
setTransferError(err instanceof Error ? err.message : "Unknown error");
|
|
159
|
+
} finally {
|
|
160
|
+
setTransferringId(null);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
[cloudApiKey, convertToCloudConfig],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const updateCloudConnection = useCallback(
|
|
167
|
+
async (config: DatabaseConfig): Promise<UpdateCloudConnectionResult> => {
|
|
168
|
+
if (!cloudApiKey) {
|
|
169
|
+
const error = "No cloud API key configured";
|
|
170
|
+
setUpdateError(error);
|
|
171
|
+
return { success: false, error };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (config.source !== "cloud" || !config.cloud?.id) {
|
|
175
|
+
const error = "Config is not a cloud connection";
|
|
176
|
+
setUpdateError(error);
|
|
177
|
+
return { success: false, error };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
setIsUpdating(true);
|
|
181
|
+
setUpdateError(null);
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const response = await fetch(`${CLOUD_URL}/api/connections`, {
|
|
185
|
+
method: "PUT",
|
|
186
|
+
headers: {
|
|
187
|
+
"Content-Type": "application/json",
|
|
188
|
+
"x-api-key": cloudApiKey,
|
|
189
|
+
},
|
|
190
|
+
body: JSON.stringify({
|
|
191
|
+
id: config.cloud.id,
|
|
192
|
+
name: config.display.name,
|
|
193
|
+
config: {
|
|
194
|
+
display: config.display,
|
|
195
|
+
connection: config.connection,
|
|
196
|
+
},
|
|
197
|
+
}),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
if (!response.ok) {
|
|
201
|
+
const data = (await response.json()) as CloudConnectionsErrorResponse;
|
|
202
|
+
throw new Error(data.error || `HTTP ${response.status}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Parse response to confirm success
|
|
206
|
+
(await response.json()) as UpdateConnectionResponse;
|
|
207
|
+
|
|
208
|
+
return { success: true };
|
|
209
|
+
} catch (err) {
|
|
210
|
+
const error = err instanceof Error ? err.message : "Unknown error";
|
|
211
|
+
setUpdateError(error);
|
|
212
|
+
return { success: false, error };
|
|
213
|
+
} finally {
|
|
214
|
+
setIsUpdating(false);
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
[cloudApiKey],
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const deleteCloudConnection = useCallback(
|
|
221
|
+
async (cloudId: string): Promise<DeleteCloudConnectionResult> => {
|
|
222
|
+
if (!cloudApiKey) {
|
|
223
|
+
const error = "No cloud API key configured";
|
|
224
|
+
setDeleteError(error);
|
|
225
|
+
return { success: false, error };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
setIsDeleting(true);
|
|
229
|
+
setDeleteError(null);
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
const response = await fetch(
|
|
233
|
+
`${CLOUD_URL}/api/connections/${cloudId}`,
|
|
234
|
+
{
|
|
235
|
+
method: "DELETE",
|
|
236
|
+
headers: {
|
|
237
|
+
"x-api-key": cloudApiKey,
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
if (!response.ok) {
|
|
243
|
+
const data = (await response.json()) as CloudConnectionsErrorResponse;
|
|
244
|
+
throw new Error(data.error || `HTTP ${response.status}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return { success: true };
|
|
248
|
+
} catch (err) {
|
|
249
|
+
const error = err instanceof Error ? err.message : "Unknown error";
|
|
250
|
+
setDeleteError(error);
|
|
251
|
+
return { success: false, error };
|
|
252
|
+
} finally {
|
|
253
|
+
setIsDeleting(false);
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
[cloudApiKey],
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
sync,
|
|
261
|
+
isSyncing: cloudSyncState.status === "syncing",
|
|
262
|
+
error: cloudSyncState.error,
|
|
263
|
+
lastSyncedAt: cloudSyncState.lastSyncedAt,
|
|
264
|
+
transferToCloud,
|
|
265
|
+
transferringId,
|
|
266
|
+
transferError,
|
|
267
|
+
updateCloudConnection,
|
|
268
|
+
isUpdating,
|
|
269
|
+
updateError,
|
|
270
|
+
deleteCloudConnection,
|
|
271
|
+
isDeleting,
|
|
272
|
+
deleteError,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { useCallback, useRef } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
ColumnConstraints,
|
|
4
|
+
ColumnInfo,
|
|
5
|
+
ConfigSyncState,
|
|
6
|
+
IndexInfo,
|
|
7
|
+
SchemaMetadata,
|
|
8
|
+
TableMetadata,
|
|
9
|
+
} from "../types";
|
|
10
|
+
import { useStore } from "./store";
|
|
11
|
+
|
|
12
|
+
// SQL Queries for schema metadata
|
|
13
|
+
|
|
14
|
+
const COLUMNS_QUERY = `
|
|
15
|
+
SELECT c.table_schema, c.table_name, c.column_name, c.ordinal_position,
|
|
16
|
+
c.data_type, c.character_maximum_length, c.numeric_precision,
|
|
17
|
+
c.numeric_scale, c.is_nullable, c.column_default
|
|
18
|
+
FROM information_schema.columns c
|
|
19
|
+
JOIN information_schema.tables t
|
|
20
|
+
ON c.table_schema = t.table_schema AND c.table_name = t.table_name
|
|
21
|
+
WHERE t.table_type = 'BASE TABLE'
|
|
22
|
+
AND c.table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
23
|
+
ORDER BY c.table_schema, c.table_name, c.ordinal_position
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
const PRIMARY_KEYS_QUERY = `
|
|
27
|
+
SELECT tc.table_schema, tc.table_name, kcu.column_name
|
|
28
|
+
FROM information_schema.table_constraints tc
|
|
29
|
+
JOIN information_schema.key_column_usage kcu
|
|
30
|
+
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
31
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
32
|
+
AND tc.table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
33
|
+
`;
|
|
34
|
+
|
|
35
|
+
const FOREIGN_KEYS_QUERY = `
|
|
36
|
+
SELECT tc.table_schema, tc.table_name, kcu.column_name,
|
|
37
|
+
ccu.table_schema AS ref_schema, ccu.table_name AS ref_table, ccu.column_name AS ref_column
|
|
38
|
+
FROM information_schema.table_constraints tc
|
|
39
|
+
JOIN information_schema.key_column_usage kcu
|
|
40
|
+
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
41
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
42
|
+
ON tc.constraint_name = ccu.constraint_name
|
|
43
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
44
|
+
AND tc.table_schema NOT IN ('pg_catalog', 'information_schema')
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
const INDEXES_QUERY = `
|
|
48
|
+
SELECT n.nspname AS table_schema, t.relname AS table_name, i.relname AS index_name,
|
|
49
|
+
array_agg(a.attname ORDER BY x.ordinality) AS columns,
|
|
50
|
+
ix.indisunique AS is_unique, ix.indisprimary AS is_primary
|
|
51
|
+
FROM pg_index ix
|
|
52
|
+
JOIN pg_class t ON t.oid = ix.indrelid
|
|
53
|
+
JOIN pg_class i ON i.oid = ix.indexrelid
|
|
54
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
55
|
+
CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, ordinality)
|
|
56
|
+
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
|
|
57
|
+
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
58
|
+
GROUP BY n.nspname, t.relname, i.relname, ix.indisunique, ix.indisprimary
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
// Types for raw query results
|
|
62
|
+
|
|
63
|
+
interface ColumnRow {
|
|
64
|
+
table_schema: string;
|
|
65
|
+
table_name: string;
|
|
66
|
+
column_name: string;
|
|
67
|
+
ordinal_position: number;
|
|
68
|
+
data_type: string;
|
|
69
|
+
character_maximum_length: number | null;
|
|
70
|
+
numeric_precision: number | null;
|
|
71
|
+
numeric_scale: number | null;
|
|
72
|
+
is_nullable: string;
|
|
73
|
+
column_default: string | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface PrimaryKeyRow {
|
|
77
|
+
table_schema: string;
|
|
78
|
+
table_name: string;
|
|
79
|
+
column_name: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface ForeignKeyRow {
|
|
83
|
+
table_schema: string;
|
|
84
|
+
table_name: string;
|
|
85
|
+
column_name: string;
|
|
86
|
+
ref_schema: string;
|
|
87
|
+
ref_table: string;
|
|
88
|
+
ref_column: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface IndexRow {
|
|
92
|
+
table_schema: string;
|
|
93
|
+
table_name: string;
|
|
94
|
+
index_name: string;
|
|
95
|
+
columns: string[];
|
|
96
|
+
is_unique: boolean;
|
|
97
|
+
is_primary: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Helper to format data type with length/precision
|
|
101
|
+
function formatDataType(row: ColumnRow): string {
|
|
102
|
+
const {
|
|
103
|
+
data_type,
|
|
104
|
+
character_maximum_length,
|
|
105
|
+
numeric_precision,
|
|
106
|
+
numeric_scale,
|
|
107
|
+
} = row;
|
|
108
|
+
|
|
109
|
+
if (character_maximum_length) {
|
|
110
|
+
return `${data_type}(${character_maximum_length})`;
|
|
111
|
+
}
|
|
112
|
+
if (numeric_precision && numeric_scale) {
|
|
113
|
+
return `${data_type}(${numeric_precision},${numeric_scale})`;
|
|
114
|
+
}
|
|
115
|
+
if (numeric_precision) {
|
|
116
|
+
return `${data_type}(${numeric_precision})`;
|
|
117
|
+
}
|
|
118
|
+
return data_type;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Build schema metadata from query results
|
|
122
|
+
function buildSchemaMetadata(
|
|
123
|
+
columns: ColumnRow[],
|
|
124
|
+
primaryKeys: PrimaryKeyRow[],
|
|
125
|
+
foreignKeys: ForeignKeyRow[],
|
|
126
|
+
indexes: IndexRow[],
|
|
127
|
+
): SchemaMetadata[] {
|
|
128
|
+
// Build lookup maps
|
|
129
|
+
const pkMap = new Map<string, Set<string>>(); // "schema.table" -> Set of column names
|
|
130
|
+
for (const pk of primaryKeys) {
|
|
131
|
+
const key = `${pk.table_schema}.${pk.table_name}`;
|
|
132
|
+
if (!pkMap.has(key)) pkMap.set(key, new Set());
|
|
133
|
+
pkMap.get(key)!.add(pk.column_name);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const fkMap = new Map<
|
|
137
|
+
string,
|
|
138
|
+
{ schema: string; table: string; column: string }
|
|
139
|
+
>(); // "schema.table.column" -> ref
|
|
140
|
+
for (const fk of foreignKeys) {
|
|
141
|
+
const key = `${fk.table_schema}.${fk.table_name}.${fk.column_name}`;
|
|
142
|
+
fkMap.set(key, {
|
|
143
|
+
schema: fk.ref_schema,
|
|
144
|
+
table: fk.ref_table,
|
|
145
|
+
column: fk.ref_column,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Build index lookup: "schema.table.column" -> { isUnique, isIndexed }
|
|
150
|
+
const indexMap = new Map<string, { isUnique: boolean; isIndexed: boolean }>();
|
|
151
|
+
for (const idx of indexes) {
|
|
152
|
+
// Skip primary key indexes (handled separately)
|
|
153
|
+
if (idx.is_primary) continue;
|
|
154
|
+
|
|
155
|
+
for (const col of idx.columns) {
|
|
156
|
+
const key = `${idx.table_schema}.${idx.table_name}.${col}`;
|
|
157
|
+
const existing = indexMap.get(key) ?? {
|
|
158
|
+
isUnique: false,
|
|
159
|
+
isIndexed: false,
|
|
160
|
+
};
|
|
161
|
+
indexMap.set(key, {
|
|
162
|
+
isUnique: existing.isUnique || idx.is_unique,
|
|
163
|
+
isIndexed: existing.isIndexed || !idx.is_unique,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Group columns by schema and table
|
|
169
|
+
const schemaMap = new Map<string, Map<string, ColumnRow[]>>();
|
|
170
|
+
for (const col of columns) {
|
|
171
|
+
if (!schemaMap.has(col.table_schema)) {
|
|
172
|
+
schemaMap.set(col.table_schema, new Map());
|
|
173
|
+
}
|
|
174
|
+
const tableMap = schemaMap.get(col.table_schema)!;
|
|
175
|
+
if (!tableMap.has(col.table_name)) {
|
|
176
|
+
tableMap.set(col.table_name, []);
|
|
177
|
+
}
|
|
178
|
+
tableMap.get(col.table_name)!.push(col);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Build index info per table
|
|
182
|
+
const tableIndexMap = new Map<string, IndexInfo[]>();
|
|
183
|
+
for (const idx of indexes) {
|
|
184
|
+
const key = `${idx.table_schema}.${idx.table_name}`;
|
|
185
|
+
if (!tableIndexMap.has(key)) {
|
|
186
|
+
tableIndexMap.set(key, []);
|
|
187
|
+
}
|
|
188
|
+
tableIndexMap.get(key)!.push({
|
|
189
|
+
name: idx.index_name,
|
|
190
|
+
columns: idx.columns,
|
|
191
|
+
isUnique: idx.is_unique,
|
|
192
|
+
isPrimary: idx.is_primary,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Build schema metadata
|
|
197
|
+
const schemas: SchemaMetadata[] = [];
|
|
198
|
+
for (const [schemaName, tableMap] of schemaMap) {
|
|
199
|
+
const tables: TableMetadata[] = [];
|
|
200
|
+
|
|
201
|
+
for (const [tableName, cols] of tableMap) {
|
|
202
|
+
const tableKey = `${schemaName}.${tableName}`;
|
|
203
|
+
const pkColumns = pkMap.get(tableKey) ?? new Set();
|
|
204
|
+
|
|
205
|
+
const columnInfos: ColumnInfo[] = cols.map((col) => {
|
|
206
|
+
const colKey = `${schemaName}.${tableName}.${col.column_name}`;
|
|
207
|
+
const isPrimaryKey = pkColumns.has(col.column_name);
|
|
208
|
+
const foreignKeyRef = fkMap.get(colKey);
|
|
209
|
+
const indexInfo = indexMap.get(colKey) ?? {
|
|
210
|
+
isUnique: false,
|
|
211
|
+
isIndexed: false,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const constraints: ColumnConstraints = {
|
|
215
|
+
isPrimaryKey,
|
|
216
|
+
isForeignKey: !!foreignKeyRef,
|
|
217
|
+
isUnique: indexInfo.isUnique,
|
|
218
|
+
isIndexed: indexInfo.isIndexed,
|
|
219
|
+
foreignKeyRef,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
name: col.column_name,
|
|
224
|
+
dataType: formatDataType(col),
|
|
225
|
+
isNullable: col.is_nullable === "YES",
|
|
226
|
+
defaultValue: col.column_default,
|
|
227
|
+
constraints,
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
tables.push({
|
|
232
|
+
schema: schemaName,
|
|
233
|
+
name: tableName,
|
|
234
|
+
columns: columnInfos,
|
|
235
|
+
primaryKey: Array.from(pkColumns),
|
|
236
|
+
indexes: tableIndexMap.get(tableKey) ?? [],
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Sort tables by name
|
|
241
|
+
tables.sort((a, b) => a.name.localeCompare(b.name));
|
|
242
|
+
schemas.push({ name: schemaName, tables });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Sort schemas by name, but put 'public' first
|
|
246
|
+
schemas.sort((a, b) => {
|
|
247
|
+
if (a.name === "public") return -1;
|
|
248
|
+
if (b.name === "public") return 1;
|
|
249
|
+
return a.name.localeCompare(b.name);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
return schemas;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Build legacy tables list for backward compatibility
|
|
256
|
+
function buildTablesList(schemas: SchemaMetadata[]): string[] {
|
|
257
|
+
const tables: string[] = [];
|
|
258
|
+
for (const schema of schemas) {
|
|
259
|
+
for (const table of schema.tables) {
|
|
260
|
+
// Only include schema prefix for non-public schemas
|
|
261
|
+
if (schema.name === "public") {
|
|
262
|
+
tables.push(table.name);
|
|
263
|
+
} else {
|
|
264
|
+
tables.push(`${schema.name}.${table.name}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return tables.sort();
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export const DEFAULT_SYNC_STATE: ConfigSyncState = {
|
|
272
|
+
status: "idle",
|
|
273
|
+
executionId: null,
|
|
274
|
+
startedAt: null,
|
|
275
|
+
completedAt: null,
|
|
276
|
+
error: null,
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/** Sync database schema (tables, columns, indexes, keys) for a specific database config */
|
|
280
|
+
export function useSyncDatabase(configId: string | undefined) {
|
|
281
|
+
const syncState =
|
|
282
|
+
useStore((state) =>
|
|
283
|
+
configId ? state.configSyncStates[configId] : undefined,
|
|
284
|
+
) ?? DEFAULT_SYNC_STATE;
|
|
285
|
+
|
|
286
|
+
const updateConfigSyncState = useStore(
|
|
287
|
+
(state) => state.updateConfigSyncState,
|
|
288
|
+
);
|
|
289
|
+
const updateConfigCache = useStore((state) => state.updateConfigCache);
|
|
290
|
+
|
|
291
|
+
const getDatabaseConfig = useCallback(() => {
|
|
292
|
+
if (!configId) return null;
|
|
293
|
+
return (
|
|
294
|
+
useStore.getState().databaseConfigs.find((c) => c.id === configId) ?? null
|
|
295
|
+
);
|
|
296
|
+
}, [configId]);
|
|
297
|
+
|
|
298
|
+
// Use ref to track current execution ID to handle race conditions
|
|
299
|
+
const currentExecutionRef = useRef<string | null>(null);
|
|
300
|
+
|
|
301
|
+
const sync = useCallback(async () => {
|
|
302
|
+
const config = getDatabaseConfig();
|
|
303
|
+
if (!config || !configId) return;
|
|
304
|
+
|
|
305
|
+
// Generate unique execution ID for race condition handling
|
|
306
|
+
const executionId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
307
|
+
currentExecutionRef.current = executionId;
|
|
308
|
+
|
|
309
|
+
// Set executing state
|
|
310
|
+
updateConfigSyncState(configId, {
|
|
311
|
+
status: "executing",
|
|
312
|
+
executionId,
|
|
313
|
+
startedAt: Date.now(),
|
|
314
|
+
completedAt: null,
|
|
315
|
+
error: null,
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
try {
|
|
319
|
+
// Execute all queries in parallel
|
|
320
|
+
const [columnsRes, pkRes, fkRes, indexesRes] = await Promise.all([
|
|
321
|
+
fetch("/api/query", {
|
|
322
|
+
method: "POST",
|
|
323
|
+
headers: { "Content-Type": "application/json" },
|
|
324
|
+
body: JSON.stringify({
|
|
325
|
+
connection: config.connection,
|
|
326
|
+
query: COLUMNS_QUERY,
|
|
327
|
+
}),
|
|
328
|
+
}),
|
|
329
|
+
fetch("/api/query", {
|
|
330
|
+
method: "POST",
|
|
331
|
+
headers: { "Content-Type": "application/json" },
|
|
332
|
+
body: JSON.stringify({
|
|
333
|
+
connection: config.connection,
|
|
334
|
+
query: PRIMARY_KEYS_QUERY,
|
|
335
|
+
}),
|
|
336
|
+
}),
|
|
337
|
+
fetch("/api/query", {
|
|
338
|
+
method: "POST",
|
|
339
|
+
headers: { "Content-Type": "application/json" },
|
|
340
|
+
body: JSON.stringify({
|
|
341
|
+
connection: config.connection,
|
|
342
|
+
query: FOREIGN_KEYS_QUERY,
|
|
343
|
+
}),
|
|
344
|
+
}),
|
|
345
|
+
fetch("/api/query", {
|
|
346
|
+
method: "POST",
|
|
347
|
+
headers: { "Content-Type": "application/json" },
|
|
348
|
+
body: JSON.stringify({
|
|
349
|
+
connection: config.connection,
|
|
350
|
+
query: INDEXES_QUERY,
|
|
351
|
+
}),
|
|
352
|
+
}),
|
|
353
|
+
]);
|
|
354
|
+
|
|
355
|
+
// Check if this execution is still current (race condition check)
|
|
356
|
+
if (currentExecutionRef.current !== executionId) {
|
|
357
|
+
return; // Stale response, discard
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Check for errors
|
|
361
|
+
if (!columnsRes.ok) {
|
|
362
|
+
const data = await columnsRes.json();
|
|
363
|
+
throw new Error(data.error || "Failed to fetch columns");
|
|
364
|
+
}
|
|
365
|
+
if (!pkRes.ok) {
|
|
366
|
+
const data = await pkRes.json();
|
|
367
|
+
throw new Error(data.error || "Failed to fetch primary keys");
|
|
368
|
+
}
|
|
369
|
+
if (!fkRes.ok) {
|
|
370
|
+
const data = await fkRes.json();
|
|
371
|
+
throw new Error(data.error || "Failed to fetch foreign keys");
|
|
372
|
+
}
|
|
373
|
+
if (!indexesRes.ok) {
|
|
374
|
+
const data = await indexesRes.json();
|
|
375
|
+
throw new Error(data.error || "Failed to fetch indexes");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const [columnsData, pkData, fkData, indexesData] = await Promise.all([
|
|
379
|
+
columnsRes.json(),
|
|
380
|
+
pkRes.json(),
|
|
381
|
+
fkRes.json(),
|
|
382
|
+
indexesRes.json(),
|
|
383
|
+
]);
|
|
384
|
+
|
|
385
|
+
// Double-check after async operation
|
|
386
|
+
if (currentExecutionRef.current !== executionId) {
|
|
387
|
+
return; // Stale response, discard
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Build schema metadata
|
|
391
|
+
const schemas = buildSchemaMetadata(
|
|
392
|
+
columnsData.rows as ColumnRow[],
|
|
393
|
+
pkData.rows as PrimaryKeyRow[],
|
|
394
|
+
fkData.rows as ForeignKeyRow[],
|
|
395
|
+
indexesData.rows as IndexRow[],
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
// Build legacy tables list for backward compatibility
|
|
399
|
+
const tables = buildTablesList(schemas);
|
|
400
|
+
|
|
401
|
+
// Update cache with both new and legacy formats
|
|
402
|
+
updateConfigCache(config.id, { schemas, tables });
|
|
403
|
+
updateConfigSyncState(configId, {
|
|
404
|
+
status: "completed",
|
|
405
|
+
completedAt: Date.now(),
|
|
406
|
+
});
|
|
407
|
+
} catch (err) {
|
|
408
|
+
// Check if this execution is still current
|
|
409
|
+
if (currentExecutionRef.current !== executionId) {
|
|
410
|
+
return; // Stale response, discard
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
updateConfigSyncState(configId, {
|
|
414
|
+
status: "error",
|
|
415
|
+
completedAt: Date.now(),
|
|
416
|
+
error: err instanceof Error ? err.message : "Unknown error",
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}, [configId, getDatabaseConfig, updateConfigCache, updateConfigSyncState]);
|
|
420
|
+
|
|
421
|
+
return { sync, isSyncing: syncState.status === "executing" };
|
|
422
|
+
}
|