fix(session-query): harden SQLite search reconciliation

This commit is contained in:
Hypatia May
2026-07-15 12:10:24 +08:00
parent ecf90ff382
commit f88ca85ffd
40 files changed
+1315 -227

No files matched your search

@@ -6,12 +6,17 @@
import { createHash, randomUUID } from 'node:crypto'
import { DatabaseSync } from 'node:sqlite'
import { Context } from 'cordis'
import { Context, 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'
import type {
SessionPersistenceRevision,
SessionPersistenceSnapshot,
} from '@deepseek-ai/dsh-session-persistence'
import {
SessionQueryError,
SessionSearchCursor,
SessionSearchService,
assertSessionHeadersCompatible,
buildSessionEventSearchDocuments,
@@ -22,6 +27,7 @@ import type {
SessionEventSearchRequest,
SessionSearchExecContext,
SessionSearchHit,
SessionSearchCursor as SessionSearchCursorValue,
SessionSearchPage,
SessionSearchRequest,
} from '@deepseek-ai/dsh-session-query'
@@ -32,6 +38,8 @@ import {
import {
type NormalizedEventRequest,
type NormalizedSessionRequest,
FTS_HIGHLIGHT_END,
FTS_HIGHLIGHT_START,
buildEventWhere,
buildSessionWhere,
makeSnippet,
@@ -39,6 +47,7 @@ import {
normalizeSessionRequest,
quoteFtsData,
requestFingerprint,
sanitizeFtsText,
} from './query.ts'
export {
@@ -78,19 +87,30 @@ interface ResolvedConfig {
interface ObservedSession {
header: SessionHeader
events: SessionEvent[]
documents: SessionEventSearchDocument[]
fingerprint: string
}
interface ObservedPersistedSession {
header: SessionHeader
revision: SessionPersistenceRevision
loaded?: ObservedSession
}
interface Observation {
persistence: SessionPersistence | undefined
persistenceRevision: number
persisted: Map<SessionId, ObservedSession>
persisted: Map<SessionId, ObservedPersistedSession>
live: Map<SessionId, ObservedSession>
}
interface IndexedRow {
interface IndexedPersistedRow {
id: string
revision: string
generation: number
}
interface IndexedLiveRow {
id: string
fingerprint: string
generation: number
@@ -109,8 +129,9 @@ interface SearchRow {
type: string
time: number
surface: string
text: string
score: number
marked_text: string
match_count: number
document_length: number
}
interface CursorPayload {
@@ -149,27 +170,32 @@ export class SessionSearchSqlite extends SessionSearchService {
private _localGeneration = 0
private _tail: Promise<void> = Promise.resolve()
private _closed = false
private _closePromise: Promise<void> | undefined
private readonly _optionalPersistenceFiber: Fiber
constructor(ctx: Context, config: Config) {
super(ctx)
this.config = resolveConfig(config)
this._ready = this._open()
ctx.effect(() => {
const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => {
const service = childCtx.sessionPersistence
const binding = {}
this._persistenceBinding = binding
this._persistence = service
// 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 = {}
this._persistenceBinding = binding
this._persistence = service
this._persistenceRevision += 1
childCtx.effect(() => () => {
/* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */
if (this._persistenceBinding !== binding) return
this._persistenceBinding = undefined
this._persistence = undefined
this._persistenceRevision += 1
childCtx.effect(() => () => {
/* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */
if (this._persistenceBinding !== binding) return
this._persistenceBinding = undefined
this._persistence = undefined
this._persistenceRevision += 1
}, 'sessionSearchSqlite.persistenceBinding')
})
return () => void fiber.dispose()
}, 'sessionSearchSqlite.persistenceBinding')
})
ctx.effect(() => {
return () => this._optionalPersistenceFiber.dispose()
}, 'sessionSearchSqlite.optionalPersistence')
ctx.effect(() => async () => this.close(), 'sessionSearchSqlite.close')
}
@@ -179,17 +205,18 @@ export class SessionSearchSqlite extends SessionSearchService {
exec?: SessionSearchExecContext,
): Promise<SessionSearchPage<SessionSearchHit>> {
const normalized = normalizeSessionRequest(request, this.config)
return this._serialized(exec?.signal, async () => {
await this._ensureReady(exec?.signal)
await this._reconcile(exec?.signal)
assertNotAborted(exec?.signal)
const signal = exec?.signal
return this._serialized(signal, async () => {
await this._ensureReady(signal)
await this._reconcile(signal)
assertNotAborted(signal)
const generation = String(this._globalGeneration)
const fingerprint = requestFingerprint(normalized)
const offset = normalized.cursor === undefined
? 0
: decodeCursor(normalized.cursor, this._instance, 'sessions', fingerprint, generation)
const rows = this._querySessions(normalized, offset)
return page(rows, normalized.limit, row => this._sessionHit(row, normalized.query), cursorOffset => encodeCursor({
return page(rows, normalized.limit, row => this._sessionHit(row), cursorOffset => encodeCursor({
version: 1,
instance: this._instance,
scope: 'sessions',
@@ -205,17 +232,18 @@ export class SessionSearchSqlite extends SessionSearchService {
exec?: SessionSearchExecContext,
): Promise<SessionSearchPage<SessionEventSearchHit>> {
const normalized = normalizeEventRequest(request, this.config)
return this._serialized(exec?.signal, async () => {
await this._ensureReady(exec?.signal)
await this._reconcile(exec?.signal)
assertNotAborted(exec?.signal)
const signal = exec?.signal
return this._serialized(signal, async () => {
await this._ensureReady(signal)
await this._reconcile(signal)
assertNotAborted(signal)
const generation = this._targetGeneration(normalized.sessionId)
const fingerprint = requestFingerprint(normalized)
const offset = normalized.cursor === undefined
? 0
: decodeCursor(normalized.cursor, this._instance, 'events', fingerprint, generation)
const rows = this._queryEvents(normalized, offset)
return page(rows, normalized.limit, row => this._eventHit(row, normalized.query), cursorOffset => encodeCursor({
return page(rows, normalized.limit, row => this._eventHit(row), cursorOffset => encodeCursor({
version: 1,
instance: this._instance,
scope: 'events',
@@ -227,8 +255,12 @@ export class SessionSearchSqlite extends SessionSearchService {
}
/** Close the database after every accepted operation reaches quiescence. */
async close(): Promise<void> {
if (this._closed) return
close(): Promise<void> {
this._closePromise ??= this._close()
return this._closePromise
}
private async _close(): Promise<void> {
this._closed = true
await this._tail
try {
@@ -287,20 +319,20 @@ export class SessionSearchSqlite extends SessionSearchService {
}
private async _reconcile(signal: AbortSignal | undefined): Promise<void> {
const observation = await this._observeStable(signal)
assertNotAborted(signal)
const db = this._requireDb()
const persistedRows = db.prepare(
'SELECT id, fingerprint, generation FROM persisted_sessions',
).all() as unknown as IndexedRow[]
'SELECT id, revision, generation FROM persisted_sessions',
).all() as unknown as IndexedPersistedRow[]
const liveRows = db.prepare(
'SELECT id, fingerprint, generation FROM temp.live_sessions',
).all() as unknown as IndexedRow[]
).all() as unknown as IndexedLiveRow[]
const persistedById = new Map(persistedRows.map(row => [row.id as SessionId, row]))
const liveById = new Map(liveRows.map(row => [row.id as SessionId, row]))
const observation = await this._observeStable(persistedById, signal)
assertNotAborted(signal)
const persistentChanges = observation.persistence === undefined
? []
: [...observation.persisted.values()].filter(entry => persistedById.get(entry.header.id)?.fingerprint !== entry.fingerprint)
: [...observation.persisted.values()].filter(entry => entry.loaded !== undefined)
const persistentDeletes = observation.persistence === undefined
? []
: persistedRows.filter(row => !observation.persisted.has(row.id as SessionId))
@@ -327,13 +359,17 @@ export class SessionSearchSqlite extends SessionSearchService {
db.exec('BEGIN IMMEDIATE')
began = true
for (const row of persistentDeletes) this._deleteSession('persisted', row.id as SessionId)
for (const entry of persistentChanges) this._replaceSession('persisted', entry, nextMainGeneration)
for (const entry of persistentChanges) {
/* v8 ignore next -- observation loads every entry whose revision differs */
if (entry.loaded === undefined) throw new Error(`missing loaded revision for session "${entry.header.id}"`)
this._replacePersistedSession(entry.loaded, entry.revision, nextMainGeneration)
}
if (persistentChanges.length > 0 || persistentDeletes.length > 0) {
db.prepare('UPDATE search_state SET global_generation = ? WHERE singleton = 1').run(nextMainGeneration)
}
for (const row of liveDeletes) this._deleteSession('live', row.id as SessionId)
for (const { entry, generation } of liveReplacements) {
this._replaceSession('live', entry, generation)
this._replaceLiveSession(entry, generation)
}
db.exec('COMMIT')
} catch (error: unknown) {
@@ -360,21 +396,39 @@ export class SessionSearchSqlite extends SessionSearchService {
this._lastPersistenceRevision = observation.persistenceRevision
}
private async _observeStable(signal: AbortSignal | undefined): Promise<Observation> {
private async _observeStable(
indexed: ReadonlyMap<SessionId, IndexedPersistedRow>,
signal: AbortSignal | undefined,
): Promise<Observation> {
for (;;) {
assertNotAborted(signal)
const persistence = this._persistence
const persistenceRevision = this._persistenceRevision
const persisted = new Map<SessionId, ObservedSession>()
let persisted = new Map<SessionId, ObservedPersistedSession>()
if (persistence !== undefined) {
try {
const headers = await waitWithAbort(persistence.list(), signal)
for (const listed of headers) {
const loaded = await waitWithAbort(persistence.load(listed.id), signal)
assertSessionHeadersCompatible(listed, loaded.meta)
persisted.set(listed.id, observeSession(loaded.meta, loaded.events))
const canReuseIndexed = this._lastPersistenceRevision === undefined
|| this._lastPersistenceRevision === persistenceRevision
const before = await waitWithAbort(persistence.listSnapshots(), signal)
persisted = materializePersistenceSnapshots(before)
for (const entry of persisted.values()) {
if (canReuseIndexed && indexed.get(entry.header.id)?.revision === entry.revision) continue
const loaded = await waitWithAbort(persistence.load(entry.header.id), signal)
assertSessionHeadersCompatible(entry.header, loaded.meta)
entry.loaded = observeSession(loaded.meta, loaded.events)
}
const after = materializePersistenceSnapshots(
await waitWithAbort(persistence.listSnapshots(), signal),
)
if (!samePersistenceSnapshots(persisted, after)) continue
if (this._persistenceRevision !== persistenceRevision) continue
} catch (error: unknown) {
if (isAbort(error) || signal?.aborted) {
throw new SessionQueryError('session-search aborted', 'SESSION_QUERY_ABORTED', {
cause: error,
})
}
if (this._persistenceRevision !== persistenceRevision) continue
if (error instanceof SessionQueryError) throw error
throw new SessionQueryError(
`session-search persistence observation failed: ${errorMessage(error)}`,
@@ -414,13 +468,50 @@ export class SessionSearchSqlite extends SessionSearchService {
}
}
private _replaceSession(source: 'persisted' | 'live', entry: ObservedSession, generation: number): void {
this._deleteSession(source, entry.header.id)
private _replacePersistedSession(
entry: ObservedSession,
revision: SessionPersistenceRevision,
generation: number,
): void {
this._deleteSession('persisted', entry.header.id)
const db = this._requireDb()
const sessionTable = source === 'persisted' ? 'persisted_sessions' : 'temp.live_sessions'
const docsTable = source === 'persisted' ? 'persisted_docs' : 'temp.live_docs'
db.prepare(`
INSERT INTO ${sessionTable}
INSERT INTO persisted_sessions
(id, version, created_at, cwd, parent_session, seed_length, revision, generation)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
entry.header.id,
entry.header.version,
entry.header.createdAt,
entry.header.cwd ?? null,
entry.header.parentSession ?? null,
entry.header.seedLength ?? null,
revision,
generation,
)
const insert = db.prepare(`
INSERT INTO persisted_docs (text, session_id, seq, type, time, surface, codepoint_length)
VALUES (?, ?, ?, ?, ?, ?, ?)
`)
for (const document of entry.documents) {
const text = sanitizeFtsText(document.text)
insert.run(
text,
document.sessionId,
document.seq,
document.type,
document.time,
document.surface,
Array.from(text).length,
)
}
}
private _replaceLiveSession(entry: ObservedSession, generation: number): void {
this._deleteSession('live', entry.header.id)
const db = this._requireDb()
db.prepare(`
INSERT INTO temp.live_sessions
(id, version, created_at, cwd, parent_session, seed_length, fingerprint, generation)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
@@ -434,11 +525,20 @@ export class SessionSearchSqlite extends SessionSearchService {
generation,
)
const insert = db.prepare(`
INSERT INTO ${docsTable} (text, session_id, seq, type, time, surface)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO temp.live_docs (text, session_id, seq, type, time, surface, codepoint_length)
VALUES (?, ?, ?, ?, ?, ?, ?)
`)
for (const document of entry.documents) {
insert.run(document.text, document.sessionId, document.seq, document.type, document.time, document.surface)
const text = sanitizeFtsText(document.text)
insert.run(
text,
document.sessionId,
document.seq,
document.type,
document.time,
document.surface,
Array.from(text).length,
)
}
}
@@ -455,19 +555,16 @@ export class SessionSearchSqlite extends SessionSearchService {
ranked AS (
SELECT *, ROW_NUMBER() OVER (
PARTITION BY session_id
ORDER BY score ASC, time DESC, seq DESC
ORDER BY match_count DESC, document_length ASC, time DESC, seq DESC
) AS event_rank
FROM filtered
)
SELECT * FROM ranked
WHERE event_rank = 1
ORDER BY score ASC, time DESC, session_id ASC, seq DESC
ORDER BY match_count DESC, document_length ASC, time DESC, session_id ASC, seq DESC
LIMIT ? OFFSET ?
`).all(
quoteFtsData(request.query),
this._persistence === undefined ? 0 : 1,
this._persistence === undefined ? 0 : 1,
quoteFtsData(request.query),
...selectedDocumentsParams(request.query, this._persistence !== undefined),
...sessionWhere.params,
...eventWhere.params,
request.limit + 1,
@@ -483,13 +580,10 @@ export class SessionSearchSqlite extends SessionSearchService {
${selected.sql}
SELECT * FROM matched
WHERE ${where}
ORDER BY score ASC, time DESC, seq DESC
ORDER BY match_count DESC, document_length ASC, time DESC, seq DESC
LIMIT ? OFFSET ?
`).all(
quoteFtsData(request.query),
this._persistence === undefined ? 0 : 1,
this._persistence === undefined ? 0 : 1,
quoteFtsData(request.query),
...selectedDocumentsParams(request.query, this._persistence !== undefined),
request.sessionId,
...eventWhere.params,
request.limit + 1,
@@ -515,23 +609,23 @@ export class SessionSearchSqlite extends SessionSearchService {
)
}
private _sessionHit(row: SearchRow, query: string): SessionSearchHit {
private _sessionHit(row: SearchRow): SessionSearchHit {
return {
header: rowHeader(row),
live: row.live === 1,
persisted: row.persisted === 1,
bestMatch: this._eventHit(row, query),
bestMatch: this._eventHit(row),
}
}
private _eventHit(row: SearchRow, query: string): SessionEventSearchHit {
private _eventHit(row: SearchRow): SessionEventSearchHit {
return {
sessionId: row.session_id as SessionId,
seq: row.seq,
type: row.type as SessionEventSearchHit['type'],
time: row.time,
surface: row.surface as SessionEventSearchHit['surface'],
snippet: makeSnippet(row.text, query, this.config.snippetChars),
snippet: makeSnippet(row.marked_text, this.config.snippetChars),
}
}
@@ -548,7 +642,7 @@ export class SessionSearchSqlite extends SessionSearchService {
function selectedDocumentsSql(): { sql: string } {
return {
sql: `WITH matched AS (
sql: `WITH candidates AS (
SELECT
pd.session_id AS session_id,
ps.version AS version,
@@ -562,8 +656,8 @@ function selectedDocumentsSql(): { sql: string } {
pd.type AS type,
CAST(pd.time AS INTEGER) AS time,
pd.surface AS surface,
pd.text AS text,
bm25(persisted_docs) AS score
highlight(persisted_docs, 0, ?, ?) AS marked_text,
CAST(pd.codepoint_length AS INTEGER) AS document_length
FROM persisted_docs AS pd
JOIN persisted_sessions AS ps ON ps.id = pd.session_id
WHERE persisted_docs MATCH ?
@@ -585,15 +679,39 @@ function selectedDocumentsSql(): { sql: string } {
ld.type AS type,
CAST(ld.time AS INTEGER) AS time,
ld.surface AS surface,
ld.text AS text,
bm25(live_docs) AS score
highlight(live_docs, 0, ?, ?) AS marked_text,
CAST(ld.codepoint_length AS INTEGER) AS document_length
FROM temp.live_docs AS ld
JOIN temp.live_sessions AS ls ON ls.id = ld.session_id
WHERE live_docs MATCH ?
), matched AS (
SELECT *,
(
length(CAST(marked_text AS BLOB))
- length(CAST(replace(marked_text, ?, '') AS BLOB))
) / ? AS match_count
FROM candidates
)`,
}
}
function selectedDocumentsParams(query: string, persistenceVisible: boolean): Array<string | number> {
const expression = quoteFtsData(query)
const visible = persistenceVisible ? 1 : 0
return [
FTS_HIGHLIGHT_START,
FTS_HIGHLIGHT_END,
expression,
visible,
visible,
FTS_HIGHLIGHT_START,
FTS_HIGHLIGHT_END,
expression,
FTS_HIGHLIGHT_START,
Buffer.byteLength(FTS_HIGHLIGHT_START, 'utf8'),
]
}
function observeLive(session: Session): ObservedSession {
return observeSession(
structuredClone(session.header),
@@ -606,7 +724,6 @@ function observeSession(header: SessionHeader, events: readonly SessionEvent[]):
const detachedEvents = events.map(event => structuredClone(event))
return {
header: detachedHeader,
events: detachedEvents,
documents: buildSessionEventSearchDocuments(detachedHeader.id, detachedEvents),
fingerprint: createHash('sha256')
.update(JSON.stringify({ header: detachedHeader, events: detachedEvents }))
@@ -614,6 +731,49 @@ function observeSession(header: SessionHeader, events: readonly SessionEvent[]):
}
}
function materializePersistenceSnapshots(
snapshots: readonly SessionPersistenceSnapshot[],
): Map<SessionId, ObservedPersistedSession> {
if (!isRuntimeArray(snapshots)) throw new Error('persistence snapshots must be an array')
const result = new Map<SessionId, ObservedPersistedSession>()
for (const snapshot of snapshots) {
if (typeof snapshot.revision !== 'string') {
throw new Error('persistence snapshot revision must be a string')
}
const header = structuredClone(snapshot.header)
if (result.has(header.id)) {
throw new Error(`persistence listed duplicate session "${header.id}"`)
}
result.set(header.id, { header, revision: snapshot.revision })
}
return result
}
function samePersistenceSnapshots(
before: ReadonlyMap<SessionId, ObservedPersistedSession>,
after: ReadonlyMap<SessionId, ObservedPersistedSession>,
): boolean {
if (before.size !== after.size) return false
for (const [id, first] of before) {
const second = after.get(id)
if (
second === undefined
|| first.revision !== second.revision
|| !sameHeader(first.header, second.header)
) return false
}
return true
}
function sameHeader(a: SessionHeader, b: SessionHeader): boolean {
return a.version === b.version
&& a.id === b.id
&& a.createdAt === b.createdAt
&& a.cwd === b.cwd
&& a.parentSession === b.parentSession
&& a.seedLength === b.seedLength
}
function rowHeader(row: SearchRow): SessionHeader {
return {
version: row.version,
@@ -629,7 +789,7 @@ function page<Row, Item>(
rows: readonly Row[],
limit: number,
convert: (row: Row) => Item,
nextCursor: (offset: number) => string,
nextCursor: (offset: number) => SessionSearchCursorValue,
offset: number,
): SessionSearchPage<Item> {
const hasMore = rows.length > limit
@@ -639,12 +799,12 @@ function page<Row, Item>(
}
}
function encodeCursor(payload: CursorPayload): string {
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
function encodeCursor(payload: CursorPayload): SessionSearchCursorValue {
return SessionSearchCursor(Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'))
}
function decodeCursor(
cursor: string,
cursor: SessionSearchCursorValue,
instance: string,
scope: CursorPayload['scope'],
fingerprint: string,
@@ -762,4 +922,8 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'unknown error'
}
function isRuntimeArray(value: unknown): boolean {
return Array.isArray(value)
}
export default SessionSearchSqlite