fix: write data directory compatibility gate on migration and import

Harden the startup migration path: fail fast and show an error dialog if
schema migration cannot complete. Repair the gate retroactively for
databases that were already migrated before the gate system existed.

Raise the gate after every import path (full import, incremental import,
stream import) and after opening any database that already carries a
current-segment schema. Centralise gate-raising inside DatabaseManager so
call sites cannot accidentally skip it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
diguaandClaude Sonnet 4.6 committed 2026-06-08 10:14:42 +08:00
1 parent 1c7781d85b
commit e7561e224e
25 files changed
+941 -124

No files matched your search

+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import type { PathProvider } from '@openchatlab/core'
import { DatabaseManager, raiseDataDirMinRuntimeVersion, readDataDirCompatibilityMeta } from '@openchatlab/node-runtime'
import { importData } from './importer'
const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node')
function makeTempDir(): string {
const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()
return fs.mkdtempSync(path.join(baseDir, 'chatlab-cli-import-'))
}
function createPathProvider(root: string): PathProvider {
return {
getSystemDir: () => root,
getUserDataDir: () => path.join(root, 'data'),
getDatabaseDir: () => path.join(root, 'data', 'databases'),
getAiDataDir: () => path.join(root, 'ai'),
getSettingsDir: () => path.join(root, 'settings'),
getCacheDir: () => path.join(root, 'cache'),
getTempDir: () => path.join(root, 'temp'),
getLogsDir: () => path.join(root, 'logs'),
getDownloadsDir: () => path.join(root, 'downloads'),
}
}
test('importData raises the data directory gate after creating a current-schema database', async () => {
const root = makeTempDir()
fs.mkdirSync(path.join(root, 'data', 'databases'), { recursive: true })
const manager = new DatabaseManager(createPathProvider(root), {
nativeBinding,
runtime: { version: '0.25.1', kind: 'cli' },
})
const result = await importData(
manager,
{
meta: { name: 'Fresh Import', platform: 'qq', type: 'group' },
members: [{ platformId: 'u1', accountName: 'Alice' }],
messages: [
{
senderPlatformId: 'u1',
senderAccountName: 'Alice',
timestamp: 1000,
type: 0,
content: 'hello',
},
],
},
{ nativeBinding }
)
assert.equal(result.success, true)
const meta = readDataDirCompatibilityMeta(path.join(root, 'data'))
assert.equal(meta?.minRuntimeVersion, '0.25.1')
assert.equal(meta?.dataCompatibilityVersion, 1)
assert.deepEqual(meta?.reasons, ['segment-schema'])
})
test('importData re-checks data directory compatibility before raw database writes', async () => {
const root = makeTempDir()
fs.mkdirSync(path.join(root, 'data', 'databases'), { recursive: true })
const pathProvider = createPathProvider(root)
raiseDataDirMinRuntimeVersion(pathProvider, {
minRuntimeVersion: '0.26.0',
dataCompatibilityVersion: 2,
reason: 'future-schema',
runtime: { version: '0.26.0', kind: 'desktop' },
module: 'future-migration',
now: () => 1780830000,
})
const manager = new DatabaseManager(pathProvider, {
nativeBinding,
runtime: { version: '0.25.1', kind: 'cli' },
})
const result = await importData(
manager,
{
meta: { name: 'Blocked Import', platform: 'qq', type: 'group' },
members: [{ platformId: 'u1', accountName: 'Alice' }],
messages: [
{
senderPlatformId: 'u1',
senderAccountName: 'Alice',
timestamp: 1000,
type: 0,
content: 'hello',
},
],
},
{ nativeBinding }
)
assert.equal(result.success, false)
assert.match(result.error ?? '', /requires runtime version 0\.26\.0 or newer/)
assert.equal(fs.readdirSync(path.join(root, 'data', 'databases')).filter((name) => name.endsWith('.db')).length, 0)
})
+16 -26
View File
@@ -10,7 +10,7 @@ import * as crypto from 'crypto'
import type { DatabaseAdapter } from '@openchatlab/core'
import { CHAT_DB_SCHEMA, FTS_TABLE_SCHEMA, generateMessageKey, buildMemberIdMap } from '@openchatlab/core'
import type { DatabaseManager } from '@openchatlab/node-runtime'
import { openBetterSqliteDatabase, writeParseResultToDb } from '@openchatlab/node-runtime'
import { writeParseResultToDb } from '@openchatlab/node-runtime'
import type { ParsedData, ImportMessage } from './chatlab-reader'
export interface ImportResult {
@@ -272,40 +272,30 @@ export async function importData(
try {
if (exists) {
dbManager.close(sessionId)
const db = openBetterSqliteDatabase(dbPath, { readonly: false, nativeBinding: options?.nativeBinding })
const db = dbManager.openRawSessionDatabase(sessionId, { readonly: false })
let result: { messageCount: number; memberCount: number; duplicateCount: number }
try {
const result = incrementalImport(db, data, options?.onProgress)
result = incrementalImport(db, data, options?.onProgress)
} finally {
db.close()
return { success: true, sessionId, created: false, ...result }
} catch (err) {
db.close()
throw err
}
dbManager.raiseCurrentChatDbCompatibilityGate()
return { success: true, sessionId, created: false, ...result }
}
const db = openBetterSqliteDatabase(dbPath, { nativeBinding: options?.nativeBinding })
const result = fullImport(db, data, options?.onProgress)
db.close()
const db = dbManager.openRawSessionDatabase(sessionId, { create: true })
let result: { messageCount: number; memberCount: number; duplicateCount: number }
try {
result = fullImport(db, data, options?.onProgress)
} finally {
db.close()
}
dbManager.raiseCurrentChatDbCompatibilityGate()
return { success: true, sessionId, created: true, ...result }
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err)
if (!exists) {
try {
fs.unlinkSync(dbPath)
} catch {
/* ignore */
}
try {
fs.unlinkSync(dbPath + '-wal')
} catch {
/* ignore */
}
try {
fs.unlinkSync(dbPath + '-shm')
} catch {
/* ignore */
}
dbManager.deleteSessionDatabaseFiles(sessionId)
}
return {
success: false,
+81
View File
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import type { PathProvider } from '@openchatlab/core'
import { DatabaseManager, readDataDirCompatibilityMeta } from '@openchatlab/node-runtime'
import { incrementalImport } from './stream-import'
const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node')
function makeTempDir(): string {
const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()
return fs.mkdtempSync(path.join(baseDir, 'chatlab-cli-stream-import-'))
}
function createPathProvider(root: string): PathProvider {
return {
getSystemDir: () => root,
getUserDataDir: () => path.join(root, 'data'),
getDatabaseDir: () => path.join(root, 'data', 'databases'),
getAiDataDir: () => path.join(root, 'ai'),
getSettingsDir: () => path.join(root, 'settings'),
getCacheDir: () => path.join(root, 'cache'),
getTempDir: () => path.join(root, 'temp'),
getLogsDir: () => path.join(root, 'logs'),
getDownloadsDir: () => path.join(root, 'downloads'),
}
}
function writeIncrementalJsonl(filePath: string): void {
const rows = [
{
_type: 'header',
chatlab: { version: '1.0.0', exportedAt: 1780830000 },
meta: { name: 'Incremental Chat', platform: 'qq', type: 'group' },
},
{ _type: 'member', platformId: 'u1', accountName: 'Alice' },
{
_type: 'message',
sender: 'u1',
accountName: 'Alice',
timestamp: 2000,
type: 0,
content: 'new incremental message',
platformMessageId: 'm1',
},
]
fs.writeFileSync(filePath, rows.map((row) => JSON.stringify(row)).join('\n'), 'utf-8')
}
test('incrementalImport raises the data directory gate after successful writes', async () => {
const root = makeTempDir()
const pathProvider = createPathProvider(root)
const manager = new DatabaseManager(pathProvider, {
nativeBinding,
runtime: { version: '0.25.1', kind: 'cli' },
})
const db = manager.openRawSessionDatabase('existing', { create: true, initializeChatTables: true })
db.prepare('INSERT INTO meta (name, platform, type, imported_at) VALUES (?, ?, ?, ?)').run(
'Existing Chat',
'qq',
'group',
1000
)
db.prepare('INSERT INTO member (platform_id, account_name) VALUES (?, ?)').run('u0', 'Existing User')
db.close()
const filePath = path.join(root, 'incremental.jsonl')
writeIncrementalJsonl(filePath)
const result = await incrementalImport(manager, 'existing', filePath)
assert.equal(result.success, true)
assert.equal(result.newMessageCount, 1)
const meta = readDataDirCompatibilityMeta(pathProvider.getUserDataDir())
assert.equal(meta?.minRuntimeVersion, '0.25.1')
assert.equal(meta?.dataCompatibilityVersion, 1)
assert.deepEqual(meta?.reasons, ['segment-schema'])
})
+56 -32
View File
@@ -6,12 +6,10 @@
* nickname history, FTS, format fallback).
*/
import * as fs from 'fs'
import * as path from 'path'
import type { DatabaseManager } from '@openchatlab/node-runtime'
import {
DataDirCompatibilityError,
streamingImport,
openBetterSqliteDatabase,
analyzeNewImport as sharedAnalyzeNewImport,
analyzeIncrementalImport as sharedAnalyzeIncremental,
incrementalImport as sharedIncrementalImport,
@@ -27,7 +25,6 @@ import type {
ImportOptions,
AnalyzeNewImportResult,
} from '@openchatlab/node-runtime'
import { CHAT_DB_TABLES } from '@openchatlab/core'
import {
detectFormat as parserDetectFormat,
detectAllFormats,
@@ -65,31 +62,13 @@ function generateSessionId(): string {
return `chat_${ts}_${rand}`
}
function resolveNativeBinding(dbManager: DatabaseManager): string | undefined {
return (dbManager as any).nativeBinding
}
function buildStreamImportDeps(dbManager: DatabaseManager, onProgress?: ImportProgressCallback): StreamImportDeps {
const nativeBinding = resolveNativeBinding(dbManager)
return {
openDatabase(sessionId: string) {
const dbPath = dbManager.getDbPath(sessionId)
const dir = path.dirname(dbPath)
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
const db = openBetterSqliteDatabase(dbPath, { nativeBinding })
db.exec(CHAT_DB_TABLES)
return db
return dbManager.openRawSessionDatabase(sessionId, { create: true, initializeChatTables: true })
},
deleteDatabase(sessionId: string) {
const dbPath = dbManager.getDbPath(sessionId)
for (const suffix of ['', '-wal', '-shm']) {
try {
const p = dbPath + suffix
if (fs.existsSync(p)) fs.unlinkSync(p)
} catch {
/* ignore */
}
}
dbManager.deleteSessionDatabaseFiles(sessionId)
},
onProgress: onProgress ?? (() => {}),
postImportHook(db) {
@@ -103,6 +82,10 @@ function buildStreamImportDeps(dbManager: DatabaseManager, onProgress?: ImportPr
}
}
function deleteSessionDatabase(dbManager: DatabaseManager, sessionId: string): void {
dbManager.deleteSessionDatabaseFiles(sessionId)
}
/**
* High-performance streaming import: parse a file and write to DB
* with batched transactions, deferred indexes, and FTS.
@@ -157,20 +140,38 @@ export async function streamImport(
: () => {}
const deps = buildStreamImportDeps(dbManager, progressAdapter)
return streamingImport(filePath, deps, formatOptions)
const result = await streamingImport(filePath, deps, formatOptions)
if (!result.success || !result.sessionId) return result
try {
dbManager.raiseCurrentChatDbCompatibilityGate()
} catch (error) {
deleteSessionDatabase(dbManager, result.sessionId)
return {
success: false,
error: error instanceof Error ? error.message : String(error),
diagnostics: result.diagnostics,
}
}
return result
}
// ==================== Incremental import ====================
function buildIncrementalDeps(dbManager: DatabaseManager, onProgress?: ImportProgressCallback): IncrementalImportDeps {
const nativeBinding = resolveNativeBinding(dbManager)
function buildIncrementalDeps(
dbManager: DatabaseManager,
onProgress?: ImportProgressCallback,
onCompatibilityError?: (error: DataDirCompatibilityError) => void
): IncrementalImportDeps {
return {
openDatabase(sessionId: string, readonly?: boolean) {
const dbPath = dbManager.getDbPath(sessionId)
if (!fs.existsSync(dbPath)) {
throw new Error(`Session database not found: ${sessionId}`)
try {
return dbManager.openRawSessionDatabase(sessionId, { readonly: readonly ?? false })
} catch (error) {
if (error instanceof DataDirCompatibilityError) onCompatibilityError?.(error)
throw error
}
return openBetterSqliteDatabase(dbPath, { readonly: readonly ?? false, nativeBinding })
},
onProgress: onProgress ?? (() => {}),
postImportHook(db) {
@@ -190,7 +191,30 @@ export async function incrementalImport(
options?: ImportOptions & { onProgress?: ImportProgressCallback }
): Promise<IncrementalImportResult> {
const { onProgress, ...importOpts } = options || {}
return sharedIncrementalImport(sessionId, filePath, buildIncrementalDeps(dbManager, onProgress), importOpts)
let compatibilityError: DataDirCompatibilityError | null = null
const result = await sharedIncrementalImport(
sessionId,
filePath,
buildIncrementalDeps(dbManager, onProgress, (error) => {
compatibilityError = error
}),
importOpts
)
if (compatibilityError) throw compatibilityError
if (!result.success) return result
try {
dbManager.raiseCurrentChatDbCompatibilityGate()
} catch (error) {
return {
...result,
success: false,
newMessageCount: 0,
error: error instanceof Error ? error.message : String(error),
}
}
return result
}
export async function analyzeIncrementalImport(
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import type { PathProvider } from '@openchatlab/core'
import { DatabaseManager, DataDirCompatibilityError, raiseDataDirMinRuntimeVersion } from '@openchatlab/node-runtime'
import { DirectImporter } from './adapters'
const nativeBinding = path.resolve('apps/cli/native/better_sqlite3.node')
function makeTempDir(): string {
const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()
return fs.mkdtempSync(path.join(baseDir, 'chatlab-cli-sync-'))
}
function createPathProvider(root: string): PathProvider {
return {
getSystemDir: () => root,
getUserDataDir: () => path.join(root, 'data'),
getDatabaseDir: () => path.join(root, 'data', 'databases'),
getAiDataDir: () => path.join(root, 'ai'),
getSettingsDir: () => path.join(root, 'settings'),
getCacheDir: () => path.join(root, 'cache'),
getTempDir: () => path.join(root, 'temp'),
getLogsDir: () => path.join(root, 'logs'),
getDownloadsDir: () => path.join(root, 'downloads'),
}
}
test('DirectImporter.sessionExists preserves session DBs when compatibility gate blocks access', () => {
const root = makeTempDir()
const pathProvider = createPathProvider(root)
const dbDir = pathProvider.getDatabaseDir()
fs.mkdirSync(dbDir, { recursive: true })
const dbPath = path.join(dbDir, 'blocked.db')
fs.writeFileSync(dbPath, 'sqlite content is not opened before the compatibility check', 'utf-8')
raiseDataDirMinRuntimeVersion(pathProvider, {
minRuntimeVersion: '0.26.0',
dataCompatibilityVersion: 2,
reason: 'future-schema',
runtime: { version: '0.26.0', kind: 'desktop' },
module: 'future-migration',
now: () => 1780830000,
})
const manager = new DatabaseManager(pathProvider, {
nativeBinding,
runtime: { version: '0.25.1', kind: 'cli' },
})
const importer = new DirectImporter(manager)
assert.throws(
() => importer.sessionExists('blocked'),
(error) => error instanceof DataDirCompatibilityError && error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME'
)
assert.equal(fs.existsSync(dbPath), true)
})
+5 -15
View File
@@ -14,10 +14,9 @@ import type { HttpFetcher, DataImporter, SyncNotifier, ImportResult, FetchParams
import { NOOP_LOGGER } from '@openchatlab/sync'
import { buildPullUrl } from '@openchatlab/sync'
import type { DatabaseManager } from '@openchatlab/node-runtime'
import { openBetterSqliteDatabase } from '@openchatlab/node-runtime'
import { DataDirCompatibilityError } from '@openchatlab/node-runtime'
import { parseFile } from '../import/chatlab-reader'
import { importData } from '../import/importer'
import { resolveCliPath } from '../paths'
function getTempFilePath(ext: string): string {
const id = crypto.randomBytes(8).toString('hex')
@@ -51,21 +50,12 @@ export class NodeFetcher implements HttpFetcher {
// ==================== DirectImporter ====================
function resolveNativeBinding(): string | undefined {
if (process.versions.electron) return undefined
const nativePath = resolveCliPath('native/better_sqlite3.node')
if (fs.existsSync(nativePath)) return nativePath
return undefined
}
export class DirectImporter implements DataImporter {
private dbManager: DatabaseManager
private nativeBinding: string | undefined
private logger: SyncLogger
constructor(dbManager: DatabaseManager, logger?: SyncLogger) {
this.dbManager = dbManager
this.nativeBinding = resolveNativeBinding()
this.logger = logger ?? NOOP_LOGGER
}
@@ -73,7 +63,7 @@ export class DirectImporter implements DataImporter {
const dbPath = this.dbManager.getDbPath(sessionId)
if (!fs.existsSync(dbPath)) return false
try {
const db = openBetterSqliteDatabase(dbPath, { readonly: true, nativeBinding: this.nativeBinding })
const db = this.dbManager.openRawSessionDatabase(sessionId, { readonly: true })
const row = db
.prepare("SELECT COUNT(*) as cnt FROM sqlite_master WHERE type='table' AND name='message'")
.get() as { cnt: number }
@@ -88,7 +78,9 @@ export class DirectImporter implements DataImporter {
return false
}
return true
} catch {
} catch (error) {
if (error instanceof DataDirCompatibilityError) throw error
this.logger.warn(`[DirectImporter] Cannot validate DB file: ${sessionId}, removing`)
try {
fs.unlinkSync(dbPath)
@@ -119,7 +111,6 @@ export class DirectImporter implements DataImporter {
this.dbManager.close(sessionId)
const result = await importData(this.dbManager, data, {
sessionId,
nativeBinding: this.nativeBinding,
})
if (result.success) {
@@ -154,7 +145,6 @@ export class DirectImporter implements DataImporter {
const result = await importData(this.dbManager, data, {
sessionId: externalId,
nativeBinding: this.nativeBinding,
})
if (result.success) {