Files
Ncurator/tests/case/unit/storage/sqlite-migration.test.ts
Lingu 8ff59164bf feat: add shared user skills and separate toolset loading
Copy complete Skill folders into shared user data with per-partner enable bindings. Separate load_skill instruction context from load_toolset registration, state, prompts and tool-budget eviction.

Revalidate instruction bindings across inference, compaction and restore. Add management UI, safe import and storage migration coverage, real graph regressions, and persisted snapshot recovery checks.

Signed-off-by: Lingu <132213669+AntonyLang@users.noreply.github.com>
2026-10-23 17:04:35 +08:00

338 lines
13 KiB
TypeScript

import { execFile } from 'node:child_process'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { promisify } from 'node:util'
import Database from 'better-sqlite3'
import { afterEach, describe, expect, it } from 'vitest'
import {
CURRENT_SCHEMA_VERSION,
DatabaseVersionTooNewError,
InvalidMigrationHistoryError,
SqliteCore
} from '../../../../src/main/services/storage/sqlite/SqliteCore'
const execFileAsync = promisify(execFile)
const temporaryDirectories: string[] = []
const releasedV9FixturePath = path.resolve('tests/fixtures/sqlite/released-v9.sql')
const createDatabasePath = async (): Promise<string> => {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'ncurator-sqlite-migration-'))
temporaryDirectories.push(directory)
return path.join(directory, 'app.sqlite')
}
const openRawDatabase = (databasePath: string): Database.Database => {
const nativeBinding = String(process.env.NCURATOR_BETTER_SQLITE3_NATIVE_BINDING || '').trim()
return new Database(databasePath, nativeBinding ? { nativeBinding } : undefined)
}
const createReleasedV9Database = async (databasePath: string): Promise<void> => {
const db = openRawDatabase(databasePath)
try {
db.exec(await fs.readFile(releasedV9FixturePath, 'utf8'))
} finally {
db.close()
}
}
const schemaSignature = (databasePath: string): unknown => {
const db = openRawDatabase(databasePath)
const tables = db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> 'schema_migrations' ORDER BY name"
)
.all() as Array<{ name: string }>
const columns = Object.fromEntries(
tables.map(({ name }) => [
name,
db
.prepare(`PRAGMA table_info(${name})`)
.all()
.map((row) => {
const column = row as {
name: string
type: string
notnull: number
dflt_value: string | null
pk: number
}
return {
name: column.name,
type: column.type,
notnull: column.notnull,
defaultValue: column.dflt_value,
primaryKey: column.pk
}
})
])
)
const indexes = db
.prepare(
"SELECT name, tbl_name AS tableName, sql FROM sqlite_master WHERE type = 'index' AND name NOT LIKE 'sqlite_%' ORDER BY name"
)
.all()
.map((row) => {
const index = row as { name: string; tableName: string; sql: string }
return { ...index, sql: index.sql.replace(/\s+/gu, ' ').trim() }
})
db.close()
return { tables: tables.map(({ name }) => name), columns, indexes }
}
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => fs.rm(directory, { recursive: true, force: true }))
)
})
describe('SQLite migration contract', () => {
it('creates the current schema directly and upgrades the released v9 schema to the same final structure', async () => {
const freshPath = await createDatabasePath()
const upgradedPath = await createDatabasePath()
const fresh = new SqliteCore(freshPath)
fresh.initialize()
fresh.close()
await createReleasedV9Database(upgradedPath)
const upgraded = new SqliteCore(upgradedPath)
upgraded.initialize()
const migrationRows = upgraded
.prepare('SELECT version, name FROM schema_migrations ORDER BY version')
.all()
const partnerTable = upgraded
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'partner'")
.get()
const sessionTable = upgraded
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'chat_session'")
.get()
const searchTurnTable = upgraded
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'chat_search_turn'")
.get()
const integrity = upgraded.getConnection().pragma('quick_check')
const foreignKeys = upgraded.getConnection().pragma('foreign_key_check')
upgraded.close()
expect(migrationRows).toHaveLength(CURRENT_SCHEMA_VERSION)
expect(migrationRows).toContainEqual({ version: 10, name: 'upgrade_from_v9' })
expect(migrationRows.at(-1)).toEqual({ version: 19, name: 'user_skills' })
expect(partnerTable).toEqual({ name: 'partner' })
expect(sessionTable).toBeUndefined()
expect(searchTurnTable).toBeUndefined()
expect(integrity).toEqual([{ quick_check: 'ok' }])
expect(foreignKeys).toEqual([])
expect(schemaSignature(upgradedPath)).toEqual(schemaSignature(freshPath))
})
it('records a pending v10 afterCommit hook and runs it only once', async () => {
const databasePath = await createDatabasePath()
const sqlite = new SqliteCore(databasePath)
sqlite.initialize()
const pending = sqlite.prepare('SELECT version, name, status FROM schema_migration_hooks').all()
expect(pending).toEqual([{ version: 10, name: 'upgrade_from_v9', status: 'pending' }])
const lanceDbPath = path.join(path.dirname(databasePath), 'lancedb')
const leftoverPath = path.join(lanceDbPath, 'stale-vector-data')
await fs.mkdir(lanceDbPath, { recursive: true })
await fs.writeFile(leftoverPath, 'old-chunks')
await sqlite.runPendingAfterCommitHooks({ lanceDbPath })
await expect(fs.access(leftoverPath)).rejects.toMatchObject({ code: 'ENOENT' })
expect(
sqlite.prepare('SELECT status FROM schema_migration_hooks WHERE version = 10').get()
).toEqual({ status: 'done' })
await fs.mkdir(lanceDbPath, { recursive: true })
await fs.writeFile(leftoverPath, 'new-index')
await sqlite.runPendingAfterCommitHooks({ lanceDbPath })
await expect(fs.access(leftoverPath)).resolves.toBeUndefined()
sqlite.close()
})
it('rolls back the complete pending batch when the v9 schema is invalid', async () => {
const databasePath = await createDatabasePath()
await createReleasedV9Database(databasePath)
const damaged = openRawDatabase(databasePath)
damaged.exec('DROP TABLE chat_conversation_memory;')
damaged.close()
const sqlite = new SqliteCore(databasePath)
expect(() => sqlite.initialize()).toThrow()
const inspected = openRawDatabase(databasePath)
const version = inspected.prepare('SELECT MAX(version) AS version FROM schema_migrations').get()
const scheduledTask = inspected
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'scheduled_task'")
.get()
const messageLog = inspected
.prepare(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'chat_session_message_log'"
)
.get()
inspected.close()
expect(version).toEqual({ version: 9 })
expect(scheduledTask).toBeUndefined()
expect(messageLog).toBeUndefined()
})
it('rolls back every earlier migration when a later migration fails on an empty database', async () => {
const databasePath = await createDatabasePath()
const conflicting = openRawDatabase(databasePath)
conflicting.exec('CREATE TABLE scheduled_task (fixture_marker TEXT NOT NULL);')
conflicting.close()
expect(() => new SqliteCore(databasePath).initialize()).toThrow(
'table scheduled_task already exists'
)
const inspected = openRawDatabase(databasePath)
const generatedTables = inspected
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('schema_migrations', 'partner', 'chat_session_message_log') ORDER BY name"
)
.all()
const fixtureColumns = inspected.prepare('PRAGMA table_info(scheduled_task)').all()
inspected.close()
expect(generatedTables).toEqual([])
expect(fixtureColumns).toMatchObject([{ name: 'fixture_marker' }])
})
it('rejects a newer database before writing to it', async () => {
const databasePath = await createDatabasePath()
const current = new SqliteCore(databasePath)
current.initialize()
const futureVersion = CURRENT_SCHEMA_VERSION + 1
current
.prepare('INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)')
.run(futureVersion, 'future_schema', 1)
current.close()
const sqlite = new SqliteCore(databasePath)
let thrown: unknown
try {
sqlite.initialize()
} catch (error) {
thrown = error
}
expect(thrown).toBeInstanceOf(DatabaseVersionTooNewError)
expect(thrown).toMatchObject({
databaseVersion: futureVersion,
supportedDatabaseVersion: CURRENT_SCHEMA_VERSION
})
const inspected = openRawDatabase(databasePath)
const rows = inspected
.prepare('SELECT version, name FROM schema_migrations ORDER BY version')
.all()
inspected.close()
expect(rows.at(-1)).toEqual({ version: futureVersion, name: 'future_schema' })
expect(rows).toHaveLength(futureVersion)
})
it('accepts the known version 2 alias and rejects unknown names or gaps', async () => {
const aliasPath = await createDatabasePath()
const alias = new SqliteCore(aliasPath)
alias.initialize()
alias
.prepare("UPDATE schema_migrations SET name = 'workspace_change_sets' WHERE version = 2")
.run()
alias.close()
expect(() => {
const reopened = new SqliteCore(aliasPath)
reopened.initialize()
reopened.close()
}).not.toThrow()
const nameMismatchPath = await createDatabasePath()
const nameMismatch = new SqliteCore(nameMismatchPath)
nameMismatch.initialize()
nameMismatch
.prepare("UPDATE schema_migrations SET name = 'unexpected_name' WHERE version = 4")
.run()
nameMismatch.close()
expect(() => new SqliteCore(nameMismatchPath).initialize()).toThrow(
InvalidMigrationHistoryError
)
const gapPath = await createDatabasePath()
const gap = new SqliteCore(gapPath)
gap.initialize()
gap.prepare('DELETE FROM schema_migrations WHERE version = 5').run()
gap.close()
expect(() => new SqliteCore(gapPath).initialize()).toThrow(InvalidMigrationHistoryError)
})
it('drops leftover terminal file-change rows on upgrade to v11', async () => {
const databasePath = await createDatabasePath()
const prepared = new SqliteCore(databasePath)
prepared.initialize()
prepared.prepare('DELETE FROM schema_migrations WHERE version >= 11').run()
prepared.exec(`DROP INDEX idx_chat_session_message_log_subagent_seq;
DROP INDEX idx_chat_session_message_log_partner_seq;
ALTER TABLE chat_session_message_log DROP COLUMN subagent_id;
DROP TABLE ai_subagent;
CREATE UNIQUE INDEX idx_chat_session_message_log_partner_seq ON chat_session_message_log(partner_id, seq);`)
prepared.exec('DROP TABLE IF EXISTS app_settings')
prepared.exec('DROP TABLE open_with_preferences')
prepared.exec('DROP TABLE partner_user_skill; DROP TABLE user_skill;')
prepared.exec('ALTER TABLE ai_run DROP COLUMN pending_messages_json')
prepared
.prepare(
`INSERT INTO partner (
id, name, avatar_shape, avatar_color, pinned, created_at, updated_at, last_viewed_at
) VALUES (?, ?, ?, ?, 0, 1, 1, 1)`
)
.run('partner-1', 'partner', 'circle', 'blue')
const insertChange = prepared.prepare(
`INSERT INTO ai_file_change (
id, partner_id, run_id, status, capability, path, file_name, operation, source,
change_count, insert_count, delete_count, created_at, updated_at
) VALUES (?, 'partner-1', 'run-1', 'applied', ?, '/tmp/a.txt', ?, 'updated', ?, 1, 0, 0, 1, 1)`
)
insertChange.run('keep-text', 'text_file', 'keep.txt', 'text_tool')
insertChange.run('drop-terminal-source', 'text_file', 'tracked.txt', 'terminal_tool')
insertChange.run('drop-scan-source', 'text_file', 'scanned.txt', 'workspace_scan')
insertChange.run('drop-terminal-capability', 'terminal_exec', 'exec.bin', 'text_tool')
prepared.close()
const upgraded = new SqliteCore(databasePath)
upgraded.initialize()
const rows = upgraded
.prepare('SELECT id, source, capability FROM ai_file_change ORDER BY id')
.all()
upgraded.close()
expect(rows).toEqual([{ id: 'keep-text', source: 'text_tool', capability: 'text_file' }])
})
it('keeps the published migration lock valid and rejects a changed locked migration', async () => {
const script = path.resolve('scripts/release/shared/sqlite-migration-lock.mjs')
const lockPath = path.resolve(
'src/main/services/storage/sqlite/migrations/released-migrations.json'
)
const lock = JSON.parse(await fs.readFile(lockPath, 'utf8')) as {
publishedThrough: number
migrations: Array<{ sha256: string }>
}
const { stdout } = await execFileAsync(process.execPath, [script], { cwd: process.cwd() })
expect(stdout).toContain(
`verified SQLite migrations through published version ${lock.publishedThrough}`
)
const tamperedLockPath = await createDatabasePath()
lock.migrations[0].sha256 = '0'.repeat(64)
await fs.writeFile(tamperedLockPath, `${JSON.stringify(lock)}\n`, 'utf8')
await expect(
execFileAsync(process.execPath, [script, '--lock-file', tamperedLockPath], {
cwd: process.cwd()
})
).rejects.toThrow('published SQLite migration 1 changed')
})
})