pi-mega-compact 0.11.0 → 0.11.2

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 (55) hide show
  1. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +19 -0
  2. package/dist/extensions/dashboard-server/api-contracts/maintenance.js +7 -0
  3. package/dist/extensions/dashboard-server/api-contracts.test.js +1 -1
  4. package/dist/extensions/dashboard-server/routes-maintenance.js +293 -0
  5. package/dist/extensions/dashboard-server/routes.js +1 -0
  6. package/dist/extensions/dashboard-server/server.js +3 -1
  7. package/dist/src/store/sqlite/maintenance.js +20 -1
  8. package/dist/src/store/sqlite/schema.js +8 -5
  9. package/extensions/dashboard-client/dist/assets/{AchievementTiles-DZp_xQOW.js → AchievementTiles-Cml6YC8w.js} +2 -2
  10. package/extensions/dashboard-client/dist/assets/{AchievementTiles-DZp_xQOW.js.map → AchievementTiles-Cml6YC8w.js.map} +1 -1
  11. package/extensions/dashboard-client/dist/assets/{AchievementsTab-BzFHpAHM.js → AchievementsTab-B8NqDt7I.js} +2 -2
  12. package/extensions/dashboard-client/dist/assets/{AchievementsTab-BzFHpAHM.js.map → AchievementsTab-B8NqDt7I.js.map} +1 -1
  13. package/extensions/dashboard-client/dist/assets/{CacheTab-DEQbTq2T.js → CacheTab-BCLzVL4S.js} +2 -2
  14. package/extensions/dashboard-client/dist/assets/{CacheTab-DEQbTq2T.js.map → CacheTab-BCLzVL4S.js.map} +1 -1
  15. package/extensions/dashboard-client/dist/assets/{ConfigTab-DQ4FG33J.js → ConfigTab-CBYvp2mV.js} +2 -2
  16. package/extensions/dashboard-client/dist/assets/{ConfigTab-DQ4FG33J.js.map → ConfigTab-CBYvp2mV.js.map} +1 -1
  17. package/extensions/dashboard-client/dist/assets/{EventsTab-D5HoEVYo.js → EventsTab-DlTJqiDm.js} +2 -2
  18. package/extensions/dashboard-client/dist/assets/{EventsTab-D5HoEVYo.js.map → EventsTab-DlTJqiDm.js.map} +1 -1
  19. package/extensions/dashboard-client/dist/assets/{GameTab-Jzsc4yeY.js → GameTab-DkGTqKJW.js} +2 -2
  20. package/extensions/dashboard-client/dist/assets/{GameTab-Jzsc4yeY.js.map → GameTab-DkGTqKJW.js.map} +1 -1
  21. package/extensions/dashboard-client/dist/assets/MaintenanceTab-Cw43zmUx.js +2 -0
  22. package/extensions/dashboard-client/dist/assets/MaintenanceTab-Cw43zmUx.js.map +1 -0
  23. package/extensions/dashboard-client/dist/assets/{MetricsTab-D_XnsEYX.js → MetricsTab-BFo7pKM-.js} +2 -2
  24. package/extensions/dashboard-client/dist/assets/{MetricsTab-D_XnsEYX.js.map → MetricsTab-BFo7pKM-.js.map} +1 -1
  25. package/extensions/dashboard-client/dist/assets/{OverviewTab-BMREvAth.js → OverviewTab-CClCUd3-.js} +2 -2
  26. package/extensions/dashboard-client/dist/assets/{OverviewTab-BMREvAth.js.map → OverviewTab-CClCUd3-.js.map} +1 -1
  27. package/extensions/dashboard-client/dist/assets/{ReposTab-DuyhOktG.js → ReposTab-_3R4RSLZ.js} +2 -2
  28. package/extensions/dashboard-client/dist/assets/{ReposTab-DuyhOktG.js.map → ReposTab-_3R4RSLZ.js.map} +1 -1
  29. package/extensions/dashboard-client/dist/assets/{SessionsTab-mz_vz8i2.js → SessionsTab-CCYd-kSz.js} +2 -2
  30. package/extensions/dashboard-client/dist/assets/{SessionsTab-mz_vz8i2.js.map → SessionsTab-CCYd-kSz.js.map} +1 -1
  31. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B8AIjNTY.js → TimeSavedCard-0TqsrY6y.js} +2 -2
  32. package/extensions/dashboard-client/dist/assets/{TimeSavedCard-B8AIjNTY.js.map → TimeSavedCard-0TqsrY6y.js.map} +1 -1
  33. package/extensions/dashboard-client/dist/assets/{TopicsTab-DKuct4oD.js → TopicsTab-DRLJmVv0.js} +2 -2
  34. package/extensions/dashboard-client/dist/assets/{TopicsTab-DKuct4oD.js.map → TopicsTab-DRLJmVv0.js.map} +1 -1
  35. package/extensions/dashboard-client/dist/assets/{TurnsTab-11MGLxY8.js → TurnsTab-CLNM0T5R.js} +2 -2
  36. package/extensions/dashboard-client/dist/assets/{TurnsTab-11MGLxY8.js.map → TurnsTab-CLNM0T5R.js.map} +1 -1
  37. package/extensions/dashboard-client/dist/assets/{index-CqLN7STl.js → index-BcoSz-L4.js} +10 -10
  38. package/extensions/dashboard-client/dist/assets/{index-CqLN7STl.js.map → index-BcoSz-L4.js.map} +1 -1
  39. package/extensions/dashboard-client/dist/assets/{useSSE-CXrQSbVY.js → useSSE-Cezlih5V.js} +2 -2
  40. package/extensions/dashboard-client/dist/assets/{useSSE-CXrQSbVY.js.map → useSSE-Cezlih5V.js.map} +1 -1
  41. package/extensions/dashboard-client/dist/index.html +1 -1
  42. package/extensions/dashboard-client/src/App.tsx +5 -1
  43. package/extensions/dashboard-client/src/api/client.ts +23 -0
  44. package/extensions/dashboard-client/src/tabs/MaintenanceTab.tsx +293 -0
  45. package/extensions/dashboard-server/api-contracts/endpoints.ts +30 -0
  46. package/extensions/dashboard-server/api-contracts/index.ts +12 -0
  47. package/extensions/dashboard-server/api-contracts/maintenance.ts +84 -0
  48. package/extensions/dashboard-server/api-contracts.test.ts +1 -1
  49. package/extensions/dashboard-server/api-contracts.ts +9 -0
  50. package/extensions/dashboard-server/routes-maintenance.ts +345 -0
  51. package/extensions/dashboard-server/routes.ts +1 -0
  52. package/extensions/dashboard-server/server.ts +2 -0
  53. package/package.json +1 -1
  54. package/src/store/sqlite/maintenance.ts +20 -1
  55. package/src/store/sqlite/schema.ts +62 -49
@@ -0,0 +1,345 @@
1
+ /**
2
+ * dashboard-server/routes-maintenance.ts — S49B Maintenance tab API routes.
3
+ *
4
+ * Handlers for:
5
+ * GET /api/maintenance — DB stats (table row counts + storage)
6
+ * GET /api/maintenance/schema-health — SCHEMA_VERSION + integrity audit
7
+ * POST /api/maintenance/action — Trigger a maintenance action
8
+ *
9
+ * All pi-agnostic; lazy-requires maintenance primitives to avoid a top-level
10
+ * import of the store from the dashboard server process. PREVENT-PI-004: local
11
+ * SQLite only; zero network calls.
12
+ */
13
+
14
+ import { createRequire } from "node:module";
15
+ import type { IncomingMessage, ServerResponse } from "node:http";
16
+
17
+ import type { RouteContext } from "./routes-core.js";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Lazy imports (via createRequire)
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const lazyMaintenance = (() => {
24
+ let mod: typeof import("../../src/store/sqlite/maintenance.js") | null = null;
25
+ return () => {
26
+ if (!mod)
27
+ mod = createRequire(import.meta.url)(
28
+ "../../src/store/sqlite/maintenance.js",
29
+ ) as typeof import("../../src/store/sqlite/maintenance.js");
30
+ return mod;
31
+ };
32
+ })();
33
+
34
+ const lazyStoreUtils = (() => {
35
+ let mod: typeof import("../../src/store/sqlite/utils.js") | null = null;
36
+ return () => {
37
+ if (!mod)
38
+ mod = createRequire(import.meta.url)(
39
+ "../../src/store/sqlite/utils.js",
40
+ ) as typeof import("../../src/store/sqlite/utils.js");
41
+ return mod;
42
+ };
43
+ })();
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Types for JSON deserialisation
47
+ // ---------------------------------------------------------------------------
48
+
49
+ interface MaintenanceActionBody {
50
+ action: string;
51
+ daysOld?: number | string;
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // GET /api/maintenance and sub-paths
56
+ // ---------------------------------------------------------------------------
57
+
58
+ export function handleMaintenance(
59
+ req: IncomingMessage,
60
+ res: ServerResponse,
61
+ ctx: RouteContext,
62
+ ): boolean {
63
+ const url = req.url ?? "";
64
+ if (!url.startsWith("/api/maintenance")) return false;
65
+
66
+ const method = (req.method ?? "GET").toUpperCase();
67
+
68
+ // ── GET /api/maintenance/schema-health ────────────────────────────
69
+ if (method === "GET" && url === "/api/maintenance/schema-health") {
70
+ sendSchemaHealth(res, ctx);
71
+ return true;
72
+ }
73
+
74
+ // ── POST /api/maintenance/action ──────────────────────────────────
75
+ if (method === "POST" && url === "/api/maintenance/action") {
76
+ handleMaintenanceAction(req, res, ctx);
77
+ return true;
78
+ }
79
+
80
+ // ── GET /api/maintenance ──────────────────────────────────────────
81
+ if (method === "GET" && url === "/api/maintenance") {
82
+ sendDbStats(res, ctx);
83
+ return true;
84
+ }
85
+
86
+ // ── Unknown sub-path ──────────────────────────────────────────────
87
+ sendJson(res, 404, { error: "not found" });
88
+ return true;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // GET /api/maintenance — DB stats
93
+ // ---------------------------------------------------------------------------
94
+
95
+ function sendDbStats(res: ServerResponse, ctx: RouteContext): void {
96
+ try {
97
+ const { getDbStats } = lazyMaintenance();
98
+ const raw = getDbStats(ctx.stateDir);
99
+
100
+ const tables = Object.entries(raw.tableCounts).map(([table, rowCount]) => ({
101
+ table,
102
+ rowCount,
103
+ }));
104
+
105
+ sendJson(res, 200, {
106
+ type: "db-stats" as const,
107
+ tables,
108
+ storage: {
109
+ files: {
110
+ dbBytes: raw.dbBytes,
111
+ walBytes: raw.walBytes,
112
+ shmBytes: raw.shmBytes,
113
+ },
114
+ pageSize: raw.pageSize,
115
+ pageCount: raw.pageCount,
116
+ freelistPages: raw.freelistPages,
117
+ },
118
+ });
119
+ } catch (err) {
120
+ sendJson(res, 500, { error: (err as Error).message });
121
+ }
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // GET /api/maintenance/schema-health — schema version + integrity audit
126
+ // ---------------------------------------------------------------------------
127
+
128
+ function sendSchemaHealth(res: ServerResponse, ctx: RouteContext): void {
129
+ try {
130
+ const { integrityCheck } = lazyMaintenance();
131
+ const integrity = integrityCheck(ctx.stateDir);
132
+
133
+ const { openStore } = lazyStoreUtils();
134
+ const db = openStore(ctx.stateDir);
135
+
136
+ // SCHEMA_VERSION from meta table
137
+ const metaRow = db
138
+ .prepare("SELECT value FROM meta WHERE key = ?")
139
+ .get("schema_version") as { value: string } | undefined;
140
+ const schemaVersion = metaRow ? Number(metaRow.value) : 0;
141
+
142
+ // PRAGMA foreign_key_check
143
+ const fkRows = db
144
+ .prepare("PRAGMA foreign_key_check")
145
+ .all() as Array<Record<string, unknown>>;
146
+ const fkCheck = fkRows.map((r) => JSON.stringify(r));
147
+
148
+ // Per-column audit: every ensureColumn migration column declared in
149
+ // schema.ts, checked via PRAGMA table_info (read-only).
150
+ const expectedColumns: Array<{
151
+ table: string;
152
+ column: string;
153
+ decl: string;
154
+ }> = [
155
+ { table: "context_chunks", column: "original_token_estimate", decl: "INTEGER" },
156
+ { table: "raw_transcript", column: "content_ref", decl: "TEXT" },
157
+ { table: "memories", column: "category", decl: "TEXT" },
158
+ { table: "memories", column: "target", decl: "TEXT" },
159
+ { table: "memories", column: "last_referenced", decl: "INTEGER" },
160
+ { table: "memories", column: "source_turn", decl: "INTEGER" },
161
+ { table: "raptor_nodes", column: "built_at", decl: "INTEGER" },
162
+ { table: "raw_transcript", column: "turn_index", decl: "INTEGER" },
163
+ { table: "session_state", column: "conversation_id", decl: "TEXT" },
164
+ { table: "session_state", column: "last_turn_id", decl: "INTEGER" },
165
+ ];
166
+
167
+ const columns = expectedColumns.map((ec) => {
168
+ const cols = db
169
+ .prepare(`PRAGMA table_info(${ec.table})`)
170
+ .all() as Array<{ name: string }>;
171
+ const present = cols.some((c) => c.name === ec.column);
172
+ return {
173
+ table: ec.table,
174
+ column: ec.column,
175
+ present,
176
+ expectedDecl: ec.decl,
177
+ };
178
+ });
179
+
180
+ const healthy =
181
+ integrity.length === 1 &&
182
+ integrity[0] === "ok" &&
183
+ columns.every((c) => c.present);
184
+
185
+ sendJson(res, 200, {
186
+ type: "schema-health" as const,
187
+ schemaVersion,
188
+ integrity,
189
+ fkCheck,
190
+ columns,
191
+ healthy,
192
+ });
193
+ } catch (err) {
194
+ sendJson(res, 500, { error: (err as Error).message });
195
+ }
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // POST /api/maintenance/action — Trigger a maintenance action
200
+ // ---------------------------------------------------------------------------
201
+
202
+ function handleMaintenanceAction(
203
+ req: IncomingMessage,
204
+ res: ServerResponse,
205
+ ctx: RouteContext,
206
+ ): void {
207
+ let body = "";
208
+ req.setEncoding("utf8");
209
+ req.on("data", (chunk: string) => {
210
+ body += chunk;
211
+ if (body.length > 10_000) req.destroy();
212
+ });
213
+ req.on("end", () => {
214
+ let payload: MaintenanceActionBody;
215
+ try {
216
+ payload = JSON.parse(body) as MaintenanceActionBody;
217
+ if (!payload || !payload.action) {
218
+ sendJson(res, 400, { error: "missing 'action' field" });
219
+ return;
220
+ }
221
+ } catch {
222
+ sendJson(res, 400, { error: "invalid JSON" });
223
+ return;
224
+ }
225
+
226
+ try {
227
+ const m = lazyMaintenance();
228
+ const u = lazyStoreUtils();
229
+ // All destructive actions: temp backup first
230
+ if (payload.action !== "integrity-check") {
231
+ // Non-blocking — backup failure is non-fatal
232
+ m.backupDb(ctx.stateDir);
233
+ }
234
+
235
+ switch (payload.action) {
236
+ case "vacuum": {
237
+ const r = m.vacuumDb(ctx.stateDir);
238
+ sendJson(res, 200, {
239
+ operation: "vacuum",
240
+ success: true,
241
+ affected: r.affected,
242
+ reclaimedBytes: r.reclaimedBytes,
243
+ summary: r.summary,
244
+ });
245
+ return;
246
+ }
247
+ case "checkpoint": {
248
+ const r = m.checkpointWal(ctx.stateDir);
249
+ sendJson(res, 200, {
250
+ operation: "checkpoint",
251
+ success: true,
252
+ affected: r.affected,
253
+ reclaimedBytes: r.reclaimedBytes,
254
+ summary: r.summary,
255
+ });
256
+ return;
257
+ }
258
+ case "reindex": {
259
+ const db = u.openStore(ctx.stateDir);
260
+ db.exec("REINDEX");
261
+ sendJson(res, 200, {
262
+ operation: "reindex",
263
+ success: true,
264
+ affected: 0,
265
+ reclaimedBytes: 0,
266
+ summary: "REINDEX completed",
267
+ });
268
+ return;
269
+ }
270
+ case "fts5-rebuild": {
271
+ const db = u.openStore(ctx.stateDir);
272
+ db.exec(
273
+ "INSERT INTO context_chunks_trgm(context_chunks_trgm) VALUES('rebuild')",
274
+ );
275
+ sendJson(res, 200, {
276
+ operation: "fts5-rebuild",
277
+ success: true,
278
+ affected: 0,
279
+ reclaimedBytes: 0,
280
+ summary: "FTS5 rebuild triggered",
281
+ });
282
+ return;
283
+ }
284
+ case "reconcile-dedup": {
285
+ const r = m.reconcileDedupMirror(ctx.stateDir);
286
+ const lines: string[] = [];
287
+ if (r.fixedRefCount) lines.push(`${r.fixedRefCount} ref_counts corrected`);
288
+ if (r.orphansDeleted) lines.push(`${r.orphansDeleted} orphans deleted`);
289
+ if (r.refsBackfilled) lines.push(`${r.refsBackfilled} refs backfilled`);
290
+ sendJson(res, 200, {
291
+ operation: "reconcile-dedup",
292
+ success: true,
293
+ affected: r.fixedRefCount + r.orphansDeleted + r.refsBackfilled,
294
+ reclaimedBytes: 0,
295
+ summary: lines.length ? lines.join(", ") : "nothing to fix",
296
+ });
297
+ return;
298
+ }
299
+ case "prune": {
300
+ const days = Number(payload.daysOld) || 30;
301
+ const r = m.pruneOldRows(ctx.stateDir, days);
302
+ sendJson(res, 200, {
303
+ operation: "prune",
304
+ success: true,
305
+ affected: r.affected,
306
+ reclaimedBytes: r.reclaimedBytes,
307
+ summary: r.summary,
308
+ });
309
+ return;
310
+ }
311
+ case "integrity-check": {
312
+ const lines = m.integrityCheck(ctx.stateDir);
313
+ sendJson(res, 200, {
314
+ operation: "integrity-check",
315
+ success: lines.length === 1 && lines[0] === "ok",
316
+ affected: 0,
317
+ reclaimedBytes: 0,
318
+ summary: lines.join("; "),
319
+ detail: lines,
320
+ });
321
+ return;
322
+ }
323
+ default:
324
+ sendJson(res, 400, {
325
+ error: `unknown action: ${payload.action}`,
326
+ });
327
+ }
328
+ } catch (err) {
329
+ sendJson(res, 500, { error: (err as Error).message });
330
+ }
331
+ });
332
+ }
333
+
334
+ // ---------------------------------------------------------------------------
335
+ // JSON helper
336
+ // ---------------------------------------------------------------------------
337
+
338
+ function sendJson(res: ServerResponse, status: number, data: unknown): void {
339
+ const body = JSON.stringify(data);
340
+ res.writeHead(status, {
341
+ "Content-Type": "application/json",
342
+ "Content-Length": Buffer.byteLength(body),
343
+ });
344
+ res.end(body);
345
+ }
@@ -18,3 +18,4 @@ export {
18
18
  export { handleEvents, handleSessions } from "./routes-sessions.js";
19
19
  export { handleTopics } from "./routes-topics.js";
20
20
  export { handleTurns } from "./routes-turns.js";
21
+ export { handleMaintenance } from "./routes-maintenance.js";
@@ -36,6 +36,7 @@ import {
36
36
  handleSessions,
37
37
  handleTopics,
38
38
  handleTurns,
39
+ handleMaintenance,
39
40
  handleStatic,
40
41
  } from "./routes.js";
41
42
 
@@ -223,6 +224,7 @@ export async function launchDashboardServer(
223
224
  if (handleSessions(req, res, ctx)) return;
224
225
  if (handleTopics(req, res, ctx)) return;
225
226
  if (handleTurns(req, res, ctx)) return;
227
+ if (handleMaintenance(req, res, ctx)) return;
226
228
  handleStatic(req, res, ctx);
227
229
  });
228
230
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-3-Clause",
@@ -4,8 +4,9 @@
4
4
  * All pi-agnostic, all parameterized (PREVENT-002), all local (PREVENT-PI-004).
5
5
  * Exposed via the /mega-db-* slash commands in extensions/mega-db-cmds.ts.
6
6
  */
7
- import { statSync } from "node:fs";
7
+ import { statSync, mkdirSync, existsSync } from "node:fs";
8
8
  import { join } from "node:path";
9
+ import { tmpdir } from "node:os";
9
10
  import { getStateDir } from "../../store.js";
10
11
  import { openStore, withTx } from "./utils.js";
11
12
 
@@ -100,6 +101,24 @@ export interface MaintenanceResult {
100
101
  summary: string;
101
102
  }
102
103
 
104
+ /**
105
+ * Lightweight temp backup of the main sqlite.db via VACUUM INTO.
106
+ *
107
+ * Creates a one-off backup in the OS tmpdir. Returns the backup path.
108
+ * VACUUM INTO produces a clean, defragmented copy — also serves as a
109
+ * pre-op safeguard for any destructive maintenance action.
110
+ */
111
+ export function backupDb(stateDir: string = getStateDir()): string {
112
+ const backupDir = join(tmpdir(), "pi-mega-compact-backups");
113
+ if (!existsSync(backupDir)) mkdirSync(backupDir, { recursive: true });
114
+ const ts = Date.now();
115
+ const backupPath = join(backupDir, `sqlite-${ts}.db`);
116
+ const db = openStore(stateDir);
117
+ // guardrails-disable-next-line PREVENT-002: path built from tmpdir + timestamp, not user input
118
+ db.exec(`VACUUM INTO '${backupPath.replace(/'/g, "''")}'`);
119
+ return backupPath;
120
+ }
121
+
103
122
  /**
104
123
  * Prune raw_transcript + checkpoint_epochs rows older than `daysOld`.
105
124
  * Uses `message_timestamp` (raw_transcript) and `created_at` (epochs), both
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * schema.ts — table creation, migrations, `ensureColumn`, PRAGMA setup.
3
3
  */
4
- import { DatabaseSync } from "node:sqlite";
4
+ import type { DatabaseSync } from "node:sqlite";
5
5
  import { ACHIEVEMENT_DEFS } from "../../game/scoring.js";
6
6
 
7
7
  const SCHEMA_VERSION = 2;
@@ -13,14 +13,21 @@ const SCHEMA_VERSION = 2;
13
13
  * input), so the unavoidable identifier interpolation here does not violate
14
14
  * PREVENT-002 (no external data reaches this SQL).
15
15
  */
16
- export function ensureColumn(db: DatabaseSync, table: string, column: string, decl: string): void {
17
- const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
18
- if (cols.some((c) => c.name === column)) return;
19
- db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${decl}`);
16
+ export function ensureColumn(
17
+ db: DatabaseSync,
18
+ table: string,
19
+ column: string,
20
+ decl: string,
21
+ ): void {
22
+ const cols = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{
23
+ name: string;
24
+ }>;
25
+ if (cols.some((c) => c.name === column)) return;
26
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${decl}`);
20
27
  }
21
28
 
22
29
  export function initSchema(db: DatabaseSync): void {
23
- db.exec(`
30
+ db.exec(`
24
31
  CREATE TABLE IF NOT EXISTS context_chunks (
25
32
  id TEXT NOT NULL,
26
33
  session_id TEXT NOT NULL,
@@ -111,10 +118,6 @@ export function initSchema(db: DatabaseSync): void {
111
118
  );
112
119
  CREATE INDEX IF NOT EXISTS idx_raptor_session ON raptor_nodes(session_id);
113
120
  CREATE INDEX IF NOT EXISTS idx_raptor_parent ON raptor_nodes(parent_id);
114
- -- S42D/QA perf: session-scoped built_at ordering so the per-search cache
115
- -- invalidation MAX(built_at) is index-satisfied.
116
- CREATE INDEX IF NOT EXISTS idx_raptor_session_built
117
- ON raptor_nodes(session_id, built_at DESC);
118
121
 
119
122
  -- S42D: structured RAPTOR build history. One row per tree build; the
120
123
  -- freshness check (buildHistory.ts) uses completed_at + leaf_count to skip
@@ -364,45 +367,55 @@ export function initSchema(db: DatabaseSync): void {
364
367
  CREATE INDEX IF NOT EXISTS idx_perf_samples_ts ON perf_samples(ts);
365
368
  CREATE INDEX IF NOT EXISTS idx_perf_samples_kind_ts ON perf_samples(kind, ts);
366
369
  `);
367
- // Idempotent column migrations. `CREATE TABLE IF NOT EXISTS` is a no-op on a
368
- // pre-existing table, so new columns added to context_chunks after a store was
369
- // first created (e.g. original_token_estimate in v0.4.2) must be ALTERed in for
370
- // databases created by an older version — otherwise repoStats()/upsert crash
371
- // with "no such column" and the extension fails to load. Additive only.
372
- ensureColumn(db, "context_chunks", "original_token_estimate", "INTEGER");
373
- // S27 Task 6: content_ref column in raw_transcript for dedup_mirror references.
374
- ensureColumn(db, "raw_transcript", "content_ref", "TEXT");
375
- // S20 memory-RAG extension: additive columns for auto-review ops. Idempotent —
376
- // only alters DBs created by an older version that lack these columns.
377
- ensureColumn(db, "memories", "category", "TEXT");
378
- ensureColumn(db, "memories", "target", "TEXT");
379
- ensureColumn(db, "memories", "last_referenced", "INTEGER");
380
- ensureColumn(db, "memories", "source_turn", "INTEGER");
381
- // S25: RAPTOR freshness-guard timestamp. Additive; old DBs have NULL → 0 →
382
- // treated as stale → flat fallback (safe).
383
- ensureColumn(db, "raptor_nodes", "built_at", "INTEGER");
384
- // S43: turn_index on raw_transcript so a message points directly at its
385
- // conversation turn (otherwise it must be inferred from seq ordering). NULL
386
- // for legacy rows turns written before S43 have no turn link.
387
- ensureColumn(db, "raw_transcript", "turn_index", "INTEGER");
388
- // S43: conversation_id + last_turn_id on session_state (legacy DBs have NULL).
389
- ensureColumn(db, "session_state", "conversation_id", "TEXT");
390
- ensureColumn(db, "session_state", "last_turn_id", "INTEGER");
391
- // S35: idempotent seed of the 9 achievement rows. ON CONFLICT(id) DO
392
- // NOTHING so a re-open never clobbers an already-unlocked row's
393
- // unlocked_at. No user input reaches this SQL (PREVENT-002 safe).
394
- const seedAch = db.prepare(
395
- `INSERT INTO game_achievements (id, title, description, hidden, icon)
370
+ // Idempotent column migrations. `CREATE TABLE IF NOT EXISTS` is a no-op on a
371
+ // pre-existing table, so new columns added to context_chunks after a store was
372
+ // first created (e.g. original_token_estimate in v0.4.2) must be ALTERed in for
373
+ // databases created by an older version — otherwise repoStats()/upsert crash
374
+ // with "no such column" and the extension fails to load. Additive only.
375
+ ensureColumn(db, "context_chunks", "original_token_estimate", "INTEGER");
376
+ // S27 Task 6: content_ref column in raw_transcript for dedup_mirror references.
377
+ ensureColumn(db, "raw_transcript", "content_ref", "TEXT");
378
+ // S20 memory-RAG extension: additive columns for auto-review ops. Idempotent —
379
+ // only alters DBs created by an older version that lack these columns.
380
+ ensureColumn(db, "memories", "category", "TEXT");
381
+ ensureColumn(db, "memories", "target", "TEXT");
382
+ ensureColumn(db, "memories", "last_referenced", "INTEGER");
383
+ ensureColumn(db, "memories", "source_turn", "INTEGER");
384
+ // S25: RAPTOR freshness-guard timestamp. Additive; old DBs have NULL → 0 →
385
+ // treated as stale → flat fallback (safe).
386
+ ensureColumn(db, "raptor_nodes", "built_at", "INTEGER");
387
+ // S42D/QA perf: session-scoped built_at ordering. Must follow ensureColumn
388
+ // above old DBs already have raptor_nodes without built_at, so CREATE TABLE
389
+ // IF NOT EXISTS is a no-op that doesn't add the column, and moving this index
390
+ // inside the DDL block caused "no such column: built_at" on upgrade.
391
+ db.exec(
392
+ "CREATE INDEX IF NOT EXISTS idx_raptor_session_built ON raptor_nodes(session_id, built_at DESC)",
393
+ );
394
+ // S43: turn_index on raw_transcript so a message points directly at its
395
+ // conversation turn (otherwise it must be inferred from seq ordering). NULL
396
+ // for legacy rows turns written before S43 have no turn link.
397
+ ensureColumn(db, "raw_transcript", "turn_index", "INTEGER");
398
+ // S43: conversation_id + last_turn_id on session_state (legacy DBs have NULL).
399
+ ensureColumn(db, "session_state", "conversation_id", "TEXT");
400
+ ensureColumn(db, "session_state", "last_turn_id", "INTEGER");
401
+ // S35: idempotent seed of the 9 achievement rows. ON CONFLICT(id) DO
402
+ // NOTHING so a re-open never clobbers an already-unlocked row's
403
+ // unlocked_at. No user input reaches this SQL (PREVENT-002 safe).
404
+ const seedAch = db.prepare(
405
+ `INSERT INTO game_achievements (id, title, description, hidden, icon)
396
406
  VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING`,
397
- );
398
- for (const d of ACHIEVEMENT_DEFS) {
399
- seedAch.run(d.id, d.title, d.description, d.hidden ? 1 : 0, d.icon);
400
- }
407
+ );
408
+ for (const d of ACHIEVEMENT_DEFS) {
409
+ seedAch.run(d.id, d.title, d.description, d.hidden ? 1 : 0, d.icon);
410
+ }
401
411
 
402
- const v = db.prepare("SELECT value FROM meta WHERE key='schema_version'").get() as
403
- | { value: string }
404
- | undefined;
405
- if (!v) {
406
- db.prepare("INSERT INTO meta(key, value) VALUES(?, ?)").run("schema_version", String(SCHEMA_VERSION));
407
- }
412
+ const v = db
413
+ .prepare("SELECT value FROM meta WHERE key='schema_version'")
414
+ .get() as { value: string } | undefined;
415
+ if (!v) {
416
+ db.prepare("INSERT INTO meta(key, value) VALUES(?, ?)").run(
417
+ "schema_version",
418
+ String(SCHEMA_VERSION),
419
+ );
420
+ }
408
421
  }