src/vs/platform/agentHost/node/sessionDatabase.ts

781 LOC · 651 covered · 130 uncovered · 128 ranges · 974 concepts · 50 introducers · 509 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the related-file, concept, and source links on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- sessionDatabase.ts ×50
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import * as fs from 'fs';
7 > import { SequencerByKey } from '../../../base/common/async.js';
8 > import type { Database, RunResult } from '@vscode/sqlite3';
9 > import type { IFileEditContent, IFileEditRecord, ILocalTurnRecord, IReviewedFileRecord, ISessionDatabase } from '../common/sessionDataService.js';
10 > import { dirname } from '../../../base/common/path.js';
11 > import { URI } from '../../../base/common/uri.js';
12 > import type { Message } from '../common/state/sessionState.js';
13 >
14 > /**
15 > * A single numbered migration. Migrations are applied in order of
16 > * {@link version} and tracked via `PRAGMA user_version`.
17 > */
18 > export interface ISessionDatabaseMigration {
19 > /** Monotonically-increasing version number (1-based). */
20 > readonly version: number;
21 > /** SQL to execute for this migration. */
22 > readonly sql: string;
23 > }
24 >
25 > /**
26 > * The set of migrations that define the current session database schema.
27 > * New migrations should be **appended** to this array with the next version
28 > * number. Never reorder or mutate existing entries.
29 > */
30 > export const sessionDatabaseMigrations: readonly ISessionDatabaseMigration[] = [
31 > {
32 > version: 1,
33 > sql: [
34 > `CREATE TABLE IF NOT EXISTS turns (
35 > id TEXT PRIMARY KEY NOT NULL
36 > )`,
37 > `CREATE TABLE IF NOT EXISTS file_edits (
38 > turn_id TEXT NOT NULL REFERENCES turns(id) ON DELETE CASCADE,
39 > tool_call_id TEXT NOT NULL,
40 > file_path TEXT NOT NULL,
41 > before_content BLOB NOT NULL,
42 > after_content BLOB NOT NULL,
43 > added_lines INTEGER,
44 > removed_lines INTEGER,
45 > PRIMARY KEY (tool_call_id, file_path)
46 > )`,
47 > ].join(';\n'),
48 > },
49 > {
50 > version: 2,
51 > sql: `CREATE TABLE IF NOT EXISTS session_metadata (
52 > key TEXT PRIMARY KEY NOT NULL,
53 > value TEXT NOT NULL
54 > )`,
55 > },
56 > {
57 > version: 3,
58 > sql: [
59 > // Recreate file_edits with new columns: edit_type, original_path,
60 > // and nullable before_content/after_content.
61 > `CREATE TABLE file_edits_v3 (
62 > turn_id TEXT NOT NULL REFERENCES turns(id) ON DELETE CASCADE,
63 > tool_call_id TEXT NOT NULL,
64 > file_path TEXT NOT NULL,
65 > edit_type TEXT NOT NULL DEFAULT 'edit',
66 > original_path TEXT,
67 > before_content BLOB,
68 > after_content BLOB,
69 > added_lines INTEGER,
70 > removed_lines INTEGER,
71 > PRIMARY KEY (tool_call_id, file_path)
72 > )`,
73 > `INSERT INTO file_edits_v3 (turn_id, tool_call_id, file_path, edit_type, before_content, after_content, added_lines, removed_lines)
74 > SELECT turn_id, tool_call_id, file_path, 'edit', before_content, after_content, added_lines, removed_lines FROM file_edits`,
75 > `DROP TABLE file_edits`,
76 > `ALTER TABLE file_edits_v3 RENAME TO file_edits`,
77 > ].join(';\n'),
78 > },
79 > {
80 > version: 4,
81 > sql: [
82 > `ALTER TABLE turns ADD COLUMN event_id TEXT`,
83 > `CREATE INDEX IF NOT EXISTS idx_turns_event_id ON turns(event_id)`,
84 > ].join(';\n'),
85 > },
86 > {
87 > version: 5,
88 > sql: `ALTER TABLE turns ADD COLUMN checkpoint_ref TEXT`,
89 > },
90 > {
91 > version: 6,
92 > sql: `CREATE TABLE IF NOT EXISTS chat_drafts (
93 > chat_uri TEXT PRIMARY KEY NOT NULL,
94 > draft TEXT NOT NULL
95 > )`,
96 > },
97 > {
98 > version: 7,
99 > sql: `CREATE TABLE IF NOT EXISTS reviewed_files (
100 > uri TEXT NOT NULL,
101 > nonce TEXT NOT NULL,
102 > PRIMARY KEY (uri, nonce)
103 > )`,
104 > },
105 > {
106 > version: 8,
107 > sql: `CREATE TABLE IF NOT EXISTS local_turns (
108 > turn_id TEXT PRIMARY KEY NOT NULL,
109 > chat_uri TEXT NOT NULL,
110 > anchor_turn_id TEXT,
111 > seq INTEGER NOT NULL,
112 > payload TEXT NOT NULL
113 > )`,
114 > },
115 > ];
116 >
117 > // ---- Promise wrappers around callback-based @vscode/sqlite3 API -----------
118 >
119 > function dbExec(db: Database, sql: string): Promise<void> { sessionDatabase.ts ×13
120 > return new Promise((resolve, reject) => {
121 > db.exec(sql, err => err ? reject(err) : resolve());
122 > });
123 > }
125 > function dbRun(db: Database, sql: string, params: unknown[]): Promise<{ changes: number; lastID: number }> { sessionDatabase.ts ×2
126 > return new Promise((resolve, reject) => {
127 > db.run(sql, params, function (this: RunResult, err: Error | null) {
128 > if (err) {
129 return reject(err);
130 }
131 > resolve({ changes: this.changes, lastID: this.lastID }); sessionDatabase.ts ×2
132 > });
133 > });
134 > }
136 > function dbGet(db: Database, sql: string, params: unknown[]): Promise<Record<string, unknown> | undefined> { sessionDatabase.ts ×13
137 > return new Promise((resolve, reject) => {
138 > db.get(sql, params, (err: Error | null, row: Record<string, unknown> | undefined) => {
139 > if (err) {
140 return reject(err);
141 }
142 > resolve(row); sessionDatabase.ts ×13
143 > });
144 > });
145 > }
147 > function dbAll(db: Database, sql: string, params: unknown[]): Promise<Record<string, unknown>[]> { sessionDatabase.ts ×2
148 > return new Promise((resolve, reject) => {
149 > db.all(sql, params, (err: Error | null, rows: Record<string, unknown>[]) => {
150 > if (err) {
151 return reject(err);
152 }
153 > resolve(rows); sessionDatabase.ts ×2
154 > });
155 > });
156 > }
158 > function dbClose(db: Database): Promise<void> { sessionDatabase.ts ×13
159 > return new Promise((resolve, reject) => {
160 > db.close(err => err ? reject(err) : resolve());
161 > });
162 > }
164 > function dbOpen(path: string): Promise<Database> { sessionDatabase.ts ×13
165 > return new Promise((resolve, reject) => {
166 > import('@vscode/sqlite3').then(sqlite3 => {
167 > const db = new sqlite3.default.Database(path, (err: Error | null) => {
168 > if (err) {
169 return reject(err);
170 }
171 > resolve(db); sessionDatabase.ts ×13
172 > });
173 > }, reject);
174 > });
175 > }
177 > /**
178 > * Applies any pending {@link ISessionDatabaseMigration migrations} to a
179 > * database. Migrations whose version is greater than the current
180 > * `PRAGMA user_version` are run inside a serialized transaction. After all
181 > * migrations complete the pragma is updated to the highest applied version.
182 > */
183 > export async function runMigrations(db: Database, migrations: readonly ISessionDatabaseMigration[]): Promise<void> { sessionDatabase.ts ×13
184 > // Enable foreign key enforcement — must be set outside a transaction
185 > // and every time a connection is opened.
186 > await dbExec(db, 'PRAGMA foreign_keys = ON');
187 >
188 > const row = await dbGet(db, 'PRAGMA user_version', []);
189 > const currentVersion = (row?.user_version as number | undefined) ?? 0;
190 >
191 > const pending = migrations
192 > .filter(m => m.version > currentVersion)
193 > .sort((a, b) => a.version - b.version);
194 >
195 > if (pending.length === 0) {
196 > return; sessionDatabase.ts ×1
197 > }
199 > await dbExec(db, 'BEGIN TRANSACTION');
200 > try {
201 > for (const migration of pending) {
202 > await dbExec(db, migration.sql);
203 > // PRAGMA cannot be parameterized; the version is a trusted literal.
204 > await dbExec(db, `PRAGMA user_version = ${migration.version}`);
205 > }
206 > await dbExec(db, 'COMMIT');
207 > } catch (err) {
208 > await dbExec(db, 'ROLLBACK'); sessionDatabase.ts ×2
209 > throw err;
210 > }
213 > /**
214 > * A wrapper around a `@vscode/sqlite3` {@link Database} instance with
215 > * lazy initialisation.
216 > *
217 > * The underlying connection is opened on the first async method call
218 > * (not at construction time), allowing the object to be created
219 > * synchronously and shared via a {@link ReferenceCollection}.
220 > *
221 > * Calling {@link dispose} closes the connection.
222 > */
223 > export class SessionDatabase implements ISessionDatabase {
224 >
225 > protected _dbPromise: Promise<Database> | undefined;
226 > protected _closed: Promise<void> | true | undefined;
227 > private readonly _fileEditSequencer = new SequencerByKey<string>();
228 >
229 > /**
230 > * Serializes `setMetadata` writes per key. `@vscode/sqlite3` runs in
231 > * parallelized mode, so two `db.run()` calls on the same connection
232 > * can be dispatched to the libuv thread pool and complete out of
233 > * submission order. For "last writer wins" keys (notably `configValues`
234 > * via {@link setMetadata}), that meant a fast-following second write
235 > * could be overtaken by the first and silently lose its value — see
236 > * the "Session Config persistence across restarts" integration test.
237 > * Sequencing by key preserves intra-key order while still allowing
238 > * writes for different keys to run concurrently.
239 > */
240 > private readonly _metadataSequencer = new SequencerByKey<string>();
241 >
242 > /**
243 > * In-flight write operations. Tracked so {@link whenIdle} can await them
244 > * before the process exits — without this, a `SIGTERM` arriving between
245 > * a fire-and-forget mutating call (e.g. `setMetadata`) being invoked and
246 > * its underlying SQLite query completing would silently drop the write.
247 > * Every public mutating method routes its returned promise through
248 > * {@link _track}; reads (`getMetadata`, `getFileEdits`, ...) skip
249 > * tracking since shutdown does not need to wait for them.
250 > */
251 > private readonly _pendingWrites = new Set<Promise<unknown>>();
252 >
253 > constructor(
254 > private readonly _path: string, sessionDatabase.ts ×2
255 > private readonly _migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations,
256 > ) { }
258 > /**
259 > * Opens (or creates) a SQLite database at {@link path} and applies
260 > * any pending migrations. Only used in tests where synchronous
261 > * construction + immediate readiness is desired.
262 > */
263 > static async open(path: string, migrations: readonly ISessionDatabaseMigration[] = sessionDatabaseMigrations): Promise<SessionDatabase> {
264 > const inst = new SessionDatabase(path, migrations); sessionDatabase.ts ×1
265 > await inst._ensureDb();
266 > return inst;
267 > }
269 > protected _ensureDb(): Promise<Database> {
270 > if (this._closed) { sessionDatabase.ts ×2
271 > return Promise.reject(new Error('SessionDatabase has been disposed')); sessionDatabase.ts ×1
272 > }
273 > if (!this._dbPromise) { sessionDatabase.ts ×13
274 > this._dbPromise = (async () => {
275 > // Ensure the parent directory exists before SQLite tries to
276 > // create the database file.
277 > await fs.promises.mkdir(dirname(this._path), { recursive: true });
278 > const db = await dbOpen(this._path);
279 > try {
280 > await runMigrations(db, this._migrations);
281 > } catch (err) {
282 > await dbClose(db); sessionDatabase.ts ×2
283 > this._dbPromise = undefined;
284 > throw err;
285 > }
286 > // If dispose() was called while we were opening, close immediately. sessionDatabase.ts ×13
287 > if (this._closed) {
288 await dbClose(db);
289 throw new Error('SessionDatabase has been disposed');
290 }
291 > return db; sessionDatabase.ts ×13
292 > })().catch(err => {
293 > this._dbPromise = undefined; sessionDatabase.ts ×1
294 > throw err;
296 > }
297 > return this._dbPromise;
300 > /**
301 > * Returns the names of all user-created tables in the database.
302 > * Useful for testing migration behavior.
303 > */
304 > async getAllTables(): Promise<string[]> {
305 > const db = await this._ensureDb(); sessionDatabase.ts ×1
306 > const rows = await dbAll(db, `SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`, []);
307 > return rows.map(r => r.name as string);
308 > }
310 > // ---- Turns ----------------------------------------------------------
311 >
312 > createTurn(turnId: string): Promise<void> {
313 > return this._track(async () => { sessionDatabase.ts ×2
314 > const db = await this._ensureDb();
315 > await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]); sessionDatabase.ts ×1
317 > }
319 > deleteTurn(turnId: string): Promise<void> {
320 > return this._track(async () => { sessionDatabase.ts ×1
321 > const db = await this._ensureDb();
322 > await dbRun(db, 'DELETE FROM turns WHERE id = ?', [turnId]);
323 > });
324 > }
326 > setTurnEventId(turnId: string, eventId: string): Promise<void> {
327 > return this._track(async () => { sessionDatabase.ts ×1
328 > const db = await this._ensureDb();
329 > await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]);
330 > // Only set the event ID if not already set — steering messages
331 > // trigger additional user.message events within the same turn,
332 > // and we must preserve the first (boundary) event ID.
333 > await dbRun(db, 'UPDATE turns SET event_id = ? WHERE id = ? AND event_id IS NULL', [eventId, turnId]);
334 > });
335 > }
337 > async getTurnEventId(turnId: string): Promise<string | undefined> {
338 > const db = await this._ensureDb(); sessionDatabase.ts ×2
339 > const row = await dbGet(db, 'SELECT event_id FROM turns WHERE id = ?', [turnId]);
340 > return row?.event_id as string | undefined ?? undefined;
341 > }
343 > async getNextTurnEventId(turnId: string): Promise<string | undefined> {
344 > const db = await this._ensureDb(); sessionDatabase.ts ×1
345 > // `turns.id` is the canonical turn key — either a live `request_xxx`
346 > // dispatched by the client or, for sessions restored from disk, the
347 > // SDK envelope id surfaced by `mapSessionEvents`. The `event_id`
348 > // fallback covers the case where the caller asks about a turn that
349 > // was set up live (id=`request_xxx`) but is now being referenced
350 > // via the SDK event id, or vice versa.
351 > const row = await dbGet(
352 > db,
353 > `SELECT event_id FROM turns
354 > WHERE rowid > (
355 > SELECT rowid FROM turns WHERE id = ?1 OR event_id = ?1 LIMIT 1
356 > )
357 > ORDER BY rowid LIMIT 1`,
358 > [turnId],
359 > );
360 > return row?.event_id as string | undefined ?? undefined;
361 > }
363 > async getFirstTurnEventId(): Promise<string | undefined> {
364 const db = await this._ensureDb();
365 const row = await dbGet(db, 'SELECT event_id FROM turns ORDER BY rowid LIMIT 1', []);
366 return row?.event_id as string | undefined ?? undefined;
367 }
369 > setTurnCheckpointRef(turnId: string, ref: string): Promise<void> {
370 > return this._track(async () => { sessionDatabase.ts ×1
371 > const db = await this._ensureDb();
372 > await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [turnId]);
373 > await dbRun(db, 'UPDATE turns SET checkpoint_ref = ? WHERE id = ?', [ref, turnId]);
374 > });
375 > }
377 > async getTurnCheckpointRef(turnId: string): Promise<string | undefined> {
378 > const db = await this._ensureDb(); sessionDatabase.ts ×1
379 > const row = await dbGet(db, 'SELECT checkpoint_ref FROM turns WHERE id = ?1 OR event_id = ?1 LIMIT 1', [turnId]);
380 > return row?.checkpoint_ref as string | undefined ?? undefined;
381 > }
383 > async getPreviousCheckpointRef(turnId: string): Promise<string | undefined> {
384 > const db = await this._ensureDb(); sessionDatabase.ts ×1
385 > const row = await dbGet(
386 > db,
387 > `SELECT checkpoint_ref FROM turns
388 > WHERE rowid < (SELECT rowid FROM turns WHERE id = ?1 OR event_id = ?1 LIMIT 1)
389 > AND checkpoint_ref IS NOT NULL
390 > ORDER BY rowid DESC LIMIT 1`,
391 > [turnId],
392 > );
393 > return row?.checkpoint_ref as string | undefined ?? undefined;
394 > }
396 > async getAllCheckpointRefs(): Promise<string[]> {
397 const db = await this._ensureDb();
398 const rows = await dbAll(db, 'SELECT checkpoint_ref FROM turns WHERE checkpoint_ref IS NOT NULL ORDER BY rowid', []);
399 return rows.map(r => r.checkpoint_ref as string);
400 }
402 > truncateFromTurn(turnId: string): Promise<void> {
403 return this._track(async () => {
404 const db = await this._ensureDb();
405 // Delete the target turn and all turns inserted after it (by rowid order).
406 // File edits cascade-delete via the foreign key constraint.
407 await dbRun(db,
408 `DELETE FROM turns WHERE rowid >= (SELECT rowid FROM turns WHERE id = ?)`,
409 [turnId],
410 );
411 });
412 }
414 > deleteTurnsAfter(turnId: string): Promise<void> {
415 return this._track(async () => {
416 const db = await this._ensureDb();
417 // Delete all turns inserted after the given turn (by rowid order),
418 // keeping the given turn itself.
419 // File edits cascade-delete via the foreign key constraint.
420 await dbRun(db,
421 `DELETE FROM turns WHERE rowid > (SELECT rowid FROM turns WHERE id = ?)`,
422 [turnId],
423 );
424 });
425 }
427 > deleteAllTurns(): Promise<void> {
428 return this._track(async () => {
429 const db = await this._ensureDb();
430 await dbExec(db, 'DELETE FROM turns');
431 });
432 }
434 > // ---- Local (host-injected) turns ------------------------------------
435 >
436 > insertLocalTurn(record: ILocalTurnRecord): Promise<void> {
437 > return this._track(async () => { sessionDatabase.ts ×2
438 > const db = await this._ensureDb();
439 > await dbRun(db,
440 > 'INSERT OR REPLACE INTO local_turns (turn_id, chat_uri, anchor_turn_id, seq, payload) VALUES (?, ?, ?, ?, ?)',
441 > [record.turnId, record.chatUri, record.anchorTurnId ?? null, record.seq, record.payload],
442 > );
443 > });
444 > }
446 > async getLocalTurns(): Promise<ILocalTurnRecord[]> {
447 > const db = await this._ensureDb(); sessionDatabase.ts ×2
448 > const rows = await dbAll(db, 'SELECT turn_id, chat_uri, anchor_turn_id, seq, payload FROM local_turns ORDER BY seq', []);
449 > return rows.map(r => ({
450 > turnId: r.turn_id as string, sessionDatabase.ts ×2
451 > chatUri: r.chat_uri as string,
452 > anchorTurnId: (r.anchor_turn_id as string | null) ?? undefined,
453 > seq: r.seq as number,
454 > payload: r.payload as string,
456 > }
458 > deleteLocalTurns(turnIds: readonly string[]): Promise<void> {
459 return this._track(async () => {
460 if (turnIds.length === 0) {
461 return;
462 }
463 const db = await this._ensureDb();
464 const placeholders = turnIds.map(() => '?').join(',');
465 await dbRun(db, `DELETE FROM local_turns WHERE turn_id IN (${placeholders})`, [...turnIds]);
466 });
467 }
469 > // ---- File edits -----------------------------------------------------
470 >
471 > storeFileEdit(edit: IFileEditRecord & IFileEditContent): Promise<void> {
472 > return this._track(() => this._fileEditSequencer.queue(edit.filePath, async () => { sessionDatabase.ts ×1
473 > const db = await this._ensureDb();
474 > // Ensure the turn exists — lazily insert since the turn record
475 > // may not have been created by an explicit createTurn() call.
476 > await dbRun(db, 'INSERT OR IGNORE INTO turns (id) VALUES (?)', [edit.turnId]);
477 > await dbRun(
478 > db,
479 > `INSERT OR REPLACE INTO file_edits
480 > (turn_id, tool_call_id, file_path, edit_type, original_path, before_content, after_content, added_lines, removed_lines)
481 > VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
482 > [
483 > edit.turnId,
484 > edit.toolCallId,
485 > edit.filePath,
486 > edit.kind,
487 > edit.originalPath ?? null,
488 > edit.beforeContent ? Buffer.from(edit.beforeContent) : null,
489 > edit.afterContent ? Buffer.from(edit.afterContent) : null,
490 > edit.addedLines ?? null,
491 > edit.removedLines ?? null,
492 > ],
493 > );
494 > }));
495 > }
497 > async getFileEdits(toolCallIds: string[]): Promise<IFileEditRecord[]> {
498 > if (toolCallIds.length === 0) { sessionDatabase.ts ×2
499 > return []; sessionDatabase.ts ×1
500 > }
501 > const db = await this._ensureDb(); sessionDatabase.ts ×2
502 > const placeholders = toolCallIds.map(() => '?').join(',');
503 > const rows = await dbAll(
504 > db,
505 > `SELECT turn_id, tool_call_id, file_path, edit_type, original_path, added_lines, removed_lines
506 > FROM file_edits
507 > WHERE tool_call_id IN (${placeholders})
508 > ORDER BY rowid`,
509 > toolCallIds,
510 > );
511 > return rows.map(row => ({
512 > turnId: row.turn_id as string, sessionDatabase.ts ×1
513 > toolCallId: row.tool_call_id as string,
514 > filePath: row.file_path as string,
515 > kind: (row.edit_type as IFileEditRecord['kind']) ?? 'edit',
516 > originalPath: row.original_path as string | undefined ?? undefined,
517 > addedLines: row.added_lines as number | undefined ?? undefined,
518 > removedLines: row.removed_lines as number | undefined ?? undefined,
522 > async getAllFileEdits(): Promise<IFileEditRecord[]> {
523 > const db = await this._ensureDb(); sessionDatabase.ts ×1
524 > const rows = await dbAll(
525 > db,
526 > `SELECT turn_id, tool_call_id, file_path, edit_type, original_path, added_lines, removed_lines
527 > FROM file_edits
528 > ORDER BY rowid`,
529 > [],
530 > );
531 > return rows.map(row => ({
532 > turnId: row.turn_id as string,
533 > toolCallId: row.tool_call_id as string,
534 > filePath: row.file_path as string,
535 > kind: (row.edit_type as IFileEditRecord['kind']) ?? 'edit',
536 > originalPath: row.original_path as string | undefined ?? undefined,
537 > addedLines: row.added_lines as number | undefined ?? undefined,
538 > removedLines: row.removed_lines as number | undefined ?? undefined,
539 > }));
540 > }
542 > async getFileEditsByTurn(turnId: string): Promise<IFileEditRecord[]> {
543 const db = await this._ensureDb();
544 const rows = await dbAll(
545 db,
546 `SELECT turn_id, tool_call_id, file_path, edit_type, original_path, added_lines, removed_lines
547 FROM file_edits
548 WHERE turn_id = ?
549 ORDER BY rowid`,
550 [turnId],
551 );
552 return rows.map(row => ({
553 turnId: row.turn_id as string,
554 toolCallId: row.tool_call_id as string,
555 filePath: row.file_path as string,
556 kind: (row.edit_type as IFileEditRecord['kind']) ?? 'edit',
557 originalPath: row.original_path as string | undefined ?? undefined,
558 addedLines: row.added_lines as number | undefined ?? undefined,
559 removedLines: row.removed_lines as number | undefined ?? undefined,
560 }));
561 }
563 > async readFileEditContent(toolCallId: string, filePath: string): Promise<IFileEditContent | undefined> {
564 > return this._fileEditSequencer.queue(filePath, async () => { sessionDatabase.ts ×2
565 > const db = await this._ensureDb();
566 > const row = await dbGet(
567 > db,
568 > `SELECT before_content, after_content
569 > FROM file_edits
570 > WHERE tool_call_id = ? AND file_path = ?`,
571 > [toolCallId, filePath],
572 > );
573 > if (!row) {
574 > return undefined; sessionDatabase.ts ×1
575 > }
576 > return { sessionDatabase.ts ×2
577 > beforeContent: row.before_content ? toUint8Array(row.before_content) : undefined, sessionDatabase.ts ×2
578 > afterContent: row.after_content ? toUint8Array(row.after_content) : undefined,
579 > };
580 > });
581 > }
583 > // ---- Session metadata -----------------------------------------------
584 >
585 > async getMetadata(key: string): Promise<string | undefined> {
586 > const db = await this._ensureDb(); sessionDatabase.ts ×1
587 > const row = await dbGet(db, 'SELECT value FROM session_metadata WHERE key = ?', [key]);
588 > return row?.value as string | undefined;
589 > }
591 > async getMetadataObject<T extends Record<string, unknown>>(obj: T): Promise<{ [K in keyof T]: string | undefined }> {
592 > const keys = Object.keys(obj) as (keyof T & string)[]; sessionDatabase.ts ×3
593 > // eslint-disable-next-line local/code-no-dangerous-type-assertions
594 > const result = {} as { [K in keyof T]: string | undefined };
595 > if (keys.length === 0) {
596 return result;
597 }
598 > const db = await this._ensureDb(); sessionDatabase.ts ×3
599 > const placeholders = keys.map(() => '?').join(',');
600 > const rows = await dbAll(db, `SELECT key, value FROM session_metadata WHERE key IN (${placeholders})`, keys);
601 > for (const key of keys) {
602 > result[key] = undefined;
603 > }
604 > for (const row of rows) {
605 > result[row.key as keyof T] = row.value as string; sessionDatabase.ts ×1
606 > }
607 > return result; sessionDatabase.ts ×3
608 > }
610 > setMetadata(key: string, value: string): Promise<void> {
611 > return this._track(() => this._metadataSequencer.queue(key, async () => { sessionDatabase.ts ×1
612 > const db = await this._ensureDb();
613 > await dbRun(db, 'INSERT OR REPLACE INTO session_metadata (key, value) VALUES (?, ?)', [key, value]);
614 > }));
615 > }
617 > setChatDraft(chat: URI, draft: Message | undefined): Promise<void> {
618 > const chatUri = chat.toString(); sessionDatabase.ts ×2
619 > return this._track(async () => {
620 > const db = await this._ensureDb();
621 > if (!draft) {
622 > await dbRun(db, 'DELETE FROM chat_drafts WHERE chat_uri = ?', [chatUri]); sessionDatabase.ts ×1
623 > return;
624 > }
625 > await dbRun(db, 'INSERT OR REPLACE INTO chat_drafts (chat_uri, draft) VALUES (?, ?)', [chatUri, JSON.stringify(draft)]); sessionDatabase.ts ×2
626 > });
627 > }
629 > async getChatDraft(chat: URI): Promise<Message | undefined> {
630 > const db = await this._ensureDb(); sessionDatabase.ts ×2
631 > const row = await dbGet(db, 'SELECT draft FROM chat_drafts WHERE chat_uri = ?', [chat.toString()]);
632 > if (typeof row?.draft !== 'string') {
633 > return undefined; sessionDatabase.ts ×1
634 > }
636 > return JSON.parse(row.draft) as Message;
637 > } catch {
638 > return undefined; sessionDatabase.ts ×1
639 > }
642 > // ---- Reviewed files -------------------------------------------------
643 >
644 > markFileReviewed(uri: URI, nonce: string): Promise<void> {
645 > return this._track(async () => { sessionDatabase.ts ×1
646 > const db = await this._ensureDb();
647 > await dbRun(db, 'INSERT OR IGNORE INTO reviewed_files (uri, nonce) VALUES (?, ?)', [uri.toString(), nonce]);
648 > });
649 > }
651 > unmarkFileReviewed(uri: URI, nonce: string): Promise<void> {
652 > return this._track(async () => { sessionDatabase.ts ×1
653 > const db = await this._ensureDb();
654 > await dbRun(db, 'DELETE FROM reviewed_files WHERE uri = ? AND nonce = ?', [uri.toString(), nonce]);
655 > });
656 > }
658 > async getReviewedFiles(): Promise<IReviewedFileRecord[]> {
659 > const db = await this._ensureDb(); sessionDatabase.ts ×1
660 > const rows = await dbAll(db, 'SELECT uri, nonce FROM reviewed_files ORDER BY rowid', []);
661 > return rows.map(toReviewedFileRecord);
662 > }
664 > async getReviewedFilesForUri(uri: URI): Promise<IReviewedFileRecord[]> {
665 > const db = await this._ensureDb(); sessionDatabase.ts ×1
666 > const rows = await dbAll(db, 'SELECT uri, nonce FROM reviewed_files WHERE uri = ? ORDER BY rowid', [uri.toString()]);
667 > return rows.map(toReviewedFileRecord);
668 > }
670 > async isFileReviewed(uri: URI, nonce: string): Promise<boolean> {
671 > const db = await this._ensureDb(); sessionDatabase.ts ×1
672 > const row = await dbGet(db, 'SELECT 1 FROM reviewed_files WHERE uri = ? AND nonce = ? LIMIT 1', [uri.toString(), nonce]);
673 > return !!row;
674 > }
676 > remapTurnIds(mapping: ReadonlyMap<string, string>): Promise<void> {
677 return this._track(async () => {
678 const db = await this._ensureDb();
679 // Defer FK checks to commit time so we can update turns.id and
680 // file_edits.turn_id in any order without mid-statement violations.
681 // This pragma auto-resets after the transaction ends.
682 await dbExec(db, 'PRAGMA defer_foreign_keys = ON');
683 await dbExec(db, 'BEGIN TRANSACTION');
684 try {
685 // Delete turns not present in the mapping (e.g. turns beyond
686 // the fork point). File edits cascade-delete via FK.
687 const oldIds = [...mapping.keys()];
688 if (oldIds.length > 0) {
689 const placeholders = oldIds.map(() => '?').join(',');
690 await dbRun(db,
691 `DELETE FROM turns WHERE id NOT IN (${placeholders})`,
692 oldIds,
693 );
694 }
695
696 // Remap the remaining turn IDs to their new values
697 for (const [oldId, newId] of mapping) {
698 await dbRun(db, 'UPDATE turns SET id = ? WHERE id = ?', [newId, oldId]);
699 await dbRun(db, 'UPDATE file_edits SET turn_id = ? WHERE turn_id = ?', [newId, oldId]);
700 }
701
702 if (oldIds.length > 0) {
703 const placeholders = oldIds.map(() => '?').join(',');
704 await dbRun(db,
705 `DELETE FROM local_turns WHERE turn_id NOT IN (${placeholders})`,
706 oldIds,
707 );
708 }
709 for (const [oldId, newId] of mapping) {
710 await dbRun(db, 'UPDATE local_turns SET turn_id = ? WHERE turn_id = ?', [newId, oldId]);
711 await dbRun(db, 'UPDATE local_turns SET anchor_turn_id = ? WHERE anchor_turn_id = ?', [newId, oldId]);
712 }
713 await dbExec(db, 'COMMIT');
714 } catch (err) {
715 await dbExec(db, 'ROLLBACK');
716 throw err;
717 }
718 });
719 }
721 > /**
722 > * Resolves once all currently in-flight write operations have settled.
723 > * Used by graceful shutdown to flush pending fire-and-forget writes
724 > * before the process exits. Should be called from a path where no
725 > * further writes are expected; loops until idle to also drain any
726 > * writes that get queued while we're awaiting.
727 > */
728 > async whenIdle(): Promise<void> {
729 while (this._pendingWrites.size > 0) {
730 await Promise.allSettled([...this._pendingWrites]);
731 }
732 }
734 > async vacuumInto(targetPath: string) {
735 > const db = await this._ensureDb(); sessionDatabase.ts ×2
736 > await dbRun(db, 'VACUUM INTO ?', [targetPath]);
737 > }
739 > /**
740 > * Wrap a mutating operation's promise so {@link whenIdle} can await it.
741 > * Invoke at the **outermost** layer of every public mutating method so
742 > * that any internal awaits (notably `_ensureDb()`) are covered too —
743 > * tracking only the leaf `dbRun`/`dbExec` would miss the window
744 > * between the method being called and the query actually being queued.
745 > */
746 > private _track<T>(fn: () => Promise<T>): Promise<T> {
747 > const p = fn(); sessionDatabase.ts ×1
748 > this._pendingWrites.add(p);
749 > const untrack = () => { this._pendingWrites.delete(p); };
750 > p.then(untrack, untrack);
751 > return p;
752 > }
754 > async close() {
755 > await (this._closed ??= this._dbPromise?.then(db => dbClose(db)).catch(() => { }) || true); sessionDatabase.ts ×2
756 > }
758 > dispose(): void {
759 > this.close(); sessionDatabase.ts ×1
760 > }
762 >
763 > function toReviewedFileRecord(row: Record<string, unknown>): IReviewedFileRecord { sessionDatabase.ts ×1
764 > return {
765 > uri: URI.parse(row.uri as string),
766 > nonce: row.nonce as string,
767 > };
768 > }
770 > function toUint8Array(value: unknown): Uint8Array { sessionDatabase.ts ×2
771 > if (value instanceof Buffer) {
772 > return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
773 > }
774 if (value instanceof Uint8Array) {
775 return value;
776 }
777 if (typeof value === 'string') {
778 return new TextEncoder().encode(value);
779 }
780 return new Uint8Array(0);
781 }