fix(storage): avoid rewriting satisfied data directory compatibility markers

This commit is contained in:
digua committed 2026-09-24 10:11:59 +08:00
1 parent 8a77a759db
commit af0e844f8a
3 files changed
+121

No files matched your search

@@ -291,3 +291,63 @@ test('raising minRuntimeVersion records prerelease runtime by stable core versio
version: '0.26.4',
})
})
test('satisfied compatibility requirements preserve the original metadata across runtime upgrades', () => {
for (const minRuntimeVersion of ['0.25.1', '0.26.0']) {
const userDataDir = makeTempDir()
const provider = makePathProvider(userDataDir)
const existing = raiseDataDirMinRuntimeVersion(provider, {
minRuntimeVersion,
dataCompatibilityVersion: 2,
reason: 'segment-schema',
runtime: { version: '0.37.3', kind: 'cli' },
module: 'chat-db-migration',
now: () => 1000,
})
const metaPath = path.join(userDataDir, '.chatlab-meta.json')
const original = fs.readFileSync(metaPath, 'utf-8')
const result = raiseDataDirMinRuntimeVersion(provider, {
minRuntimeVersion: '0.25.1',
dataCompatibilityVersion: 1,
reason: 'segment-schema',
runtime: { version: '0.37.4', kind: 'desktop' },
module: 'chat-db-migration',
now: () => 2000,
})
assert.deepEqual(result, existing)
assert.equal(fs.readFileSync(metaPath, 'utf-8'), original)
}
})
test('required compatibility updates propagate replacement failures and preserve the previous metadata', (t) => {
const userDataDir = makeTempDir()
const provider = makePathProvider(userDataDir)
const input = {
minRuntimeVersion: '0.25.1',
dataCompatibilityVersion: 1,
reason: 'segment-schema',
runtime: { version: '0.37.4', kind: 'desktop' as const },
module: 'chat-db-migration',
}
const existing = raiseDataDirMinRuntimeVersion(provider, input)
const metaPath = path.join(userDataDir, '.chatlab-meta.json')
const original = fs.readFileSync(metaPath, 'utf-8')
const denied = Object.assign(new Error('Metadata replacement denied'), { code: 'EPERM' })
const renameSync = fs.renameSync
t.mock.method(fs, 'renameSync', (source: fs.PathLike, target: fs.PathLike) => {
if (target === metaPath) throw denied
renameSync(source, target)
})
for (const update of [{ minRuntimeVersion: '0.26.0' }, { dataCompatibilityVersion: 2 }, { reason: 'new-schema' }]) {
assert.throws(
() => raiseDataDirMinRuntimeVersion(provider, { ...input, ...update }),
(error) => error === denied
)
assert.equal(fs.readFileSync(metaPath, 'utf-8'), original)
assert.deepEqual(readDataDirCompatibilityMeta(userDataDir), existing)
assert.deepEqual(fs.readdirSync(userDataDir), ['.chatlab-meta.json'])
}
})
@@ -157,6 +157,17 @@ export function raiseDataDirMinRuntimeVersion(
}
const existing = readDataDirCompatibilityMeta(userDataDir)
// This is a migration marker, not a last-open timestamp. Replacing an already sufficient file
// can fail on Windows even when the metadata and chat databases are readable.
if (
existing &&
compareStableSemver(existing.minRuntimeVersion, input.minRuntimeVersion) >= 0 &&
existing.dataCompatibilityVersion >= input.dataCompatibilityVersion &&
existing.reasons.includes(input.reason)
) {
return existing
}
const minRuntimeVersion =
existing && compareStableSemver(existing.minRuntimeVersion, input.minRuntimeVersion) > 0
? existing.minRuntimeVersion
@@ -999,6 +999,56 @@ test('open repairs the data directory gate for existing current-schema databases
assert.deepEqual(meta?.reasons, ['segment-schema'])
})
test('existing sessions remain readable after upgrades when compatible metadata cannot be replaced', (t) => {
const root = makeTempDir()
const provider = createPathProvider(root)
fs.mkdirSync(provider.getDatabaseDir(), { recursive: true })
for (const id of ['first', 'second']) {
const db = new Database(path.join(provider.getDatabaseDir(), `${id}.db`), { nativeBinding })
try {
db.exec(CHAT_DB_SCHEMA)
db.prepare('INSERT INTO meta (name, platform, type, imported_at) VALUES (?, ?, ?, ?)').run(
id,
'qq',
'group',
1000
)
db.exec(`
INSERT INTO member (platform_id, account_name) VALUES ('u1', 'Test Member');
INSERT INTO message (sender_id, ts, type, content) VALUES (1, 1000, 0, 'Saved message');
`)
} finally {
db.close()
}
}
const previousManager = new DatabaseManager(provider, {
nativeBinding,
runtime: { version: '0.37.3', kind: 'desktop' },
})
previousManager.raiseCurrentChatDbCompatibilityGate()
const metaPath = path.join(provider.getUserDataDir(), '.chatlab-meta.json')
const original = fs.readFileSync(metaPath, 'utf-8')
const renameSync = fs.renameSync
t.mock.method(fs, 'renameSync', (source: fs.PathLike, target: fs.PathLike) => {
if (target === metaPath) throw Object.assign(new Error('Metadata replacement denied'), { code: 'EPERM' })
renameSync(source, target)
})
for (const kind of ['desktop', 'cli'] as const) {
const manager = new DatabaseManager(provider, { nativeBinding, runtime: { version: '0.37.4', kind } })
t.after(() => manager.closeAll())
assert.deepEqual(manager.listSessionIds().sort(), ['first', 'second'])
for (const id of manager.listSessionIds()) {
const db = manager.open(id)
assert.ok(db)
assert.equal(getSessionInfo(db)?.name, id)
assert.deepEqual(db.prepare('SELECT content FROM message').all(), [{ content: 'Saved message' }])
}
assert.equal(fs.readFileSync(metaPath, 'utf-8'), original)
}
})
test('open keeps a higher existing data directory runtime requirement after migration', () => {
const root = makeTempDir()
const dbDir = path.join(root, 'data', 'databases')