Browse Source

fix(session-query): fail mount when index open fails

Hypatia May 2 months ago
parent
commit
1c6d26c44d

+ 6 - 4
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<void> {
+    await this._ensureReady(undefined)
+  }
+
   override async searchSessions(
     request: SessionSearchRequest,
     exec?: SessionSearchExecContext,

+ 18 - 12
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<void>((resolve) => { setImmediate(resolve) })
       expect(unhandled).toEqual([])
-      await (ctx.sessionQuery as SessionQuerySqlite).close()
+      expect(ctx.sessionQuery).toBeUndefined()
     } finally {
       process.off('unhandledRejection', onUnhandled)
     }