@hienlh/ppm 0.6.3 → 0.6.5
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 +21 -0
- package/dist/web/assets/api-client-4Ni0i4Hl.js +1 -0
- package/dist/web/assets/{chat-tab-DjE_8Csw.js → chat-tab-DkgRZpbj.js} +3 -3
- package/dist/web/assets/{code-editor-witrClmz.js → code-editor-CVMeIylx.js} +1 -1
- package/dist/web/assets/database-viewer-BX0F2yv0.js +1 -0
- package/dist/web/assets/{diff-viewer-DSU--yFW.js → diff-viewer-B1vnegRS.js} +1 -1
- package/dist/web/assets/dist-Jb3Tnkpc.js +16 -0
- package/dist/web/assets/{git-graph-HpcOYt3G.js → git-graph-Bi4PM-z2.js} +1 -1
- package/dist/web/assets/index-DSg2VjxL.css +2 -0
- package/dist/web/assets/{index-CcXQ5iQw.js → index-DUb5kwfL.js} +6 -6
- package/dist/web/assets/{input-CCCPR1s4.js → input-nI4xe1Y9.js} +1 -1
- package/dist/web/assets/keybindings-store-BVTJScRw.js +1 -0
- package/dist/web/assets/{markdown-renderer-DSw-4oxk.js → markdown-renderer-ChvoCZNm.js} +1 -1
- package/dist/web/assets/postgres-viewer-DPsoDR4y.js +1 -0
- package/dist/web/assets/settings-store-CfB0vCtQ.js +1 -0
- package/dist/web/assets/settings-tab-D7pNWvVE.js +1 -0
- package/dist/web/assets/sqlite-viewer-CTPkNEEe.js +1 -0
- package/dist/web/assets/{tab-store-DhXold0e.js → tab-store-DIyJSjtr.js} +1 -1
- package/dist/web/assets/table-DCVKGOr2.js +1 -0
- package/dist/web/assets/{terminal-tab-CAQvs2wj.js → terminal-tab-B_75oJaQ.js} +1 -1
- package/dist/web/assets/{use-monaco-theme-GX0lrqac.js → use-monaco-theme-Dexl3s3E.js} +1 -1
- package/dist/web/index.html +8 -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 +21 -4
- package/src/server/index.ts +6 -0
- package/src/server/routes/chat.ts +2 -2
- package/src/server/routes/database.ts +261 -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 +77 -4
- package/src/services/table-cache.service.ts +75 -0
- package/src/types/config.ts +10 -2
- package/src/types/database.ts +50 -0
- package/src/web/app.tsx +9 -4
- package/src/web/components/chat/tool-cards.tsx +2 -2
- 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 +257 -0
- package/src/web/components/database/database-sidebar.tsx +89 -0
- package/src/web/components/database/database-viewer.tsx +228 -0
- package/src/web/components/database/use-connections.ts +92 -0
- package/src/web/components/database/use-database.ts +117 -0
- package/src/web/components/layout/command-palette.tsx +56 -6
- package/src/web/components/layout/draggable-tab.tsx +13 -2
- package/src/web/components/layout/editor-panel.tsx +1 -0
- package/src/web/components/layout/mobile-drawer.tsx +7 -2
- package/src/web/components/layout/mobile-nav.tsx +1 -1
- package/src/web/components/layout/sidebar.tsx +7 -3
- package/src/web/components/layout/tab-bar.tsx +1 -0
- package/src/web/components/layout/tab-content.tsx +5 -0
- package/src/web/components/postgres/postgres-viewer.tsx +42 -25
- package/src/web/components/postgres/use-postgres.ts +54 -21
- package/src/web/components/settings/ai-settings-section.tsx +0 -1
- package/src/web/components/sqlite/sqlite-viewer.tsx +43 -13
- package/src/web/components/sqlite/use-sqlite.ts +24 -15
- package/src/web/hooks/use-chat.ts +1 -1
- package/src/web/hooks/use-usage.ts +1 -1
- package/src/web/lib/api-client.ts +7 -1
- package/src/web/lib/color-utils.ts +23 -0
- package/src/web/stores/settings-store.ts +2 -2
- package/src/web/stores/tab-store.ts +1 -0
- package/dist/web/assets/api-client-D0pZeYY8.js +0 -1
- package/dist/web/assets/dist-PpKqMvyx.js +0 -16
- package/dist/web/assets/index-DyEgsogR.css +0 -2
- package/dist/web/assets/keybindings-store-C_KQKrsc.js +0 -1
- package/dist/web/assets/postgres-viewer-BnkGPi0L.js +0 -1
- package/dist/web/assets/settings-store-B5g1Gis-.js +0 -1
- package/dist/web/assets/settings-tab-DpQdg9OW.js +0 -1
- package/dist/web/assets/sqlite-viewer-JZvegGV-.js +0 -1
- /package/dist/web/assets/{react-l9v2XLcs.js → react-DHSo28we.js} +0 -0
- /package/dist/web/assets/{utils-CAPYyGV3.js → utils-siJJ3uG0.js} +0 -0
|
@@ -0,0 +1,261 @@
|
|
|
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, getTablesFromCache } 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 — ?cached=1 reads from cache, otherwise live sync */
|
|
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 useCached = c.req.query("cached") === "1";
|
|
147
|
+
const result = useCached ? getTablesFromCache(conn.id) : await syncTables(conn.id);
|
|
148
|
+
const tables = result.map((t) => ({ name: t.tableName, schema: t.schemaName, rowCount: t.rowCount }));
|
|
149
|
+
return c.json(ok(tables));
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return c.json(err((e as Error).message), 500);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
/** GET /api/db/connections/:id/schema?table=...&schema=... */
|
|
156
|
+
databaseRoutes.get("/connections/:id/schema", async (c) => {
|
|
157
|
+
try {
|
|
158
|
+
const conn = resolveConn(c.req.param("id"));
|
|
159
|
+
if (!conn) return c.json(err("Connection not found"), 404);
|
|
160
|
+
const table = c.req.query("table");
|
|
161
|
+
const schema = c.req.query("schema");
|
|
162
|
+
if (!table) return c.json(err("table query param required"), 400);
|
|
163
|
+
const config = JSON.parse(conn.connection_config) as DbConnectionConfig;
|
|
164
|
+
const adapter = getAdapter(conn.type);
|
|
165
|
+
const cols = await adapter.getTableSchema(config, table, schema);
|
|
166
|
+
return c.json(ok(cols));
|
|
167
|
+
} catch (e) {
|
|
168
|
+
return c.json(err((e as Error).message), 500);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/** GET /api/db/connections/:id/data?table=...&page=1&limit=100&orderBy=...&orderDir=ASC */
|
|
173
|
+
databaseRoutes.get("/connections/:id/data", async (c) => {
|
|
174
|
+
try {
|
|
175
|
+
const conn = resolveConn(c.req.param("id"));
|
|
176
|
+
if (!conn) return c.json(err("Connection not found"), 404);
|
|
177
|
+
const table = c.req.query("table");
|
|
178
|
+
if (!table) return c.json(err("table query param required"), 400);
|
|
179
|
+
const config = JSON.parse(conn.connection_config) as DbConnectionConfig;
|
|
180
|
+
const adapter = getAdapter(conn.type);
|
|
181
|
+
const data = await adapter.getTableData(config, table, {
|
|
182
|
+
schema: c.req.query("schema"),
|
|
183
|
+
page: parseInt(c.req.query("page") ?? "1", 10),
|
|
184
|
+
limit: Math.min(parseInt(c.req.query("limit") ?? "100", 10), 1000),
|
|
185
|
+
orderBy: c.req.query("orderBy"),
|
|
186
|
+
orderDir: (c.req.query("orderDir") as "ASC" | "DESC") ?? "ASC",
|
|
187
|
+
});
|
|
188
|
+
return c.json(ok(data));
|
|
189
|
+
} catch (e) {
|
|
190
|
+
return c.json(err((e as Error).message), 500);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
/** POST /api/db/connections/:id/query — body: { sql } — enforces readonly */
|
|
195
|
+
databaseRoutes.post("/connections/:id/query", async (c) => {
|
|
196
|
+
try {
|
|
197
|
+
const conn = resolveConn(c.req.param("id"));
|
|
198
|
+
if (!conn) return c.json(err("Connection not found"), 404);
|
|
199
|
+
const body = await c.req.json<{ sql: string }>();
|
|
200
|
+
if (!body.sql) return c.json(err("sql is required"), 400);
|
|
201
|
+
|
|
202
|
+
if (conn.readonly && !isReadOnlyQuery(body.sql)) {
|
|
203
|
+
return c.json(err("Connection is readonly — only SELECT queries allowed. Change this in PPM web UI."), 403);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const config = JSON.parse(conn.connection_config) as DbConnectionConfig;
|
|
207
|
+
const adapter = getAdapter(conn.type);
|
|
208
|
+
const result = await adapter.executeQuery(config, body.sql);
|
|
209
|
+
return c.json(ok(result));
|
|
210
|
+
} catch (e) {
|
|
211
|
+
return c.json(err((e as Error).message), 500);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
/** PUT /api/db/connections/:id/cell — body: { table, schema?, pkColumn, pkValue, column, value } — enforces readonly */
|
|
216
|
+
databaseRoutes.put("/connections/:id/cell", async (c) => {
|
|
217
|
+
try {
|
|
218
|
+
const conn = resolveConn(c.req.param("id"));
|
|
219
|
+
if (!conn) return c.json(err("Connection not found"), 404);
|
|
220
|
+
|
|
221
|
+
if (conn.readonly) {
|
|
222
|
+
return c.json(err("Connection is readonly — cell editing is disabled. Change this in PPM web UI."), 403);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const body = await c.req.json<{
|
|
226
|
+
table: string; schema?: string;
|
|
227
|
+
pkColumn: string; pkValue: unknown; column: string; value: unknown;
|
|
228
|
+
}>();
|
|
229
|
+
if (!body.table || !body.pkColumn || !body.column) {
|
|
230
|
+
return c.json(err("table, pkColumn, and column are required"), 400);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const config = JSON.parse(conn.connection_config) as DbConnectionConfig;
|
|
234
|
+
const adapter = getAdapter(conn.type);
|
|
235
|
+
await adapter.updateCell(config, body.table, {
|
|
236
|
+
schema: body.schema,
|
|
237
|
+
pkColumn: body.pkColumn,
|
|
238
|
+
pkValue: body.pkValue,
|
|
239
|
+
column: body.column,
|
|
240
|
+
value: body.value,
|
|
241
|
+
});
|
|
242
|
+
return c.json(ok({ updated: true }));
|
|
243
|
+
} catch (e) {
|
|
244
|
+
return c.json(err((e as Error).message), 500);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Search
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
/** GET /api/db/search?q=... — search cached tables across all connections */
|
|
253
|
+
databaseRoutes.get("/search", (c) => {
|
|
254
|
+
try {
|
|
255
|
+
const q = c.req.query("q") ?? "";
|
|
256
|
+
return c.json(ok(searchTables(q)));
|
|
257
|
+
} catch (e) {
|
|
258
|
+
return c.json(err((e as Error).message), 500);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
@@ -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;
|
|
@@ -142,6 +142,31 @@ function runMigrations(database: Database): void {
|
|
|
142
142
|
PRAGMA user_version = 2;
|
|
143
143
|
`);
|
|
144
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
|
+
}
|
|
145
170
|
}
|
|
146
171
|
|
|
147
172
|
// ---------------------------------------------------------------------------
|
|
@@ -331,6 +356,8 @@ export interface ConnectionRow {
|
|
|
331
356
|
connection_config: string;
|
|
332
357
|
group_name: string | null;
|
|
333
358
|
color: string | null;
|
|
359
|
+
/** 1 = readonly (default), 0 = writable. UI-only toggle — CLI cannot change this. */
|
|
360
|
+
readonly: number;
|
|
334
361
|
sort_order: number;
|
|
335
362
|
created_at: string;
|
|
336
363
|
updated_at: string;
|
|
@@ -383,7 +410,7 @@ export function deleteConnection(nameOrId: string): boolean {
|
|
|
383
410
|
}
|
|
384
411
|
|
|
385
412
|
export function updateConnection(
|
|
386
|
-
id: number, updates: { name?: string; config?: ConnectionConfig; groupName?: string | null; color?: string | null },
|
|
413
|
+
id: number, updates: { name?: string; config?: ConnectionConfig; groupName?: string | null; color?: string | null; readonly?: number },
|
|
387
414
|
): void {
|
|
388
415
|
const sets: string[] = [];
|
|
389
416
|
const vals: unknown[] = [];
|
|
@@ -391,10 +418,56 @@ export function updateConnection(
|
|
|
391
418
|
if (updates.config !== undefined) { sets.push("connection_config = ?"); vals.push(JSON.stringify(updates.config)); }
|
|
392
419
|
if (updates.groupName !== undefined) { sets.push("group_name = ?"); vals.push(updates.groupName); }
|
|
393
420
|
if (updates.color !== undefined) { sets.push("color = ?"); vals.push(updates.color); }
|
|
421
|
+
if (updates.readonly !== undefined) { sets.push("readonly = ?"); vals.push(updates.readonly); }
|
|
394
422
|
if (sets.length === 0) return;
|
|
395
423
|
sets.push("updated_at = datetime('now')");
|
|
396
424
|
vals.push(id);
|
|
397
|
-
getDb().query(`UPDATE connections SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
|
|
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 }>;
|
|
398
471
|
}
|
|
399
472
|
|
|
400
473
|
// Auto-close on process exit
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getCachedTables, upsertTableCache, deleteTableCache, searchTableCache,
|
|
3
|
+
getConnectionById, type TableCacheRow,
|
|
4
|
+
} from "./db.service.ts";
|
|
5
|
+
import { getAdapter } from "./database/adapter-registry.ts";
|
|
6
|
+
import type { DbConnectionConfig } from "../types/database.ts";
|
|
7
|
+
|
|
8
|
+
export interface CachedTable {
|
|
9
|
+
connectionId: number;
|
|
10
|
+
tableName: string;
|
|
11
|
+
schemaName: string;
|
|
12
|
+
rowCount: number;
|
|
13
|
+
cachedAt: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TableSearchResult {
|
|
17
|
+
connectionId: number;
|
|
18
|
+
connectionName: string;
|
|
19
|
+
connectionType: string;
|
|
20
|
+
connectionColor: string | null;
|
|
21
|
+
tableName: string;
|
|
22
|
+
schemaName: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function rowToTable(r: TableCacheRow): CachedTable {
|
|
26
|
+
return {
|
|
27
|
+
connectionId: r.connection_id,
|
|
28
|
+
tableName: r.table_name,
|
|
29
|
+
schemaName: r.schema_name,
|
|
30
|
+
rowCount: r.row_count,
|
|
31
|
+
cachedAt: r.cached_at,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Get cached tables for a connection (no live fetch) */
|
|
36
|
+
export function getTablesFromCache(connectionId: number): CachedTable[] {
|
|
37
|
+
return getCachedTables(connectionId).map(rowToTable);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Fetch live tables via adapter, update cache, return result */
|
|
41
|
+
export async function syncTables(connectionId: number): Promise<CachedTable[]> {
|
|
42
|
+
const conn = getConnectionById(connectionId);
|
|
43
|
+
if (!conn) throw new Error(`Connection not found: ${connectionId}`);
|
|
44
|
+
|
|
45
|
+
const config = JSON.parse(conn.connection_config) as DbConnectionConfig;
|
|
46
|
+
const adapter = getAdapter(conn.type);
|
|
47
|
+
const tables = await adapter.getTables(config);
|
|
48
|
+
|
|
49
|
+
// Delete stale cache entries, then upsert fresh ones
|
|
50
|
+
deleteTableCache(connectionId);
|
|
51
|
+
for (const t of tables) {
|
|
52
|
+
upsertTableCache(connectionId, t.name, t.schema || "main", t.rowCount);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return tables.map((t) => ({
|
|
56
|
+
connectionId,
|
|
57
|
+
tableName: t.name,
|
|
58
|
+
schemaName: t.schema || "main",
|
|
59
|
+
rowCount: t.rowCount,
|
|
60
|
+
cachedAt: new Date().toISOString(),
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Search cached tables across all connections (for command palette) */
|
|
65
|
+
export function searchTables(query: string): TableSearchResult[] {
|
|
66
|
+
if (!query || query.length < 2) return [];
|
|
67
|
+
return searchTableCache(query).map((r) => ({
|
|
68
|
+
connectionId: r.connection_id,
|
|
69
|
+
connectionName: r.connection_name,
|
|
70
|
+
connectionType: r.connection_type,
|
|
71
|
+
connectionColor: r.connection_color,
|
|
72
|
+
tableName: r.table_name,
|
|
73
|
+
schemaName: r.schema_name,
|
|
74
|
+
}));
|
|
75
|
+
}
|
package/src/types/config.ts
CHANGED
|
@@ -38,7 +38,7 @@ export interface AIProviderConfig {
|
|
|
38
38
|
api_key_env?: string;
|
|
39
39
|
// Agent SDK-specific settings (ignored by mock provider)
|
|
40
40
|
model?: string;
|
|
41
|
-
effort?: "low" | "medium" | "high"
|
|
41
|
+
effort?: "low" | "medium" | "high";
|
|
42
42
|
max_turns?: number;
|
|
43
43
|
max_budget_usd?: number;
|
|
44
44
|
thinking_budget_tokens?: number;
|
|
@@ -66,7 +66,7 @@ export const DEFAULT_CONFIG: PpmConfig = {
|
|
|
66
66
|
};
|
|
67
67
|
|
|
68
68
|
const VALID_TYPES = ["agent-sdk", "mock"] as const;
|
|
69
|
-
const VALID_EFFORTS = ["low", "medium", "high"
|
|
69
|
+
const VALID_EFFORTS = ["low", "medium", "high"] as const;
|
|
70
70
|
const VALID_MODELS = ["claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5"] as const;
|
|
71
71
|
/** Only these values are allowed for default_provider in config */
|
|
72
72
|
export const VALID_PROVIDERS = ["claude"] as const;
|
|
@@ -130,5 +130,13 @@ export function sanitizeConfig(config: PpmConfig): boolean {
|
|
|
130
130
|
dirty = true;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
// Downgrade "max" effort → "high" (not available for Claude.ai subscribers)
|
|
134
|
+
for (const provider of Object.values(config.ai.providers)) {
|
|
135
|
+
if ((provider as any).effort === "max") {
|
|
136
|
+
provider.effort = "high";
|
|
137
|
+
dirty = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
133
141
|
return dirty;
|
|
134
142
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type DbType = "sqlite" | "postgres";
|
|
2
|
+
|
|
3
|
+
export interface DbConnectionConfig {
|
|
4
|
+
type: DbType;
|
|
5
|
+
path?: string; // sqlite
|
|
6
|
+
connectionString?: string; // postgres
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface DbTableInfo {
|
|
11
|
+
name: string;
|
|
12
|
+
schema: string; // "main" for sqlite, actual schema for postgres
|
|
13
|
+
rowCount: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DbColumnInfo {
|
|
17
|
+
name: string;
|
|
18
|
+
type: string;
|
|
19
|
+
nullable: boolean;
|
|
20
|
+
pk: boolean;
|
|
21
|
+
defaultValue: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DbQueryResult {
|
|
25
|
+
columns: string[];
|
|
26
|
+
rows: Record<string, unknown>[];
|
|
27
|
+
rowsAffected: number;
|
|
28
|
+
changeType: "select" | "modify";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DbPagedData {
|
|
32
|
+
columns: string[];
|
|
33
|
+
rows: Record<string, unknown>[];
|
|
34
|
+
total: number;
|
|
35
|
+
page: number;
|
|
36
|
+
limit: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface DatabaseAdapter {
|
|
40
|
+
testConnection(config: DbConnectionConfig): Promise<{ ok: boolean; error?: string }>;
|
|
41
|
+
getTables(config: DbConnectionConfig): Promise<DbTableInfo[]>;
|
|
42
|
+
getTableSchema(config: DbConnectionConfig, table: string, schema?: string): Promise<DbColumnInfo[]>;
|
|
43
|
+
getTableData(config: DbConnectionConfig, table: string, opts: {
|
|
44
|
+
schema?: string; page?: number; limit?: number; orderBy?: string; orderDir?: "ASC" | "DESC";
|
|
45
|
+
}): Promise<DbPagedData>;
|
|
46
|
+
executeQuery(config: DbConnectionConfig, sql: string): Promise<DbQueryResult>;
|
|
47
|
+
updateCell(config: DbConnectionConfig, table: string, opts: {
|
|
48
|
+
schema?: string; pkColumn: string; pkValue: unknown; column: string; value: unknown;
|
|
49
|
+
}): Promise<void>;
|
|
50
|
+
}
|