From 1c6d26c44dcb744147b847cb801cff86d78f972c Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 23 Jul 2026 20:32:51 +0800 Subject: [PATCH] fix(session-query): fail mount when index open fails --- .../session-query-sqlite/src/index.ts | 10 ++++--- .../session-query-sqlite/tests/sqlite.spec.ts | 30 +++++++++++-------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 43e0784dfa..290279579b 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -6,7 +6,7 @@ import { createHash, randomUUID } from 'node:crypto' import { DatabaseSync } from 'node:sqlite' -import { Context, type Fiber } from 'cordis' +import { Context, Service, type Fiber } from 'cordis' import z from 'schemastery' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' @@ -193,9 +193,6 @@ export class SessionQuerySqlite extends SessionQueryService { super(ctx, config) this.config = resolveConfig(config) this._ready = this._open() - // Attach a rejection observer immediately; callers still receive the same - // rejection from `_ready`, including when no search is ever attempted. - void this._ready.catch(() => undefined) this._optionalPersistenceFiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => { const service = childCtx.sessionPersistence const binding = { identity: Symbol(), service } @@ -212,6 +209,11 @@ export class SessionQuerySqlite extends SessionQueryService { ctx.effect(() => async () => this.close(), 'sessionQuerySqlite.close') } + /** Open the index before Cordis publishes this combined service as active. */ + protected async [Service.init](): Promise { + await this._ensureReady(undefined) + } + override async searchSessions( request: SessionSearchRequest, exec?: SessionSearchExecContext, diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 8a99108460..de7deb5efc 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -966,13 +966,14 @@ describe('SQLite schema, cancellation, and real persistence integration', () => it('surfaces filesystem failures while pre-creating the database', async () => { const path = `${await temporaryPath()}\0` - const ctx = await liveContext({ path }) + const ctx = new Context() + await ctx.plugin(SessionStore) - await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })).rejects.toMatchObject({ + await expect(ctx.plugin(SessionQuerySqlite, { path })).rejects.toMatchObject({ code: 'SESSION_QUERY_INDEX_FAILED', cause: { code: 'ERR_INVALID_ARG_VALUE' }, }) - await (ctx.sessionQuery as SessionQuerySqlite).close() + expect(ctx.sessionQuery).toBeUndefined() }) it('resets a recognized incompatible derived schema but refuses a foreign database', async () => { @@ -998,26 +999,28 @@ describe('SQLite schema, cancellation, and real persistence integration', () => foreign.exec('CREATE TABLE canonical(value TEXT)') foreign.exec("INSERT INTO canonical VALUES ('safe')") foreign.close() - const foreignCtx = await liveContext({ path: foreignPath, journalMode: 'delete' }) - await expect(foreignCtx.sessionQuery.searchSessions({ query: 'needle' })) + const foreignCtx = new Context() + await foreignCtx.plugin(SessionStore) + await expect(foreignCtx.plugin(SessionQuerySqlite, { path: foreignPath, journalMode: 'delete' })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + expect(foreignCtx.sessionQuery).toBeUndefined() const stillForeign = new DatabaseSync(foreignPath) expect(stillForeign.prepare('SELECT value FROM canonical').get()).toEqual({ value: 'safe' }) expect(stillForeign.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'wal' }) stillForeign.close() - await (foreignCtx.sessionQuery as SessionQuerySqlite).close() const otherAppPath = await temporaryPath('other-app.db') const otherApp = new DatabaseSync(otherAppPath) otherApp.exec('PRAGMA application_id = 123') otherApp.close() - const otherAppCtx = await liveContext({ path: otherAppPath }) - await expect(otherAppCtx.sessionQuery.searchSessions({ query: 'needle' })) + const otherAppCtx = new Context() + await otherAppCtx.plugin(SessionStore) + await expect(otherAppCtx.plugin(SessionQuerySqlite, { path: otherAppPath })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) - await (otherAppCtx.sessionQuery as SessionQuerySqlite).close() + expect(otherAppCtx.sessionQuery).toBeUndefined() }) - it('observes asynchronous open rejection even when no query is made', async () => { + it('fails plugin initialization without an unhandled rejection or partial service', async () => { const path = await temporaryPath('never-queried.db') const foreign = new DatabaseSync(path) foreign.exec('CREATE TABLE canonical(value TEXT)') @@ -1026,10 +1029,13 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const onUnhandled = (reason: unknown) => { unhandled.push(reason) } process.on('unhandledRejection', onUnhandled) try { - const ctx = await liveContext({ path }) + const ctx = new Context() + await ctx.plugin(SessionStore) + await expect(ctx.plugin(SessionQuerySqlite, { path })) + .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) await new Promise((resolve) => { setImmediate(resolve) }) expect(unhandled).toEqual([]) - await (ctx.sessionQuery as SessionQuerySqlite).close() + expect(ctx.sessionQuery).toBeUndefined() } finally { process.off('unhandledRejection', onUnhandled) }