mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
feat(runtime): add data directory compatibility gate
This commit is contained in:
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'
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user