fix: avoid history reclamation during structure-only upgrades
Ordinary database schema upgrades previously ran VACUUM on free pages and showed misleading history-cleanup progress. Separate structure upgrades from required legacy conversions, retaining reclamation intent only for retries within the same startup. Preserve released data without new schema versions or persistent migration markers; Agent and normal writes remain unchanged. Add real SQLite and Worker regressions, bilingual operation-specific startup copy, and migration documentation. Synchronize native Electron drag input with actual resize state without weakening layout assertions. Validation: 47 focused migration tests passed; typecheck, lint and diff checks passed. Final full suite: 5137 passed, 74 skipped, 2 unrelated Electron failures. Both failing Electron tests passed an isolated rerun; full CI remains unverified. Release note: 修复普通数据库结构升级再次整理历史和回收空间的问题;仅在需要转换旧数据时执行相关处理,启动页准确显示当前升级操作。
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
// @vitest-environment node
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { build } from 'esbuild'
|
||||
import { afterAll, beforeAll, expect, it } from 'vitest'
|
||||
import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from '../src/main/assistant/assistant-database'
|
||||
import { getPendingAssistantStorageUpgrade } from '../src/main/assistant/assistant-storage-upgrade'
|
||||
import { assistantStorageProgressSchema } from '../src/shared/assistant-storage-contracts'
|
||||
|
||||
let directory: string
|
||||
let workerPath: string
|
||||
beforeAll(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'goodbuddy-storage-worker-'))
|
||||
workerPath = join(directory, 'worker.cjs')
|
||||
await build({
|
||||
entryPoints: [resolve('src/main/assistant-storage-worker.ts')],
|
||||
outfile: workerPath, bundle: true, platform: 'node', format: 'cjs', target: 'node24'
|
||||
})
|
||||
})
|
||||
afterAll(async () => {
|
||||
if (directory) await rm(directory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it.each([false, true])('runs the real startup worker with legacy notes=%s without touching unrelated data', async (legacyNotes) => {
|
||||
const root = await mkdtemp(join(directory, 'case-'))
|
||||
const databasePath = join(root, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(databasePath)
|
||||
database.initialize(root)
|
||||
const note = database.createMagicNote({
|
||||
title: 'Worker note', content: { version: 1, ops: [{ insert: 'Keep note\n' }] }
|
||||
})
|
||||
const header = { id: randomUUID(), title: 'Worker chat', updatedAt: 1000 }
|
||||
const message = { id: randomUUID(), role: 'assistant' as const, state: 'complete' as const,
|
||||
content: 'Normal chat'.repeat(100_000), createdAt: 1000 }
|
||||
database.saveLocalConversations([{ header, messages: [message] }])
|
||||
database.saveLocalConversations([{ header, messages: [{ ...message, content: 'Keep response' }] }])
|
||||
const expected = database.getConversation(header.id)
|
||||
database.close()
|
||||
const sql = new DatabaseSync(databasePath)
|
||||
try {
|
||||
sql.exec(`DROP VIEW supervision_review_current;
|
||||
DROP TABLE supervision_review_navigation; DROP TABLE supervision_review_batches;
|
||||
DROP TABLE supervision_review_sources; DROP TABLE supervision_review_runs;
|
||||
PRAGMA user_version = 46;`)
|
||||
if (legacyNotes) sql.prepare('UPDATE magic_note_entries SET content_json = ? WHERE id = ?')
|
||||
.run(JSON.stringify(note.entries[0]!.content), note.entries[0]!.id)
|
||||
sql.exec('PRAGMA wal_checkpoint(TRUNCATE)')
|
||||
expect(sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBeGreaterThan(0)
|
||||
const bytesBefore = (await stat(databasePath)).size
|
||||
const upgrade = getPendingAssistantStorageUpgrade(databasePath)!
|
||||
expect(upgrade).toEqual({ migrateNotes: legacyNotes, reclaimSpace: legacyNotes })
|
||||
const run = async (cancelled: boolean) => {
|
||||
const cancellation = new SharedArrayBuffer(4)
|
||||
if (cancelled) Atomics.store(new Int32Array(cancellation), 0, 1)
|
||||
const worker = new Worker(workerPath, { workerData: { databasePath, cancellation, upgrade } })
|
||||
const messages: Array<{ progress?: unknown; done?: boolean; cancelled?: boolean; error?: string }> = []
|
||||
worker.on('message', message => messages.push(message))
|
||||
try {
|
||||
const code = await new Promise<number>((resolve, reject) => {
|
||||
worker.once('error', reject)
|
||||
worker.once('exit', resolve)
|
||||
})
|
||||
expect(code).toBe(0)
|
||||
return messages
|
||||
} finally { await worker.terminate() }
|
||||
}
|
||||
expect((await run(true)).at(-1)).toMatchObject({ cancelled: true })
|
||||
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(46)
|
||||
const messages = await run(false)
|
||||
expect(messages.at(-1)).toEqual({ done: true })
|
||||
expect(messages.some(message => message.error)).toBe(false)
|
||||
const stages = messages.flatMap(message => message.progress
|
||||
? [assistantStorageProgressSchema.parse(message.progress).stage] : [])
|
||||
expect(stages).toContain('upgrading')
|
||||
if (legacyNotes) {
|
||||
expect(stages).toContain('converting')
|
||||
expect(stages).toContain('compacting')
|
||||
} else {
|
||||
expect(stages).toEqual(['upgrading'])
|
||||
expect((await stat(databasePath)).size).toBeGreaterThanOrEqual(bytesBefore)
|
||||
}
|
||||
expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
|
||||
expect(getPendingAssistantStorageUpgrade(databasePath)).toBeUndefined()
|
||||
database.initialize(root)
|
||||
expect(database.getConversation(header.id)).toEqual(expected)
|
||||
expect(database.getMagicNote(note.id)).toEqual(note)
|
||||
expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
|
||||
} finally {
|
||||
database.close()
|
||||
sql.close()
|
||||
}
|
||||
}, 30_000)
|
||||
@@ -48,14 +48,19 @@ it('resizes record columns with native Electron input and preserves desktop widt
|
||||
const click = async selector => { await js('document.querySelector(' + JSON.stringify(selector) + ').click()'); await settle(); };
|
||||
const rect = selector => js('(() => { const r = document.querySelector(' + JSON.stringify(selector) + ').getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; })()');
|
||||
const drag = async (selector, delta) => {
|
||||
win.focus(); win.webContents.focus();
|
||||
await wait('document.hasFocus()');
|
||||
const r = await rect(selector); const x = Math.round(r.x + r.width / 2), y = Math.round(r.y + 80);
|
||||
const target = 'document.querySelector(' + JSON.stringify(selector) + ')';
|
||||
const before = await js(target + '.getAttribute("aria-valuenow")');
|
||||
win.webContents.sendInputEvent({ type: 'mouseMove', x, y });
|
||||
await settle();
|
||||
win.webContents.sendInputEvent({ type: 'mouseDown', x, y, button: 'left', clickCount: 1 });
|
||||
await settle();
|
||||
win.webContents.sendInputEvent({ type: 'mouseMove', x: x + delta, y, button: 'left' });
|
||||
await settle();
|
||||
await new Promise(resolve => setTimeout(resolve, 80));
|
||||
await wait('!!document.querySelector(".magic-notes-layout--resizing, .magic-todo-workspace--resizing")');
|
||||
win.webContents.sendInputEvent({ type: 'mouseMove', x: x + delta, y, button: 'left', modifiers: ['leftButtonDown'] });
|
||||
await wait(target + '.getAttribute("aria-valuenow") !== ' + JSON.stringify(before));
|
||||
win.webContents.sendInputEvent({ type: 'mouseUp', x: x + delta, y, button: 'left', clickCount: 1 });
|
||||
await wait('!document.querySelector(".magic-notes-layout--resizing, .magic-todo-workspace--resizing")');
|
||||
await settle();
|
||||
};
|
||||
const key = async (selector, keyCode) => {
|
||||
|
||||
Reference in New Issue
Block a user