@hienlh/ppm 0.5.20 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +6 -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-C24nbKz1.js +7 -0
  5. package/dist/web/assets/{code-editor-0YVgeS1c.js → code-editor-DjIL6ta3.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-CtEmKn4e.js → diff-viewer-BnvcXY3g.js} +1 -1
  7. package/dist/web/assets/{git-graph-DycoowxO.js → git-graph-iAf_zaqe.js} +1 -1
  8. package/dist/web/assets/index-BwLVvoev.js +21 -0
  9. package/dist/web/assets/index-CP_2zE5O.css +2 -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-LHjvxp5Q.js → markdown-renderer-CIfiE3o8.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-B_QwULcp.js +1 -0
  17. package/dist/web/assets/sqlite-viewer-DpGb3i2g.js +16 -0
  18. package/dist/web/assets/tab-store-L0a7ao4c.js +1 -0
  19. package/dist/web/assets/{terminal-tab-B2QEABNU.js → terminal-tab-4-DINw_B.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/routes/tunnel.ts +17 -2
  39. package/src/server/ws/chat.ts +33 -1
  40. package/src/services/config.service.ts +182 -58
  41. package/src/services/db.service.ts +303 -0
  42. package/src/services/push-notification.service.ts +23 -37
  43. package/src/services/session-log.service.ts +12 -24
  44. package/src/services/sqlite.service.ts +144 -0
  45. package/src/web/components/chat/chat-history-bar.tsx +68 -8
  46. package/src/web/components/chat/chat-tab.tsx +10 -1
  47. package/src/web/components/chat/file-picker.tsx +1 -1
  48. package/src/web/components/chat/slash-command-picker.tsx +1 -1
  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/project-bar.tsx +40 -17
  54. package/src/web/components/layout/tab-bar.tsx +16 -1
  55. package/src/web/components/layout/tab-content.tsx +5 -0
  56. package/src/web/components/sqlite/sqlite-data-grid.tsx +165 -0
  57. package/src/web/components/sqlite/sqlite-query-editor.tsx +97 -0
  58. package/src/web/components/sqlite/sqlite-table-list.tsx +48 -0
  59. package/src/web/components/sqlite/sqlite-viewer.tsx +117 -0
  60. package/src/web/components/sqlite/use-sqlite.ts +97 -0
  61. package/src/web/hooks/use-chat.ts +12 -0
  62. package/src/web/stores/tab-store.ts +1 -0
  63. package/dist/web/assets/chat-tab-D_LO6cRM.js +0 -7
  64. package/dist/web/assets/index-82E_pIrH.css +0 -2
  65. package/dist/web/assets/index-y49eIXuR.js +0 -21
  66. package/dist/web/assets/settings-store-DikslxSJ.js +0 -1
  67. package/dist/web/assets/settings-tab-Dt9Sv1zx.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,75 @@
1
+ import { Hono } from "hono";
2
+ import { sqliteService } from "../../services/sqlite.service.ts";
3
+ import { ok, err } from "../../types/api.ts";
4
+
5
+ type Env = { Variables: { projectPath: string; projectName: string } };
6
+
7
+ export const sqliteRoutes = new Hono<Env>();
8
+
9
+ /** GET /sqlite/tables?path=relative/path.db */
10
+ sqliteRoutes.get("/tables", (c) => {
11
+ try {
12
+ const dbPath = c.req.query("path");
13
+ if (!dbPath) return c.json(err("Missing query parameter: path"), 400);
14
+ const tables = sqliteService.getTables(c.get("projectPath"), dbPath);
15
+ return c.json(ok(tables));
16
+ } catch (e) {
17
+ return c.json(err((e as Error).message), 500);
18
+ }
19
+ });
20
+
21
+ /** GET /sqlite/schema?path=...&table=... */
22
+ sqliteRoutes.get("/schema", (c) => {
23
+ try {
24
+ const dbPath = c.req.query("path");
25
+ const table = c.req.query("table");
26
+ if (!dbPath || !table) return c.json(err("Missing query parameters: path, table"), 400);
27
+ const schema = sqliteService.getTableSchema(c.get("projectPath"), dbPath, table);
28
+ return c.json(ok(schema));
29
+ } catch (e) {
30
+ return c.json(err((e as Error).message), 500);
31
+ }
32
+ });
33
+
34
+ /** GET /sqlite/data?path=...&table=...&page=1&limit=100&orderBy=...&orderDir=ASC */
35
+ sqliteRoutes.get("/data", (c) => {
36
+ try {
37
+ const dbPath = c.req.query("path");
38
+ const table = c.req.query("table");
39
+ if (!dbPath || !table) return c.json(err("Missing query parameters: path, table"), 400);
40
+ const page = parseInt(c.req.query("page") ?? "1", 10);
41
+ const limit = Math.min(parseInt(c.req.query("limit") ?? "100", 10), 1000);
42
+ const orderBy = c.req.query("orderBy");
43
+ const orderDir = c.req.query("orderDir") === "DESC" ? "DESC" : "ASC";
44
+ const data = sqliteService.getTableData(c.get("projectPath"), dbPath, table, page, limit, orderBy, orderDir as "ASC" | "DESC");
45
+ return c.json(ok(data));
46
+ } catch (e) {
47
+ return c.json(err((e as Error).message), 500);
48
+ }
49
+ });
50
+
51
+ /** POST /sqlite/query — body: { path, sql } */
52
+ sqliteRoutes.post("/query", async (c) => {
53
+ try {
54
+ const body = await c.req.json<{ path: string; sql: string }>();
55
+ if (!body.path || !body.sql) return c.json(err("Missing required fields: path, sql"), 400);
56
+ const result = sqliteService.executeQuery(c.get("projectPath"), body.path, body.sql);
57
+ return c.json(ok(result));
58
+ } catch (e) {
59
+ return c.json(err((e as Error).message), 500);
60
+ }
61
+ });
62
+
63
+ /** PUT /sqlite/cell — body: { path, table, rowid, column, value } */
64
+ sqliteRoutes.put("/cell", async (c) => {
65
+ try {
66
+ const body = await c.req.json<{ path: string; table: string; rowid: number; column: string; value: unknown }>();
67
+ if (!body.path || !body.table || body.rowid == null || !body.column) {
68
+ return c.json(err("Missing required fields: path, table, rowid, column"), 400);
69
+ }
70
+ sqliteService.updateCell(c.get("projectPath"), body.path, body.table, body.rowid, body.column, body.value);
71
+ return c.json(ok({ updated: true }));
72
+ } catch (e) {
73
+ return c.json(err((e as Error).message), 500);
74
+ }
75
+ });
@@ -1,14 +1,29 @@
1
1
  import { Hono } from "hono";
2
+ import { networkInterfaces } from "node:os";
2
3
  import { tunnelService } from "../../services/tunnel.service.ts";
3
4
  import { configService } from "../../services/config.service.ts";
4
5
  import { ok, err } from "../../types/api.ts";
5
6
 
7
+ /** Return first non-internal IPv4 address */
8
+ function getLocalIp(): string | null {
9
+ const nets = networkInterfaces();
10
+ for (const name of Object.keys(nets)) {
11
+ for (const net of nets[name] ?? []) {
12
+ if (net.family === "IPv4" && !net.internal) return net.address;
13
+ }
14
+ }
15
+ return null;
16
+ }
17
+
6
18
  export const tunnelRoutes = new Hono();
7
19
 
8
- /** GET /api/tunnel — current tunnel status */
20
+ /** GET /api/tunnel — current tunnel status + local URL */
9
21
  tunnelRoutes.get("/", (c) => {
10
22
  const url = tunnelService.getTunnelUrl();
11
- return c.json(ok({ active: !!url, url }));
23
+ const port = configService.get("port") ?? 8080;
24
+ const localIp = getLocalIp();
25
+ const localUrl = localIp ? `http://${localIp}:${port}` : null;
26
+ return c.json(ok({ active: !!url, url, localUrl }));
12
27
  });
13
28
 
14
29
  /** POST /api/tunnel/start — start tunnel if not already running */
@@ -2,6 +2,7 @@ import { chatService } from "../../services/chat.service.ts";
2
2
  import { providerRegistry } from "../../providers/registry.ts";
3
3
  import { resolveProjectPath } from "../helpers/resolve-project.ts";
4
4
  import { logSessionEvent } from "../../services/session-log.service.ts";
5
+ import { listSessions as sdkListSessions } from "@anthropic-ai/claude-agent-sdk";
5
6
  import type { ChatWsClientMessage } from "../../types/api.ts";
6
7
 
7
8
  const PING_INTERVAL_MS = 15_000; // 15s keepalive
@@ -148,6 +149,16 @@ async function runStreamLoop(sessionId: string, providerId: string, content: str
148
149
  } else if (evType === "done") {
149
150
  logSessionEvent(sessionId, "DONE", `subtype=${ev.resultSubtype ?? "none"} turns=${ev.numTurns ?? "?"} ctx=${ev.contextWindowPct ?? "?"}%`);
150
151
  if (ev.contextWindowPct != null) lastContextWindowPct = ev.contextWindowPct;
152
+ // Fire-and-forget: fetch updated session title from SDK summary
153
+ sdkListSessions({ dir: entry.projectPath, limit: 50 }).then((sessions) => {
154
+ const found = sessions.find((s) => s.sessionId === sessionId || s.sessionId === ev.sessionId);
155
+ if (found?.summary) {
156
+ safeSend(sessionId, { type: "title_updated", title: found.summary });
157
+ // Also update in-memory session title
158
+ const session = chatService.getSession(sessionId);
159
+ if (session) session.title = found.summary;
160
+ }
161
+ }).catch(() => {});
151
162
  // Fire-and-forget push notification
152
163
  import("../../services/push-notification.service.ts").then(({ pushService }) => {
153
164
  const project = entry.projectName || "Project";
@@ -254,7 +265,18 @@ export const chatWebSocket = {
254
265
  sessionId,
255
266
  isStreaming: existing.isStreaming,
256
267
  pendingApproval: existing.pendingApprovalEvent ?? null,
268
+ sessionTitle: session?.title || null,
257
269
  }));
270
+ // Async: resolve title from SDK if in-memory title is generic
271
+ if (!session?.title || session.title === "Chat" || session.title === "Resumed Chat") {
272
+ sdkListSessions({ dir: projectPath, limit: 50 }).then((sessions) => {
273
+ const found = sessions.find((s) => s.sessionId === sessionId);
274
+ if (found?.summary) {
275
+ safeSend(sessionId, { type: "title_updated", title: found.summary });
276
+ if (session) session.title = found.summary;
277
+ }
278
+ }).catch(() => {});
279
+ }
258
280
  console.log(`[chat] session=${sessionId} FE reconnected (streaming=${existing.isStreaming}, catchUp=${existing.needsCatchUp})`);
259
281
  return;
260
282
  }
@@ -276,7 +298,17 @@ export const chatWebSocket = {
276
298
  needsCatchUp: false,
277
299
  catchUpText: "",
278
300
  });
279
- ws.send(JSON.stringify({ type: "connected", sessionId }));
301
+ ws.send(JSON.stringify({ type: "connected", sessionId, sessionTitle: session?.title || null }));
302
+ // Async: resolve title from SDK if in-memory title is generic
303
+ if (!session?.title || session.title === "Chat" || session.title === "Resumed Chat") {
304
+ sdkListSessions({ dir: projectPath, limit: 50 }).then((sessions) => {
305
+ const found = sessions.find((s) => s.sessionId === sessionId);
306
+ if (found?.summary) {
307
+ safeSend(sessionId, { type: "title_updated", title: found.summary });
308
+ if (session) session.title = found.summary;
309
+ }
310
+ }).catch(() => {});
311
+ }
280
312
  },
281
313
 
282
314
  async message(ws: ChatWsSocket, msg: string | ArrayBuffer | Uint8Array) {
@@ -1,68 +1,78 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
2
- import { resolve, dirname } from "node:path";
1
+ import { existsSync, readFileSync, renameSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { randomBytes } from "node:crypto";
5
- import yaml from "js-yaml";
6
- import type { PpmConfig } from "../types/config.ts";
5
+ import type { PpmConfig, ProjectConfig } from "../types/config.ts";
7
6
  import { DEFAULT_CONFIG, sanitizeConfig } from "../types/config.ts";
7
+ import {
8
+ getConfigValue,
9
+ setConfigValue,
10
+ getAllConfig,
11
+ getProjects,
12
+ upsertProject,
13
+ deleteProject as dbDeleteProject,
14
+ getDb,
15
+ getDbFilePath,
16
+ } from "./db.service.ts";
8
17
 
9
18
  const PPM_DIR = resolve(homedir(), ".ppm");
10
- const GLOBAL_CONFIG_PATH = resolve(PPM_DIR, "config.yaml");
11
- const LOCAL_CONFIG_PATH = resolve(process.cwd(), "ppm.yaml");
19
+
20
+ /** Top-level config keys stored in the config table (not projects) */
21
+ const CONFIG_TABLE_KEYS: (keyof PpmConfig)[] = [
22
+ "device_name", "port", "host", "theme", "auth", "ai", "push",
23
+ ];
12
24
 
13
25
  class ConfigService {
14
26
  private config: PpmConfig = structuredClone(DEFAULT_CONFIG);
15
- private configPath: string = GLOBAL_CONFIG_PATH;
16
27
 
17
- /** Load config from: explicit path ./ppm.yaml ~/.ppm/config.yaml */
28
+ /** Load config from DB. If explicitPath given, import that YAML first. */
18
29
  load(explicitPath?: string): PpmConfig {
19
- const searchPaths = [
20
- explicitPath,
21
- LOCAL_CONFIG_PATH,
22
- GLOBAL_CONFIG_PATH,
23
- ].filter(Boolean) as string[];
24
-
25
- for (const p of searchPaths) {
26
- const found = existsSync(p);
27
- if (!found) {
28
- console.log(`[config] Not found: ${p}`);
29
- continue;
30
- }
31
- try {
32
- const raw = readFileSync(p, "utf-8");
33
- const parsed = yaml.load(raw) as Partial<PpmConfig> | null;
34
- if (parsed) {
35
- this.config = { ...structuredClone(DEFAULT_CONFIG), ...parsed };
36
- this.configPath = p;
37
- console.log(`[config] Loaded from: ${p}`);
38
- // Auto-generate token if auth enabled but token is empty
39
- if (this.config.auth.enabled && !this.config.auth.token) {
40
- this.config.auth.token = randomBytes(16).toString("hex");
41
- this.save();
42
- }
43
- // Fix invalid config values and persist corrections
44
- if (sanitizeConfig(this.config)) {
45
- this.save();
46
- }
47
- return this.config;
48
- }
49
- console.log(`[config] Empty or invalid YAML: ${p}`);
50
- } catch (err) {
51
- console.error(`[config] Error reading ${p}:`, (err as Error).message);
52
- }
30
+ // Import explicit YAML if provided (e.g. `ppm start -c path`)
31
+ if (explicitPath && existsSync(explicitPath)) {
32
+ this.importFromYaml(explicitPath);
33
+ }
34
+
35
+ // Auto-migrate: if config.yaml exists but DB has no config rows
36
+ // Skip migration when using in-memory DB (tests)
37
+ if (!getDbFilePath().includes(":memory:")) {
38
+ this.migrateYamlIfNeeded();
39
+ }
40
+
41
+ // Load from DB
42
+ const dbConfig = getAllConfig();
43
+ const dbProjects = getProjects();
44
+
45
+ if (Object.keys(dbConfig).length > 0 || dbProjects.length > 0) {
46
+ this.config = this.assembleConfig(dbConfig, dbProjects);
47
+ console.log("[config] Loaded from SQLite");
48
+ } else {
49
+ console.log("[config] No config found, creating defaults");
50
+ this.config = this.createDefault();
51
+ }
52
+
53
+ // Auto-generate token if auth enabled but empty
54
+ if (this.config.auth.enabled && !this.config.auth.token) {
55
+ this.config.auth.token = randomBytes(16).toString("hex");
56
+ this.save();
57
+ }
58
+
59
+ if (sanitizeConfig(this.config)) {
60
+ this.save();
53
61
  }
54
62
 
55
- // No config found — create default
56
- console.log(`[config] No config found, creating default at ${GLOBAL_CONFIG_PATH}`);
57
- this.config = this.createDefault();
58
63
  return this.config;
59
64
  }
60
65
 
61
- /** Save current config to disk */
66
+ /** Save current config to DB */
62
67
  save(): void {
63
- const dir = dirname(this.configPath);
64
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
65
- writeFileSync(this.configPath, yaml.dump(this.config), "utf-8");
68
+ for (const key of CONFIG_TABLE_KEYS) {
69
+ const value = this.config[key];
70
+ if (value !== undefined) {
71
+ setConfigValue(String(key), JSON.stringify(value));
72
+ }
73
+ }
74
+ // Sync projects to DB
75
+ this.syncProjectsToDb(this.config.projects);
66
76
  }
67
77
 
68
78
  /** Get a top-level config key */
@@ -70,9 +80,14 @@ class ConfigService {
70
80
  return this.config[key];
71
81
  }
72
82
 
73
- /** Set a top-level config key */
83
+ /** Set a top-level config key (persists immediately) */
74
84
  set<K extends keyof PpmConfig>(key: K, value: PpmConfig[K]): void {
75
85
  this.config[key] = value;
86
+ if (key === "projects") {
87
+ this.syncProjectsToDb(value as ProjectConfig[]);
88
+ } else {
89
+ setConfigValue(String(key), JSON.stringify(value));
90
+ }
76
91
  }
77
92
 
78
93
  /** Get the full config object */
@@ -80,24 +95,133 @@ class ConfigService {
80
95
  return this.config;
81
96
  }
82
97
 
83
- /** Get the path config was loaded from */
98
+ /** Get the DB file path (replaces getConfigPath for YAML) */
84
99
  getConfigPath(): string {
85
- return this.configPath;
100
+ return getDbFilePath();
86
101
  }
87
102
 
88
- /** Set the config path explicitly (for init command) */
89
- setConfigPath(p: string): void {
90
- this.configPath = p;
91
- }
103
+ /** No-op kept for backward compatibility (init command) */
104
+ setConfigPath(_p: string): void {}
105
+
106
+ // ── Private helpers ──────────────────────────────────────────────────
92
107
 
93
- /** Create default config with auto-generated auth token */
94
108
  private createDefault(): PpmConfig {
95
109
  const config = structuredClone(DEFAULT_CONFIG);
96
110
  config.auth.token = randomBytes(16).toString("hex");
97
- this.configPath = GLOBAL_CONFIG_PATH;
111
+ this.config = config;
98
112
  this.save();
99
113
  return config;
100
114
  }
115
+
116
+ private assembleConfig(
117
+ dbRows: Record<string, string>,
118
+ dbProjects: { path: string; name: string; color: string | null }[],
119
+ ): PpmConfig {
120
+ const config = structuredClone(DEFAULT_CONFIG);
121
+ for (const [key, jsonValue] of Object.entries(dbRows)) {
122
+ if (key in config && key !== "projects") {
123
+ try {
124
+ (config as any)[key] = JSON.parse(jsonValue);
125
+ } catch { /* keep default */ }
126
+ }
127
+ }
128
+ // Projects from dedicated table
129
+ config.projects = dbProjects.map((p) => ({
130
+ path: p.path,
131
+ name: p.name,
132
+ ...(p.color ? { color: p.color } : {}),
133
+ }));
134
+ return config;
135
+ }
136
+
137
+ private syncProjectsToDb(projects: ProjectConfig[]): void {
138
+ const db = getDb();
139
+ db.exec("DELETE FROM projects");
140
+ const stmt = db.query(
141
+ "INSERT INTO projects (path, name, color, sort_order) VALUES (?, ?, ?, ?)",
142
+ );
143
+ for (let i = 0; i < projects.length; i++) {
144
+ const p = projects[i]!;
145
+ stmt.run(p.path, p.name, p.color ?? null, i);
146
+ }
147
+ }
148
+
149
+ private migrateYamlIfNeeded(): void {
150
+ const yamlPaths = [
151
+ resolve(PPM_DIR, "config.yaml"),
152
+ resolve(PPM_DIR, "config.dev.yaml"),
153
+ ];
154
+ for (const yamlPath of yamlPaths) {
155
+ if (!existsSync(yamlPath)) continue;
156
+ const existing = getAllConfig();
157
+ if (Object.keys(existing).length > 0) return;
158
+ this.importFromYaml(yamlPath);
159
+ try {
160
+ renameSync(yamlPath, yamlPath + ".bak");
161
+ console.log(`[config] Migrated ${yamlPath} → SQLite (backup: .bak)`);
162
+ } catch {}
163
+ }
164
+ this.migrateSessionMapIfNeeded();
165
+ this.migratePushSubsIfNeeded();
166
+ }
167
+
168
+ private importFromYaml(path: string): void {
169
+ try {
170
+ const yaml = require("js-yaml");
171
+ const raw = readFileSync(path, "utf-8");
172
+ const parsed = yaml.load(raw) as Partial<PpmConfig> | null;
173
+ if (!parsed) return;
174
+ const merged = { ...structuredClone(DEFAULT_CONFIG), ...parsed };
175
+ for (const key of CONFIG_TABLE_KEYS) {
176
+ const value = (merged as any)[key];
177
+ if (value !== undefined) {
178
+ setConfigValue(String(key), JSON.stringify(value));
179
+ }
180
+ }
181
+ if (merged.projects?.length) {
182
+ this.syncProjectsToDb(merged.projects);
183
+ }
184
+ } catch (err) {
185
+ console.error(`[config] Error importing YAML ${path}:`, (err as Error).message);
186
+ }
187
+ }
188
+
189
+ private migrateSessionMapIfNeeded(): void {
190
+ const mapPath = resolve(PPM_DIR, "session-map.json");
191
+ if (!existsSync(mapPath)) return;
192
+ try {
193
+ const { setSessionMapping } = require("./db.service.ts");
194
+ const map = JSON.parse(readFileSync(mapPath, "utf-8")) as Record<string, string>;
195
+ for (const [ppmId, sdkId] of Object.entries(map)) {
196
+ setSessionMapping(ppmId, sdkId);
197
+ }
198
+ renameSync(mapPath, mapPath + ".bak");
199
+ console.log("[config] Migrated session-map.json → SQLite");
200
+ } catch {}
201
+ }
202
+
203
+ private migratePushSubsIfNeeded(): void {
204
+ const subsPath = resolve(PPM_DIR, "push-subscriptions.json");
205
+ if (!existsSync(subsPath)) return;
206
+ try {
207
+ const { upsertPushSubscription } = require("./db.service.ts");
208
+ const subs = JSON.parse(readFileSync(subsPath, "utf-8")) as Array<{
209
+ endpoint: string;
210
+ keys: { p256dh: string; auth: string };
211
+ expirationTime?: number | null;
212
+ }>;
213
+ for (const sub of subs) {
214
+ upsertPushSubscription(
215
+ sub.endpoint,
216
+ sub.keys.p256dh,
217
+ sub.keys.auth,
218
+ sub.expirationTime != null ? String(sub.expirationTime) : null,
219
+ );
220
+ }
221
+ renameSync(subsPath, subsPath + ".bak");
222
+ console.log("[config] Migrated push-subscriptions.json → SQLite");
223
+ } catch {}
224
+ }
101
225
  }
102
226
 
103
227
  /** Singleton config service */