feat(runtime): add data directory compatibility gate

This commit is contained in:
digua committed 2026-06-08 10:14:42 +08:00
1 parent 2111abae5f
commit c0885ba053
3 files changed
+496

No files matched your search

@@ -0,0 +1,198 @@
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 {
assertDataDirCompatible,
DataDirCompatibilityError,
raiseDataDirMinRuntimeVersion,
readDataDirCompatibilityMeta,
} from './data-dir-compat'
function makeTempDir(): string {
const baseDir = fs.existsSync('/private/tmp') ? '/private/tmp' : os.tmpdir()
return fs.mkdtempSync(path.join(baseDir, 'chatlab-data-compat-'))
}
function makePathProvider(userDataDir: string): PathProvider {
return {
getSystemDir: () => path.join(userDataDir, '..', 'system'),
getUserDataDir: () => userDataDir,
getDatabaseDir: () => path.join(userDataDir, 'databases'),
getAiDataDir: () => path.join(userDataDir, '..', 'system', 'ai'),
getSettingsDir: () => path.join(userDataDir, '..', 'system', 'settings'),
getCacheDir: () => path.join(userDataDir, '..', 'system', 'cache'),
getTempDir: () => path.join(userDataDir, '..', 'system', 'temp'),
getLogsDir: () => path.join(userDataDir, '..', 'system', 'logs'),
getDownloadsDir: () => path.join(userDataDir, '..', 'downloads'),
}
}
function writeMeta(userDataDir: string, meta: unknown): void {
fs.mkdirSync(userDataDir, { recursive: true })
fs.writeFileSync(path.join(userDataDir, '.chatlab-meta.json'), JSON.stringify(meta, null, 2), 'utf-8')
}
function withOverride<T>(value: string | undefined, fn: () => T): T {
const previous = process.env.CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR
if (value === undefined) {
delete process.env.CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR
} else {
process.env.CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR = value
}
try {
return fn()
} finally {
if (previous === undefined) {
delete process.env.CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR
} else {
process.env.CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR = previous
}
}
}
test('missing data dir compatibility meta is compatible', () => {
const userDataDir = makeTempDir()
const provider = makePathProvider(userDataDir)
assert.equal(readDataDirCompatibilityMeta(userDataDir), null)
assert.doesNotThrow(() => {
assertDataDirCompatible(provider, { version: '0.25.1', kind: 'cli' })
})
})
test('current runtime satisfying minRuntimeVersion is compatible', () => {
const userDataDir = makeTempDir()
writeMeta(userDataDir, {
formatVersion: 1,
minRuntimeVersion: '0.25.1',
dataCompatibilityVersion: 1,
reasons: ['segment-schema'],
updatedBy: { runtime: 'cli', module: 'chat-db-migration', version: '0.25.1' },
updatedAt: 1780830000,
})
assert.doesNotThrow(() => {
assertDataDirCompatible(makePathProvider(userDataDir), { version: '0.25.1', kind: 'desktop' })
})
})
test('current runtime below minRuntimeVersion is blocked', () => {
const userDataDir = makeTempDir()
writeMeta(userDataDir, {
formatVersion: 1,
minRuntimeVersion: '0.25.1',
dataCompatibilityVersion: 1,
reasons: ['segment-schema'],
updatedBy: { runtime: 'desktop', module: 'chat-db-migration', version: '0.25.1' },
updatedAt: 1780830000,
})
assert.throws(
() => {
assertDataDirCompatible(makePathProvider(userDataDir), { version: '0.25.0', kind: 'cli' })
},
(error) =>
error instanceof DataDirCompatibilityError &&
error.code === 'DATA_DIR_REQUIRES_NEWER_RUNTIME' &&
error.minRuntimeVersion === '0.25.1' &&
error.currentVersion === '0.25.0' &&
error.userDataDir === userDataDir
)
})
test('override bypasses only version insufficiency and emits a warning', () => {
const userDataDir = makeTempDir()
const warnings: string[] = []
writeMeta(userDataDir, {
formatVersion: 1,
minRuntimeVersion: '0.25.1',
dataCompatibilityVersion: 1,
reasons: ['segment-schema'],
updatedBy: { runtime: 'desktop', module: 'chat-db-migration', version: '0.25.1' },
updatedAt: 1780830000,
})
withOverride('1', () => {
assert.doesNotThrow(() => {
assertDataDirCompatible(
makePathProvider(userDataDir),
{ version: '0.25.0', kind: 'cli' },
{
warn: (message) => warnings.push(message),
}
)
})
})
assert.equal(warnings.length, 1)
assert.match(warnings[0], /CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR=1/)
assert.match(warnings[0], /0\.25\.0/)
assert.match(warnings[0], /0\.25\.1/)
assert.match(warnings[0], new RegExp(userDataDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
})
test('broken JSON and invalid meta are blocked even with override', () => {
const brokenDir = makeTempDir()
fs.writeFileSync(path.join(brokenDir, '.chatlab-meta.json'), '{ broken', 'utf-8')
withOverride('1', () => {
assert.throws(
() => assertDataDirCompatible(makePathProvider(brokenDir), { version: '0.25.0', kind: 'cli' }),
(error) => error instanceof DataDirCompatibilityError && error.code === 'DATA_DIR_COMPATIBILITY_META_INVALID'
)
})
const invalidDir = makeTempDir()
writeMeta(invalidDir, {
formatVersion: 1,
minRuntimeVersion: '0.25.1-beta.0',
dataCompatibilityVersion: 1,
reasons: ['segment-schema'],
updatedBy: { runtime: 'cli', module: 'chat-db-migration', version: '0.25.1' },
updatedAt: 1780830000,
})
withOverride('1', () => {
assert.throws(
() => assertDataDirCompatible(makePathProvider(invalidDir), { version: '0.25.0', kind: 'cli' }),
(error) => error instanceof DataDirCompatibilityError && error.code === 'DATA_DIR_COMPATIBILITY_META_INVALID'
)
})
})
test('raising minRuntimeVersion writes atomically and never lowers existing requirements', () => {
const userDataDir = makeTempDir()
const provider = makePathProvider(userDataDir)
raiseDataDirMinRuntimeVersion(provider, {
minRuntimeVersion: '0.26.0',
dataCompatibilityVersion: 2,
reason: 'future-schema',
runtime: { version: '0.26.0', kind: 'desktop' },
module: 'future-migration',
now: () => 1780830000,
})
raiseDataDirMinRuntimeVersion(provider, {
minRuntimeVersion: '0.25.1',
dataCompatibilityVersion: 1,
reason: 'segment-schema',
runtime: { version: '0.25.1', kind: 'cli' },
module: 'chat-db-migration',
now: () => 1780830001,
})
const meta = readDataDirCompatibilityMeta(userDataDir)
assert.equal(meta?.formatVersion, 1)
assert.equal(meta?.minRuntimeVersion, '0.26.0')
assert.equal(meta?.dataCompatibilityVersion, 2)
assert.deepEqual(meta?.reasons, ['future-schema', 'segment-schema'])
assert.deepEqual(meta?.updatedBy, { runtime: 'cli', module: 'chat-db-migration', version: '0.25.1' })
assert.equal(meta?.updatedAt, 1780830001)
assert.equal(fs.existsSync(path.join(userDataDir, '.chatlab-meta.json')), true)
})
@@ -0,0 +1,285 @@
import fs from 'node:fs'
import path from 'node:path'
import type { PathProvider } from '@openchatlab/core'
export type RuntimeKind = 'cli' | 'desktop' | 'mcp' | 'unknown'
export interface RuntimeIdentity {
version: string
kind: RuntimeKind
}
export interface DataDirCompatibilityMeta {
formatVersion: 1
minRuntimeVersion: string
dataCompatibilityVersion: number
reasons: string[]
updatedBy: {
runtime: RuntimeKind
module: string
version: string
}
updatedAt: number
}
export interface RaiseDataDirCompatibilityInput {
minRuntimeVersion: string
dataCompatibilityVersion: number
reason: string
runtime: RuntimeIdentity
module: string
now?: () => number
}
export interface AssertDataDirCompatibilityOptions {
warn?: (message: string) => void
env?: Pick<NodeJS.ProcessEnv, 'CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR'>
}
type DataDirCompatibilityErrorCode = 'DATA_DIR_REQUIRES_NEWER_RUNTIME' | 'DATA_DIR_COMPATIBILITY_META_INVALID'
export class DataDirCompatibilityError extends Error {
readonly code: DataDirCompatibilityErrorCode
readonly userDataDir: string
readonly metaPath: string
readonly currentVersion?: string
readonly minRuntimeVersion?: string
readonly statusCode = 409
constructor(
code: DataDirCompatibilityErrorCode,
message: string,
options: {
userDataDir: string
metaPath: string
currentVersion?: string
minRuntimeVersion?: string
cause?: unknown
}
) {
super(message, { cause: options.cause })
this.name = 'DataDirCompatibilityError'
this.code = code
this.userDataDir = options.userDataDir
this.metaPath = options.metaPath
this.currentVersion = options.currentVersion
this.minRuntimeVersion = options.minRuntimeVersion
}
}
export function readDataDirCompatibilityMeta(userDataDir: string): DataDirCompatibilityMeta | null {
const metaPath = getMetaPath(userDataDir)
if (!fs.existsSync(metaPath)) return null
let parsed: unknown
try {
parsed = JSON.parse(fs.readFileSync(metaPath, 'utf-8'))
} catch (error) {
throw createInvalidMetaError(userDataDir, metaPath, 'Invalid data directory compatibility meta JSON.', error)
}
return validateDataDirCompatibilityMeta(parsed, userDataDir, metaPath)
}
export function assertDataDirCompatible(
pathProvider: PathProvider,
runtime: RuntimeIdentity,
options: AssertDataDirCompatibilityOptions = {}
): void {
const userDataDir = pathProvider.getUserDataDir()
const meta = readDataDirCompatibilityMeta(userDataDir)
if (!meta) return
if (!isStableSemver(runtime.version) || compareStableSemver(runtime.version, meta.minRuntimeVersion) < 0) {
const env = options.env ?? process.env
if (env.CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR === '1') {
const warn = options.warn ?? console.warn
warn(
[
'CHATLAB_ALLOW_INCOMPATIBLE_DATA_DIR=1 is set. ChatLab will bypass the data directory runtime version gate.',
`Current runtime ${runtime.kind}@${runtime.version} is below required version ${meta.minRuntimeVersion}.`,
`Data directory: ${userDataDir}. Continuing may risk data corruption.`,
].join(' ')
)
return
}
throw new DataDirCompatibilityError(
'DATA_DIR_REQUIRES_NEWER_RUNTIME',
`ChatLab data directory requires runtime version ${meta.minRuntimeVersion} or newer; current version is ${runtime.version}.`,
{
userDataDir,
metaPath: getMetaPath(userDataDir),
currentVersion: runtime.version,
minRuntimeVersion: meta.minRuntimeVersion,
}
)
}
}
export function raiseDataDirMinRuntimeVersion(
pathProvider: PathProvider,
input: RaiseDataDirCompatibilityInput
): DataDirCompatibilityMeta {
const userDataDir = pathProvider.getUserDataDir()
const metaPath = getMetaPath(userDataDir)
assertStableVersion(input.minRuntimeVersion, userDataDir, metaPath, 'Invalid minRuntimeVersion.')
assertStableVersion(input.runtime.version, userDataDir, metaPath, 'Invalid runtime version.')
if (!isRuntimeKind(input.runtime.kind)) {
throw createInvalidMetaError(userDataDir, metaPath, 'Invalid runtime kind.')
}
if (!isNonEmptyString(input.module) || !isNonEmptyString(input.reason)) {
throw createInvalidMetaError(userDataDir, metaPath, 'Invalid compatibility raise metadata.')
}
if (!Number.isInteger(input.dataCompatibilityVersion) || input.dataCompatibilityVersion < 0) {
throw createInvalidMetaError(userDataDir, metaPath, 'Invalid dataCompatibilityVersion.')
}
const existing = readDataDirCompatibilityMeta(userDataDir)
const minRuntimeVersion =
existing && compareStableSemver(existing.minRuntimeVersion, input.minRuntimeVersion) > 0
? existing.minRuntimeVersion
: input.minRuntimeVersion
const dataCompatibilityVersion = Math.max(existing?.dataCompatibilityVersion ?? 0, input.dataCompatibilityVersion)
const reasons = mergeReasons(existing?.reasons ?? [], input.reason)
const now = input.now ?? (() => Math.floor(Date.now() / 1000))
const nextMeta: DataDirCompatibilityMeta = {
formatVersion: 1,
minRuntimeVersion,
dataCompatibilityVersion,
reasons,
updatedBy: {
runtime: input.runtime.kind,
module: input.module,
version: input.runtime.version,
},
updatedAt: now(),
}
writeMetaAtomic(userDataDir, metaPath, nextMeta)
return nextMeta
}
function getMetaPath(userDataDir: string): string {
return path.join(userDataDir, '.chatlab-meta.json')
}
function validateDataDirCompatibilityMeta(
value: unknown,
userDataDir: string,
metaPath: string
): DataDirCompatibilityMeta {
if (!isRecord(value)) {
throw createInvalidMetaError(userDataDir, metaPath, 'Data directory compatibility meta must be an object.')
}
if (value.formatVersion !== 1) {
throw createInvalidMetaError(userDataDir, metaPath, 'Unsupported data directory compatibility meta format.')
}
const minRuntimeVersion = value.minRuntimeVersion
const dataCompatibilityVersion = value.dataCompatibilityVersion
const reasons = value.reasons
const updatedBy = value.updatedBy
const updatedAt = value.updatedAt
if (
!isStableSemver(minRuntimeVersion) ||
typeof dataCompatibilityVersion !== 'number' ||
!Number.isInteger(dataCompatibilityVersion) ||
dataCompatibilityVersion < 0 ||
!Array.isArray(reasons) ||
!reasons.every(isNonEmptyString) ||
!isRecord(updatedBy) ||
!isRuntimeKind(updatedBy.runtime) ||
!isNonEmptyString(updatedBy.module) ||
!isStableSemver(updatedBy.version) ||
typeof updatedAt !== 'number' ||
!Number.isInteger(updatedAt) ||
updatedAt < 0
) {
throw createInvalidMetaError(userDataDir, metaPath, 'Invalid data directory compatibility meta fields.')
}
return {
formatVersion: 1,
minRuntimeVersion,
dataCompatibilityVersion,
reasons: [...reasons],
updatedBy: {
runtime: updatedBy.runtime,
module: updatedBy.module,
version: updatedBy.version,
},
updatedAt,
}
}
function createInvalidMetaError(
userDataDir: string,
metaPath: string,
message: string,
cause?: unknown
): DataDirCompatibilityError {
return new DataDirCompatibilityError('DATA_DIR_COMPATIBILITY_META_INVALID', message, {
userDataDir,
metaPath,
cause,
})
}
function assertStableVersion(version: string, userDataDir: string, metaPath: string, message: string): void {
if (!isStableSemver(version)) {
throw createInvalidMetaError(userDataDir, metaPath, message)
}
}
function isStableSemver(version: unknown): version is string {
return typeof version === 'string' && /^\d+\.\d+\.\d+$/.test(version)
}
function compareStableSemver(left: string, right: string): number {
const leftParts = left.split('.').map(Number)
const rightParts = right.split('.').map(Number)
for (let i = 0; i < 3; i += 1) {
if (leftParts[i] > rightParts[i]) return 1
if (leftParts[i] < rightParts[i]) return -1
}
return 0
}
function mergeReasons(existing: string[], next: string): string[] {
return [...new Set([...existing, next])]
}
function writeMetaAtomic(userDataDir: string, metaPath: string, meta: DataDirCompatibilityMeta): void {
fs.mkdirSync(userDataDir, { recursive: true })
const tempPath = path.join(userDataDir, `.chatlab-meta.json.${process.pid}.${Date.now()}.tmp`)
try {
fs.writeFileSync(tempPath, `${JSON.stringify(meta, null, 2)}\n`, 'utf-8')
fs.renameSync(tempPath, metaPath)
} catch (error) {
try {
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath)
} catch {
// Ignore cleanup failures so callers see the original write/rename error.
}
throw error
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0
}
function isRuntimeKind(value: unknown): value is RuntimeKind {
return value === 'cli' || value === 'desktop' || value === 'mcp' || value === 'unknown'
}
+13
View File
@@ -80,6 +80,19 @@ export type {
RunPendingDataDirMigrationDeps,
RunPendingDataDirMigrationResult,
} from './data-dir-switch'
export {
assertDataDirCompatible,
DataDirCompatibilityError,
raiseDataDirMinRuntimeVersion,
readDataDirCompatibilityMeta,
} from './data-dir-compat'
export type {
AssertDataDirCompatibilityOptions,
DataDirCompatibilityMeta,
RaiseDataDirCompatibilityInput,
RuntimeIdentity,
RuntimeKind,
} from './data-dir-compat'
// NLP 分词引擎、词频统计、词库管理
export {