@hienlh/ppm 0.5.21 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/bun.lock +45 -0
  3. package/dist/web/assets/{api-client-BxCvlogn.js → api-client-ANLU-Irq.js} +1 -1
  4. package/dist/web/assets/chat-tab-CjKO_uYf.js +7 -0
  5. package/dist/web/assets/code-editor-CCvD-8SS.js +1 -0
  6. package/dist/web/assets/{diff-viewer-CwMGJLkZ.js → diff-viewer-D_bM4Kmw.js} +1 -1
  7. package/dist/web/assets/{git-graph-HUZNEwuR.js → git-graph-zmdDLInW.js} +1 -1
  8. package/dist/web/assets/index-CP_2zE5O.css +2 -0
  9. package/dist/web/assets/index-l7z-nYoz.js +21 -0
  10. package/dist/web/assets/{input-Bzyi1GeB.js → input-DV4tynJq.js} +1 -1
  11. package/dist/web/assets/{jsx-runtime-Bzk8w7Zh.js → jsx-runtime-B4BJKQ1u.js} +1 -1
  12. package/dist/web/assets/{markdown-renderer-DhYu0Drk.js → markdown-renderer-BKfKwtec.js} +2 -2
  13. package/dist/web/assets/react-WvgCEYPV.js +1 -0
  14. package/dist/web/assets/{rotate-ccw-ZqeedZLA.js → rotate-ccw-BesidNnx.js} +1 -1
  15. package/dist/web/assets/settings-store-BGF8--S9.js +1 -0
  16. package/dist/web/assets/settings-tab-CP5UZGRD.js +1 -0
  17. package/dist/web/assets/sqlite-viewer-C1MIuoOX.js +16 -0
  18. package/dist/web/assets/tab-store-L0a7ao4c.js +1 -0
  19. package/dist/web/assets/{terminal-tab-DhPMvT7b.js → terminal-tab-CmdZtyZW.js} +1 -1
  20. package/dist/web/assets/{use-monaco-theme-BFv4d2_j.js → use-monaco-theme-RFoGvnp0.js} +2 -2
  21. package/dist/web/index.html +9 -8
  22. package/dist/web/sw.js +1 -1
  23. package/docs/codebase-summary.md +96 -61
  24. package/docs/deployment-guide.md +16 -14
  25. package/docs/design-guidelines.md +5 -2
  26. package/docs/project-overview-pdr.md +20 -17
  27. package/docs/project-roadmap.md +35 -23
  28. package/docs/system-architecture.md +27 -18
  29. package/package.json +4 -1
  30. package/src/cli/commands/init.ts +7 -2
  31. package/src/cli/commands/restart.ts +6 -0
  32. package/src/index.ts +9 -1
  33. package/src/providers/claude-agent-sdk.ts +59 -28
  34. package/src/server/index.ts +10 -2
  35. package/src/server/routes/chat.ts +19 -0
  36. package/src/server/routes/project-scoped.ts +2 -0
  37. package/src/server/routes/sqlite.ts +75 -0
  38. package/src/server/ws/chat.ts +33 -1
  39. package/src/services/config.service.ts +182 -58
  40. package/src/services/db.service.ts +303 -0
  41. package/src/services/push-notification.service.ts +23 -37
  42. package/src/services/session-log.service.ts +12 -24
  43. package/src/services/sqlite.service.ts +145 -0
  44. package/src/web/components/chat/chat-history-bar.tsx +68 -8
  45. package/src/web/components/chat/chat-tab.tsx +10 -1
  46. package/src/web/components/chat/file-picker.tsx +1 -1
  47. package/src/web/components/chat/slash-command-picker.tsx +1 -1
  48. package/src/web/components/editor/code-editor.tsx +8 -0
  49. package/src/web/components/explorer/file-tree.tsx +3 -1
  50. package/src/web/components/layout/draggable-tab.tsx +50 -4
  51. package/src/web/components/layout/editor-panel.tsx +1 -0
  52. package/src/web/components/layout/mobile-nav.tsx +2 -2
  53. package/src/web/components/layout/tab-bar.tsx +16 -1
  54. package/src/web/components/layout/tab-content.tsx +5 -0
  55. package/src/web/components/sqlite/sqlite-data-grid.tsx +166 -0
  56. package/src/web/components/sqlite/sqlite-query-editor.tsx +97 -0
  57. package/src/web/components/sqlite/sqlite-table-list.tsx +48 -0
  58. package/src/web/components/sqlite/sqlite-viewer.tsx +117 -0
  59. package/src/web/components/sqlite/use-sqlite.ts +97 -0
  60. package/src/web/hooks/use-chat.ts +12 -0
  61. package/src/web/stores/tab-store.ts +1 -0
  62. package/dist/web/assets/chat-tab-ClNqZsi6.js +0 -7
  63. package/dist/web/assets/code-editor-kXJmlnIt.js +0 -1
  64. package/dist/web/assets/index-B1ga7VY4.js +0 -21
  65. package/dist/web/assets/index-c5tJni8Z.css +0 -2
  66. package/dist/web/assets/settings-store-DikslxSJ.js +0 -1
  67. package/dist/web/assets/settings-tab-Dt3jaLUC.js +0 -1
  68. package/dist/web/assets/tab-store-BNgVKR5w.js +0 -1
  69. /package/dist/web/assets/{utils-EM9hC5pN.js → utils-C2KxHr1H.js} +0 -0
@@ -0,0 +1,303 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { resolve } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { mkdirSync, existsSync } from "node:fs";
5
+
6
+ const PPM_DIR = resolve(homedir(), ".ppm");
7
+ const CURRENT_SCHEMA_VERSION = 1;
8
+
9
+ let db: Database | null = null;
10
+ let dbProfile: string | null = null;
11
+
12
+ /** Set DB profile before first access. "dev" → ppm.dev.db, null → ppm.db */
13
+ export function setDbProfile(profile: string | null): void {
14
+ if (db) throw new Error("Cannot change DB profile after DB is open");
15
+ dbProfile = profile;
16
+ }
17
+
18
+ function getDbPath(): string {
19
+ if (dbProfile) return resolve(PPM_DIR, `ppm.${dbProfile}.db`);
20
+ return resolve(PPM_DIR, "ppm.db");
21
+ }
22
+
23
+ /** Get or create the singleton DB instance (lazy init) */
24
+ export function getDb(): Database {
25
+ if (db) return db;
26
+ if (!existsSync(PPM_DIR)) mkdirSync(PPM_DIR, { recursive: true });
27
+ db = new Database(getDbPath());
28
+ db.exec("PRAGMA journal_mode = WAL");
29
+ db.exec("PRAGMA foreign_keys = ON");
30
+ runMigrations(db);
31
+ return db;
32
+ }
33
+
34
+ /** Close the DB (for graceful shutdown or tests) */
35
+ export function closeDb(): void {
36
+ if (db) { db.close(); db = null; }
37
+ }
38
+
39
+ /** For tests: open an isolated in-memory DB with schema applied */
40
+ export function openTestDb(): Database {
41
+ const testDb = new Database(":memory:");
42
+ testDb.exec("PRAGMA journal_mode = WAL");
43
+ testDb.exec("PRAGMA foreign_keys = ON");
44
+ runMigrations(testDb);
45
+ return testDb;
46
+ }
47
+
48
+ /** Override the singleton with a custom DB instance (for tests) */
49
+ export function setDb(instance: Database): void {
50
+ if (db) db.close();
51
+ db = instance;
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Schema migrations
56
+ // ---------------------------------------------------------------------------
57
+
58
+ function runMigrations(database: Database): void {
59
+ const row = database.query("PRAGMA user_version").get() as { user_version: number };
60
+ const current = row.user_version;
61
+
62
+ if (current < 1) {
63
+ database.exec(`
64
+ CREATE TABLE IF NOT EXISTS config (
65
+ key TEXT PRIMARY KEY,
66
+ value TEXT NOT NULL,
67
+ updated_at TEXT DEFAULT (datetime('now'))
68
+ );
69
+
70
+ CREATE TABLE IF NOT EXISTS projects (
71
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
72
+ path TEXT NOT NULL UNIQUE,
73
+ name TEXT NOT NULL UNIQUE,
74
+ color TEXT,
75
+ sort_order INTEGER NOT NULL DEFAULT 0,
76
+ created_at TEXT DEFAULT (datetime('now'))
77
+ );
78
+
79
+ CREATE TABLE IF NOT EXISTS session_map (
80
+ ppm_id TEXT PRIMARY KEY,
81
+ sdk_id TEXT NOT NULL,
82
+ project_name TEXT,
83
+ created_at TEXT DEFAULT (datetime('now'))
84
+ );
85
+
86
+ CREATE TABLE IF NOT EXISTS push_subscriptions (
87
+ endpoint TEXT PRIMARY KEY,
88
+ p256dh TEXT NOT NULL,
89
+ auth TEXT NOT NULL,
90
+ expiration_time TEXT,
91
+ created_at TEXT DEFAULT (datetime('now'))
92
+ );
93
+
94
+ CREATE TABLE IF NOT EXISTS session_logs (
95
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
96
+ session_id TEXT NOT NULL,
97
+ level TEXT NOT NULL,
98
+ message TEXT NOT NULL,
99
+ created_at TEXT DEFAULT (datetime('now'))
100
+ );
101
+
102
+ CREATE TABLE IF NOT EXISTS usage_history (
103
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
104
+ cost_usd REAL,
105
+ input_tokens INTEGER,
106
+ output_tokens INTEGER,
107
+ model TEXT,
108
+ session_id TEXT,
109
+ project_name TEXT,
110
+ five_hour_pct REAL,
111
+ weekly_pct REAL,
112
+ recorded_at TEXT DEFAULT (datetime('now'))
113
+ );
114
+
115
+ CREATE INDEX IF NOT EXISTS idx_session_logs_session ON session_logs(session_id);
116
+ CREATE INDEX IF NOT EXISTS idx_session_logs_created ON session_logs(created_at);
117
+ CREATE INDEX IF NOT EXISTS idx_usage_session ON usage_history(session_id);
118
+ CREATE INDEX IF NOT EXISTS idx_usage_recorded ON usage_history(recorded_at);
119
+ CREATE INDEX IF NOT EXISTS idx_projects_sort ON projects(sort_order);
120
+
121
+ PRAGMA user_version = 1;
122
+ `);
123
+ }
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Config helpers
128
+ // ---------------------------------------------------------------------------
129
+
130
+ export function getConfigValue(key: string): string | null {
131
+ const row = getDb().query("SELECT value FROM config WHERE key = ?").get(key) as { value: string } | null;
132
+ return row?.value ?? null;
133
+ }
134
+
135
+ export function setConfigValue(key: string, value: string): void {
136
+ getDb().query(
137
+ "INSERT INTO config (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
138
+ ).run(key, value);
139
+ }
140
+
141
+ export function getAllConfig(): Record<string, string> {
142
+ const rows = getDb().query("SELECT key, value FROM config").all() as { key: string; value: string }[];
143
+ const result: Record<string, string> = {};
144
+ for (const r of rows) result[r.key] = r.value;
145
+ return result;
146
+ }
147
+
148
+ export function deleteConfigValue(key: string): void {
149
+ getDb().query("DELETE FROM config WHERE key = ?").run(key);
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+ // Project helpers
154
+ // ---------------------------------------------------------------------------
155
+
156
+ export interface ProjectRow {
157
+ id: number;
158
+ path: string;
159
+ name: string;
160
+ color: string | null;
161
+ sort_order: number;
162
+ }
163
+
164
+ export function getProjects(): ProjectRow[] {
165
+ return getDb().query("SELECT id, path, name, color, sort_order FROM projects ORDER BY sort_order, id").all() as ProjectRow[];
166
+ }
167
+
168
+ export function upsertProject(path: string, name: string, color?: string | null): void {
169
+ const maxOrder = (getDb().query("SELECT COALESCE(MAX(sort_order), -1) as m FROM projects").get() as { m: number }).m;
170
+ getDb().query(
171
+ "INSERT INTO projects (path, name, color, sort_order) VALUES (?, ?, ?, ?) ON CONFLICT(path) DO UPDATE SET name = excluded.name, color = excluded.color",
172
+ ).run(path, name, color ?? null, maxOrder + 1);
173
+ }
174
+
175
+ export function deleteProject(nameOrPath: string): void {
176
+ getDb().query("DELETE FROM projects WHERE name = ? OR path = ?").run(nameOrPath, nameOrPath);
177
+ }
178
+
179
+ export function updateProject(currentName: string, newName: string, newPath: string, color?: string | null): void {
180
+ getDb().query(
181
+ "UPDATE projects SET name = ?, path = ?, color = ? WHERE name = ?",
182
+ ).run(newName, newPath, color ?? null, currentName);
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Session map helpers
187
+ // ---------------------------------------------------------------------------
188
+
189
+ export function getSessionMapping(ppmId: string): string | null {
190
+ const row = getDb().query("SELECT sdk_id FROM session_map WHERE ppm_id = ?").get(ppmId) as { sdk_id: string } | null;
191
+ return row?.sdk_id ?? null;
192
+ }
193
+
194
+ export function setSessionMapping(ppmId: string, sdkId: string, projectName?: string): void {
195
+ getDb().query(
196
+ "INSERT INTO session_map (ppm_id, sdk_id, project_name) VALUES (?, ?, ?) ON CONFLICT(ppm_id) DO UPDATE SET sdk_id = excluded.sdk_id, project_name = excluded.project_name",
197
+ ).run(ppmId, sdkId, projectName ?? null);
198
+ }
199
+
200
+ export function getAllSessionMappings(): Record<string, string> {
201
+ const rows = getDb().query("SELECT ppm_id, sdk_id FROM session_map").all() as { ppm_id: string; sdk_id: string }[];
202
+ const result: Record<string, string> = {};
203
+ for (const r of rows) result[r.ppm_id] = r.sdk_id;
204
+ return result;
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Push subscription helpers
209
+ // ---------------------------------------------------------------------------
210
+
211
+ export interface PushSubRow {
212
+ endpoint: string;
213
+ p256dh: string;
214
+ auth: string;
215
+ expiration_time: string | null;
216
+ }
217
+
218
+ export function getPushSubscriptions(): PushSubRow[] {
219
+ return getDb().query("SELECT endpoint, p256dh, auth, expiration_time FROM push_subscriptions").all() as PushSubRow[];
220
+ }
221
+
222
+ export function upsertPushSubscription(endpoint: string, p256dh: string, auth: string, expirationTime?: string | null): void {
223
+ getDb().query(
224
+ "INSERT INTO push_subscriptions (endpoint, p256dh, auth, expiration_time) VALUES (?, ?, ?, ?) ON CONFLICT(endpoint) DO UPDATE SET p256dh = excluded.p256dh, auth = excluded.auth, expiration_time = excluded.expiration_time",
225
+ ).run(endpoint, p256dh, auth, expirationTime ?? null);
226
+ }
227
+
228
+ export function deletePushSubscription(endpoint: string): void {
229
+ getDb().query("DELETE FROM push_subscriptions WHERE endpoint = ?").run(endpoint);
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Session log helpers
234
+ // ---------------------------------------------------------------------------
235
+
236
+ export interface SessionLogRow {
237
+ id: number;
238
+ session_id: string;
239
+ level: string;
240
+ message: string;
241
+ created_at: string;
242
+ }
243
+
244
+ export function insertSessionLog(sessionId: string, level: string, message: string): void {
245
+ getDb().query(
246
+ "INSERT INTO session_logs (session_id, level, message) VALUES (?, ?, ?)",
247
+ ).run(sessionId, level, message);
248
+ }
249
+
250
+ export function getSessionLogs(sessionId: string, limit = 100): SessionLogRow[] {
251
+ return getDb().query(
252
+ "SELECT id, session_id, level, message, created_at FROM session_logs WHERE session_id = ? ORDER BY id DESC LIMIT ?",
253
+ ).all(sessionId, limit) as SessionLogRow[];
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Usage history helpers
258
+ // ---------------------------------------------------------------------------
259
+
260
+ export interface UsageRow {
261
+ id: number;
262
+ cost_usd: number | null;
263
+ input_tokens: number | null;
264
+ output_tokens: number | null;
265
+ model: string | null;
266
+ session_id: string | null;
267
+ project_name: string | null;
268
+ five_hour_pct: number | null;
269
+ weekly_pct: number | null;
270
+ recorded_at: string;
271
+ }
272
+
273
+ export function insertUsageRecord(record: {
274
+ costUsd?: number;
275
+ inputTokens?: number;
276
+ outputTokens?: number;
277
+ model?: string;
278
+ sessionId?: string;
279
+ projectName?: string;
280
+ fiveHourPct?: number;
281
+ weeklyPct?: number;
282
+ }): void {
283
+ getDb().query(
284
+ "INSERT INTO usage_history (cost_usd, input_tokens, output_tokens, model, session_id, project_name, five_hour_pct, weekly_pct) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
285
+ ).run(
286
+ record.costUsd ?? null, record.inputTokens ?? null, record.outputTokens ?? null,
287
+ record.model ?? null, record.sessionId ?? null, record.projectName ?? null,
288
+ record.fiveHourPct ?? null, record.weeklyPct ?? null,
289
+ );
290
+ }
291
+
292
+ export function getUsageSince(since: string): UsageRow[] {
293
+ return getDb().query(
294
+ "SELECT * FROM usage_history WHERE recorded_at >= ? ORDER BY recorded_at DESC",
295
+ ).all(since) as UsageRow[];
296
+ }
297
+
298
+ export function getDbFilePath(): string {
299
+ return getDbPath();
300
+ }
301
+
302
+ // Auto-close on process exit
303
+ process.on("beforeExit", closeDb);
@@ -1,11 +1,11 @@
1
1
  import webpush from "web-push";
2
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
3
- import { resolve, dirname } from "node:path";
4
- import { homedir } from "node:os";
5
2
  import { configService } from "./config.service.ts";
6
3
  import type { PushConfig } from "../types/config.ts";
7
-
8
- const SUBS_PATH = resolve(homedir(), ".ppm", "push-subscriptions.json");
4
+ import {
5
+ getPushSubscriptions,
6
+ upsertPushSubscription,
7
+ deletePushSubscription,
8
+ } from "./db.service.ts";
9
9
 
10
10
  interface PushSubscriptionData {
11
11
  endpoint: string;
@@ -13,23 +13,6 @@ interface PushSubscriptionData {
13
13
  expirationTime?: number | null;
14
14
  }
15
15
 
16
- /** Load subscriptions from disk */
17
- function loadSubscriptions(): PushSubscriptionData[] {
18
- try {
19
- if (existsSync(SUBS_PATH)) {
20
- return JSON.parse(readFileSync(SUBS_PATH, "utf-8"));
21
- }
22
- } catch { /* corrupt file — start fresh */ }
23
- return [];
24
- }
25
-
26
- /** Save subscriptions to disk */
27
- function saveSubscriptions(subs: PushSubscriptionData[]): void {
28
- const dir = dirname(SUBS_PATH);
29
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
30
- writeFileSync(SUBS_PATH, JSON.stringify(subs, null, 2), "utf-8");
31
- }
32
-
33
16
  class PushNotificationService {
34
17
  private initialized = false;
35
18
 
@@ -67,35 +50,40 @@ class PushNotificationService {
67
50
 
68
51
  /** Save a new push subscription */
69
52
  saveSubscription(sub: PushSubscriptionData): void {
70
- const subs = loadSubscriptions();
71
- // Deduplicate by endpoint
72
- const filtered = subs.filter((s) => s.endpoint !== sub.endpoint);
73
- filtered.push(sub);
74
- saveSubscriptions(filtered);
53
+ upsertPushSubscription(
54
+ sub.endpoint,
55
+ sub.keys.p256dh,
56
+ sub.keys.auth,
57
+ sub.expirationTime != null ? String(sub.expirationTime) : null,
58
+ );
75
59
  }
76
60
 
77
61
  /** Remove a push subscription by endpoint */
78
62
  removeSubscription(endpoint: string): void {
79
- const subs = loadSubscriptions();
80
- saveSubscriptions(subs.filter((s) => s.endpoint !== endpoint));
63
+ deletePushSubscription(endpoint);
81
64
  }
82
65
 
83
66
  /** Send push notification to all subscriptions (fire-and-forget) */
84
67
  async notifyAll(title: string, body: string): Promise<void> {
85
68
  this.init();
86
- const subs = loadSubscriptions();
87
- if (subs.length === 0) return;
69
+ const dbSubs = getPushSubscriptions();
70
+ if (dbSubs.length === 0) return;
88
71
 
89
72
  const payload = JSON.stringify({ title, body });
90
73
  const expired: string[] = [];
91
74
 
75
+ const subs: PushSubscriptionData[] = dbSubs.map((r) => ({
76
+ endpoint: r.endpoint,
77
+ keys: { p256dh: r.p256dh, auth: r.auth },
78
+ expirationTime: r.expiration_time ? Number(r.expiration_time) : null,
79
+ }));
80
+
92
81
  await Promise.allSettled(
93
82
  subs.map(async (sub) => {
94
83
  try {
95
84
  await webpush.sendNotification(sub, payload);
96
85
  } catch (error: unknown) {
97
86
  const statusCode = (error as { statusCode?: number }).statusCode;
98
- // 404 or 410 = subscription expired/invalid — mark for removal
99
87
  if (statusCode === 410 || statusCode === 404) {
100
88
  expired.push(sub.endpoint);
101
89
  }
@@ -103,12 +91,10 @@ class PushNotificationService {
103
91
  }),
104
92
  );
105
93
 
106
- // Auto-cleanup expired subscriptions
94
+ for (const endpoint of expired) {
95
+ deletePushSubscription(endpoint);
96
+ }
107
97
  if (expired.length > 0) {
108
- const remaining = loadSubscriptions().filter(
109
- (s) => !expired.includes(s.endpoint),
110
- );
111
- saveSubscriptions(remaining);
112
98
  console.log(`[push] Removed ${expired.length} expired subscriptions`);
113
99
  }
114
100
  }
@@ -1,43 +1,31 @@
1
- import { resolve } from "node:path";
2
- import { homedir } from "node:os";
3
- import { appendFileSync, mkdirSync, existsSync, readFileSync } from "node:fs";
4
-
5
- const SESSION_LOG_DIR = resolve(homedir(), ".ppm", "sessions");
6
-
7
- /** Ensure log directory exists */
8
- function ensureDir() {
9
- if (!existsSync(SESSION_LOG_DIR)) mkdirSync(SESSION_LOG_DIR, { recursive: true });
10
- }
1
+ import { insertSessionLog, getSessionLogs as dbGetSessionLogs } from "./db.service.ts";
11
2
 
12
3
  /** Redact sensitive values */
13
4
  function redact(text: string): string {
14
5
  return text
15
6
  .replace(/Token:\s*\S+/gi, "Token: [REDACTED]")
16
7
  .replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
17
- .replace(/password['":\s]+\S+/gi, "password: [REDACTED]")
18
- .replace(/api[_-]?key['":\s]+\S+/gi, "api_key: [REDACTED]")
8
+ .replace(/password['\":\s]+\S+/gi, "password: [REDACTED]")
9
+ .replace(/api[_-]?key['\":\s]+\S+/gi, "api_key: [REDACTED]")
19
10
  .replace(/ANTHROPIC_API_KEY=\S+/gi, "ANTHROPIC_API_KEY=[REDACTED]")
20
- .replace(/secret['":\s]+\S+/gi, "secret: [REDACTED]");
11
+ .replace(/secret['\":\s]+\S+/gi, "secret: [REDACTED]");
21
12
  }
22
13
 
23
- /** Append a log entry to a session's log file */
14
+ /** Append a log entry to a session's log in SQLite */
24
15
  export function logSessionEvent(sessionId: string, level: string, message: string) {
25
- ensureDir();
26
- const ts = new Date().toISOString();
27
- const logFile = resolve(SESSION_LOG_DIR, `${sessionId}.log`);
28
16
  try {
29
- appendFileSync(logFile, `[${ts}] [${level}] ${redact(message)}\n`);
17
+ insertSessionLog(sessionId, level, redact(message));
30
18
  } catch { /* ignore write errors */ }
31
19
  }
32
20
 
33
- /** Read a session's log file (last N lines) */
21
+ /** Read a session's log entries (last N lines) */
34
22
  export function getSessionLog(sessionId: string, tailLines = 100): string {
35
- const logFile = resolve(SESSION_LOG_DIR, `${sessionId}.log`);
36
- if (!existsSync(logFile)) return "";
37
23
  try {
38
- const content = readFileSync(logFile, "utf-8");
39
- const lines = content.split("\n");
40
- return lines.slice(-tailLines).join("\n").trim();
24
+ const rows = dbGetSessionLogs(sessionId, tailLines);
25
+ // Reverse to chronological order (DB returns DESC)
26
+ return rows.reverse().map((r) =>
27
+ `[${r.created_at}] [${r.level}] ${r.message}`
28
+ ).join("\n").trim();
41
29
  } catch {
42
30
  return "";
43
31
  }
@@ -0,0 +1,145 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { resolve } from "node:path";
3
+ import { existsSync } from "node:fs";
4
+
5
+ export interface TableInfo {
6
+ name: string;
7
+ rowCount: number;
8
+ }
9
+
10
+ export interface ColumnInfo {
11
+ cid: number;
12
+ name: string;
13
+ type: string;
14
+ notnull: boolean;
15
+ pk: boolean;
16
+ dflt_value: string | null;
17
+ }
18
+
19
+ export interface QueryResult {
20
+ columns: string[];
21
+ rows: Record<string, unknown>[];
22
+ rowsAffected: number;
23
+ changeType: "select" | "modify";
24
+ }
25
+
26
+ /** Auto-close idle databases after 5 minutes */
27
+ const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
28
+
29
+ interface CachedDb {
30
+ db: Database;
31
+ timer: ReturnType<typeof setTimeout>;
32
+ }
33
+
34
+ class SqliteService {
35
+ private cache = new Map<string, CachedDb>();
36
+
37
+ /** Resolve db path — supports both project-relative and absolute paths */
38
+ private resolvePath(projectPath: string, dbRelPath: string): string {
39
+ const isAbsolute = /^(\/|[A-Za-z]:[/\\])/.test(dbRelPath);
40
+ const abs = isAbsolute ? dbRelPath : resolve(projectPath, dbRelPath);
41
+ if (!isAbsolute && !abs.startsWith(projectPath)) throw new Error("Access denied: path outside project");
42
+ if (!existsSync(abs)) throw new Error(`Database not found: ${dbRelPath}`);
43
+ return abs;
44
+ }
45
+
46
+ /** Open (or reuse cached) database */
47
+ private open(absPath: string): Database {
48
+ const cached = this.cache.get(absPath);
49
+ if (cached) {
50
+ clearTimeout(cached.timer);
51
+ cached.timer = setTimeout(() => this.close(absPath), IDLE_TIMEOUT_MS);
52
+ return cached.db;
53
+ }
54
+ const db = new Database(absPath);
55
+ db.exec("PRAGMA journal_mode = WAL");
56
+ const timer = setTimeout(() => this.close(absPath), IDLE_TIMEOUT_MS);
57
+ this.cache.set(absPath, { db, timer });
58
+ return db;
59
+ }
60
+
61
+ /** Close and remove from cache */
62
+ private close(absPath: string) {
63
+ const cached = this.cache.get(absPath);
64
+ if (!cached) return;
65
+ clearTimeout(cached.timer);
66
+ try { cached.db.close(); } catch { /* already closed */ }
67
+ this.cache.delete(absPath);
68
+ }
69
+
70
+ /** List all user tables with row counts */
71
+ getTables(projectPath: string, dbPath: string): TableInfo[] {
72
+ const abs = this.resolvePath(projectPath, dbPath);
73
+ const db = this.open(abs);
74
+ const tables = db.query(
75
+ "SELECT name FROM sqlite_schema WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
76
+ ).all() as { name: string }[];
77
+
78
+ return tables.map((t) => {
79
+ const row = db.query(`SELECT COUNT(*) as cnt FROM "${t.name}"`).get() as { cnt: number };
80
+ return { name: t.name, rowCount: row.cnt };
81
+ });
82
+ }
83
+
84
+ /** Get column schema for a table */
85
+ getTableSchema(projectPath: string, dbPath: string, table: string): ColumnInfo[] {
86
+ const abs = this.resolvePath(projectPath, dbPath);
87
+ const db = this.open(abs);
88
+ return db.query(`PRAGMA table_info("${table}")`).all() as ColumnInfo[];
89
+ }
90
+
91
+ /** Get paginated rows from a table */
92
+ getTableData(
93
+ projectPath: string, dbPath: string, table: string,
94
+ page = 1, limit = 100, orderBy?: string, orderDir: "ASC" | "DESC" = "ASC",
95
+ ): { columns: string[]; rows: Record<string, unknown>[]; total: number; page: number; limit: number } {
96
+ const abs = this.resolvePath(projectPath, dbPath);
97
+ const db = this.open(abs);
98
+
99
+ const total = (db.query(`SELECT COUNT(*) as cnt FROM "${table}"`).get() as { cnt: number }).cnt;
100
+ const offset = (page - 1) * limit;
101
+ const order = orderBy ? `ORDER BY "${orderBy}" ${orderDir}` : "";
102
+ const rows = db.query(`SELECT rowid, * FROM "${table}" ${order} LIMIT ? OFFSET ?`).all(limit, offset) as Record<string, unknown>[];
103
+
104
+ // Get column names from first row or pragma
105
+ const schema = db.query(`PRAGMA table_info("${table}")`).all() as { name: string }[];
106
+ const columns = ["rowid", ...schema.map((c) => c.name)];
107
+
108
+ return { columns, rows, total, page, limit };
109
+ }
110
+
111
+ /** Execute arbitrary SQL */
112
+ executeQuery(projectPath: string, dbPath: string, sql: string): QueryResult {
113
+ const abs = this.resolvePath(projectPath, dbPath);
114
+ const db = this.open(abs);
115
+ const trimmed = sql.trim().toUpperCase();
116
+ const isSelect = trimmed.startsWith("SELECT") || trimmed.startsWith("PRAGMA") || trimmed.startsWith("EXPLAIN");
117
+
118
+ if (isSelect) {
119
+ const stmt = db.query(sql);
120
+ const rows = stmt.all() as Record<string, unknown>[];
121
+ const columns = rows.length > 0 ? Object.keys(rows[0]!) : [];
122
+ return { columns, rows, rowsAffected: 0, changeType: "select" };
123
+ }
124
+
125
+ const result = db.run(sql);
126
+ return { columns: [], rows: [], rowsAffected: result.changes, changeType: "modify" };
127
+ }
128
+
129
+ /** Update a single cell value */
130
+ updateCell(
131
+ projectPath: string, dbPath: string, table: string,
132
+ rowid: number, column: string, value: unknown,
133
+ ): void {
134
+ const abs = this.resolvePath(projectPath, dbPath);
135
+ const db = this.open(abs);
136
+ db.run(`UPDATE "${table}" SET "${column}" = ? WHERE rowid = ?`, [value as never, rowid]);
137
+ }
138
+
139
+ /** Close all cached databases (for shutdown) */
140
+ closeAll() {
141
+ for (const absPath of this.cache.keys()) this.close(absPath);
142
+ }
143
+ }
144
+
145
+ export const sqliteService = new SqliteService();