mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-26 02:04:37 +08:00
perf(sessions): reuse validated overview cache on startup
This commit is contained in:
1 parent
3b123b15f2
commit
5e8d1caf30
9 files changed
+281
-14
No files matched your search
@@ -42,6 +42,7 @@
|
||||
"build:web-wasm": "vite build --config vite.web-wasm.config.mts",
|
||||
"build:web-ai-spike": "vite build --config tests/web-ai-sdk-spike/vite.config.mts",
|
||||
"bench:startup": "node scripts/bench-cli-web-startup.mjs",
|
||||
"bench:startup:fixture": "node scripts/create-startup-benchmark-fixture.mjs",
|
||||
"docs:dev": "pnpm --filter @openchatlab/docs dev",
|
||||
"docs:build": "pnpm --filter @openchatlab/docs build",
|
||||
"docs:preview": "pnpm --filter @openchatlab/docs preview",
|
||||
|
||||
@@ -13,7 +13,13 @@ import path from 'node:path'
|
||||
import Database from 'better-sqlite3'
|
||||
import Fastify, { type FastifyInstance } from 'fastify'
|
||||
import type { DatabaseAdapter, PathProvider, PreparedStatement, RunResult } from '@openchatlab/core'
|
||||
import { PreferencesManager, type DatabaseManager, type SessionRuntimeAdapter } from '@openchatlab/node-runtime'
|
||||
import {
|
||||
CACHE_KEY_OVERVIEW,
|
||||
PreferencesManager,
|
||||
setCache,
|
||||
type DatabaseManager,
|
||||
type SessionRuntimeAdapter,
|
||||
} from '@openchatlab/node-runtime'
|
||||
import type { HttpRouteContext } from '../context'
|
||||
import { registerSharedRoutes } from '../register'
|
||||
import { registerRestSessionRoutes } from './rest/sessions'
|
||||
@@ -87,7 +93,8 @@ function createSessionDb(): TestSqliteDb {
|
||||
platform_id TEXT,
|
||||
account_name TEXT,
|
||||
group_nickname TEXT,
|
||||
avatar TEXT
|
||||
avatar TEXT,
|
||||
aliases TEXT DEFAULT '[]'
|
||||
);
|
||||
CREATE TABLE message (
|
||||
id INTEGER PRIMARY KEY,
|
||||
@@ -223,6 +230,34 @@ describe('registerSharedRoutes smoke tests', () => {
|
||||
assert.ok(Array.isArray(resp.json()))
|
||||
})
|
||||
|
||||
it('GET /_web/sessions reuses a fingerprint-validated overview cache', async () => {
|
||||
const db = createSessionDb()
|
||||
const routeApp = Fastify()
|
||||
const ctx = createTestContext(new Map([['chat-1', db]]))
|
||||
setCache(
|
||||
'chat-1',
|
||||
CACHE_KEY_OVERVIEW,
|
||||
{
|
||||
totalMessages: 321,
|
||||
totalMembers: 12,
|
||||
firstMessageTs: 100,
|
||||
lastMessageTs: 300,
|
||||
maxMessageId: 3,
|
||||
},
|
||||
ctx.pathProvider.getCacheDir()
|
||||
)
|
||||
registerSessionRoutes(routeApp, ctx)
|
||||
await routeApp.ready()
|
||||
|
||||
const resp = await routeApp.inject({ method: 'GET', url: '/_web/sessions' })
|
||||
|
||||
await routeApp.close()
|
||||
db.close()
|
||||
assert.equal(resp.statusCode, 200)
|
||||
assert.equal(resp.json()[0].messageCount, 321)
|
||||
assert.equal(resp.json()[0].memberCount, 12)
|
||||
})
|
||||
|
||||
it('GET /_web/sessions/:id returns 404 for missing session', async () => {
|
||||
const resp = await app.inject({ method: 'GET', url: '/_web/sessions/nonexistent' })
|
||||
assert.equal(resp.statusCode, 404)
|
||||
@@ -360,6 +395,10 @@ describe('registerSharedRoutes smoke tests', () => {
|
||||
registerSharedRoutes(routeApp, createTestContext(new Map([['chat-1', db]])))
|
||||
await routeApp.ready()
|
||||
|
||||
const initialSessionsResp = await routeApp.inject({ method: 'GET', url: '/_web/sessions' })
|
||||
assert.equal(initialSessionsResp.json()[0].messageCount, 4)
|
||||
assert.equal(initialSessionsResp.json()[0].memberCount, 3)
|
||||
|
||||
const resp = await routeApp.inject({
|
||||
method: 'POST',
|
||||
url: '/_web/sessions/chat-1/members/batch-delete',
|
||||
@@ -368,6 +407,7 @@ describe('registerSharedRoutes smoke tests', () => {
|
||||
const remainingMembers = db.prepare('SELECT id FROM member ORDER BY id').all()
|
||||
const remainingMessages = db.prepare('SELECT sender_id AS senderId FROM message ORDER BY id').all()
|
||||
const remainingHistory = db.prepare('SELECT member_id AS memberId FROM member_name_history ORDER BY id').all()
|
||||
const refreshedSessionsResp = await routeApp.inject({ method: 'GET', url: '/_web/sessions' })
|
||||
|
||||
await routeApp.close()
|
||||
db.close()
|
||||
@@ -377,6 +417,35 @@ describe('registerSharedRoutes smoke tests', () => {
|
||||
assert.deepEqual(remainingMembers, [{ id: 3 }])
|
||||
assert.deepEqual(remainingMessages, [{ senderId: 3 }])
|
||||
assert.deepEqual(remainingHistory, [{ memberId: 3 }])
|
||||
assert.equal(refreshedSessionsResp.json()[0].messageCount, 1)
|
||||
assert.equal(refreshedSessionsResp.json()[0].memberCount, 1)
|
||||
})
|
||||
|
||||
it('POST /_web/sessions/:id/members/merge invalidates cached member totals without changing message ids', async () => {
|
||||
const db = createSessionDb()
|
||||
const routeApp = Fastify()
|
||||
const sessionId = 'merge-cache-chat'
|
||||
registerSharedRoutes(routeApp, createTestContext(new Map([[sessionId, db]])))
|
||||
await routeApp.ready()
|
||||
|
||||
const initialSessionsResp = await routeApp.inject({ method: 'GET', url: '/_web/sessions' })
|
||||
assert.equal(initialSessionsResp.json()[0].messageCount, 3)
|
||||
assert.equal(initialSessionsResp.json()[0].memberCount, 2)
|
||||
|
||||
const mergeResp = await routeApp.inject({
|
||||
method: 'POST',
|
||||
url: `/_web/sessions/${sessionId}/members/merge`,
|
||||
payload: { memberId1: 1, memberId2: 2 },
|
||||
})
|
||||
const refreshedSessionsResp = await routeApp.inject({ method: 'GET', url: '/_web/sessions' })
|
||||
|
||||
await routeApp.close()
|
||||
db.close()
|
||||
|
||||
assert.equal(mergeResp.statusCode, 200)
|
||||
assert.deepEqual(mergeResp.json(), { success: true })
|
||||
assert.equal(refreshedSessionsResp.json()[0].messageCount, 3)
|
||||
assert.equal(refreshedSessionsResp.json()[0].memberCount, 1)
|
||||
})
|
||||
|
||||
it('POST /_web/sessions/:id/members/batch-delete rejects an empty selection', async () => {
|
||||
|
||||
@@ -3,10 +3,11 @@ import type { RuntimeRouteContext } from '../../context/runtime'
|
||||
import { appLogger, memberService } from '@openchatlab/node-runtime'
|
||||
import { apiErrorFromUnknown } from '../../errors'
|
||||
|
||||
type MemberRouteContext = Pick<RuntimeRouteContext, 'sessionAdapter'>
|
||||
type MemberRouteContext = Pick<RuntimeRouteContext, 'sessionAdapter' | 'pathProvider'>
|
||||
|
||||
export function registerMemberRoutes(server: FastifyInstance, ctx: MemberRouteContext): void {
|
||||
const { sessionAdapter: adapter } = ctx
|
||||
const cacheDir = ctx.pathProvider.getCacheDir()
|
||||
|
||||
server.get<{ Params: { id: string } }>('/_web/sessions/:id/members', async (request) => {
|
||||
return memberService.getMembers(adapter, request.params.id)
|
||||
@@ -37,7 +38,7 @@ export function registerMemberRoutes(server: FastifyInstance, ctx: MemberRouteCo
|
||||
'/_web/sessions/:id/members/:memberId',
|
||||
async (request) => {
|
||||
const memberId = parseInt(request.params.memberId, 10)
|
||||
memberService.deleteMember(adapter, request.params.id, memberId)
|
||||
memberService.deleteMember(adapter, request.params.id, memberId, cacheDir)
|
||||
return { success: true }
|
||||
}
|
||||
)
|
||||
@@ -59,7 +60,7 @@ export function registerMemberRoutes(server: FastifyInstance, ctx: MemberRouteCo
|
||||
|
||||
const uniqueMemberIds = Array.from(new Set(memberIds as number[]))
|
||||
try {
|
||||
const success = memberService.deleteMembers(adapter, request.params.id, uniqueMemberIds)
|
||||
const success = memberService.deleteMembers(adapter, request.params.id, uniqueMemberIds, cacheDir)
|
||||
if (!success) {
|
||||
throw new Error('Member deletion transaction returned no result')
|
||||
}
|
||||
@@ -81,7 +82,7 @@ export function registerMemberRoutes(server: FastifyInstance, ctx: MemberRouteCo
|
||||
'/_web/sessions/:id/members/merge',
|
||||
async (request) => {
|
||||
const { memberId1, memberId2 } = request.body
|
||||
memberService.mergeMembers(adapter, request.params.id, memberId1, memberId2)
|
||||
memberService.mergeMembers(adapter, request.params.id, memberId1, memberId2, cacheDir)
|
||||
return { success: true }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ type SessionRouteContext = Pick<RuntimeRouteContext, 'sessionAdapter' | 'pathPro
|
||||
|
||||
export function registerSessionRoutes(server: FastifyInstance, ctx: SessionRouteContext): void {
|
||||
const { sessionAdapter: adapter } = ctx
|
||||
const cacheDir = ctx.pathProvider.getCacheDir()
|
||||
|
||||
// Lazy: only owner-profile routes need preferences.json access
|
||||
let preferencesInstance: PreferencesManager | null = null
|
||||
@@ -21,6 +22,7 @@ export function registerSessionRoutes(server: FastifyInstance, ctx: SessionRoute
|
||||
server.get('/_web/sessions', async () => {
|
||||
const aiChatCounts = ctx.aiChatManager?.getAIChatCountsBySession()
|
||||
return sessionService.listAnalysisSessions(adapter, {
|
||||
resolveOverview: (db, sessionId) => sessionService.resolveValidatedSessionOverview(db, sessionId, cacheDir),
|
||||
enrichSession: aiChatCounts?.size
|
||||
? (dto) => ({ ...dto, aiConversationCount: aiChatCounts.get(dto.id) ?? 0 })
|
||||
: undefined,
|
||||
@@ -28,7 +30,9 @@ export function registerSessionRoutes(server: FastifyInstance, ctx: SessionRoute
|
||||
})
|
||||
|
||||
server.get<{ Params: { id: string } }>('/_web/sessions/:id', async (request) => {
|
||||
const session = sessionService.getAnalysisSession(adapter, request.params.id)
|
||||
const session = sessionService.getAnalysisSession(adapter, request.params.id, {
|
||||
resolveOverview: (db, sessionId) => sessionService.resolveValidatedSessionOverview(db, sessionId, cacheDir),
|
||||
})
|
||||
if (!session) {
|
||||
throw Object.assign(new Error(`Session not found: ${request.params.id}`), { statusCode: 404 })
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export {
|
||||
renameSession,
|
||||
updateSessionOwnerId,
|
||||
deleteSession,
|
||||
resolveValidatedSessionOverview,
|
||||
} from './session-service'
|
||||
export type { AnalysisSessionDTO, ListSessionsOptions } from './session-service'
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
deleteMembers as coreDeleteMembers,
|
||||
} from '@openchatlab/core'
|
||||
import type { MemberWithAliases, MembersPaginationParams, MembersPaginatedResult } from '@openchatlab/core'
|
||||
import { CACHE_KEY_MEMBERS, CACHE_KEY_OVERVIEW, invalidateCache } from '../cache/session-cache'
|
||||
import type { SessionRuntimeAdapter } from './adapters'
|
||||
|
||||
export type { MemberWithAliases, MembersPaginationParams, MembersPaginatedResult }
|
||||
@@ -28,6 +29,11 @@ export interface MembersPaginatedDTO {
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
function invalidateMemberMutationCaches(sessionId: string, cacheDir: string): void {
|
||||
invalidateCache(sessionId, cacheDir, CACHE_KEY_OVERVIEW)
|
||||
invalidateCache(sessionId, cacheDir, CACHE_KEY_MEMBERS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all members with aliases for a session.
|
||||
*/
|
||||
@@ -81,19 +87,29 @@ export function mergeMembers(
|
||||
adapter: SessionRuntimeAdapter,
|
||||
sessionId: string,
|
||||
memberId1: number,
|
||||
memberId2: number
|
||||
memberId2: number,
|
||||
cacheDir: string
|
||||
): boolean {
|
||||
const db = adapter.ensureWritable(sessionId)
|
||||
return coreMergeMembers(db, memberId1, memberId2)
|
||||
const success = coreMergeMembers(db, memberId1, memberId2)
|
||||
if (success) invalidateMemberMutationCaches(sessionId, cacheDir)
|
||||
return success
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a member and all associated data:
|
||||
* - messages, member_name_history, member row (in transaction)
|
||||
*/
|
||||
export function deleteMember(adapter: SessionRuntimeAdapter, sessionId: string, memberId: number): boolean {
|
||||
export function deleteMember(
|
||||
adapter: SessionRuntimeAdapter,
|
||||
sessionId: string,
|
||||
memberId: number,
|
||||
cacheDir: string
|
||||
): boolean {
|
||||
const db = adapter.ensureWritable(sessionId)
|
||||
return coreDeleteMember(db, memberId)
|
||||
const success = coreDeleteMember(db, memberId)
|
||||
if (success) invalidateMemberMutationCaches(sessionId, cacheDir)
|
||||
return success
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,10 +118,13 @@ export function deleteMember(adapter: SessionRuntimeAdapter, sessionId: string,
|
||||
export function deleteMembers(
|
||||
adapter: SessionRuntimeAdapter,
|
||||
sessionId: string,
|
||||
memberIds: readonly number[]
|
||||
memberIds: readonly number[],
|
||||
cacheDir: string
|
||||
): boolean {
|
||||
const db = adapter.ensureWritable(sessionId)
|
||||
return coreDeleteMembers(db, memberIds)
|
||||
const success = coreDeleteMembers(db, memberIds)
|
||||
if (success) invalidateMemberMutationCaches(sessionId, cacheDir)
|
||||
return success
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import {
|
||||
getSessionInfo,
|
||||
getSessionOverview,
|
||||
getSessionMeta,
|
||||
getSummaryCount,
|
||||
getPrivateChatMemberAvatar,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
} from '@openchatlab/core'
|
||||
import type { CoreSessionInfo, DatabaseAdapter, SessionOverview } from '@openchatlab/core'
|
||||
import type { SessionRuntimeAdapter } from './adapters'
|
||||
import { getValidatedOverviewCache } from '../cache/session-cache'
|
||||
|
||||
export interface AnalysisSessionDTO extends CoreSessionInfo {
|
||||
id: string
|
||||
@@ -48,6 +50,22 @@ export interface ListSessionsOptions {
|
||||
enrichSession?(dto: AnalysisSessionDTO): AnalysisSessionDTO
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a fresh session overview through the shared JSON cache. Cache I/O or
|
||||
* validation failures fall back to live SQL so the session catalog stays usable.
|
||||
*/
|
||||
export function resolveValidatedSessionOverview(
|
||||
db: DatabaseAdapter,
|
||||
sessionId: string,
|
||||
cacheDir: string
|
||||
): SessionOverview {
|
||||
try {
|
||||
return getValidatedOverviewCache(db, sessionId, cacheDir)
|
||||
} catch {
|
||||
return getSessionOverview(db)
|
||||
}
|
||||
}
|
||||
|
||||
function buildSession(
|
||||
db: DatabaseAdapter,
|
||||
id: string,
|
||||
|
||||
@@ -18,6 +18,12 @@ function parsePositiveInteger(value, flag) {
|
||||
return parsed
|
||||
}
|
||||
|
||||
function parseNonNegativeInteger(value, flag) {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (!Number.isInteger(parsed) || parsed < 0) throw new Error(`${flag} must be a non-negative integer`)
|
||||
return parsed
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
url: DEFAULT_URL,
|
||||
@@ -40,7 +46,7 @@ function parseArgs(argv) {
|
||||
|
||||
if (arg === '--url') options.url = next()
|
||||
else if (arg === '--runs') options.runs = parsePositiveInteger(next(), arg)
|
||||
else if (arg === '--warmups') options.warmups = parsePositiveInteger(next(), arg)
|
||||
else if (arg === '--warmups') options.warmups = parseNonNegativeInteger(next(), arg)
|
||||
else if (arg === '--timeout') options.timeoutMs = parsePositiveInteger(next(), arg)
|
||||
else if (arg === '--chrome') options.chromePath = next()
|
||||
else if (arg === '--json') options.json = true
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readdir, writeFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import Database from 'better-sqlite3'
|
||||
|
||||
function parsePositiveInteger(value, flag) {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${flag} must be a positive integer`)
|
||||
return parsed
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { sessions: 100, messages: 200 }
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
if (arg === '--') continue
|
||||
const next = () => {
|
||||
const value = argv[index + 1]
|
||||
if (!value) throw new Error(`${arg} requires a value`)
|
||||
index += 1
|
||||
return value
|
||||
}
|
||||
if (arg === '--database-dir') options.databaseDir = resolve(next())
|
||||
else if (arg === '--cache-dir') options.cacheDir = resolve(next())
|
||||
else if (arg === '--sessions') options.sessions = parsePositiveInteger(next(), arg)
|
||||
else if (arg === '--messages') options.messages = parsePositiveInteger(next(), arg)
|
||||
else if (arg === '--help') {
|
||||
console.log(`Usage: pnpm bench:startup:fixture -- --database-dir <path> --cache-dir <path> [options]
|
||||
|
||||
Creates valid group-chat databases and matching overview caches for local
|
||||
startup benchmarks. Both output directories must be empty.
|
||||
|
||||
Options:
|
||||
--sessions <count> Session databases to create (default: 100)
|
||||
--messages <count> Messages in each session (default: 200)
|
||||
`)
|
||||
process.exit(0)
|
||||
} else throw new Error(`Unknown option: ${arg}`)
|
||||
}
|
||||
if (!options.databaseDir || !options.cacheDir) {
|
||||
throw new Error('--database-dir and --cache-dir are required')
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
async function ensureEmptyDirectory(directory) {
|
||||
if (existsSync(directory) && (await readdir(directory)).length > 0) {
|
||||
throw new Error(`Refusing to write benchmark fixtures into non-empty directory: ${directory}`)
|
||||
}
|
||||
await mkdir(directory, { recursive: true })
|
||||
}
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE meta (
|
||||
name TEXT,
|
||||
platform TEXT,
|
||||
type TEXT,
|
||||
imported_at INTEGER,
|
||||
group_id TEXT,
|
||||
group_avatar TEXT,
|
||||
owner_id TEXT,
|
||||
session_gap_threshold INTEGER
|
||||
);
|
||||
CREATE TABLE member (
|
||||
id INTEGER PRIMARY KEY,
|
||||
platform_id TEXT,
|
||||
account_name TEXT,
|
||||
group_nickname TEXT,
|
||||
avatar TEXT
|
||||
);
|
||||
CREATE TABLE message (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sender_id INTEGER,
|
||||
ts INTEGER,
|
||||
type INTEGER,
|
||||
content TEXT,
|
||||
platform_message_id TEXT
|
||||
);
|
||||
CREATE TABLE segment (
|
||||
id INTEGER PRIMARY KEY,
|
||||
start_ts INTEGER,
|
||||
end_ts INTEGER,
|
||||
message_count INTEGER,
|
||||
is_manual INTEGER DEFAULT 0,
|
||||
summary TEXT
|
||||
);
|
||||
`
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2))
|
||||
await ensureEmptyDirectory(options.databaseDir)
|
||||
await ensureEmptyDirectory(options.cacheDir)
|
||||
const nativeBinding = fileURLToPath(new URL('../apps/cli/native/better_sqlite3.node', import.meta.url))
|
||||
|
||||
for (let index = 1; index <= options.sessions; index += 1) {
|
||||
const sessionId = `benchmark-${String(index).padStart(3, '0')}`
|
||||
const db = new Database(resolve(options.databaseDir, `${sessionId}.db`), { nativeBinding })
|
||||
try {
|
||||
db.exec(SCHEMA)
|
||||
db.prepare(
|
||||
`INSERT INTO meta (name, platform, type, imported_at, group_id, owner_id)
|
||||
VALUES (?, 'benchmark', 'group', ?, ?, NULL)`
|
||||
).run(`Benchmark Session ${index}`, 2_000_000_000 - index, sessionId)
|
||||
db.prepare(
|
||||
`INSERT INTO member (id, platform_id, account_name, group_nickname, avatar)
|
||||
VALUES (1, 'member-1', 'Benchmark Member', 'Benchmark Member', NULL)`
|
||||
).run()
|
||||
|
||||
const insertMessage = db.prepare(
|
||||
`INSERT INTO message (sender_id, ts, type, content, platform_message_id)
|
||||
VALUES (1, ?, 0, 'benchmark message', ?)`
|
||||
)
|
||||
db.transaction(() => {
|
||||
for (let message = 1; message <= options.messages; message += 1) {
|
||||
insertMessage.run(1_700_000_000 + message, `${sessionId}-${message}`)
|
||||
}
|
||||
})()
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
|
||||
const cache = {
|
||||
overview: {
|
||||
data: {
|
||||
totalMessages: options.messages,
|
||||
totalMembers: 1,
|
||||
firstMessageTs: 1_700_000_001,
|
||||
lastMessageTs: 1_700_000_000 + options.messages,
|
||||
maxMessageId: options.messages,
|
||||
},
|
||||
ts: Math.floor(Date.now() / 1_000),
|
||||
},
|
||||
}
|
||||
await writeFile(resolve(options.cacheDir, `${sessionId}.cache.json`), JSON.stringify(cache), 'utf8')
|
||||
}
|
||||
|
||||
console.log(`Created ${options.sessions} cached sessions (${options.messages} messages each).`)
|
||||
console.log(`Databases: ${options.databaseDir}`)
|
||||
console.log(`Caches: ${options.cacheDir}`)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exitCode = 1
|
||||
})
|
||||
Reference in new issue
Block a user