diff --git a/packages/node-runtime/src/data-dir-compat.test.ts b/packages/node-runtime/src/data-dir-compat.test.ts new file mode 100644 index 00000000..41d8e414 --- /dev/null +++ b/packages/node-runtime/src/data-dir-compat.test.ts @@ -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(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) +}) diff --git a/packages/node-runtime/src/data-dir-compat.ts b/packages/node-runtime/src/data-dir-compat.ts new file mode 100644 index 00000000..61afc38f --- /dev/null +++ b/packages/node-runtime/src/data-dir-compat.ts @@ -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 +} + +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 { + 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' +} diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index eb6a3589..5e564fd8 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -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 {