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 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
@@ -0,0 +1,57 @@
1
+ import { spawn } from "child_process";
2
+ import type { DatabaseConfigConnection } from "../src/types.js";
3
+
4
+ export function pgDump(
5
+ connection: DatabaseConfigConnection,
6
+ schemaOnly: boolean,
7
+ ): Promise<string> {
8
+ return new Promise((resolve, reject) => {
9
+ const args = [
10
+ "-h",
11
+ connection.host,
12
+ "-p",
13
+ String(connection.port),
14
+ "-U",
15
+ connection.username,
16
+ "-d",
17
+ connection.database,
18
+ "--no-password",
19
+ "--format=plain",
20
+ ];
21
+ if (schemaOnly) args.push("--schema-only");
22
+
23
+ const proc = spawn("pg_dump", args, {
24
+ env: { ...process.env, PGPASSWORD: connection.password },
25
+ });
26
+
27
+ let stdout = "";
28
+ let stderr = "";
29
+ proc.stdout.on("data", (chunk: Buffer) => {
30
+ stdout += chunk;
31
+ });
32
+ proc.stderr.on("data", (chunk: Buffer) => {
33
+ stderr += chunk;
34
+ });
35
+
36
+ proc.on("error", (err) => {
37
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
38
+ reject(
39
+ new Error(
40
+ "pg_dump not found. Install PostgreSQL client tools:\n" +
41
+ " macOS: brew install postgresql\n" +
42
+ " Ubuntu: sudo apt install postgresql-client\n" +
43
+ " Windows: https://www.postgresql.org/download/windows/",
44
+ ),
45
+ );
46
+ } else {
47
+ reject(err);
48
+ }
49
+ });
50
+
51
+ proc.on("close", (code) => {
52
+ code === 0
53
+ ? resolve(stdout)
54
+ : reject(new Error(stderr || `pg_dump exited with code ${code}`));
55
+ });
56
+ });
57
+ }
@@ -0,0 +1,392 @@
1
+ import express from "express";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+ import pg from "pg";
6
+ import type {
7
+ QueryRequest,
8
+ QueryResponse,
9
+ QueryErrorResponse,
10
+ ScanLocalhostResponse,
11
+ ExportType,
12
+ DatabaseConfigConnection,
13
+ DiffResponse,
14
+ DiffTableResult,
15
+ QueryFieldInfo,
16
+ } from "../src/types.js";
17
+ import { LOCALHOST_SCANNING_ENABLED } from "../src/constants.js";
18
+ import { pgDump } from "./export.js";
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+
22
+ // Find project root (where package.json lives) — works whether run from
23
+ // server/index.ts (dev) or dist-server/server/index.js (prod)
24
+ let projectRoot = __dirname;
25
+ while (
26
+ !fs.existsSync(path.join(projectRoot, "package.json")) &&
27
+ projectRoot !== path.dirname(projectRoot)
28
+ ) {
29
+ projectRoot = path.dirname(projectRoot);
30
+ }
31
+
32
+ const app = express();
33
+ const port = parseInt(process.env.PORT || "4088");
34
+
35
+ const distPath = path.join(projectRoot, "dist");
36
+ const hasBuiltFrontend = fs.existsSync(path.join(distPath, "index.html"));
37
+
38
+ app.use(express.json());
39
+
40
+ /** Build a PostgreSQL connection string from a DatabaseConfigConnection */
41
+ function buildConnectionString(conn: {
42
+ username: string;
43
+ password: string;
44
+ host: string;
45
+ port: number;
46
+ database: string;
47
+ params?: Record<string, string>;
48
+ }): string {
49
+ const params =
50
+ conn.params && Object.keys(conn.params).length > 0
51
+ ? "?" + new URLSearchParams(conn.params).toString()
52
+ : "";
53
+ return `postgresql://${encodeURIComponent(conn.username)}:${encodeURIComponent(conn.password)}@${conn.host}:${conn.port}/${encodeURIComponent(conn.database)}${params}`;
54
+ }
55
+
56
+ // Dummy endpoint for testing
57
+ app.get("/api/ping", (_req, res) => {
58
+ res.json({ message: "pong", timestamp: Date.now() });
59
+ });
60
+
61
+ // Execute SQL query against a database connection
62
+ app.post(
63
+ "/api/query",
64
+ async (req, res: express.Response<QueryResponse | QueryErrorResponse>) => {
65
+ const { connection, query } = req.body as QueryRequest;
66
+
67
+ if (!connection || !query) {
68
+ res.status(400).json({ error: "Missing connection or query" });
69
+ return;
70
+ }
71
+
72
+ const client = new pg.Client({
73
+ connectionString: buildConnectionString(connection),
74
+ });
75
+
76
+ try {
77
+ await client.connect();
78
+ const result = await client.query(query);
79
+ // pg returns an array of QueryResult when multiple statements are executed
80
+ const lastResult = Array.isArray(result)
81
+ ? result[result.length - 1]
82
+ : result;
83
+ const response: QueryResponse = {
84
+ rows: lastResult.rows ?? [],
85
+ fields: (lastResult.fields ?? []).map((f: pg.FieldDef) => ({
86
+ name: f.name,
87
+ dataTypeID: f.dataTypeID,
88
+ })),
89
+ rowCount: lastResult.rowCount,
90
+ };
91
+ res.json(response);
92
+ } catch (err) {
93
+ const message = err instanceof Error ? err.message : "Unknown error";
94
+ res.status(500).json({ error: message });
95
+ } finally {
96
+ await client.end();
97
+ }
98
+ },
99
+ );
100
+
101
+ // Preview diff of DML statements (INSERT/UPDATE/DELETE) via rolled-back transaction
102
+ app.post(
103
+ "/api/query-diff",
104
+ async (req, res: express.Response<DiffResponse | QueryErrorResponse>) => {
105
+ const { connection, query } = req.body as QueryRequest;
106
+
107
+ if (!connection || !query) {
108
+ res.status(400).json({ error: "Missing connection or query" });
109
+ return;
110
+ }
111
+
112
+ // Parse table names from DML statements
113
+ const tableRegex =
114
+ /(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+(?:"?(\w+)"?\.)?"?(\w+)"?/gi;
115
+ const tableNames = new Set<string>();
116
+ let match;
117
+ while ((match = tableRegex.exec(query)) !== null) {
118
+ const schema = match[1] || "public";
119
+ const table = match[2];
120
+ tableNames.add(`${schema}.${table}`);
121
+ }
122
+
123
+ if (tableNames.size === 0) {
124
+ res
125
+ .status(400)
126
+ .json({ error: "No INSERT/UPDATE/DELETE statements detected" });
127
+ return;
128
+ }
129
+
130
+ const client = new pg.Client({
131
+ connectionString: buildConnectionString(connection),
132
+ });
133
+
134
+ try {
135
+ await client.connect();
136
+ await client.query("BEGIN");
137
+
138
+ // For each table, get PK columns and snapshot before state
139
+ const tableInfo: Map<
140
+ string,
141
+ {
142
+ schema: string;
143
+ table: string;
144
+ pkColumns: string[];
145
+ columns: QueryFieldInfo[];
146
+ before: Record<string, unknown>[];
147
+ }
148
+ > = new Map();
149
+
150
+ for (const fullName of tableNames) {
151
+ const [schema, table] = fullName.split(".");
152
+
153
+ // Get primary key columns
154
+ const pkResult = await client.query(
155
+ `SELECT a.attname
156
+ FROM pg_constraint c
157
+ JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey)
158
+ WHERE c.contype = 'p'
159
+ AND c.conrelid = (
160
+ SELECT oid FROM pg_class
161
+ WHERE relname = $1
162
+ AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = $2)
163
+ )
164
+ ORDER BY array_position(c.conkey, a.attnum)`,
165
+ [table, schema],
166
+ );
167
+
168
+ const pkColumns = pkResult.rows.map(
169
+ (r: { attname: string }) => r.attname,
170
+ );
171
+
172
+ if (pkColumns.length === 0) {
173
+ await client.query("ROLLBACK");
174
+ res.status(400).json({
175
+ error: `Table "${schema}"."${table}" has no primary key. Diff requires a primary key to identify rows.`,
176
+ });
177
+ return;
178
+ }
179
+
180
+ // Snapshot before
181
+ const orderBy = pkColumns.map((c: string) => `"${c}"`).join(", ");
182
+ const beforeResult = await client.query(
183
+ `SELECT * FROM "${schema}"."${table}" ORDER BY ${orderBy} LIMIT 10000`,
184
+ );
185
+
186
+ const columns: QueryFieldInfo[] = (beforeResult.fields ?? []).map(
187
+ (f: pg.FieldDef) => ({
188
+ name: f.name,
189
+ dataTypeID: f.dataTypeID,
190
+ }),
191
+ );
192
+
193
+ tableInfo.set(fullName, {
194
+ schema,
195
+ table,
196
+ pkColumns,
197
+ columns,
198
+ before: beforeResult.rows,
199
+ });
200
+ }
201
+
202
+ // Execute the user's SQL
203
+ await client.query(query);
204
+
205
+ // Snapshot after and compute diffs
206
+ const tables: DiffTableResult[] = [];
207
+
208
+ for (const [fullName, info] of tableInfo) {
209
+ const orderBy = info.pkColumns.map((c) => `"${c}"`).join(", ");
210
+ const afterResult = await client.query(
211
+ `SELECT * FROM "${info.schema}"."${info.table}" ORDER BY ${orderBy} LIMIT 10000`,
212
+ );
213
+ const afterRows: Record<string, unknown>[] = afterResult.rows;
214
+
215
+ // Index rows by PK
216
+ const pkKey = (row: Record<string, unknown>) =>
217
+ info.pkColumns.map((c) => JSON.stringify(row[c])).join("|");
218
+
219
+ const beforeMap = new Map<string, Record<string, unknown>>();
220
+ for (const row of info.before) {
221
+ beforeMap.set(pkKey(row), row);
222
+ }
223
+
224
+ const afterMap = new Map<string, Record<string, unknown>>();
225
+ for (const row of afterRows) {
226
+ afterMap.set(pkKey(row), row);
227
+ }
228
+
229
+ const deleted: Record<string, unknown>[] = [];
230
+ const added: Record<string, unknown>[] = [];
231
+ const modified: DiffTableResult["modified"] = [];
232
+ let unchangedCount = 0;
233
+
234
+ // Find deleted and modified rows
235
+ for (const [key, beforeRow] of beforeMap) {
236
+ const afterRow = afterMap.get(key);
237
+ if (!afterRow) {
238
+ deleted.push(beforeRow);
239
+ } else {
240
+ // Compare all columns
241
+ const changedColumns: string[] = [];
242
+ for (const col of info.columns) {
243
+ const bVal = beforeRow[col.name];
244
+ const aVal = afterRow[col.name];
245
+ if (JSON.stringify(bVal) !== JSON.stringify(aVal)) {
246
+ changedColumns.push(col.name);
247
+ }
248
+ }
249
+ if (changedColumns.length > 0) {
250
+ modified.push({
251
+ before: beforeRow,
252
+ after: afterRow,
253
+ changedColumns,
254
+ });
255
+ } else {
256
+ unchangedCount++;
257
+ }
258
+ }
259
+ }
260
+
261
+ // Find added rows
262
+ for (const [key, afterRow] of afterMap) {
263
+ if (!beforeMap.has(key)) {
264
+ added.push(afterRow);
265
+ }
266
+ }
267
+
268
+ tables.push({
269
+ tableName: fullName,
270
+ columns: info.columns,
271
+ primaryKeyColumns: info.pkColumns,
272
+ deleted,
273
+ added,
274
+ modified,
275
+ unchangedCount,
276
+ });
277
+ }
278
+
279
+ // Always rollback — this is a preview only
280
+ await client.query("ROLLBACK");
281
+
282
+ res.json({ tables });
283
+ } catch (err) {
284
+ try {
285
+ await client.query("ROLLBACK");
286
+ } catch {
287
+ // ignore rollback error
288
+ }
289
+ const message = err instanceof Error ? err.message : "Unknown error";
290
+ res.status(500).json({ error: message });
291
+ } finally {
292
+ await client.end();
293
+ }
294
+ },
295
+ );
296
+
297
+ // Scan localhost for PostgreSQL databases
298
+ app.get(
299
+ "/api/scan-localhost",
300
+ async (_req, res: express.Response<ScanLocalhostResponse>) => {
301
+ if (!LOCALHOST_SCANNING_ENABLED) {
302
+ res.json({ databases: [], error: "Localhost scanning is disabled" });
303
+ return;
304
+ }
305
+ const host = "localhost";
306
+ const port = 5432;
307
+ const username = "postgres";
308
+ const candidates = ["", "password", "postgres"];
309
+
310
+ for (const password of candidates) {
311
+ const client = new pg.Client({
312
+ connectionString: buildConnectionString({
313
+ username,
314
+ password,
315
+ host,
316
+ port,
317
+ database: "postgres",
318
+ }),
319
+ });
320
+
321
+ try {
322
+ await client.connect();
323
+ const result = await client.query(
324
+ "SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname",
325
+ );
326
+ const databases = result.rows.map((row: { datname: string }) => ({
327
+ host,
328
+ port,
329
+ username,
330
+ password,
331
+ database: row.datname,
332
+ }));
333
+ res.json({ databases });
334
+ return;
335
+ } catch {
336
+ // try next credential
337
+ } finally {
338
+ await client.end();
339
+ }
340
+ }
341
+
342
+ res.json({
343
+ databases: [],
344
+ error: "Could not connect with any known credentials",
345
+ });
346
+ },
347
+ );
348
+
349
+ // Export database as .sql file via pg_dump
350
+ app.post("/api/export", async (req, res) => {
351
+ const { connection, exportType } = req.body as {
352
+ connection: DatabaseConfigConnection;
353
+ exportType: ExportType;
354
+ };
355
+
356
+ if (!connection || !exportType) {
357
+ res.status(400).json({ error: "Missing connection or exportType" });
358
+ return;
359
+ }
360
+
361
+ try {
362
+ const sql = await pgDump(connection, exportType === "schema");
363
+ const suffix = exportType === "schema" ? "schema" : "full";
364
+ const filename = `${connection.database}_${suffix}_${new Date().toISOString().slice(0, 10)}.sql`;
365
+ res.setHeader("Content-Type", "application/sql");
366
+ res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
367
+ res.send(sql);
368
+ } catch (err) {
369
+ const message = err instanceof Error ? err.message : "Unknown error";
370
+ res.status(500).json({ error: message });
371
+ }
372
+ });
373
+
374
+ // Serve built frontend if it exists
375
+ if (hasBuiltFrontend) {
376
+ app.use(express.static(distPath));
377
+ app.get("*", (_req, res) => {
378
+ res.sendFile(path.join(distPath, "index.html"));
379
+ });
380
+ }
381
+
382
+ export const serverReady = new Promise<void>((resolve) => {
383
+ app.listen(port, () => {
384
+ console.log(`dbdiff running at http://localhost:${port}`);
385
+ if (hasBuiltFrontend) {
386
+ console.log("Serving frontend from dist/");
387
+ } else {
388
+ console.log("No built frontend found - API only (use Vite for frontend)");
389
+ }
390
+ resolve();
391
+ });
392
+ });
package/src/App.css ADDED
@@ -0,0 +1 @@
1
+ /* App-specific styles - mostly using Tailwind now */