@hienlh/ppm 0.6.3 → 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.
Files changed (57) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/web/assets/api-client-BHpHp5Lz.js +1 -0
  3. package/dist/web/assets/{chat-tab-DjE_8Csw.js → chat-tab-CDVCDw_H.js} +3 -3
  4. package/dist/web/assets/{code-editor-witrClmz.js → code-editor-wmS73ejX.js} +1 -1
  5. package/dist/web/assets/{diff-viewer-DSU--yFW.js → diff-viewer-BsYccTx1.js} +1 -1
  6. package/dist/web/assets/{git-graph-HpcOYt3G.js → git-graph-BbWb6_Jq.js} +1 -1
  7. package/dist/web/assets/{index-CcXQ5iQw.js → index-DhuAmTQ1.js} +6 -6
  8. package/dist/web/assets/index-aIGuIMQ8.css +2 -0
  9. package/dist/web/assets/keybindings-store-BqgrTQAC.js +1 -0
  10. package/dist/web/assets/{markdown-renderer-DSw-4oxk.js → markdown-renderer-aPdw9BhU.js} +1 -1
  11. package/dist/web/assets/postgres-viewer-V4hKmmzV.js +1 -0
  12. package/dist/web/assets/settings-store-DgOSmeGL.js +1 -0
  13. package/dist/web/assets/settings-tab-DwsKpk9T.js +1 -0
  14. package/dist/web/assets/sqlite-viewer-BRsj8GXc.js +1 -0
  15. package/dist/web/assets/{terminal-tab-CAQvs2wj.js → terminal-tab-3tDV4RCn.js} +1 -1
  16. package/dist/web/assets/{use-monaco-theme-GX0lrqac.js → use-monaco-theme-Ccqh1RD4.js} +1 -1
  17. package/dist/web/index.html +4 -4
  18. package/dist/web/sw.js +1 -1
  19. package/docs/codebase-summary.md +41 -14
  20. package/docs/project-roadmap.md +31 -6
  21. package/docs/system-architecture.md +222 -7
  22. package/package.json +1 -1
  23. package/src/cli/commands/db-cmd.ts +21 -4
  24. package/src/server/index.ts +6 -0
  25. package/src/server/routes/database.ts +259 -0
  26. package/src/services/database/adapter-registry.ts +13 -0
  27. package/src/services/database/init-adapters.ts +9 -0
  28. package/src/services/database/postgres-adapter.ts +42 -0
  29. package/src/services/database/readonly-check.ts +17 -0
  30. package/src/services/database/sqlite-adapter.ts +55 -0
  31. package/src/services/db.service.ts +77 -4
  32. package/src/services/table-cache.service.ts +75 -0
  33. package/src/types/database.ts +50 -0
  34. package/src/web/app.tsx +9 -4
  35. package/src/web/components/database/connection-color-picker.tsx +67 -0
  36. package/src/web/components/database/connection-form-dialog.tsx +234 -0
  37. package/src/web/components/database/connection-list.tsx +208 -0
  38. package/src/web/components/database/database-sidebar.tsx +100 -0
  39. package/src/web/components/database/use-connections.ts +99 -0
  40. package/src/web/components/layout/command-palette.tsx +57 -6
  41. package/src/web/components/layout/draggable-tab.tsx +13 -2
  42. package/src/web/components/layout/mobile-drawer.tsx +7 -2
  43. package/src/web/components/layout/sidebar.tsx +6 -1
  44. package/src/web/components/postgres/postgres-viewer.tsx +12 -3
  45. package/src/web/components/postgres/use-postgres.ts +57 -21
  46. package/src/web/components/sqlite/sqlite-viewer.tsx +27 -3
  47. package/src/web/components/sqlite/use-sqlite.ts +21 -12
  48. package/src/web/lib/api-client.ts +7 -1
  49. package/src/web/lib/color-utils.ts +23 -0
  50. package/src/web/stores/settings-store.ts +2 -2
  51. package/dist/web/assets/api-client-D0pZeYY8.js +0 -1
  52. package/dist/web/assets/index-DyEgsogR.css +0 -2
  53. package/dist/web/assets/keybindings-store-C_KQKrsc.js +0 -1
  54. package/dist/web/assets/postgres-viewer-BnkGPi0L.js +0 -1
  55. package/dist/web/assets/settings-store-B5g1Gis-.js +0 -1
  56. package/dist/web/assets/settings-tab-DpQdg9OW.js +0 -1
  57. package/dist/web/assets/sqlite-viewer-JZvegGV-.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
+
@@ -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 = 2;
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
+ }
@@ -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
+ }
package/src/web/app.tsx CHANGED
@@ -58,12 +58,9 @@ export function App() {
58
58
  }
59
59
  }, [theme]);
60
60
 
61
- // Fetch server info + keybindings on mount (before auth — shown on login screen)
61
+ // Fetch server info on mount (before auth — shown on login screen)
62
62
  useEffect(() => {
63
63
  fetchServerInfo();
64
- import("@/stores/keybindings-store").then(({ useKeybindingsStore }) => {
65
- useKeybindingsStore.getState().loadFromServer();
66
- });
67
64
  }, [fetchServerInfo]);
68
65
 
69
66
  // Auth check on mount
@@ -102,6 +99,14 @@ export function App() {
102
99
  // Health check — detects server crash/restart
103
100
  useHealthCheck();
104
101
 
102
+ // Load keybindings after auth confirmed (must not call ApiClient before auth)
103
+ useEffect(() => {
104
+ if (authState !== "authenticated") return;
105
+ import("@/stores/keybindings-store").then(({ useKeybindingsStore }) => {
106
+ useKeybindingsStore.getState().loadFromServer();
107
+ });
108
+ }, [authState]);
109
+
105
110
  // Fetch projects after auth, then restore from URL if applicable
106
111
  useEffect(() => {
107
112
  if (authState !== "authenticated") return;