Центрирование заглушек в графиках/Frame; лимит тела backup 100 MiB; WAL checkpoint при экспорте и очистка -wal/-shm при импорте. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import * as schema from './schema/index.js'
|
||||
import { ensureRuntimeSchema } from './runtime-migrate.js'
|
||||
|
||||
const SQLITE_HEADER = Buffer.from('SQLite format 3\0')
|
||||
|
||||
export type Db = BetterSQLite3Database<typeof schema>
|
||||
|
||||
let _db: Db | null = null
|
||||
@@ -46,10 +48,40 @@ export function closeDb(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function removeSidecarFiles(dbPath: string): void {
|
||||
for (const suffix of ['-wal', '-shm'] as const) {
|
||||
const side = `${dbPath}${suffix}`
|
||||
if (existsSync(side)) unlinkSync(side)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Согласованный снимок файла БД: checkpoint WAL → чтение основного файла.
|
||||
* Без checkpoint экспорт может быть неполным (данные только в *-wal).
|
||||
*/
|
||||
export function readDatabaseFileBuffer(): Buffer {
|
||||
const sqlite = getSqlite()
|
||||
sqlite.pragma('wal_checkpoint(TRUNCATE)')
|
||||
return readFileSync(getDbPath())
|
||||
}
|
||||
|
||||
/** Заменить файл SQLite и переоткрыть соединение. */
|
||||
export function reloadDatabaseFromBuffer(buffer: Buffer): void {
|
||||
if (!Buffer.isBuffer(buffer) || buffer.length < SQLITE_HEADER.length) {
|
||||
throw new Error('Файл не похож на SQLite базу')
|
||||
}
|
||||
if (!buffer.subarray(0, SQLITE_HEADER.length).equals(SQLITE_HEADER)) {
|
||||
throw new Error('Файл не похож на SQLite базу (неверный заголовок)')
|
||||
}
|
||||
|
||||
const dbPath = getDbPath()
|
||||
const dir = dirname(dbPath)
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
|
||||
closeDb()
|
||||
writeFileSync(getDbPath(), buffer)
|
||||
// Старые WAL/SHM от предыдущей БД ломают открытие нового файла
|
||||
removeSidecarFiles(dbPath)
|
||||
writeFileSync(dbPath, buffer)
|
||||
openDatabase()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import Database from 'better-sqlite3'
|
||||
import { mkdtempSync, readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
closeDb,
|
||||
getDb,
|
||||
getSqlite,
|
||||
readDatabaseFileBuffer,
|
||||
reloadDatabaseFromBuffer,
|
||||
} from './index.js'
|
||||
|
||||
describe('reloadDatabaseFromBuffer / readDatabaseFileBuffer', () => {
|
||||
let dir: string
|
||||
|
||||
afterEach(() => {
|
||||
closeDb()
|
||||
delete process.env.DB_PATH
|
||||
if (dir && existsSync(dir)) rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('rejects non-sqlite buffer', () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'vt-db-'))
|
||||
process.env.DB_PATH = join(dir, 'app.db')
|
||||
getDb()
|
||||
expect(() => reloadDatabaseFromBuffer(Buffer.from('not-a-db'))).toThrow(/SQLite/)
|
||||
})
|
||||
|
||||
it('replaces db and drops leftover wal/shm', () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'vt-db-'))
|
||||
const dbPath = join(dir, 'app.db')
|
||||
process.env.DB_PATH = dbPath
|
||||
getDb()
|
||||
getSqlite().exec('CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY); INSERT INTO t VALUES (1);')
|
||||
closeDb()
|
||||
|
||||
// Simulate stale sidecars after closed connection (Windows locks *-shm while open)
|
||||
writeFileSync(`${dbPath}-wal`, Buffer.alloc(32))
|
||||
writeFileSync(`${dbPath}-shm`, Buffer.alloc(32))
|
||||
|
||||
const donorPath = join(dir, 'donor.db')
|
||||
const donor = new Database(donorPath)
|
||||
donor.exec(`CREATE TABLE ok (x TEXT); INSERT INTO ok VALUES ('yes');`)
|
||||
donor.close()
|
||||
const buf = readFileSync(donorPath)
|
||||
|
||||
reloadDatabaseFromBuffer(buf)
|
||||
|
||||
const row = getSqlite().prepare('SELECT x FROM ok').get() as { x: string }
|
||||
expect(row.x).toBe('yes')
|
||||
// Старый мусорный WAL не должен подмешаться: таблица t из прошлой БД отсутствует
|
||||
const tables = getSqlite()
|
||||
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='t'`)
|
||||
.all()
|
||||
expect(tables).toHaveLength(0)
|
||||
closeDb()
|
||||
})
|
||||
|
||||
it('readDatabaseFileBuffer checkpoints wal', () => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'vt-db-'))
|
||||
process.env.DB_PATH = join(dir, 'app.db')
|
||||
getDb()
|
||||
getSqlite().exec('CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY); INSERT INTO t VALUES (42);')
|
||||
const buf = readDatabaseFileBuffer()
|
||||
expect(buf.subarray(0, 15).toString('utf8')).toBe('SQLite format 3')
|
||||
expect(buf.length).toBeGreaterThan(100)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user