fix(session-query): harden SQLite search reconciliation
This commit is contained in:
@@ -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
|
||||
@@ -2,16 +2,24 @@
|
||||
|
||||
import {
|
||||
SessionQueryError,
|
||||
filterSessionEventDocuments,
|
||||
filterSessionResults,
|
||||
materializeSessionEventResultFilters,
|
||||
materializeSessionResultFilters,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import type {
|
||||
SessionAvailability,
|
||||
SessionEventMetadataFilter,
|
||||
SessionEventResultFilter,
|
||||
SessionEventSearchRequest,
|
||||
SessionResultFilter,
|
||||
SessionSearchCursor,
|
||||
SessionSearchRequest,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
|
||||
/** Collision-free marker inserted before an FTS5 match by `highlight()`. */
|
||||
export const FTS_HIGHLIGHT_START = '\uFDD0'
|
||||
/** Collision-free marker inserted after an FTS5 match by `highlight()`. */
|
||||
export const FTS_HIGHLIGHT_END = '\uFDD1'
|
||||
|
||||
/** Limit defaults needed to normalize a search request. */
|
||||
export interface QueryLimits {
|
||||
/** Page size used when the request omits one. */
|
||||
@@ -26,7 +34,7 @@ export interface NormalizedSessionRequest {
|
||||
sessionFilters: readonly SessionResultFilter[]
|
||||
eventFilters: readonly SessionEventMetadataFilter[]
|
||||
limit: number
|
||||
cursor?: string
|
||||
cursor?: SessionSearchCursor
|
||||
}
|
||||
|
||||
/** Normalized within-session request. */
|
||||
@@ -35,7 +43,7 @@ export interface NormalizedEventRequest {
|
||||
query: string
|
||||
filters: readonly SessionEventMetadataFilter[]
|
||||
limit: number
|
||||
cursor?: string
|
||||
cursor?: SessionSearchCursor
|
||||
}
|
||||
|
||||
/** Parameterized SQL predicate fragment. */
|
||||
@@ -56,16 +64,15 @@ export function normalizeSessionRequest(
|
||||
request: SessionSearchRequest,
|
||||
limits: QueryLimits,
|
||||
): NormalizedSessionRequest {
|
||||
const sessionFilters = request.sessionFilters ?? []
|
||||
const eventFilters = request.eventFilters ?? []
|
||||
filterSessionResults([], sessionFilters)
|
||||
filterSessionEventDocuments([], eventFilters)
|
||||
const sessionFilters = materializeSessionResultFilters(request.sessionFilters ?? [])
|
||||
const eventFilters = materializeMetadataFilters(request.eventFilters ?? [])
|
||||
const cursor = materializeCursor(request.cursor)
|
||||
return {
|
||||
query: normalizeQuery(request.query),
|
||||
sessionFilters,
|
||||
eventFilters,
|
||||
limit: normalizeLimit(request.limit, limits),
|
||||
...request.cursor === undefined ? {} : { cursor: request.cursor },
|
||||
...cursor === undefined ? {} : { cursor },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,14 +86,17 @@ export function normalizeEventRequest(
|
||||
request: SessionEventSearchRequest,
|
||||
limits: QueryLimits,
|
||||
): NormalizedEventRequest {
|
||||
const filters = request.filters ?? []
|
||||
filterSessionEventDocuments([], filters)
|
||||
if (typeof request.sessionId !== 'string') {
|
||||
throw new SessionQueryError('session-search session id must be text', 'SESSION_QUERY_INVALID_FILTER')
|
||||
}
|
||||
const filters = materializeMetadataFilters(request.filters ?? [])
|
||||
const cursor = materializeCursor(request.cursor)
|
||||
return {
|
||||
sessionId: request.sessionId,
|
||||
query: normalizeQuery(request.query),
|
||||
filters,
|
||||
limit: normalizeLimit(request.limit, limits),
|
||||
...request.cursor === undefined ? {} : { cursor: request.cursor },
|
||||
...cursor === undefined ? {} : { cursor },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,9 +125,23 @@ export function buildSessionWhere(filters: readonly SessionResultFilter[]): SqlW
|
||||
case 'availability': {
|
||||
const availability = [...new Set(filter.values)]
|
||||
if (availability.length === 0) clauses.push('0')
|
||||
else if (availability.length === 1) clauses.push(`${availability[0]} = 1`)
|
||||
else if (availability.length === 1) {
|
||||
const value = availability[0] as SessionAvailability
|
||||
switch (value) {
|
||||
case 'live':
|
||||
clauses.push('live = 1')
|
||||
break
|
||||
case 'persisted':
|
||||
clauses.push('persisted = 1')
|
||||
break
|
||||
default:
|
||||
unknownAvailability(value)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
default:
|
||||
unknownFilter(filter)
|
||||
}
|
||||
}
|
||||
return { sql: clauses.join(' AND '), params }
|
||||
@@ -145,6 +169,8 @@ export function buildEventWhere(filters: readonly SessionEventMetadataFilter[]):
|
||||
case 'surface':
|
||||
addList(clauses, params, 'surface', filter.values)
|
||||
break
|
||||
default:
|
||||
unknownFilter(filter)
|
||||
}
|
||||
}
|
||||
return { sql: clauses.join(' AND '), params }
|
||||
@@ -159,6 +185,18 @@ export function quoteFtsData(query: string): string {
|
||||
return `"${query.replaceAll('"', '""')}"`
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove reserved marker collisions before text enters FTS5 or MATCH.
|
||||
* @param text - extracted document text or normalized caller query.
|
||||
* @returns text with reserved noncharacters mapped to replacement characters.
|
||||
*/
|
||||
export function sanitizeFtsText(text: string): string {
|
||||
return text
|
||||
.replaceAll('\0', '\uFFFD')
|
||||
.replaceAll(FTS_HIGHLIGHT_START, '\uFFFD')
|
||||
.replaceAll(FTS_HIGHLIGHT_END, '\uFFFD')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the stable normalized request identity stored in opaque cursors.
|
||||
* @param request - normalized request whose filter ordering is canonicalized.
|
||||
@@ -185,19 +223,16 @@ export function requestFingerprint(request: NormalizedSessionRequest | Normalize
|
||||
|
||||
/**
|
||||
* Build a whitespace-normalized excerpt no longer than `maxChars`.
|
||||
* @param text - complete extracted semantic document.
|
||||
* @param query - normalized literal query used to position the excerpt.
|
||||
* @param markedText - complete document with FTS5 `highlight()` markers.
|
||||
* @param maxChars - maximum result length in Unicode code points.
|
||||
* @returns bounded plain-text snippet.
|
||||
*/
|
||||
export function makeSnippet(text: string, query: string, maxChars: number): string {
|
||||
const clean = text.replace(/\s+/gu, ' ').trim()
|
||||
export function makeSnippet(markedText: string, maxChars: number): string {
|
||||
const { text: clean, matchStart } = normalizeMarkedText(markedText)
|
||||
const characters = Array.from(clean)
|
||||
if (characters.length <= maxChars) return clean
|
||||
if (maxChars === 1) return '…'
|
||||
const foundUnits = clean.toLowerCase().indexOf(query.toLowerCase())
|
||||
const found = foundUnits < 0 ? -1 : Array.from(clean.slice(0, foundUnits)).length
|
||||
let start = found < 0 ? 0 : Math.max(0, found - Math.floor(maxChars / 3))
|
||||
let start = Math.max(0, matchStart - Math.floor(maxChars / 3))
|
||||
let prefix = start > 0 ? '…' : ''
|
||||
let suffix = '…'
|
||||
let contentLength = maxChars - prefix.length - suffix.length
|
||||
@@ -216,6 +251,28 @@ export function makeSnippet(text: string, query: string, maxChars: number): stri
|
||||
return `${prefix}${characters.slice(start, end).join('')}${suffix}`
|
||||
}
|
||||
|
||||
function normalizeMarkedText(markedText: string): { text: string; matchStart: number } {
|
||||
const characters: string[] = []
|
||||
let matchStart: number | undefined
|
||||
for (const character of markedText) {
|
||||
if (character === FTS_HIGHLIGHT_START) {
|
||||
matchStart ??= characters.length
|
||||
continue
|
||||
}
|
||||
if (character === FTS_HIGHLIGHT_END) continue
|
||||
if (/\s/u.test(character)) {
|
||||
if (characters.length > 0 && characters.at(-1) !== ' ') characters.push(' ')
|
||||
} else {
|
||||
characters.push(character)
|
||||
}
|
||||
}
|
||||
if (characters.at(-1) === ' ') characters.pop()
|
||||
return {
|
||||
text: characters.join(''),
|
||||
matchStart: matchStart ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeQuery(value: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new SessionQueryError('session-search query must be text', 'SESSION_QUERY_INVALID_QUERY')
|
||||
@@ -227,7 +284,44 @@ function normalizeQuery(value: string): string {
|
||||
'SESSION_QUERY_INVALID_QUERY',
|
||||
)
|
||||
}
|
||||
return query
|
||||
if (query.includes('\0')) {
|
||||
throw new SessionQueryError(
|
||||
'session-search query must not contain NUL',
|
||||
'SESSION_QUERY_INVALID_QUERY',
|
||||
)
|
||||
}
|
||||
return sanitizeFtsText(query)
|
||||
}
|
||||
|
||||
function materializeCursor(cursor: SessionSearchCursor | undefined): SessionSearchCursor | undefined {
|
||||
if (cursor === undefined) return undefined
|
||||
if (typeof cursor !== 'string') {
|
||||
throw new SessionQueryError('session-search cursor must be text', 'SESSION_QUERY_INVALID_CURSOR')
|
||||
}
|
||||
return cursor
|
||||
}
|
||||
|
||||
function materializeMetadataFilters(
|
||||
filters: readonly SessionEventMetadataFilter[],
|
||||
): SessionEventMetadataFilter[] {
|
||||
const candidates: readonly SessionEventResultFilter[] = filters
|
||||
for (const filter of candidates) {
|
||||
switch (filter.kind) {
|
||||
case 'seq':
|
||||
case 'time':
|
||||
case 'type':
|
||||
case 'surface':
|
||||
break
|
||||
case 'text':
|
||||
throw new SessionQueryError(
|
||||
'session-search metadata filters do not accept text clauses',
|
||||
'SESSION_QUERY_INVALID_FILTER',
|
||||
)
|
||||
default:
|
||||
unknownFilter(filter)
|
||||
}
|
||||
}
|
||||
return materializeSessionEventResultFilters(filters) as SessionEventMetadataFilter[]
|
||||
}
|
||||
|
||||
function normalizeLimit(value: number | undefined, limits: QueryLimits): number {
|
||||
@@ -310,3 +404,18 @@ function compareNullable(a: string | null, b: string | null): number {
|
||||
if (b === null) return 1
|
||||
return a.localeCompare(b)
|
||||
}
|
||||
|
||||
function unknownAvailability(value: never): never {
|
||||
throw new SessionQueryError(
|
||||
`session availability filter contains unknown value "${String(value)}"`,
|
||||
'SESSION_QUERY_INVALID_FILTER',
|
||||
)
|
||||
}
|
||||
|
||||
function unknownFilter(filter: never): never {
|
||||
const kind = (filter as { kind?: unknown }).kind
|
||||
throw new SessionQueryError(
|
||||
`session filter contains unknown kind ${typeof kind === 'string' ? `"${kind}"` : '(missing)'}`,
|
||||
'SESSION_QUERY_INVALID_FILTER',
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { mkdir } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
/** Current derived-index schema version. Incompatible versions reset in place. */
|
||||
export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 1
|
||||
export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 2
|
||||
|
||||
/** SQLite application id protecting unrelated databases from derived resets. */
|
||||
export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851
|
||||
@@ -24,8 +24,6 @@ export async function openSearchDatabase(path: string, journalMode: JournalMode)
|
||||
if (actual !== ':memory:') await mkdir(dirname(actual), { recursive: true, mode: 0o700 })
|
||||
const db = new DatabaseSync(actual)
|
||||
try {
|
||||
// journalMode is a validated closed union, not caller-controlled SQL.
|
||||
db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`)
|
||||
const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number }
|
||||
const { user_version: version } = db.prepare('PRAGMA user_version').get() as { user_version: number }
|
||||
const userTables = listUserTables(db)
|
||||
@@ -38,6 +36,9 @@ export async function openSearchDatabase(path: string, journalMode: JournalMode)
|
||||
if (applicationId === SESSION_QUERY_SQLITE_APPLICATION_ID && version !== SESSION_QUERY_SQLITE_SCHEMA_VERSION) {
|
||||
resetDerivedSchema(db)
|
||||
}
|
||||
// Apply mutating pragmas only after refusing foreign or canonical files.
|
||||
// journalMode is a validated closed union, not caller-controlled SQL.
|
||||
db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`)
|
||||
ensurePersistentSchema(db)
|
||||
ensureTemporarySchema(db)
|
||||
return db
|
||||
@@ -78,7 +79,7 @@ function ensurePersistentSchema(db: DatabaseSync): void {
|
||||
cwd TEXT,
|
||||
parent_session TEXT,
|
||||
seed_length INTEGER,
|
||||
fingerprint TEXT NOT NULL,
|
||||
revision TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL
|
||||
) STRICT
|
||||
`)
|
||||
@@ -90,6 +91,7 @@ function ensurePersistentSchema(db: DatabaseSync): void {
|
||||
type UNINDEXED,
|
||||
time UNINDEXED,
|
||||
surface UNINDEXED,
|
||||
codepoint_length UNINDEXED,
|
||||
tokenize = 'unicode61'
|
||||
)
|
||||
`)
|
||||
@@ -117,6 +119,7 @@ function ensureTemporarySchema(db: DatabaseSync): void {
|
||||
type UNINDEXED,
|
||||
time UNINDEXED,
|
||||
surface UNINDEXED,
|
||||
codepoint_length UNINDEXED,
|
||||
tokenize = 'unicode61'
|
||||
)
|
||||
`)
|
||||
|
||||
Reference in New Issue
Block a user