Files
goodbuddy/src/main/magic-notes/magic-note-storage.test.ts
T
mesalogo 3d063937c3 feat(magic-notes): add rich table editing
Add native Quill table insertion and contextual row, column, and table actions. Preserve table-only drafts through composer validation, IPC, and storage, and document the saved behavior.\n\nRelease note: 魔法笔记支持插入和编辑表格,空单元格表格也可保存并在重新打开后恢复。
2026-10-08 02:02:24 +08:00

549 lines
32 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import fs, { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { backup, DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { MagicNoteCanvasContent, MagicNoteContent } from '../../shared/magic-notes-contracts'
import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from '../assistant/assistant-database'
import { getPendingAssistantStorageUpgrade, upgradeAssistantStorage } from '../assistant/assistant-storage-upgrade'
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>()
const readFileSync = vi.fn(actual.readFileSync)
return { ...actual, readFileSync, default: { ...actual, readFileSync } }
})
const actualFs = { renameSync: fs.renameSync, rmSync: fs.rmSync }
const directories: string[] = []
const connections: Array<{ close(): void }> = []
afterEach(() => {
vi.restoreAllMocks()
for (const connection of connections.splice(0)) connection.close()
for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true })
vi.mocked(readFileSync).mockClear()
})
function setup(options: ConstructorParameters<typeof AssistantDatabase>[1] = {}) {
const directory = mkdtempSync(join(tmpdir(), 'goodbuddy-note-files-'))
directories.push(directory)
const path = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(path, options)
connections.push(database)
database.initialize(directory)
const sql = new DatabaseSync(path)
connections.push(sql)
return { database, sql, directory, path }
}
const png = 'data:image/png;base64,iVBORw0KGgo='
const rich: MagicNoteContent = { version: 1, ops: [
{ insert: 'Task' }, { insert: '\n', attributes: { list: 'unchecked' } },
{ insert: { image: png } },
{ insert: { localVideo: { name: 'clip.mp4', mimeType: 'video/mp4', size: 12, dataUrl: `data:video/mp4;base64,${Buffer.from('0000ftyp0000').toString('base64')}` } } },
{ insert: { attachment: { name: 'file.txt', mimeType: 'text/plain', size: 5, dataUrl: 'data:text/plain;base64,aGVsbG8=' } } },
{ insert: '\n' }
] }
function canvas(size = 40): MagicNoteCanvasContent {
return {
version: 2, kind: 'paged-canvas',
pages: [{ id: 'page', width: 794, height: 1123,
background: { type: 'pdf', assetId: 'original-pdf-id', pageNumber: 1, text: 'PDF text' },
objects: [{ type: 'Image', src: png }, { type: 'IText', text: 'Annotation', left: 0 }] }],
flow: { ops: [{ insert: 'Canvas task' }, { insert: '\n', attributes: { list: 'unchecked' } }] },
assets: [{ id: 'original-pdf-id', name: 'document.pdf', mimeType: 'application/pdf',
dataUrl: `data:application/pdf;base64,${Buffer.from(`%PDF-${'x'.repeat(size)}`).toString('base64')}` }],
preview: png
}
}
describe('Magic note SQLite and filesystem storage', () => {
it('measures current entry startup body reads on real SQLite and filesystem fixtures', () => {
const { database, directory, sql } = setup()
const paths: string[] = []
for (let index = 0; index < 120; index++) {
const content = index % 2 ? canvas() : structuredClone(rich)
if (content.version === 1) content.ops.unshift({ insert: 'x'.repeat(32 * 1024) })
else content.pages[0]!.objects.push({ type: 'IText', text: 'x'.repeat(32 * 1024) })
const note = database.createMagicNote({ title: `Synthetic ${index}`, content })
paths.push(join(directory, 'notes', note.id, 'entries', `${note.entries[0]!.id}.json`))
}
database.close()
const bodyPaths = new Set(paths)
const reads = vi.mocked(readFileSync)
const durations: number[] = []
const counts: number[] = []
for (let run = 0; run < 5; run++) {
reads.mockClear()
const start = performance.now()
database.initialize(directory)
durations.push(Number((performance.now() - start).toFixed(2)))
counts.push(reads.mock.calls.filter(([path]) => bodyPaths.has(String(path))).length)
for (const path of paths) expect(reads.mock.calls.filter(([readPath]) => String(readPath) === path)).toHaveLength(1)
expect(reads.mock.calls.some(([path]) => String(path).includes(join(directory, 'notes')) && String(path).includes('assets'))).toBe(false)
database.close()
}
console.info('Synthetic note startup', { entries: paths.length, bodyBytes: paths.reduce((sum, path) => sum + statSync(path).size, 0), counts, durationsMs: durations })
expect(counts).toEqual(Array(5).fill(paths.length))
expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
})
it('upgrades schema 47 with nullable sources without rewriting bodies and reopens idempotently', () => {
const { database, sql, path, directory } = setup()
const created = database.createMagicNote({ title: 'Existing note', content: rich })
const before = database.getMagicNote(created.id)
const bodyPath = join(directory, 'notes', created.id, 'entries', `${created.createdEntryId}.json`)
const body = readFileSync(bodyPath)
const modifiedAt = statSync(bodyPath).mtimeMs
database.close()
// Reconstruct the previous released schema; production never downgrades databases.
sql.exec(`ALTER TABLE magic_note_entries DROP COLUMN source_json;
ALTER TABLE projects ADD COLUMN default_work_mode TEXT NOT NULL DEFAULT 'ask';
ALTER TABLE conversations ADD COLUMN work_mode TEXT NOT NULL DEFAULT 'ask';
ALTER TABLE tasks ADD COLUMN work_mode TEXT NOT NULL DEFAULT 'execute';
PRAGMA user_version = 47;`)
upgradeAssistantStorage(path, () => undefined)
expect(sql.prepare('PRAGMA user_version').get()).toEqual({ user_version: ASSISTANT_DATABASE_SCHEMA_VERSION })
expect(sql.prepare('SELECT source_json FROM magic_note_entries').get()).toEqual({ source_json: null })
expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
database.initialize(directory)
expect(database.getMagicNote(created.id)).toEqual(before)
expect(database.getMagicNote(created.id).entries[0]).not.toHaveProperty('source')
expect(readFileSync(bodyPath)).toEqual(body)
expect(statSync(bodyPath).mtimeMs).toBe(modifiedAt)
database.close()
database.initialize(directory)
expect(database.getMagicNote(created.id)).toEqual(before)
})
it('persists and reopens a rich table whose cells contain no text', () => {
const { database, directory } = setup()
const note = database.createMagicNote({ title: 'Empty table' })
const table: MagicNoteContent = {
version: 1,
ops: [{ insert: '\n', attributes: { table: 'row-1' } }]
}
const saved = database.createMagicNoteEntry({ noteId: note.id, content: table, plainText: 'ignored' })
expect(saved.entries.at(-1)?.content).toEqual(table)
database.close()
const reopened = new AssistantDatabase(join(directory, 'assistant.sqlite'))
connections.push(reopened)
reopened.initialize(directory)
expect(reopened.getMagicNote(note.id).entries.at(-1)?.content).toEqual(table)
})
it.each(['missing', 'invalid-json', 'invalid-media'] as const)('retains all assets when a later retained body has %s corruption', (failure) => {
const { database, directory } = setup()
const note = database.createMagicNote({ title: 'Retained bodies', content: rich })
const entries = database.createMagicNoteEntry({ noteId: note.id, content: canvas(), plainText: '' }).entries
const root = join(directory, 'notes', note.id)
const bodyPath = join(root, 'entries', `${entries[1]!.id}.json`)
const original = readFileSync(bodyPath, 'utf8')
const manifest = readFileSync(join(root, 'note.json'), 'utf8')
writeFileSync(join(root, 'assets', 'unused.bin'), 'keep until inspection succeeds')
const assets = readdirSync(join(root, 'assets')).map((name) => [name, readFileSync(join(root, 'assets', name))] as const)
database.close()
if (failure === 'missing') rmSync(bodyPath)
else if (failure === 'invalid-json') writeFileSync(bodyPath, '{')
else {
const body = JSON.parse(original)
body.content.pages = null
writeFileSync(bodyPath, JSON.stringify(body))
}
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
if (failure === 'invalid-media') {
database.initialize(directory)
expect(warning).toHaveBeenCalledWith('Magic note file cleanup deferred', expect.objectContaining({ noteId: note.id }))
} else expect(() => database.initialize(directory)).toThrow()
for (const [name, bytes] of assets) expect(readFileSync(join(root, 'assets', name))).toEqual(bytes)
expect(readFileSync(join(root, 'note.json'), 'utf8')).toBe(manifest)
database.close()
writeFileSync(bodyPath, original)
vi.mocked(readFileSync).mockClear()
database.initialize(directory)
for (const entry of entries) {
const path = join(root, 'entries', `${entry.id}.json`)
expect(vi.mocked(readFileSync).mock.calls.filter(([readPath]) => String(readPath) === path)).toHaveLength(1)
}
expect(database.getMagicNote(note.id).entries).toEqual(entries)
expect(existsSync(join(root, 'assets', 'unused.bin'))).toBe(false)
})
it('preserves textual data URLs and arbitrary $asset objects through save, migration and reopen', () => {
const { database, sql, path, directory } = setup()
const text = 'data:text/plain;base64,aGVsbG8'
const custom = { $asset: 'not-a-storage-reference', mimeType: 'text/plain', name: text, text }
const richText: MagicNoteContent = { version: 1, ops: [{ insert: text }, { insert: png }, { insert: '\n' }] }
const drawing = canvas()
drawing.pages[0]!.objects.push({ type: 'IText', ...custom }, { type: 'Group', objects: [{ type: 'Image', src: png, name: text, custom }] })
drawing.flow!.ops.push({ insert: text }, { insert: { image: png } }, { insert: { custom } })
const notes = [richText, drawing].map((content) => database.getMagicNote(database.createMagicNote({ title: 'Literal URLs', content }).id))
for (const [index, note] of notes.entries()) expect(note.entries[0]!.content).toEqual([richText, drawing][index])
const disk = JSON.parse(readFileSync(join(directory, 'notes', notes[1]!.id, 'entries', `${notes[1]!.entries[0]!.id}.json`), 'utf8'))
expect(disk.content.pages[0].objects[2]).toEqual({ type: 'IText', ...custom })
expect(disk.content.flow.ops.at(-2).insert.image).toHaveProperty('$asset')
expect(existsSync(join(directory, 'notes', notes[0]!.id, 'assets'))).toBe(false)
database.close()
for (const note of notes) sql.prepare('UPDATE magic_note_entries SET content_json = ? WHERE id = ?').run(JSON.stringify(note.entries[0]!.content), note.entries[0]!.id)
rmSync(join(directory, 'notes'), { recursive: true })
upgradeAssistantStorage(path, () => undefined)
database.initialize(directory)
expect(notes.map((note) => database.getMagicNote(note.id))).toEqual(notes)
})
it.each(['manifest', 'gc'] as const)('returns successful creates/updates despite deferred %s failures and retries cleanup', (failure) => {
const changed = vi.fn()
const logged = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const { database, directory } = setup({ onMagicNotesChanged: changed })
let failing = true
vi.spyOn(fs, 'renameSync').mockImplementation((source, target) => {
if (failing && failure === 'manifest' && String(target).endsWith('note.json')) throw new Error('manifest rename blocked')
actualFs.renameSync(source, target)
})
vi.spyOn(fs, 'rmSync').mockImplementation((path, options) => {
if (failing && failure === 'gc' && String(path).endsWith('unused.bin')) throw new Error('asset removal blocked')
actualFs.rmSync(path, options)
})
const note = database.createMagicNote({ title: 'Committed', content: rich })
const root = join(directory, 'notes', note.id)
writeFileSync(join(root, 'assets', 'unused.bin'), 'unused')
const appended = database.createMagicNoteEntry({ noteId: note.id, content: rich, plainText: '' })
const first = appended.entries[0]!
const saved = database.updateMagicNoteEntry({ entryId: first.id, expectedRevision: first.revision, content: canvas(), plainText: '' })
expect(saved.entries).toHaveLength(2)
expect(saved.entries[0]!.content).toEqual(canvas())
expect(changed).toHaveBeenCalledTimes(3)
expect(logged).toHaveBeenCalledWith('Magic note file cleanup deferred', expect.objectContaining({ noteId: note.id, error: expect.any(Error) }))
database.close()
database.initialize(directory)
expect(database.getMagicNote(note.id)).toEqual(saved)
failing = false
expect(database.listMagicNotes()[0]!.entryCount).toBe(2)
expect(existsSync(join(root, 'assets', 'unused.bin'))).toBe(false)
expect(JSON.parse(readFileSync(join(root, 'note.json'), 'utf8')).entries).toEqual(saved.entries.map((entry) => entry.id))
})
it.each(['delete', 'reset'] as const)('notifies and completes %s after SQL commit when filesystem removal fails', (operation) => {
const changed = vi.fn()
const logged = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const { database, directory } = setup({ onMagicNotesChanged: changed })
const note = database.createMagicNote({ title: 'Remove', content: rich })
const root = join(directory, 'notes', note.id)
let failing = true
vi.spyOn(fs, 'rmSync').mockImplementation((path, options) => {
if (failing && (String(path) === root || String(path) === join(directory, 'notes'))) throw new Error('directory removal blocked')
actualFs.rmSync(path, options)
})
if (operation === 'delete') database.deleteMagicNote(note.id)
else database.clearAssistantData()
expect(changed).toHaveBeenCalledTimes(2)
expect(database.listMagicNotes()).toEqual([])
expect(existsSync(root)).toBe(true)
expect(logged).toHaveBeenCalled()
// A delayed reset cleanup must not remove notes created afterwards.
const retained = database.getMagicNote(database.createMagicNote({ title: 'New note', content: canvas() }).id)
failing = false
database.listMagicNotes()
expect(existsSync(root)).toBe(false)
expect(database.getMagicNote(retained.id)).toEqual(retained)
})
it('still rejects authoritative body rename failures before committing SQL', () => {
const { database } = setup()
const note = database.getMagicNote(database.createMagicNote({ title: 'Body error', content: rich }).id)
const entry = note.entries[0]!
vi.spyOn(fs, 'renameSync').mockImplementation((source, target) => {
if (String(target).endsWith(`${entry.id}.json`)) throw new Error('body rename blocked')
actualFs.renameSync(source, target)
})
expect(() => database.updateMagicNoteEntry({ entryId: entry.id, content: canvas(), expectedRevision: entry.revision, plainText: '' })).toThrow('body rename blocked')
expect(database.getMagicNote(note.id)).toEqual(note)
})
it.each(['cancel', 'vacuum'] as const)('retries free-page reclamation after %s interrupts a fully converted migration', (failure) => {
const { database, sql, path } = setup()
const note = database.createMagicNote({ title: 'Migration retry', content: canvas(1024 * 1024) })
database.close()
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)')
const confirmedUpgrade = getPendingAssistantStorageUpgrade(path)!
let cancelled = false
const exec = DatabaseSync.prototype.exec
const injected = vi.spyOn(DatabaseSync.prototype, 'exec').mockImplementation(function (this: DatabaseSync, statement) {
if (failure === 'vacuum' && statement.includes('VACUUM')) throw new Error('vacuum disk full')
return exec.call(this, statement)
})
expect(() => upgradeAssistantStorage(path, (progress) => {
if (failure === 'cancel' && progress.stage === 'converting' && progress.processed === progress.total) cancelled = true
}, () => cancelled)).toThrow(failure === 'cancel' ? 'cancelled' : 'vacuum disk full')
injected.mockRestore()
expect(sql.prepare("SELECT json_extract(content_json, '$.storage') AS storage FROM magic_note_entries").get()!.storage).toBe('file')
expect(sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBeGreaterThan(0)
expect(getPendingAssistantStorageUpgrade(path)).toBeUndefined()
const stages: string[] = []
upgradeAssistantStorage(path, (progress) => stages.push(progress.stage), () => false, {
confirmedUpgrade
})
expect(stages).toContain('compacting')
expect(sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBe(0)
})
it('hydrates rich media, searches, writes todos back, shares assets, deletes and resets', () => {
const { database, sql, directory } = setup()
const note = database.createMagicNote({ title: 'Media', content: rich })
const first = note.entries[0]!
const root = join(directory, 'notes', note.id)
expect(first.content).toEqual(rich)
expect(readdirSync(join(root, 'assets'))).toHaveLength(3)
const body = readFileSync(join(root, 'entries', `${first.id}.json`), 'utf8')
expect(body).not.toContain('base64')
expect(body).toContain('$asset')
expect(sql.prepare('SELECT content_json FROM magic_note_entries WHERE id = ?').get(first.id)).toEqual({
content_json: JSON.stringify({ storage: 'file', version: 1, revision: 0 })
})
const second = database.createMagicNoteEntry({ noteId: note.id, content: rich, plainText: 'ignored' }).entries[1]!
expect(readdirSync(join(root, 'assets'))).toHaveLength(3)
const todo = database.listMagicTodos().find((item) => item.entryId === first.id)!
database.updateMagicTodo({ todoId: todo.id, expectedRevision: todo.revision, completed: true })
expect(database.getMagicNoteEntry(first.id).content).toMatchObject({ ops: expect.arrayContaining([{ insert: '\n', attributes: { list: 'checked' } }]) })
expect(database.searchMagicNotes('Task', 20)).toHaveLength(2)
database.deleteMagicNoteEntry(first.id)
expect(existsSync(join(root, 'entries', `${first.id}.json`))).toBe(false)
expect(database.getMagicNoteEntry(second.id).content).toEqual(rich)
expect(readdirSync(join(root, 'assets'))).toHaveLength(3)
expect(JSON.parse(readFileSync(join(root, 'note.json'), 'utf8')).entries).toEqual([second.id])
database.deleteMagicNote(note.id)
expect(existsSync(root)).toBe(false)
expect(database.listMagicTodos()).toEqual([])
database.createMagicNote({ title: 'Reset', content: canvas() })
database.clearAssistantData()
expect(existsSync(join(directory, 'notes'))).toBe(false)
expect(database.listMagicNotes()).toEqual([])
})
it('migrates both formats via the startup worker path and preserves metadata, comments and todo IDs', () => {
const { database, sql, path, directory } = setup()
const original = [rich, canvas()].map((content) => database.createMagicNote({ title: 'Legacy', content }))
for (const note of original) {
const entry = note.entries[0]!
database.saveMagicNoteAnalysis({ entryId: entry.id, expectedRevision: entry.revision,
comments: [{ id: randomUUID(), kind: 'summary', content: 'Keep comment' }] })
}
const before = original.map((note) => database.getMagicNote(note.id))
const todos = database.listMagicTodos()
database.close()
for (const note of before) {
const entry = note.entries[0]!
sql.prepare('UPDATE magic_note_entries SET content_json = ? WHERE id = ?').run(JSON.stringify(entry.content), entry.id)
}
sql.exec(`ALTER TABLE supervision_results DROP COLUMN graph_snapshot_json;
DROP TABLE activity_history_records;
ALTER TABLE activity_history RENAME COLUMN record_order_json TO records_json;
DROP TRIGGER messages_review_insert; DROP TRIGGER messages_review_update;
DROP TRIGGER messages_review_delete; DROP TRIGGER tasks_review_delete;
DROP VIEW IF EXISTS supervision_review_current;
DROP TABLE IF EXISTS supervision_review_navigation; DROP TABLE IF EXISTS supervision_review_batches;
DROP TABLE IF EXISTS supervision_review_sources; DROP TABLE IF EXISTS supervision_review_runs;
DROP TABLE review_checkpoints; ALTER TABLE messages DROP COLUMN review_revision;
ALTER TABLE magic_note_entries DROP COLUMN source_json;
ALTER TABLE projects ADD COLUMN default_work_mode TEXT NOT NULL DEFAULT 'ask';
ALTER TABLE conversations ADD COLUMN work_mode TEXT NOT NULL DEFAULT 'ask';
ALTER TABLE tasks ADD COLUMN work_mode TEXT NOT NULL DEFAULT 'execute';
PRAGMA user_version = 37`)
rmSync(join(directory, 'notes'), { recursive: true })
const progress: number[] = []
upgradeAssistantStorage(path, (value) => progress.push(value.processed))
expect(progress).toContain(2)
expect(sql.prepare('PRAGMA user_version').get()).toEqual({ user_version: ASSISTANT_DATABASE_SCHEMA_VERSION })
database.initialize(directory)
expect(original.map((note) => database.getMagicNote(note.id))).toEqual(before)
expect(database.listMagicTodos()).toEqual(todos)
database.close()
database.initialize(directory)
expect(original.map((note) => database.getMagicNote(note.id))).toEqual(before)
expect(sql.prepare('SELECT SUM(length(content_json)) AS bytes FROM magic_note_entries').get()!.bytes).toBeLessThan(200)
expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
})
it('retains legacy payload on a failed migration and retries partially completed conversion', () => {
const { database, sql, directory, path } = setup()
const note = database.createMagicNote({ title: 'Legacy', content: rich })
database.createMagicNoteEntry({ noteId: note.id, content: canvas(), plainText: '' })
const entries = database.getMagicNote(note.id).entries
database.close()
for (const entry of entries) sql.prepare('UPDATE magic_note_entries SET content_json = ? WHERE id = ?').run(JSON.stringify(entry.content), entry.id)
rmSync(join(directory, 'notes'), { recursive: true })
sql.exec(`CREATE TRIGGER fail_note_migration BEFORE UPDATE OF content_json ON magic_note_entries
BEGIN SELECT RAISE(ABORT, 'migration index failure'); END`)
expect(() => upgradeAssistantStorage(path, () => undefined)).toThrow('migration index failure')
expect(JSON.parse(String(sql.prepare('SELECT content_json FROM magic_note_entries WHERE id = ?').get(entries[0]!.id)!.content_json))).toEqual(rich)
sql.exec('DROP TRIGGER fail_note_migration')
let cancelled = false
expect(() => upgradeAssistantStorage(path, (progress) => {
if (progress.stage === 'converting') cancelled = true
}, () => cancelled)).toThrow('cancelled')
expect(sql.prepare("SELECT COUNT(*) AS count FROM magic_note_entries WHERE json_extract(content_json, '$.storage') = 'file'").get()!.count).toBe(1)
upgradeAssistantStorage(path, () => undefined)
database.initialize(directory)
expect(database.getMagicNote(note.id).entries).toEqual(entries)
})
it('reclaims migrated SQLite payload space and restores a coordinated database/files backup', async () => {
const { database, sql, directory, path } = setup()
const note = database.getMagicNote(database.createMagicNote({ title: 'Large legacy PDF', content: canvas(2 * 1024 * 1024) }).id)
const entry = note.entries[0]!
database.close()
sql.prepare('UPDATE magic_note_entries SET content_json = ? WHERE id = ?').run(JSON.stringify(entry.content), entry.id)
sql.exec('PRAGMA wal_checkpoint(TRUNCATE)')
const bytesBefore = statSync(path).size
const stages: string[] = []
upgradeAssistantStorage(path, (progress) => stages.push(progress.stage))
expect(stages).toContain('compacting')
expect(bytesBefore - statSync(path).size).toBeGreaterThan(2 * 1024 * 1024)
database.initialize(directory)
expect(database.getMagicNote(note.id)).toEqual(note)
const destination = join(directory, 'backup')
mkdirSync(destination)
await backup(sql, join(destination, 'assistant.sqlite'))
cpSync(join(directory, 'notes'), join(destination, 'notes'), { recursive: true })
database.deleteMagicNote(note.id)
const restored = new AssistantDatabase(join(destination, 'assistant.sqlite'))
connections.push(restored)
restored.initialize(destination)
expect(restored.getMagicNote(note.id)).toEqual(note)
expect(restored.listMagicTodos()[0]).toMatchObject({ title: 'Canvas task' })
})
it('rejects stale entry and todo revisions before touching files', () => {
const { database, directory } = setup()
const note = database.createMagicNote({ title: 'Conflict', content: rich })
const entry = note.entries[0]!
const path = join(directory, 'notes', note.id, 'entries', `${entry.id}.json`)
const before = readFileSync(path)
const mtime = statSync(path).mtimeMs
database.saveMagicNoteAnalysis({ entryId: entry.id, expectedRevision: 0, comments: [] })
expect(() => database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: 0, content: canvas(), plainText: '' })).toThrow('已被更新')
const todo = database.listMagicTodos()[0]!
expect(() => database.updateMagicTodo({ todoId: todo.id, expectedRevision: 100, completed: true })).toThrow('已被更新')
expect(readFileSync(path)).toEqual(before)
expect(statSync(path).mtimeMs).toBe(mtime)
expect(readdirSync(join(directory, 'notes', note.id, 'assets'))).toHaveLength(3)
})
it.each([false, true])('repairs a committed file after a real SQLite index failure (reopen=%s)', (reopen) => {
const { database, sql, directory } = setup()
const note = database.createMagicNote({ title: 'Repair', content: rich })
const entry = note.entries[0]!
database.saveMagicNoteAnalysis({ entryId: entry.id, expectedRevision: entry.revision,
comments: [{ id: randomUUID(), kind: 'summary', content: 'Stale analysis' }] })
const analyzed = database.getMagicNoteEntry(entry.id)
sql.exec(`CREATE TRIGGER fail_note_update BEFORE UPDATE OF content_json ON magic_note_entries
BEGIN SELECT RAISE(ABORT, 'index unavailable'); END`)
expect(() => database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: analyzed.revision, content: canvas(), plainText: '' })).toThrow('index unavailable')
expect(() => database.searchMagicNotes('PDF text', 10)).toThrow('index unavailable')
sql.exec('DROP TRIGGER fail_note_update')
if (reopen) { database.close(); database.initialize(directory) }
expect(database.searchMagicNotes('PDF text', 10)).toHaveLength(1)
expect(database.getMagicNoteEntry(entry.id)).toMatchObject({ content: canvas(), revision: analyzed.revision + 1, comments: [], analyzedAt: undefined })
const assetNames = readdirSync(join(directory, 'notes', note.id, 'assets'))
expect(assetNames).toHaveLength(2)
expect(assetNames.some((name) => name.endsWith('.pdf'))).toBe(true)
expect(database.listMagicTodos()).toEqual([expect.objectContaining({ title: 'Canvas task' })])
expect(() => database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: 0, content: rich, plainText: '' })).toThrow('已被更新')
})
it('repairs todo writeback from disk without replacing the todo identity', () => {
const { database, sql } = setup()
database.createMagicNote({ title: 'Todo repair', content: canvas() })
const todo = database.listMagicTodos()[0]!
sql.exec(`CREATE TRIGGER fail_todo_update BEFORE UPDATE OF completed ON magic_todos
BEGIN SELECT RAISE(ABORT, 'todo index unavailable'); END`)
expect(() => database.updateMagicTodo({ todoId: todo.id, expectedRevision: todo.revision, completed: true })).toThrow('todo index unavailable')
sql.exec('DROP TRIGGER fail_todo_update')
expect(database.getMagicTodo(todo.id)).toMatchObject({ id: todo.id, completed: true, revision: todo.revision + 1 })
expect(database.getMagicNoteEntry(todo.entryId).content).toMatchObject({ flow: { ops: expect.arrayContaining([{ insert: '\n', attributes: { list: 'checked' } }]) } })
})
it('rolls back failed creates and cleans their unaccepted files without affecting existing notes', () => {
const { database, sql, directory } = setup()
const retained = database.getMagicNote(database.createMagicNote({ title: 'Keep', content: rich }).id)
sql.exec(`CREATE TRIGGER fail_note_insert BEFORE INSERT ON magic_note_entries
BEGIN SELECT RAISE(ABORT, 'insert unavailable'); END`)
expect(() => database.createMagicNote({ title: 'Failed', content: canvas() })).toThrow('insert unavailable')
expect(() => database.createMagicNoteEntry({ noteId: retained.id, content: canvas(), plainText: '' })).toThrow('insert unavailable')
sql.exec('DROP TRIGGER fail_note_insert')
expect(database.listMagicNotes()).toHaveLength(1)
expect(database.getMagicNote(retained.id)).toEqual(retained)
expect(readdirSync(join(directory, 'notes'))).toEqual([retained.id])
expect(readdirSync(join(directory, 'notes', retained.id, 'assets'))).toHaveLength(3)
})
it('keeps body unchanged if binary persistence fails and does not hide missing body errors', () => {
const { database, directory } = setup()
const note = database.createMagicNote({ title: 'Failure', content: { version: 1, ops: [{ insert: 'Plain\n' }] } })
const entry = note.entries[0]!
const root = join(directory, 'notes', note.id)
const path = join(root, 'entries', `${entry.id}.json`)
const before = readFileSync(path)
writeFileSync(join(root, 'assets'), 'block directory creation')
expect(() => database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: 0, content: rich, plainText: '' })).toThrow()
expect(readFileSync(path)).toEqual(before)
rmSync(join(root, 'assets'))
expect(database.getMagicNoteEntry(entry.id).content).toEqual(entry.content)
rmSync(path)
expect(() => database.getMagicNoteEntry(entry.id)).toThrow('ENOENT')
database.close()
expect(() => database.initialize(directory)).toThrow('ENOENT')
})
it('updates a PDF canvas repeatedly without copying PDF bytes to SQLite, and collects only unreferenced assets', () => {
const { database, sql, directory, path } = setup()
const content = canvas(1024 * 1024)
const note = database.createMagicNote({ title: 'PDF', content })
let entry = note.entries[0]!
const root = join(directory, 'notes', note.id)
const assetFiles = readdirSync(join(root, 'assets'))
const pdf = assetFiles.find((name) => name.endsWith('.pdf'))!
const pdfPath = join(root, 'assets', pdf)
const originalMtime = statSync(pdfPath).mtimeMs
sql.exec('PRAGMA wal_checkpoint(TRUNCATE)')
const bytesBefore = statSync(path).size
for (let index = 0; index < 20; index++) {
const next = { ...content, pages: [{ ...content.pages[0]!, objects: [{ type: 'IText', text: `Edit ${index}`, left: index }] }] }
entry = database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: entry.revision, content: next, plainText: '' }).entries[0]!
}
expect(statSync(pdfPath).mtimeMs).toBe(originalMtime)
expect(readdirSync(join(root, 'assets'))).toEqual(assetFiles)
expect(statSync(join(root, 'entries', `${entry.id}.json`)).size).toBeLessThan(5000)
expect(statSync(path).size + statSync(`${path}-wal`).size - bytesBefore).toBeLessThan(2 * 1024 * 1024)
const todo = database.listMagicTodos()[0]!
database.updateMagicTodo({ todoId: todo.id, expectedRevision: todo.revision, completed: true })
const saved = database.getMagicNoteEntry(entry.id)
database.close()
database.initialize(directory)
expect(database.getMagicNoteEntry(entry.id)).toEqual(saved)
database.updateMagicNoteEntry({ entryId: entry.id, expectedRevision: saved.revision, content: { version: 1, ops: [{ insert: 'No PDF\n' }] }, plainText: '' })
expect(readdirSync(join(root, 'assets'))).toEqual([])
})
it('cleans orphan files on reopening without removing another persisted record', () => {
const { database, directory } = setup()
const note = database.createMagicNote({ title: 'Keep', content: rich })
const root = join(directory, 'notes', note.id)
const orphan = join(directory, 'notes', randomUUID())
mkdirSync(orphan)
writeFileSync(join(root, 'entries', `${randomUUID()}.json`), '{}')
writeFileSync(join(root, 'assets', 'unused.bin'), 'unused')
database.close()
database.initialize(directory)
expect(existsSync(orphan)).toBe(false)
expect(readdirSync(join(root, 'entries'))).toEqual([`${note.entries[0]!.id}.json`])
expect(readdirSync(join(root, 'assets'))).toHaveLength(3)
expect(database.getMagicNote(note.id).entries[0]!.content).toEqual(rich)
})
})