@hienlh/ppm 0.6.2 → 0.6.4
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/CHANGELOG.md +16 -0
- package/dist/web/assets/api-client-BHpHp5Lz.js +1 -0
- package/dist/web/assets/{chat-tab-BdiG3Gnr.js → chat-tab-CDVCDw_H.js} +5 -5
- package/dist/web/assets/code-editor-wmS73ejX.js +1 -0
- package/dist/web/assets/diff-viewer-BsYccTx1.js +4 -0
- package/dist/web/assets/{dist-CJbcT4CK.js → dist-PpKqMvyx.js} +2 -2
- package/dist/web/assets/git-graph-BbWb6_Jq.js +1 -0
- package/dist/web/assets/index-DhuAmTQ1.js +21 -0
- package/dist/web/assets/index-aIGuIMQ8.css +2 -0
- package/dist/web/assets/input-CCCPR1s4.js +41 -0
- package/dist/web/assets/jsx-runtime-wQxeESYQ.js +1 -0
- package/dist/web/assets/keybindings-store-BqgrTQAC.js +1 -0
- package/dist/web/assets/{markdown-renderer-BPKEwysz.js → markdown-renderer-aPdw9BhU.js} +1 -1
- package/dist/web/assets/postgres-viewer-V4hKmmzV.js +1 -0
- package/dist/web/assets/{jsx-runtime-B4BJKQ1u.js → react-CYzKIDNi.js} +1 -1
- package/dist/web/assets/react-l9v2XLcs.js +1 -0
- package/dist/web/assets/settings-store-DgOSmeGL.js +1 -0
- package/dist/web/assets/settings-tab-DwsKpk9T.js +1 -0
- package/dist/web/assets/sqlite-viewer-BRsj8GXc.js +1 -0
- package/dist/web/assets/{tab-store-Bf9z6T8D.js → tab-store-DhXold0e.js} +1 -1
- package/dist/web/assets/{terminal-tab-Dt9bjwC8.js → terminal-tab-3tDV4RCn.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-yxUtuNlu.js → use-monaco-theme-Ccqh1RD4.js} +1 -1
- package/dist/web/index.html +9 -8
- package/dist/web/sw.js +1 -1
- package/docs/codebase-summary.md +41 -14
- package/docs/project-roadmap.md +31 -6
- package/docs/system-architecture.md +222 -7
- package/package.json +1 -1
- package/src/cli/commands/db-cmd.ts +355 -0
- package/src/server/index.ts +6 -0
- package/src/server/routes/database.ts +259 -0
- package/src/server/routes/settings.ts +33 -0
- package/src/services/database/adapter-registry.ts +13 -0
- package/src/services/database/init-adapters.ts +9 -0
- package/src/services/database/postgres-adapter.ts +42 -0
- package/src/services/database/readonly-check.ts +17 -0
- package/src/services/database/sqlite-adapter.ts +55 -0
- package/src/services/db.service.ts +173 -2
- package/src/services/table-cache.service.ts +75 -0
- package/src/types/database.ts +50 -0
- package/src/web/app.tsx +11 -1
- package/src/web/components/database/connection-color-picker.tsx +67 -0
- package/src/web/components/database/connection-form-dialog.tsx +234 -0
- package/src/web/components/database/connection-list.tsx +208 -0
- package/src/web/components/database/database-sidebar.tsx +100 -0
- package/src/web/components/database/use-connections.ts +99 -0
- package/src/web/components/layout/command-palette.tsx +57 -6
- package/src/web/components/layout/draggable-tab.tsx +13 -2
- package/src/web/components/layout/mobile-drawer.tsx +7 -2
- package/src/web/components/layout/sidebar.tsx +6 -1
- package/src/web/components/postgres/postgres-viewer.tsx +12 -3
- package/src/web/components/postgres/use-postgres.ts +57 -21
- package/src/web/components/settings/keyboard-shortcuts-section.tsx +182 -0
- package/src/web/components/settings/settings-tab.tsx +5 -0
- package/src/web/components/sqlite/sqlite-viewer.tsx +27 -3
- package/src/web/components/sqlite/use-sqlite.ts +21 -12
- package/src/web/hooks/use-global-keybindings.ts +74 -14
- package/src/web/lib/api-client.ts +7 -1
- package/src/web/lib/color-utils.ts +23 -0
- package/src/web/stores/keybindings-store.ts +192 -0
- package/src/web/stores/settings-store.ts +2 -2
- package/dist/web/assets/api-client-DPWUomlf.js +0 -1
- package/dist/web/assets/code-editor-soN1frMc.js +0 -1
- package/dist/web/assets/diff-viewer-DJEB1zOd.js +0 -4
- package/dist/web/assets/git-graph-CrU7vGxw.js +0 -1
- package/dist/web/assets/index-CmrE0Xoy.js +0 -21
- package/dist/web/assets/index-g11aaU-x.css +0 -2
- package/dist/web/assets/input-DMu1FA4M.js +0 -41
- package/dist/web/assets/postgres-viewer-lBV4F44Q.js +0 -1
- package/dist/web/assets/react-Bo97Lrzq.js +0 -1
- package/dist/web/assets/rotate-ccw-Dx0ShAKj.js +0 -1
- package/dist/web/assets/settings-store-BRFvbsHd.js +0 -1
- package/dist/web/assets/settings-tab-Div5NL2d.js +0 -1
- package/dist/web/assets/sqlite-viewer-BbgWU-v3.js +0 -1
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import {
|
|
3
|
+
getConnections, getConnectionById, insertConnection, updateConnection, deleteConnection,
|
|
4
|
+
type ConnectionConfig, type ConnectionRow,
|
|
5
|
+
} from "../../services/db.service.ts";
|
|
6
|
+
import { getAdapter } from "../../services/database/adapter-registry.ts";
|
|
7
|
+
import { syncTables, searchTables } from "../../services/table-cache.service.ts";
|
|
8
|
+
import { isReadOnlyQuery } from "../../services/database/readonly-check.ts";
|
|
9
|
+
import { ok, err } from "../../types/api.ts";
|
|
10
|
+
import type { DbConnectionConfig } from "../../types/database.ts";
|
|
11
|
+
|
|
12
|
+
export const databaseRoutes = new Hono();
|
|
13
|
+
|
|
14
|
+
/** Strip sensitive connection_config from connection responses */
|
|
15
|
+
function sanitizeConn(conn: ConnectionRow): Omit<ConnectionRow, "connection_config"> {
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
17
|
+
const { connection_config: _, ...safe } = conn;
|
|
18
|
+
return safe;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Validate hex color string (e.g. #3b82f6) */
|
|
22
|
+
function isValidHex(color: string): boolean {
|
|
23
|
+
return /^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(color);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Resolve connection + parse config, return 404 on miss */
|
|
27
|
+
function resolveConn(id: string) {
|
|
28
|
+
const numId = parseInt(id, 10);
|
|
29
|
+
if (isNaN(numId)) return null;
|
|
30
|
+
return getConnectionById(numId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Connection CRUD
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/** GET /api/db/connections */
|
|
38
|
+
databaseRoutes.get("/connections", (c) => {
|
|
39
|
+
try {
|
|
40
|
+
return c.json(ok(getConnections().map(sanitizeConn)));
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return c.json(err((e as Error).message), 500);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** GET /api/db/connections/:id */
|
|
47
|
+
databaseRoutes.get("/connections/:id", (c) => {
|
|
48
|
+
const conn = resolveConn(c.req.param("id"));
|
|
49
|
+
if (!conn) return c.json(err("Connection not found"), 404);
|
|
50
|
+
return c.json(ok(sanitizeConn(conn)));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** POST /api/db/connections */
|
|
54
|
+
databaseRoutes.post("/connections", async (c) => {
|
|
55
|
+
try {
|
|
56
|
+
const body = await c.req.json<{
|
|
57
|
+
type: "sqlite" | "postgres";
|
|
58
|
+
name: string;
|
|
59
|
+
connectionConfig: ConnectionConfig;
|
|
60
|
+
groupName?: string;
|
|
61
|
+
color?: string;
|
|
62
|
+
}>();
|
|
63
|
+
if (!body.type || !body.name || !body.connectionConfig) {
|
|
64
|
+
return c.json(err("type, name, and connectionConfig are required"), 400);
|
|
65
|
+
}
|
|
66
|
+
if (!["sqlite", "postgres"].includes(body.type)) {
|
|
67
|
+
return c.json(err("type must be sqlite or postgres"), 400);
|
|
68
|
+
}
|
|
69
|
+
if (body.color && !isValidHex(body.color)) {
|
|
70
|
+
return c.json(err("color must be a valid hex color (e.g. #3b82f6)"), 400);
|
|
71
|
+
}
|
|
72
|
+
const conn = insertConnection(body.type, body.name, body.connectionConfig, body.groupName, body.color);
|
|
73
|
+
return c.json(ok(sanitizeConn(conn)), 201);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
return c.json(err((e as Error).message), 500);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** PUT /api/db/connections/:id — allows toggling readonly (UI-only) */
|
|
80
|
+
databaseRoutes.put("/connections/:id", async (c) => {
|
|
81
|
+
try {
|
|
82
|
+
const conn = resolveConn(c.req.param("id"));
|
|
83
|
+
if (!conn) return c.json(err("Connection not found"), 404);
|
|
84
|
+
|
|
85
|
+
const body = await c.req.json<{
|
|
86
|
+
name?: string;
|
|
87
|
+
connectionConfig?: ConnectionConfig;
|
|
88
|
+
groupName?: string | null;
|
|
89
|
+
color?: string | null;
|
|
90
|
+
readonly?: number;
|
|
91
|
+
}>();
|
|
92
|
+
|
|
93
|
+
if (body.color && !isValidHex(body.color)) {
|
|
94
|
+
return c.json(err("color must be a valid hex color (e.g. #3b82f6)"), 400);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
updateConnection(conn.id, {
|
|
98
|
+
name: body.name,
|
|
99
|
+
config: body.connectionConfig,
|
|
100
|
+
groupName: body.groupName,
|
|
101
|
+
color: body.color,
|
|
102
|
+
readonly: body.readonly,
|
|
103
|
+
});
|
|
104
|
+
const updated = getConnectionById(conn.id);
|
|
105
|
+
return c.json(ok(updated ? sanitizeConn(updated) : null));
|
|
106
|
+
} catch (e) {
|
|
107
|
+
return c.json(err((e as Error).message), 500);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/** DELETE /api/db/connections/:id */
|
|
112
|
+
databaseRoutes.delete("/connections/:id", (c) => {
|
|
113
|
+
try {
|
|
114
|
+
const conn = resolveConn(c.req.param("id"));
|
|
115
|
+
if (!conn) return c.json(err("Connection not found"), 404);
|
|
116
|
+
deleteConnection(String(conn.id));
|
|
117
|
+
return c.json(ok({ deleted: true }));
|
|
118
|
+
} catch (e) {
|
|
119
|
+
return c.json(err((e as Error).message), 500);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Connection operations
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/** POST /api/db/connections/:id/test */
|
|
128
|
+
databaseRoutes.post("/connections/:id/test", async (c) => {
|
|
129
|
+
try {
|
|
130
|
+
const conn = resolveConn(c.req.param("id"));
|
|
131
|
+
if (!conn) return c.json(err("Connection not found"), 404);
|
|
132
|
+
const config = JSON.parse(conn.connection_config) as DbConnectionConfig;
|
|
133
|
+
const adapter = getAdapter(conn.type);
|
|
134
|
+
const result = await adapter.testConnection(config);
|
|
135
|
+
return c.json(ok(result));
|
|
136
|
+
} catch (e) {
|
|
137
|
+
return c.json(err((e as Error).message), 500);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/** GET /api/db/connections/:id/tables — live fetch + sync cache */
|
|
142
|
+
databaseRoutes.get("/connections/:id/tables", async (c) => {
|
|
143
|
+
try {
|
|
144
|
+
const conn = resolveConn(c.req.param("id"));
|
|
145
|
+
if (!conn) return c.json(err("Connection not found"), 404);
|
|
146
|
+
const tables = await syncTables(conn.id);
|
|
147
|
+
return c.json(ok(tables));
|
|
148
|
+
} catch (e) {
|
|
149
|
+
return c.json(err((e as Error).message), 500);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
/** GET /api/db/connections/:id/schema?table=...&schema=... */
|
|
154
|
+
databaseRoutes.get("/connections/:id/schema", async (c) => {
|
|
155
|
+
try {
|
|
156
|
+
const conn = resolveConn(c.req.param("id"));
|
|
157
|
+
if (!conn) return c.json(err("Connection not found"), 404);
|
|
158
|
+
const table = c.req.query("table");
|
|
159
|
+
const schema = c.req.query("schema");
|
|
160
|
+
if (!table) return c.json(err("table query param required"), 400);
|
|
161
|
+
const config = JSON.parse(conn.connection_config) as DbConnectionConfig;
|
|
162
|
+
const adapter = getAdapter(conn.type);
|
|
163
|
+
const cols = await adapter.getTableSchema(config, table, schema);
|
|
164
|
+
return c.json(ok(cols));
|
|
165
|
+
} catch (e) {
|
|
166
|
+
return c.json(err((e as Error).message), 500);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
/** GET /api/db/connections/:id/data?table=...&page=1&limit=100&orderBy=...&orderDir=ASC */
|
|
171
|
+
databaseRoutes.get("/connections/:id/data", async (c) => {
|
|
172
|
+
try {
|
|
173
|
+
const conn = resolveConn(c.req.param("id"));
|
|
174
|
+
if (!conn) return c.json(err("Connection not found"), 404);
|
|
175
|
+
const table = c.req.query("table");
|
|
176
|
+
if (!table) return c.json(err("table query param required"), 400);
|
|
177
|
+
const config = JSON.parse(conn.connection_config) as DbConnectionConfig;
|
|
178
|
+
const adapter = getAdapter(conn.type);
|
|
179
|
+
const data = await adapter.getTableData(config, table, {
|
|
180
|
+
schema: c.req.query("schema"),
|
|
181
|
+
page: parseInt(c.req.query("page") ?? "1", 10),
|
|
182
|
+
limit: Math.min(parseInt(c.req.query("limit") ?? "100", 10), 1000),
|
|
183
|
+
orderBy: c.req.query("orderBy"),
|
|
184
|
+
orderDir: (c.req.query("orderDir") as "ASC" | "DESC") ?? "ASC",
|
|
185
|
+
});
|
|
186
|
+
return c.json(ok(data));
|
|
187
|
+
} catch (e) {
|
|
188
|
+
return c.json(err((e as Error).message), 500);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
/** POST /api/db/connections/:id/query — body: { sql } — enforces readonly */
|
|
193
|
+
databaseRoutes.post("/connections/:id/query", async (c) => {
|
|
194
|
+
try {
|
|
195
|
+
const conn = resolveConn(c.req.param("id"));
|
|
196
|
+
if (!conn) return c.json(err("Connection not found"), 404);
|
|
197
|
+
const body = await c.req.json<{ sql: string }>();
|
|
198
|
+
if (!body.sql) return c.json(err("sql is required"), 400);
|
|
199
|
+
|
|
200
|
+
if (conn.readonly && !isReadOnlyQuery(body.sql)) {
|
|
201
|
+
return c.json(err("Connection is readonly — only SELECT queries allowed. Change this in PPM web UI."), 403);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const config = JSON.parse(conn.connection_config) as DbConnectionConfig;
|
|
205
|
+
const adapter = getAdapter(conn.type);
|
|
206
|
+
const result = await adapter.executeQuery(config, body.sql);
|
|
207
|
+
return c.json(ok(result));
|
|
208
|
+
} catch (e) {
|
|
209
|
+
return c.json(err((e as Error).message), 500);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
/** PUT /api/db/connections/:id/cell — body: { table, schema?, pkColumn, pkValue, column, value } — enforces readonly */
|
|
214
|
+
databaseRoutes.put("/connections/:id/cell", async (c) => {
|
|
215
|
+
try {
|
|
216
|
+
const conn = resolveConn(c.req.param("id"));
|
|
217
|
+
if (!conn) return c.json(err("Connection not found"), 404);
|
|
218
|
+
|
|
219
|
+
if (conn.readonly) {
|
|
220
|
+
return c.json(err("Connection is readonly — cell editing is disabled. Change this in PPM web UI."), 403);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const body = await c.req.json<{
|
|
224
|
+
table: string; schema?: string;
|
|
225
|
+
pkColumn: string; pkValue: unknown; column: string; value: unknown;
|
|
226
|
+
}>();
|
|
227
|
+
if (!body.table || !body.pkColumn || !body.column) {
|
|
228
|
+
return c.json(err("table, pkColumn, and column are required"), 400);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const config = JSON.parse(conn.connection_config) as DbConnectionConfig;
|
|
232
|
+
const adapter = getAdapter(conn.type);
|
|
233
|
+
await adapter.updateCell(config, body.table, {
|
|
234
|
+
schema: body.schema,
|
|
235
|
+
pkColumn: body.pkColumn,
|
|
236
|
+
pkValue: body.pkValue,
|
|
237
|
+
column: body.column,
|
|
238
|
+
value: body.value,
|
|
239
|
+
});
|
|
240
|
+
return c.json(ok({ updated: true }));
|
|
241
|
+
} catch (e) {
|
|
242
|
+
return c.json(err((e as Error).message), 500);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Search
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
/** GET /api/db/search?q=... — search cached tables across all connections */
|
|
251
|
+
databaseRoutes.get("/search", (c) => {
|
|
252
|
+
try {
|
|
253
|
+
const q = c.req.query("q") ?? "";
|
|
254
|
+
return c.json(ok(searchTables(q)));
|
|
255
|
+
} catch (e) {
|
|
256
|
+
return c.json(err((e as Error).message), 500);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import { configService } from "../../services/config.service.ts";
|
|
3
|
+
import { getConfigValue, setConfigValue } from "../../services/db.service.ts";
|
|
3
4
|
import {
|
|
4
5
|
validateAIProviderConfig,
|
|
5
6
|
validateDefaultProvider,
|
|
@@ -102,3 +103,35 @@ settingsRoutes.put("/ai", async (c) => {
|
|
|
102
103
|
return c.json(err((e as Error).message), 400);
|
|
103
104
|
}
|
|
104
105
|
});
|
|
106
|
+
|
|
107
|
+
// ── Keybindings ──────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
const KEYBINDINGS_KEY = "keybindings";
|
|
110
|
+
|
|
111
|
+
/** GET /settings/keybindings — return user overrides (partial) */
|
|
112
|
+
settingsRoutes.get("/keybindings", (c) => {
|
|
113
|
+
const raw = getConfigValue(KEYBINDINGS_KEY);
|
|
114
|
+
const overrides: Record<string, string> = raw ? JSON.parse(raw) : {};
|
|
115
|
+
return c.json(ok(overrides));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/** PUT /settings/keybindings — save user overrides (partial, only changed keys) */
|
|
119
|
+
settingsRoutes.put("/keybindings", async (c) => {
|
|
120
|
+
try {
|
|
121
|
+
const body = await c.req.json<Record<string, string | null>>();
|
|
122
|
+
// Merge with existing overrides
|
|
123
|
+
const raw = getConfigValue(KEYBINDINGS_KEY);
|
|
124
|
+
const current: Record<string, string> = raw ? JSON.parse(raw) : {};
|
|
125
|
+
for (const [actionId, combo] of Object.entries(body)) {
|
|
126
|
+
if (combo === null) {
|
|
127
|
+
delete current[actionId]; // reset to default
|
|
128
|
+
} else {
|
|
129
|
+
current[actionId] = combo;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
setConfigValue(KEYBINDINGS_KEY, JSON.stringify(current));
|
|
133
|
+
return c.json(ok(current));
|
|
134
|
+
} catch (e) {
|
|
135
|
+
return c.json(err((e as Error).message), 400);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { DbType, DatabaseAdapter } from "../../types/database.ts";
|
|
2
|
+
|
|
3
|
+
const adapters = new Map<DbType, DatabaseAdapter>();
|
|
4
|
+
|
|
5
|
+
export function registerAdapter(type: DbType, adapter: DatabaseAdapter): void {
|
|
6
|
+
adapters.set(type, adapter);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getAdapter(type: DbType): DatabaseAdapter {
|
|
10
|
+
const adapter = adapters.get(type);
|
|
11
|
+
if (!adapter) throw new Error(`No adapter registered for database type: ${type}`);
|
|
12
|
+
return adapter;
|
|
13
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { registerAdapter } from "./adapter-registry.ts";
|
|
2
|
+
import { sqliteAdapter } from "./sqlite-adapter.ts";
|
|
3
|
+
import { postgresAdapter } from "./postgres-adapter.ts";
|
|
4
|
+
|
|
5
|
+
/** Register all database adapters. Call once at server startup. */
|
|
6
|
+
export function initAdapters(): void {
|
|
7
|
+
registerAdapter("sqlite", sqliteAdapter);
|
|
8
|
+
registerAdapter("postgres", postgresAdapter);
|
|
9
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { DatabaseAdapter, DbConnectionConfig, DbTableInfo, DbColumnInfo, DbPagedData, DbQueryResult } from "../../types/database.ts";
|
|
2
|
+
import { postgresService } from "../postgres.service.ts";
|
|
3
|
+
|
|
4
|
+
/** Thin adapter wrapping the existing PostgresService to implement DatabaseAdapter */
|
|
5
|
+
export const postgresAdapter: DatabaseAdapter = {
|
|
6
|
+
async testConnection(config: DbConnectionConfig): Promise<{ ok: boolean; error?: string }> {
|
|
7
|
+
if (!config.connectionString) return { ok: false, error: "Missing connectionString" };
|
|
8
|
+
return postgresService.testConnection(config.connectionString);
|
|
9
|
+
},
|
|
10
|
+
|
|
11
|
+
async getTables(config: DbConnectionConfig): Promise<DbTableInfo[]> {
|
|
12
|
+
if (!config.connectionString) throw new Error("Missing connectionString");
|
|
13
|
+
const tables = await postgresService.getTables(config.connectionString);
|
|
14
|
+
return tables.map((t) => ({ name: t.name, schema: t.schema, rowCount: t.rowCount }));
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
async getTableSchema(config: DbConnectionConfig, table: string, schema = "public"): Promise<DbColumnInfo[]> {
|
|
18
|
+
if (!config.connectionString) throw new Error("Missing connectionString");
|
|
19
|
+
return postgresService.getTableSchema(config.connectionString, table, schema);
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
async getTableData(config: DbConnectionConfig, table: string, opts): Promise<DbPagedData> {
|
|
23
|
+
if (!config.connectionString) throw new Error("Missing connectionString");
|
|
24
|
+
return postgresService.getTableData(
|
|
25
|
+
config.connectionString, table, opts.schema ?? "public",
|
|
26
|
+
opts.page, opts.limit, opts.orderBy, opts.orderDir,
|
|
27
|
+
);
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
async executeQuery(config: DbConnectionConfig, sql: string): Promise<DbQueryResult> {
|
|
31
|
+
if (!config.connectionString) throw new Error("Missing connectionString");
|
|
32
|
+
return postgresService.executeQuery(config.connectionString, sql);
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async updateCell(config: DbConnectionConfig, table: string, opts): Promise<void> {
|
|
36
|
+
if (!config.connectionString) throw new Error("Missing connectionString");
|
|
37
|
+
await postgresService.updateCell(
|
|
38
|
+
config.connectionString, table, opts.schema ?? "public",
|
|
39
|
+
opts.pkColumn, opts.pkValue, opts.column, opts.value,
|
|
40
|
+
);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns true if sql is a read-only statement.
|
|
3
|
+
* Handles: SELECT, EXPLAIN, SHOW, PRAGMA, DESCRIBE, WITH...SELECT.
|
|
4
|
+
* Guards against CTEs with write keywords (e.g. WITH x AS (DELETE...) SELECT).
|
|
5
|
+
*/
|
|
6
|
+
export function isReadOnlyQuery(sql: string): boolean {
|
|
7
|
+
const trimmed = sql.trim();
|
|
8
|
+
|
|
9
|
+
// Reject if the SQL contains write keywords anywhere (catches CTE attacks like
|
|
10
|
+
// "WITH x AS (DELETE ...) SELECT ...").
|
|
11
|
+
if (/\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|REPLACE|MERGE)\b/i.test(trimmed)) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Confirm it starts with a known read-only keyword.
|
|
16
|
+
return /^\s*(SELECT|EXPLAIN|SHOW|PRAGMA|DESCRIBE|WITH\b)/i.test(trimmed);
|
|
17
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { DatabaseAdapter, DbConnectionConfig, DbTableInfo, DbColumnInfo, DbPagedData, DbQueryResult } from "../../types/database.ts";
|
|
2
|
+
import { sqliteService } from "../sqlite.service.ts";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
/** Thin adapter wrapping the existing SqliteService to implement DatabaseAdapter */
|
|
6
|
+
export const sqliteAdapter: DatabaseAdapter = {
|
|
7
|
+
async testConnection(config: DbConnectionConfig): Promise<{ ok: boolean; error?: string }> {
|
|
8
|
+
try {
|
|
9
|
+
if (!config.path) return { ok: false, error: "Missing path" };
|
|
10
|
+
if (!existsSync(config.path)) return { ok: false, error: `File not found: ${config.path}` };
|
|
11
|
+
// Attempt to open and list tables
|
|
12
|
+
sqliteService.getTables(config.path, config.path);
|
|
13
|
+
return { ok: true };
|
|
14
|
+
} catch (e) {
|
|
15
|
+
return { ok: false, error: (e as Error).message };
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
async getTables(config: DbConnectionConfig): Promise<DbTableInfo[]> {
|
|
20
|
+
if (!config.path) throw new Error("Missing path");
|
|
21
|
+
const tables = sqliteService.getTables(config.path, config.path);
|
|
22
|
+
return tables.map((t) => ({ name: t.name, schema: "main", rowCount: t.rowCount }));
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
async getTableSchema(config: DbConnectionConfig, table: string): Promise<DbColumnInfo[]> {
|
|
26
|
+
if (!config.path) throw new Error("Missing path");
|
|
27
|
+
const cols = sqliteService.getTableSchema(config.path, config.path, table);
|
|
28
|
+
return cols.map((c) => ({
|
|
29
|
+
name: c.name,
|
|
30
|
+
type: c.type,
|
|
31
|
+
nullable: !c.notnull,
|
|
32
|
+
pk: !!c.pk,
|
|
33
|
+
defaultValue: c.dflt_value,
|
|
34
|
+
}));
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
async getTableData(config: DbConnectionConfig, table: string, opts): Promise<DbPagedData> {
|
|
38
|
+
if (!config.path) throw new Error("Missing path");
|
|
39
|
+
return sqliteService.getTableData(
|
|
40
|
+
config.path, config.path, table,
|
|
41
|
+
opts.page, opts.limit, opts.orderBy, opts.orderDir,
|
|
42
|
+
);
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async executeQuery(config: DbConnectionConfig, sql: string): Promise<DbQueryResult> {
|
|
46
|
+
if (!config.path) throw new Error("Missing path");
|
|
47
|
+
return sqliteService.executeQuery(config.path, config.path, sql);
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
async updateCell(config: DbConnectionConfig, table: string, opts): Promise<void> {
|
|
51
|
+
if (!config.path) throw new Error("Missing path");
|
|
52
|
+
// SQLite uses rowid as PK for cell updates
|
|
53
|
+
sqliteService.updateCell(config.path, config.path, table, Number(opts.pkValue), opts.column, opts.value);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { Database } from "bun:sqlite";
|
|
1
|
+
import { Database, type SQLQueryBindings } from "bun:sqlite";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { mkdirSync, existsSync } from "node:fs";
|
|
5
5
|
|
|
6
6
|
const PPM_DIR = resolve(homedir(), ".ppm");
|
|
7
|
-
const CURRENT_SCHEMA_VERSION =
|
|
7
|
+
const CURRENT_SCHEMA_VERSION = 3;
|
|
8
8
|
|
|
9
9
|
let db: Database | null = null;
|
|
10
10
|
let dbProfile: string | null = null;
|
|
@@ -121,6 +121,52 @@ function runMigrations(database: Database): void {
|
|
|
121
121
|
PRAGMA user_version = 1;
|
|
122
122
|
`);
|
|
123
123
|
}
|
|
124
|
+
|
|
125
|
+
if (current < 2) {
|
|
126
|
+
database.exec(`
|
|
127
|
+
CREATE TABLE IF NOT EXISTS connections (
|
|
128
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
129
|
+
type TEXT NOT NULL CHECK(type IN ('sqlite', 'postgres')),
|
|
130
|
+
name TEXT NOT NULL UNIQUE,
|
|
131
|
+
connection_config TEXT NOT NULL,
|
|
132
|
+
group_name TEXT,
|
|
133
|
+
color TEXT,
|
|
134
|
+
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
135
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
136
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
CREATE INDEX IF NOT EXISTS idx_connections_type ON connections(type);
|
|
140
|
+
CREATE INDEX IF NOT EXISTS idx_connections_group ON connections(group_name);
|
|
141
|
+
|
|
142
|
+
PRAGMA user_version = 2;
|
|
143
|
+
`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (current < 3) {
|
|
147
|
+
// Add readonly column (safe-by-default: 1 = block writes, UI-only toggle)
|
|
148
|
+
try {
|
|
149
|
+
database.exec(`ALTER TABLE connections ADD COLUMN readonly INTEGER NOT NULL DEFAULT 1`);
|
|
150
|
+
} catch {
|
|
151
|
+
// Column may already exist (fresh DB created in this session)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
database.exec(`
|
|
155
|
+
CREATE TABLE IF NOT EXISTS connection_table_cache (
|
|
156
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
157
|
+
connection_id INTEGER NOT NULL REFERENCES connections(id) ON DELETE CASCADE,
|
|
158
|
+
table_name TEXT NOT NULL,
|
|
159
|
+
schema_name TEXT NOT NULL DEFAULT 'public',
|
|
160
|
+
row_count INTEGER NOT NULL DEFAULT 0,
|
|
161
|
+
cached_at TEXT DEFAULT (datetime('now')),
|
|
162
|
+
UNIQUE(connection_id, schema_name, table_name)
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
CREATE INDEX IF NOT EXISTS idx_table_cache_conn ON connection_table_cache(connection_id);
|
|
166
|
+
|
|
167
|
+
PRAGMA user_version = 3;
|
|
168
|
+
`);
|
|
169
|
+
}
|
|
124
170
|
}
|
|
125
171
|
|
|
126
172
|
// ---------------------------------------------------------------------------
|
|
@@ -299,5 +345,130 @@ export function getDbFilePath(): string {
|
|
|
299
345
|
return getDbPath();
|
|
300
346
|
}
|
|
301
347
|
|
|
348
|
+
// ---------------------------------------------------------------------------
|
|
349
|
+
// Connection helpers
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
|
|
352
|
+
export interface ConnectionRow {
|
|
353
|
+
id: number;
|
|
354
|
+
type: "sqlite" | "postgres";
|
|
355
|
+
name: string;
|
|
356
|
+
connection_config: string;
|
|
357
|
+
group_name: string | null;
|
|
358
|
+
color: string | null;
|
|
359
|
+
/** 1 = readonly (default), 0 = writable. UI-only toggle — CLI cannot change this. */
|
|
360
|
+
readonly: number;
|
|
361
|
+
sort_order: number;
|
|
362
|
+
created_at: string;
|
|
363
|
+
updated_at: string;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Parsed config stored in connection_config JSON */
|
|
367
|
+
export type ConnectionConfig =
|
|
368
|
+
| { type: "sqlite"; path: string }
|
|
369
|
+
| { type: "postgres"; connectionString: string };
|
|
370
|
+
|
|
371
|
+
export function getConnections(): ConnectionRow[] {
|
|
372
|
+
return getDb().query(
|
|
373
|
+
"SELECT * FROM connections ORDER BY sort_order, id",
|
|
374
|
+
).all() as ConnectionRow[];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function getConnectionById(id: number): ConnectionRow | null {
|
|
378
|
+
return getDb().query("SELECT * FROM connections WHERE id = ?").get(id) as ConnectionRow | null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function getConnectionByName(name: string): ConnectionRow | null {
|
|
382
|
+
return getDb().query("SELECT * FROM connections WHERE name = ?").get(name) as ConnectionRow | null;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Resolve a connection by name or numeric ID */
|
|
386
|
+
export function resolveConnection(nameOrId: string): ConnectionRow | null {
|
|
387
|
+
const asNum = Number(nameOrId);
|
|
388
|
+
if (!Number.isNaN(asNum) && Number.isInteger(asNum)) {
|
|
389
|
+
return getConnectionById(asNum) ?? getConnectionByName(nameOrId);
|
|
390
|
+
}
|
|
391
|
+
return getConnectionByName(nameOrId);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function insertConnection(
|
|
395
|
+
type: "sqlite" | "postgres", name: string, config: ConnectionConfig,
|
|
396
|
+
groupName?: string | null, color?: string | null,
|
|
397
|
+
): ConnectionRow {
|
|
398
|
+
const maxOrder = (getDb().query("SELECT COALESCE(MAX(sort_order), -1) as m FROM connections").get() as { m: number }).m;
|
|
399
|
+
getDb().query(
|
|
400
|
+
"INSERT INTO connections (type, name, connection_config, group_name, color, sort_order) VALUES (?, ?, ?, ?, ?, ?)",
|
|
401
|
+
).run(type, name, JSON.stringify(config), groupName ?? null, color ?? null, maxOrder + 1);
|
|
402
|
+
return getConnectionByName(name)!;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function deleteConnection(nameOrId: string): boolean {
|
|
406
|
+
const conn = resolveConnection(nameOrId);
|
|
407
|
+
if (!conn) return false;
|
|
408
|
+
getDb().query("DELETE FROM connections WHERE id = ?").run(conn.id);
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function updateConnection(
|
|
413
|
+
id: number, updates: { name?: string; config?: ConnectionConfig; groupName?: string | null; color?: string | null; readonly?: number },
|
|
414
|
+
): void {
|
|
415
|
+
const sets: string[] = [];
|
|
416
|
+
const vals: unknown[] = [];
|
|
417
|
+
if (updates.name !== undefined) { sets.push("name = ?"); vals.push(updates.name); }
|
|
418
|
+
if (updates.config !== undefined) { sets.push("connection_config = ?"); vals.push(JSON.stringify(updates.config)); }
|
|
419
|
+
if (updates.groupName !== undefined) { sets.push("group_name = ?"); vals.push(updates.groupName); }
|
|
420
|
+
if (updates.color !== undefined) { sets.push("color = ?"); vals.push(updates.color); }
|
|
421
|
+
if (updates.readonly !== undefined) { sets.push("readonly = ?"); vals.push(updates.readonly); }
|
|
422
|
+
if (sets.length === 0) return;
|
|
423
|
+
sets.push("updated_at = datetime('now')");
|
|
424
|
+
vals.push(id);
|
|
425
|
+
getDb().query(`UPDATE connections SET ${sets.join(", ")} WHERE id = ?`).run(...(vals as SQLQueryBindings[]));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
// Table cache helpers
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
export interface TableCacheRow {
|
|
433
|
+
id: number;
|
|
434
|
+
connection_id: number;
|
|
435
|
+
table_name: string;
|
|
436
|
+
schema_name: string;
|
|
437
|
+
row_count: number;
|
|
438
|
+
cached_at: string;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function getCachedTables(connectionId: number): TableCacheRow[] {
|
|
442
|
+
return getDb().query(
|
|
443
|
+
"SELECT * FROM connection_table_cache WHERE connection_id = ? ORDER BY schema_name, table_name",
|
|
444
|
+
).all(connectionId) as TableCacheRow[];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function upsertTableCache(connectionId: number, tableName: string, schemaName: string, rowCount: number): void {
|
|
448
|
+
getDb().query(
|
|
449
|
+
`INSERT INTO connection_table_cache (connection_id, table_name, schema_name, row_count, cached_at)
|
|
450
|
+
VALUES (?, ?, ?, ?, datetime('now'))
|
|
451
|
+
ON CONFLICT(connection_id, schema_name, table_name)
|
|
452
|
+
DO UPDATE SET row_count = excluded.row_count, cached_at = excluded.cached_at`,
|
|
453
|
+
).run(connectionId, tableName, schemaName, rowCount);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export function deleteTableCache(connectionId: number): void {
|
|
457
|
+
getDb().query("DELETE FROM connection_table_cache WHERE connection_id = ?").run(connectionId);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function searchTableCache(query: string): Array<TableCacheRow & { connection_name: string; connection_type: string; connection_color: string | null }> {
|
|
461
|
+
// Escape LIKE wildcards so user input is treated as literal text
|
|
462
|
+
const escaped = query.replace(/[%_\\]/g, "\\$&");
|
|
463
|
+
return getDb().query(
|
|
464
|
+
`SELECT tc.*, c.name as connection_name, c.type as connection_type, c.color as connection_color
|
|
465
|
+
FROM connection_table_cache tc
|
|
466
|
+
JOIN connections c ON tc.connection_id = c.id
|
|
467
|
+
WHERE tc.table_name LIKE ? ESCAPE '\\'
|
|
468
|
+
ORDER BY tc.table_name, c.name
|
|
469
|
+
LIMIT 50`,
|
|
470
|
+
).all(`%${escaped}%`) as Array<TableCacheRow & { connection_name: string; connection_type: string; connection_color: string | null }>;
|
|
471
|
+
}
|
|
472
|
+
|
|
302
473
|
// Auto-close on process exit
|
|
303
474
|
process.on("beforeExit", closeDb);
|