Skip to content
This repository was archived by the owner on Jun 8, 2026. It is now read-only.

Commit b6cdcaa

Browse files
committed
FEA-1550: Resolve PGlite migration review gaps
- Preserve live SQLite when prior backup and PGlite data already exist. - Copy SQLite source rows in bounded batches instead of materializing entire tables. - Sanitize the remaining SQLite path in migration failure logs. - Add regression coverage for recreated SQLite, multi-batch event copy, and sanitized logs. Testing: Focused SQLite-to-PGlite migration tests, desktop typecheck, desktop lint, desktop build, and full desktop test suite passed. Risks: PGlite remains a prepared background copy while SQLite is the active runtime until async stores cut over.
1 parent f131f61 commit b6cdcaa

2 files changed

Lines changed: 122 additions & 8 deletions

File tree

apps/desktop/src/main/database/sqlite-to-pglite-migration.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,6 @@ export async function migrateSqliteToPglite(
221221
}
222222

223223
if (backupExists && pgdataExists) {
224-
await rm(sqlitePath, { force: true });
225224
return {
226225
status: "skipped",
227226
reason: "already_migrated",
@@ -240,12 +239,14 @@ export async function migrateSqliteToPglite(
240239
);
241240
try {
242241
sqlite = new DatabaseSync(sqlitePath);
242+
sqlite.exec("BEGIN");
243243
assertAllTablesManaged(sqlite);
244244
pglite = await PGlite.create(stagingDir);
245245

246246
const sourceSchema = readSqliteSchema(sqlite);
247247
const sourceCounts = readSourceCounts(sqlite, sourceSchema);
248248
await initializeAndCopy(sqlite, pglite, sourceSchema, sourceCounts);
249+
sqlite.exec("COMMIT");
249250

250251
sqlite.close();
251252
sqlite = null;
@@ -285,8 +286,13 @@ export async function migrateSqliteToPglite(
285286
);
286287
}
287288
} catch (error) {
289+
try {
290+
sqlite?.exec("ROLLBACK");
291+
} catch {
292+
/* ignore rollback failure */
293+
}
288294
log(
289-
`SQLite to PGlite migration failed: sqlite=${sqlitePath}, pglite=${sanitizePath(pgliteDataDir)}, error=${sanitizeError(error)}`,
295+
`SQLite to PGlite migration failed: sqlite=${sanitizePath(sqlitePath)}, pglite=${sanitizePath(pgliteDataDir)}, error=${sanitizeError(error)}`,
290296
);
291297
return {
292298
status: "failed",
@@ -344,19 +350,27 @@ async function initializeAndCopy(
344350
if (columns.length === 0) {
345351
continue;
346352
}
347-
const rows = sqlite
348-
.prepare(`SELECT ${columns.join(", ")} FROM ${table.name}`)
349-
.all() as Record<string, unknown>[];
350353

351-
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
352-
const batch = rows.slice(i, i + BATCH_SIZE);
354+
const selectBatchStmt = sqlite.prepare(
355+
`SELECT ${columns.join(", ")} FROM ${table.name} ORDER BY rowid LIMIT ? OFFSET ?`,
356+
);
357+
let offset = 0;
358+
while (true) {
359+
const batch = selectBatchStmt.all(BATCH_SIZE, offset) as Record<
360+
string,
361+
unknown
362+
>[];
363+
if (batch.length === 0) {
364+
break;
365+
}
353366
await batchInsertRows(
354367
tx,
355368
table.name,
356369
table.conflictTarget,
357370
columns,
358371
batch,
359372
);
373+
offset += batch.length;
360374
}
361375
}
362376

apps/desktop/test/sqlite-to-pglite-migration.test.ts

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,42 @@ function seedSqlite(dbPath: string): void {
175175
}
176176
}
177177

178+
function insertAdditionalEvents(dbPath: string, count: number): void {
179+
const db = new DatabaseSync(dbPath);
180+
try {
181+
const insert = db.prepare(`
182+
INSERT INTO events (
183+
id, session_id, agent_id, event_type, tool_name, summary, data,
184+
created_at, user_id, organization_id
185+
)
186+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
187+
`);
188+
db.exec("BEGIN");
189+
try {
190+
for (let i = 0; i < count; i += 1) {
191+
insert.run(
192+
`event-extra-${i}`,
193+
"session-1",
194+
"session-1-main",
195+
"PostToolUse",
196+
"Read",
197+
`Batch event ${i}`,
198+
JSON.stringify({ index: i }),
199+
`2026-06-01T00:${String(i % 60).padStart(2, "0")}:00.000Z`,
200+
"user-1",
201+
"org-1",
202+
);
203+
}
204+
db.exec("COMMIT");
205+
} catch (error) {
206+
db.exec("ROLLBACK");
207+
throw error;
208+
}
209+
} finally {
210+
db.close();
211+
}
212+
}
213+
178214
test("migrateSqliteToPglite copies rows, preserves attribution columns, and renames SQLite to .bak", async () => {
179215
const dir = makeTempDir();
180216
try {
@@ -216,17 +252,53 @@ test("migrateSqliteToPglite copies rows, preserves attribution columns, and rena
216252
}
217253
});
218254

255+
test("migrateSqliteToPglite copies large tables in multiple bounded batches", async () => {
256+
const dir = makeTempDir();
257+
try {
258+
const sqlitePath = path.join(dir, "agent-dashboard.sqlite");
259+
seedSqlite(sqlitePath);
260+
insertAdditionalEvents(sqlitePath, 1000);
261+
262+
const result = await migrateSqliteToPglite({ sqlitePath });
263+
264+
assert.equal(result.status, "migrated");
265+
assert.equal(result.status === "migrated" ? result.rowCounts.events : 0, 1001);
266+
267+
const pg = await PGlite.create(resolvePgliteDataDir(sqlitePath));
268+
try {
269+
const eventCount = await pg.query<{ count: string }>(
270+
"SELECT COUNT(*)::text AS count FROM events",
271+
);
272+
assert.equal(Number(eventCount.rows[0]?.count), 1001);
273+
} finally {
274+
await pg.close();
275+
}
276+
} finally {
277+
rmSync(dir, { recursive: true, force: true });
278+
}
279+
});
280+
219281
test("migrateSqliteToPglite returns failed and leaves SQLite intact with unmanaged source tables", async () => {
220282
const dir = makeTempDir();
221283
try {
222284
const sqlitePath = path.join(dir, "agent-dashboard.sqlite");
223285
const db = new DatabaseSync(sqlitePath);
224286
db.exec("CREATE TABLE compute_target (id TEXT PRIMARY KEY)");
225287
db.close();
288+
const messages: string[] = [];
226289

227-
const result = await migrateSqliteToPglite({ sqlitePath });
290+
const result = await migrateSqliteToPglite({
291+
sqlitePath,
292+
log: (message) => messages.push(message),
293+
});
228294

229295
assert.equal(result.status, "failed");
296+
assert.ok(messages.length > 0);
297+
assert.equal(
298+
messages.some((message) => message.includes(sqlitePath)),
299+
false,
300+
"failure logs must not include absolute SQLite paths",
301+
);
230302
assert.equal(
231303
existsSync(sqlitePath),
232304
true,
@@ -259,6 +331,34 @@ test("migrateSqliteToPglite skips when backup and pgdata exist", async () => {
259331
}
260332
});
261333

334+
test("migrateSqliteToPglite does not delete live SQLite when backup and pgdata already exist", async () => {
335+
const dir = makeTempDir();
336+
try {
337+
const sqlitePath = path.join(dir, "agent-dashboard.sqlite");
338+
seedSqlite(sqlitePath);
339+
writeFileSync(`${sqlitePath}.bak`, "backup");
340+
mkdirSync(resolvePgliteDataDir(sqlitePath));
341+
342+
const result = await migrateSqliteToPglite({ sqlitePath });
343+
344+
assert.equal(result.status, "skipped");
345+
assert.equal(result.status === "skipped" ? result.reason : "", "already_migrated");
346+
assert.equal(existsSync(sqlitePath), true, "live SQLite must not be deleted");
347+
348+
const db = new DatabaseSync(sqlitePath);
349+
try {
350+
const row = db.prepare("SELECT id FROM sessions WHERE id = ?").get("session-1") as
351+
| { id: string }
352+
| undefined;
353+
assert.equal(row?.id, "session-1");
354+
} finally {
355+
db.close();
356+
}
357+
} finally {
358+
rmSync(dir, { recursive: true, force: true });
359+
}
360+
});
361+
262362
test("migrateSqliteToPglite returns sqlite_missing when only .bak exists without .pgdata", async () => {
263363
const dir = makeTempDir();
264364
try {

0 commit comments

Comments
 (0)