diff --git a/packages/core/src/middlewares/system/conversation_manage.ts b/packages/core/src/middlewares/system/conversation_manage.ts index 8487895b9..065ca781f 100644 --- a/packages/core/src/middlewares/system/conversation_manage.ts +++ b/packages/core/src/middlewares/system/conversation_manage.ts @@ -45,6 +45,7 @@ export function apply(ctx: Context, config: Config, chain: ChatChain) { if ( resolved.constraint.manageMode === 'admin' && + resolved.constraint.routeMode !== 'personal' && !(await checkAdmin(session)) ) { context.message = session.text( diff --git a/packages/core/src/middlewares/system/wipe.ts b/packages/core/src/middlewares/system/wipe.ts index bcf4be38d..96c84c12a 100644 --- a/packages/core/src/middlewares/system/wipe.ts +++ b/packages/core/src/middlewares/system/wipe.ts @@ -5,6 +5,7 @@ import { createLogger } from 'koishi-plugin-chatluna/utils/logger' import fs from 'fs/promises' import { createLegacyTableRetention, + createPassedValidationResult, dropTableIfExists, getLegacySchemaSentinel, getLegacySchemaSentinelDir, @@ -15,6 +16,7 @@ import { readMetaValue, writeMetaValue } from '../../migration/validators' +import { BUILTIN_SCHEMA_VERSION } from '../../migration/room_to_conversation' let logger: Logger @@ -76,12 +78,13 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { 'chatluna_binding', 'chatluna_constraint', 'chatluna_archive', - 'chatluna_acl', - 'chatluna_meta' + 'chatluna_acl' ]) { await dropTableIfExists(ctx, table) } + await ctx.database.remove('chatluna_meta', {}) + for (const table of LEGACY_MIGRATION_TABLES) { await dropTableIfExists(ctx, table) } @@ -115,6 +118,22 @@ export function apply(ctx: Context, _config: Config, chain: ChatChain) { logger.warn(`wipe: ${e}`) } + const validated = createPassedValidationResult() + await writeMetaValue(ctx, 'schema_version', BUILTIN_SCHEMA_VERSION) + await writeMetaValue(ctx, 'validation_result', validated) + await writeMetaValue(ctx, 'room_migration_done', true) + await writeMetaValue(ctx, 'message_migration_done', true) + await writeMetaValue( + ctx, + LEGACY_RETENTION_META_KEY, + createLegacyTableRetention('purged') + ) + await writeMetaValue( + ctx, + 'migration_finished_at', + new Date().toISOString() + ) + const sentinelDir = getLegacySchemaSentinelDir(ctx.baseDir) const sentinelPath = getLegacySchemaSentinel(ctx.baseDir) await fs.mkdir(sentinelDir, { recursive: true }) diff --git a/packages/core/src/migration/legacy_tables.ts b/packages/core/src/migration/legacy_tables.ts index 5b780c100..80c2f9707 100644 --- a/packages/core/src/migration/legacy_tables.ts +++ b/packages/core/src/migration/legacy_tables.ts @@ -258,6 +258,7 @@ export function defineLegacyMigrationTables(ctx: Context) { function isMissingTableError(error: unknown) { const message = String(error).toLowerCase() return ( + message.includes('cannot resolve table') || message.includes('no such table') || message.includes('unknown table') || message.includes('not found') || diff --git a/packages/core/src/migration/room_to_conversation.ts b/packages/core/src/migration/room_to_conversation.ts index 41203aa35..4f2c5af5c 100644 --- a/packages/core/src/migration/room_to_conversation.ts +++ b/packages/core/src/migration/room_to_conversation.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'fs' import { createHash, randomUUID } from 'crypto' import type { Context } from 'koishi' import type { Config } from '../config' @@ -21,7 +22,9 @@ import { aclKey, createLegacyBindingKey, createLegacyTableRetention, + createPassedValidationResult, filterValidRooms, + getLegacySchemaSentinel, inferLegacyGroupRouteModes, isComplexRoom, LEGACY_RETENTION_META_KEY, @@ -43,6 +46,10 @@ export async function runRoomToConversationMigration( ctx: Context, config: Config ) { + if (existsSync(getLegacySchemaSentinel(ctx.baseDir))) { + return ensureMigrationValidated(ctx, config) + } + defineLegacyMigrationTables(ctx) const result = await readMetaValue< @@ -88,6 +95,9 @@ export async function runRoomToConversationMigration( throw new Error('ChatLuna migration validation failed.') } + // Mark completion before purge so a restart after writing the sentinel + // can resume from the finished state instead of re-reading legacy tables. + await writeMigrationDone(ctx) await purgeLegacyTables(ctx) await writeMigrationFinished(ctx) @@ -106,8 +116,68 @@ export async function ensureMigrationValidated(ctx: Context, config: Config) { const retention = await readMetaValue<{ state?: string }>(ctx, LEGACY_RETENTION_META_KEY) + const hasSentinel = existsSync(getLegacySchemaSentinel(ctx.baseDir)) + + if (hasSentinel) { + if (result?.passed === true) { + await writeMetaValue(ctx, 'schema_version', BUILTIN_SCHEMA_VERSION) + await writeMigrationDone(ctx) + await writeMigrationFinished(ctx) + return result + } + + const conversations = (await ctx.database.get( + 'chatluna_conversation', + {}, + { + limit: 1 + } + )) as ConversationRecord[] + const messages = (await ctx.database.get( + 'chatluna_message', + {}, + { + limit: 1 + } + )) as MessageRecord[] + const bindings = (await ctx.database.get( + 'chatluna_binding', + {}, + { + limit: 1 + } + )) as BindingRecord[] + const acl = (await ctx.database.get( + 'chatluna_acl', + {}, + { + limit: 1 + } + )) as ACLRecord[] + const hasData = + conversations.length > 0 || + messages.length > 0 || + bindings.length > 0 || + acl.length > 0 + + ctx.logger.warn( + hasData + ? 'Legacy sentinel exists and ChatLuna data is present; adopting current ChatLuna state and marking migration finished.' + : 'Legacy sentinel exists and ChatLuna data is empty; treating startup as fresh install.' + ) + + const validated = createPassedValidationResult() + await writeMetaValue(ctx, 'schema_version', BUILTIN_SCHEMA_VERSION) + await writeMetaValue(ctx, 'validation_result', validated) + await writeMigrationDone(ctx) + await writeMigrationFinished(ctx) + return validated + } if (result?.passed === true) { + await writeMetaValue(ctx, 'schema_version', BUILTIN_SCHEMA_VERSION) + await writeMigrationDone(ctx) + if (retention?.state !== 'purged') { await purgeLegacyTables(ctx) } @@ -145,21 +215,25 @@ export async function ensureMigrationValidated(ctx: Context, config: Config) { throw new Error('ChatLuna migration validation failed.') } + await writeMigrationDone(ctx) await purgeLegacyTables(ctx) await writeMigrationFinished(ctx) return validated } -async function writeMigrationFinished(ctx: Context) { - await writeMetaValue(ctx, 'migration_finished_at', new Date().toISOString()) +async function writeMigrationDone(ctx: Context) { await writeMetaValue(ctx, 'room_migration_done', true) await writeMetaValue(ctx, 'message_migration_done', true) +} + +async function writeMigrationFinished(ctx: Context) { await writeMetaValue( ctx, LEGACY_RETENTION_META_KEY, createLegacyTableRetention('purged') ) + await writeMetaValue(ctx, 'migration_finished_at', new Date().toISOString()) } // (#6) removed redundant inner `done` check — the caller already guards on room_migration_done diff --git a/packages/core/src/migration/validators.ts b/packages/core/src/migration/validators.ts index 5636d1d80..f3062a715 100644 --- a/packages/core/src/migration/validators.ts +++ b/packages/core/src/migration/validators.ts @@ -17,9 +17,9 @@ import type { import type { MigrationValidationResult } from './types' export type { MigrationValidationResult } from './types' export { + getLegacySchemaSentinel, dropTableIfExists, getLegacySchemaSentinelDir, - getLegacySchemaSentinel, LEGACY_MIGRATION_TABLES, LEGACY_RETENTION_META_KEY, LEGACY_RUNTIME_TABLES, @@ -30,6 +30,42 @@ export { const VALIDATION_BATCH_SIZE = 500 +export function createPassedValidationResult(): MigrationValidationResult { + return { + passed: true, + checkedAt: new Date().toISOString(), + conversation: { + legacy: 0, + migrated: 0, + matched: true + }, + message: { + legacy: 0, + migrated: 0, + matched: true + }, + latestMessageId: { + missingConversationIds: [], + matched: true + }, + bindingKey: { + inconsistentConversationIds: [], + matched: true + }, + binding: { + missingBindingKeys: [], + missingConversationIds: [], + matched: true + }, + acl: { + expected: 0, + migrated: 0, + missing: [], + matched: true + } + } +} + export async function validateRoomMigration(ctx: Context, _config: Config) { ctx.logger.info('Validating built-in ChatLuna migration.') diff --git a/packages/core/src/services/conversation.ts b/packages/core/src/services/conversation.ts index ce9d5095f..14a002e6c 100644 --- a/packages/core/src/services/conversation.ts +++ b/packages/core/src/services/conversation.ts @@ -336,8 +336,6 @@ export class ConversationService { } } - await assertManageAllowed(session, resolved.constraint) - if (!resolved.constraint.allowNew) { throw new Error('Conversation creation is disabled by constraint.') } diff --git a/packages/core/tests/conversation-migration.spec.ts b/packages/core/tests/conversation-migration.spec.ts index eb77b14fe..6139267c4 100644 --- a/packages/core/tests/conversation-migration.spec.ts +++ b/packages/core/tests/conversation-migration.spec.ts @@ -1,17 +1,27 @@ /// -import { assert } from 'chai' +import fs from 'node:fs/promises' import path from 'node:path' +import { assert } from 'chai' import { createLegacyBindingKey, + dropTableIfExists, inferLegacyGroupRouteModes } from '../src/migration/validators' import { getLegacySchemaSentinel, getLegacySchemaSentinelDir } from '../src/migration/legacy_tables' -import { runRoomToConversationMigration } from '../src/migration/room_to_conversation' -import { createConfig, createService, type TableRow } from './helpers' +import { + ensureMigrationValidated, + runRoomToConversationMigration +} from '../src/migration/room_to_conversation' +import { + createConfig, + createConversation, + createService, + type TableRow +} from './helpers' import type { BindingRecord, ConversationRecord, @@ -122,6 +132,131 @@ it('runRoomToConversationMigration migrates legacy rooms, messages, bindings, an assert.equal(JSON.parse(meta.value ?? '{}').passed, true) }) +it('ensureMigrationValidated treats sentinel plus incomplete migration metadata as fresh install', async () => { + const { ctx, database } = await createService({ + tables: { + chatluna_meta: [ + { + key: 'migration_started_at', + value: JSON.stringify('2026-04-06T05:59:16.380Z'), + updatedAt: new Date('2026-04-06T13:59:16.000Z') + } as unknown as TableRow, + { + key: 'schema_version', + value: JSON.stringify(1), + updatedAt: new Date('2026-04-06T13:59:16.000Z') + } as unknown as TableRow, + { + key: 'legacy_binding_route_mode', + value: JSON.stringify('shared'), + updatedAt: new Date('2026-04-06T13:59:16.000Z') + } as unknown as TableRow, + { + key: 'legacy_table_retention', + value: JSON.stringify({ + state: 'migration-visible', + migrationTables: [ + 'chathub_room_member', + 'chathub_room_group_member', + 'chathub_user', + 'chathub_room', + 'chathub_message', + 'chathub_conversation' + ], + runtimeTables: [] + }), + updatedAt: new Date('2026-04-06T13:59:16.000Z') + } as unknown as TableRow + ] + } + }) + + await fs.mkdir(getLegacySchemaSentinelDir(ctx.baseDir), { recursive: true }) + await fs.writeFile(getLegacySchemaSentinel(ctx.baseDir), '{}', 'utf8') + + await ensureMigrationValidated(ctx, createConfig()) + + const validation = database.tables.chatluna_meta.find( + (item) => item.key === 'validation_result' + ) as { value?: string | null } + const retention = database.tables.chatluna_meta.find( + (item) => item.key === 'legacy_table_retention' + ) as { value?: string | null } + const roomDone = database.tables.chatluna_meta.find( + (item) => item.key === 'room_migration_done' + ) as { value?: string | null } + const messageDone = database.tables.chatluna_meta.find( + (item) => item.key === 'message_migration_done' + ) as { value?: string | null } + + assert.equal(JSON.parse(validation.value ?? '{}').passed, true) + assert.equal(JSON.parse(retention.value ?? '{}').state, 'purged') + assert.equal(roomDone.value, 'true') + assert.equal(messageDone.value, 'true') + assert.equal(database.tables.chatluna_conversation.length, 0) +}) + +it('ensureMigrationValidated keeps existing chatluna data when sentinel already exists', async () => { + const { ctx, database } = await createService({ + tables: { + chatluna_conversation: [ + createConversation({ + id: 'existing-conversation' + }) as unknown as TableRow + ] + } + }) + + await fs.mkdir(getLegacySchemaSentinelDir(ctx.baseDir), { recursive: true }) + await fs.writeFile(getLegacySchemaSentinel(ctx.baseDir), '{}', 'utf8') + + await ensureMigrationValidated(ctx, createConfig()) + + const validation = database.tables.chatluna_meta.find( + (item) => item.key === 'validation_result' + ) as { value?: string | null } + + assert.equal(database.tables.chatluna_conversation.length, 1) + assert.equal( + (database.tables.chatluna_conversation[0] as ConversationRecord).id, + 'existing-conversation' + ) + assert.equal(JSON.parse(validation.value ?? '{}').passed, true) +}) + +it('runRoomToConversationMigration falls back to startup recovery when sentinel already exists', async () => { + const { ctx, database } = await createService() + + await fs.mkdir(getLegacySchemaSentinelDir(ctx.baseDir), { recursive: true }) + await fs.writeFile(getLegacySchemaSentinel(ctx.baseDir), '{}', 'utf8') + + await runRoomToConversationMigration(ctx, createConfig()) + + const validation = database.tables.chatluna_meta.find( + (item) => item.key === 'validation_result' + ) as { value?: string | null } + + assert.equal(JSON.parse(validation.value ?? '{}').passed, true) +}) + +it('dropTableIfExists treats cannot resolve table as a missing table', async () => { + const dropped = await dropTableIfExists( + { + database: { + drop: async () => { + throw new Error('cannot resolve table "chathub_room"') + } + }, + logger: { + warn: () => {} + } + } as never, + 'chathub_room' + ) + + assert.equal(dropped, false) +}) + it('inferLegacyGroupRouteModes preserves per-group legacy routing semantics', () => { const users = [ { userId: 'a', groupId: 'g1', defaultRoomId: 1 },