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.
Files changed (69) hide show
  1. package/README.md +73 -0
  2. package/bin/cli.js +83 -0
  3. package/bin/install-local.js +57 -0
  4. package/electron/generate-icon.mjs +54 -0
  5. package/electron/icon.icns +0 -0
  6. package/electron/icon.png +0 -0
  7. package/electron/icon.svg +21 -0
  8. package/electron/main.js +169 -0
  9. package/electron/patch-dev-plist.js +31 -0
  10. package/electron/preload.cjs +18 -0
  11. package/electron/wait-for-vite.js +43 -0
  12. package/index.html +13 -0
  13. package/package.json +91 -0
  14. package/public/favicon.svg +15 -0
  15. package/public/vite.svg +1 -0
  16. package/server/export.ts +57 -0
  17. package/server/index.ts +392 -0
  18. package/src/App.css +1 -0
  19. package/src/App.tsx +543 -0
  20. package/src/assets/react.svg +1 -0
  21. package/src/components/CommandPalette.tsx +243 -0
  22. package/src/components/ConnectedView.tsx +78 -0
  23. package/src/components/ConnectionPicker.tsx +381 -0
  24. package/src/components/ConsoleView.tsx +360 -0
  25. package/src/components/CsvExportModal.tsx +144 -0
  26. package/src/components/DataGrid/DataGrid.tsx +262 -0
  27. package/src/components/DataGrid/DataGridCell.tsx +73 -0
  28. package/src/components/DataGrid/DataGridHeader.tsx +89 -0
  29. package/src/components/DataGrid/index.ts +20 -0
  30. package/src/components/DataGrid/types.ts +63 -0
  31. package/src/components/DataGrid/useColumnResize.ts +153 -0
  32. package/src/components/DataGrid/useDataGridSelection.ts +340 -0
  33. package/src/components/DataGrid/utils.ts +184 -0
  34. package/src/components/DatabaseMenu.tsx +93 -0
  35. package/src/components/DatabaseSwitcher.tsx +208 -0
  36. package/src/components/DiffView.tsx +215 -0
  37. package/src/components/EditConnectionModal.tsx +417 -0
  38. package/src/components/ErrorBoundary.tsx +69 -0
  39. package/src/components/GlobalShortcuts.tsx +201 -0
  40. package/src/components/InnerTabBar.tsx +129 -0
  41. package/src/components/JsonTreeViewer.tsx +387 -0
  42. package/src/components/MemberAccessEditor.tsx +443 -0
  43. package/src/components/MembersModal.tsx +446 -0
  44. package/src/components/NewConnectionModal.tsx +274 -0
  45. package/src/components/Resizer.tsx +66 -0
  46. package/src/components/ScanSuccessModal.tsx +113 -0
  47. package/src/components/ShortcutSettingsModal.tsx +318 -0
  48. package/src/components/Sidebar.tsx +532 -0
  49. package/src/components/TabBar.tsx +188 -0
  50. package/src/components/TableView.tsx +2147 -0
  51. package/src/components/ThemeToggle.tsx +44 -0
  52. package/src/components/index.ts +17 -0
  53. package/src/constants.ts +12 -0
  54. package/src/electron.d.ts +12 -0
  55. package/src/index.css +44 -0
  56. package/src/main.tsx +13 -0
  57. package/src/stores/hooks.ts +1146 -0
  58. package/src/stores/index.ts +12 -0
  59. package/src/stores/store.ts +1514 -0
  60. package/src/stores/useCloudSync.ts +274 -0
  61. package/src/stores/useSyncDatabase.ts +422 -0
  62. package/src/types.ts +277 -0
  63. package/src/utils/csv.ts +27 -0
  64. package/src/vite-env.d.ts +2 -0
  65. package/tsconfig.app.json +28 -0
  66. package/tsconfig.json +7 -0
  67. package/tsconfig.node.json +26 -0
  68. package/tsconfig.server.json +14 -0
  69. 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
+ }