From ecf90ff382344b706a123a5db417869a5084d9d6 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 10:51:38 +0800 Subject: [PATCH 01/19] feat(session-query): add SQLite full-text search --- docs/architecture.md | 1 + docs/capability-seams.md | 13 +- docs/config-catalog.md | 25 + docs/cordis-catalog/services.md | 16 +- docs/core-data-structures/core.md | 2 +- docs/core-data-structures/session-query.md | 82 +- docs/module-graph.md | 5 + docs/rfc/INDEX.md | 2 +- .../2026-07-10-session-query-service.md | 12 +- ...026-07-10-sqlite-session-query-provider.md | 57 ++ ...026-07-10-sqlite-session-query-provider.md | 51 -- packages/README.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 57 ++ packages/session-query/README.md | 7 +- .../session-query-sqlite/README.md | 35 + .../session-query-sqlite/package.json | 46 ++ .../session-query-sqlite/src/index.ts | 765 ++++++++++++++++++ .../session-query-sqlite/src/query.ts | 312 +++++++ .../session-query-sqlite/src/schema.ts | 127 +++ .../tests/load-path.e2e.ts | 60 ++ .../session-query-sqlite/tests/query.spec.ts | 179 ++++ .../session-query-sqlite/tests/sqlite.spec.ts | 594 ++++++++++++++ .../session-query-sqlite/tsconfig.json | 30 + .../session-query/session-query/README.md | 19 +- .../session-query/session-query/src/config.ts | 11 +- .../session-query/session-query/src/corpus.ts | 21 +- .../session-query/src/documents.ts | 74 ++ .../session-query/src/extraction.ts | 93 +++ .../session-query/src/filters.ts | 132 +++ .../session-query/session-query/src/index.ts | 92 ++- .../session-query/src/sources.ts | 25 + .../session-query/session-query/src/types.ts | 96 +++ .../tests/search-helpers.spec.ts | 209 +++++ pnpm-lock.yaml | 25 + scripts/gen-doc-graphs.ts | 14 +- scripts/type-equiv.manifest.json | 8 + tsconfig.build.json | 1 + tsconfig.json | 1 + 38 files changed, 3181 insertions(+), 120 deletions(-) create mode 100644 docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md delete mode 100644 docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md create mode 100644 packages/session-query/session-query-sqlite/README.md create mode 100644 packages/session-query/session-query-sqlite/package.json create mode 100644 packages/session-query/session-query-sqlite/src/index.ts create mode 100644 packages/session-query/session-query-sqlite/src/query.ts create mode 100644 packages/session-query/session-query-sqlite/src/schema.ts create mode 100644 packages/session-query/session-query-sqlite/tests/load-path.e2e.ts create mode 100644 packages/session-query/session-query-sqlite/tests/query.spec.ts create mode 100644 packages/session-query/session-query-sqlite/tests/sqlite.spec.ts create mode 100644 packages/session-query/session-query-sqlite/tsconfig.json create mode 100644 packages/session-query/session-query/src/documents.ts create mode 100644 packages/session-query/session-query/src/extraction.ts create mode 100644 packages/session-query/session-query/src/filters.ts create mode 100644 packages/session-query/session-query/src/sources.ts create mode 100644 packages/session-query/session-query/tests/search-helpers.spec.ts diff --git a/docs/architecture.md b/docs/architecture.md index 8dc7f6f4b1..e7b6f25ac3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,6 +35,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs | | `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred logical-corpus and exact-event reads | +| `ctx.sessionSearch` | [`session-query/`](../packages/session-query/README.md) | SQLite full-text search | ## Event diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 13af1cde1c..189b910149 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -19,6 +19,7 @@ flowchart LR pkg_agent["agent"] pkg_session_persistence["session-persistence"] pkg_session_query["session-query"] + pkg_session_query_sqlite["session-query-sqlite"] pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] svc_sessionPersistence["ctx.sessionPersistence
Durable session persistence seam"] @@ -26,6 +27,7 @@ flowchart LR pkg_session_persistence_sqlite["session-persistence-sqlite"] pkg_acp["acp"] svc_sessionQuery["ctx.sessionQuery
Exact session-history reads"] + svc_sessionSearch["ctx.sessionSearch
Full-text session search"] pkg_system_prompt["system-prompt"] svc_systemPrompt["ctx.systemPrompt
System prompt assembly registry"] pkg_tools["tools"] @@ -108,6 +110,8 @@ flowchart LR pkg_session_persistence_jsonl --> svc_sessionPersistence pkg_session_persistence_sqlite --> svc_sessionPersistence pkg_session_query --> svc_sessionQuery + pkg_session_query --> svc_sessionSearch + pkg_session_query_sqlite --> svc_sessionSearch pkg_skill --> svc_skills pkg_skill_local --> svc_skills pkg_stdio_agent --> svc_userInteraction @@ -146,11 +150,13 @@ flowchart LR svc_sessionPersistence --> pkg_acp svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_session_query + svc_sessionPersistence --> pkg_session_query_sqlite svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_invariants svc_sessions --> pkg_session_persistence svc_sessions --> pkg_session_query + svc_sessions --> pkg_session_query_sqlite svc_sessions --> pkg_subagent_inprocess svc_skills --> pkg_tool_skill svc_subagents --> pkg_tool_subagent @@ -179,9 +185,10 @@ flowchart LR | ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note | | --- | --- | --- | --- | --- | --- | --- | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | -| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | -| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | -| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads. | +| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | +| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | +| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | - | - | Resolves live and optional persisted logs into one logical corpus for exact reads and semantic scans. | +| `ctx.sessionSearch` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | - | The concrete backend owns source reconciliation, ranking, snippets, and cursor generations as one lifecycle. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | | `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tool-bash`](../packages/bash/tool-bash), [`tool-cordis`](../packages/cordis/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web), [`acp`](../packages/ui/acp) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | | `ctx.userInteraction` | `seam` | [`user-interaction`](../packages/ui/user-interaction) | [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | [`tool-ask-user`](../packages/ui/tool-ask-user), [`stdio-agent`](../packages/ui/stdio-agent), [`acp`](../packages/ui/acp) | - | UI front doors provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 24aee5adc2..3a802231d7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -590,6 +590,31 @@ export interface Config { Source: [`packages/session-query/session-query/src/config.ts:9`](../packages/session-query/session-query/src/config.ts) +## `@deepseek-ai/dsh-session-query-sqlite` + +Requires: `sessions` + +```ts config-catalog +/** SQLite session-search configuration. */ +export interface Config { + /** Dedicated derived-index path; `:memory:` is supported for tests. */ + path: string + /** SQLite journal mode. Defaults to `wal`. */ + journalMode?: JournalMode + /** Page size when a request omits `limit`. Defaults to 20. */ + defaultLimit?: number + /** Largest accepted page size. Defaults to 100. */ + maxLimit?: number + /** Maximum snippet length in Unicode code points. Defaults to 240. */ + snippetChars?: number +} + +/** Supported SQLite journal modes. */ +export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' +``` + +Source: [`packages/session-query/session-query-sqlite/src/index.ts:58`](../packages/session-query/session-query-sqlite/src/index.ts) + ## `@deepseek-ai/dsh-skill` ```ts config-catalog diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6f8508f5ee..fef691aae1 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -208,10 +208,11 @@ Live-preferred logical-corpus and exact-event read service. ```ts cordis-catalog listSessions(): Promise async listEvents(sessionId: SessionId): Promise +async filterEvents( sessionId: SessionId, filters: readonly SessionEventResultFilter[], ): Promise async readEvent(request: SessionEventReadRequest): Promise ``` -Source: [`packages/session-query/session-query/src/index.ts:35`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:83`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessions` — `SessionStore` @@ -232,6 +233,19 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Source: [`packages/core/session/src/index.ts:590`](../../packages/core/session/src/index.ts) +## `ctx.sessionSearch` — `SessionSearchService` (abstract seam) + +Abstract full-text search service implemented by one concrete backend. + +The implementation owns source observation, reconciliation, cursor generations, ranking, and query execution as one lifecycle. + +```ts cordis-catalog +abstract searchSessions( request: SessionSearchRequest, exec?: SessionSearchExecContext, ): Promise> +abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise> +``` + +Source: [`packages/session-query/session-query/src/index.ts:54`](../../packages/session-query/session-query/src/index.ts) + ## `ctx.skills` — `SkillService` Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted model-visible summaries, and loads full skill bodies on demand. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 5536526aec..e632b36f4d 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -19,7 +19,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context | | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant | | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | -| [session-query.md](session-query.md) | logical session/event records and bounded exact-event reads | +| [session-query.md](session-query.md) | logical records, semantic filters/documents, exact reads, and full-text result pages | | [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly | | [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, and the guarded execution pipeline | | [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index ded8ca3f7e..27ef9959de 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -1,6 +1,6 @@ # Session Query -Exact reads over the live-preferred logical session corpus. The [package contract](../../packages/session-query/session-query) owns source precedence, dynamic optional persistence, cloning, surface classification, bounded windows, and typed failures. Full-text search is a separate proposed SQLite phase. +Query vocabulary over the live-preferred logical session corpus. The [interface package](../../packages/session-query/session-query) owns exact reads, source precedence, semantic extraction and provider-independent filters, while the [SQLite package](../../packages/session-query/session-query-sqlite) owns the concrete full-text index lifecycle. Source: [`packages/session-query/session-query/src/types.ts`](../../packages/session-query/session-query/src/types.ts) @@ -30,6 +30,79 @@ export interface SessionEventRecord { } ``` +## Provider-independent filters and documents + +Session and event filter arrays are ANDed; values inside one list clause are ORed. Ranges are inclusive. The event `text` clause is a literal Unicode case-insensitive, whitespace-flexible regular-expression scan over extracted semantic text, independent of full-text providers. + +```ts type-equiv +export type SessionResultFilter = + | { kind: 'id'; values: readonly SessionId[] } + | { kind: 'cwd'; values: readonly (string | null)[] } + | ({ kind: 'created-at' } & SessionResultRange) + | { kind: 'parent'; values: readonly (SessionId | null)[] } + | { kind: 'availability'; values: readonly SessionAvailability[] } +``` + +```ts type-equiv +export type SessionEventResultFilter = + | ({ kind: 'seq' } & SessionResultRange) + | ({ kind: 'time' } & SessionResultRange) + | { kind: 'type'; values: readonly SessionEventType[] } + | { kind: 'surface'; values: readonly SessionEventSurface[] } + | { kind: 'text'; text: string } +``` + +```ts type-equiv +export interface SessionEventSearchDocument extends SessionEventRecord { + text: string +} +``` + +`ctx.sessionQuery.filterEvents(sessionId, filters)` returns these documents in ascending seq order. Messages, reasoning, tool calls/results, blocked prompts, todos, and failure/status detail contribute semantic text; structural events and stream chunks do not. + +## Full-text search pages + +The independent `ctx.sessionSearch` seam has two scopes. `searchSessions()` groups the corpus by strongest matching event; `searchEvents()` searches one session. Requests bind an opaque cursor to the normalized query, metadata filters, and limit. The event text scan is intentionally absent from provider metadata filters. + +```ts type-equiv +export interface SessionSearchRequest { + query: string + sessionFilters?: readonly SessionResultFilter[] + eventFilters?: readonly SessionEventMetadataFilter[] + limit?: number + cursor?: string +} +``` + +```ts type-equiv +export interface SessionEventSearchRequest { + sessionId: SessionId + query: string + filters?: readonly SessionEventMetadataFilter[] + limit?: number + cursor?: string +} +``` + +```ts type-equiv +export interface SessionSearchPage { + items: readonly T[] + nextCursor?: string +} +``` + +```ts type-equiv +export interface SessionEventSearchHit extends SessionEventRecord { + snippet: string +} +``` + +```ts type-equiv +export interface SessionSearchHit extends SessionRecord { + bestMatch: SessionEventSearchHit +} +``` + ## Bounded event reads The request addresses one raw seq and optional neighboring counts. The result carries a `SessionHeader` rather than availability flags so a known live target can remain independent of persistence health. @@ -59,11 +132,18 @@ The closed code union distinguishes request validation, missing targets, malform ```ts type-equiv export type SessionQueryErrorCode = + | 'SESSION_QUERY_ABORTED' | 'SESSION_QUERY_EVENT_NOT_FOUND' + | 'SESSION_QUERY_INDEX_FAILED' | 'SESSION_QUERY_INVALID_CONFIG' + | 'SESSION_QUERY_INVALID_CURSOR' + | 'SESSION_QUERY_INVALID_FILTER' + | 'SESSION_QUERY_INVALID_LIMIT' + | 'SESSION_QUERY_INVALID_QUERY' | 'SESSION_QUERY_INVALID_SURFACE' | 'SESSION_QUERY_INVALID_WINDOW' | 'SESSION_QUERY_PERSISTENCE_FAILED' | 'SESSION_QUERY_SESSION_NOT_FOUND' + | 'SESSION_QUERY_STALE_CURSOR' | 'SESSION_QUERY_SOURCE_CONFLICT' ``` diff --git a/docs/module-graph.md b/docs/module-graph.md index 8a1ad9239d..6fcec53dd4 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -84,6 +84,7 @@ flowchart TD end subgraph group_session_query["packages/session-query"] pkg_session_query["session-query"] + pkg_session_query_sqlite["session-query-sqlite"] end subgraph group_support["packages/support"] pkg_acp_snapshot["acp-snapshot"] @@ -195,6 +196,9 @@ flowchart TD pkg_bash_sandbox --> pkg_bash pkg_bash_sandbox --> pkg_bash_local pkg_bash_sandbox --> pkg_sandbox + pkg_session_query_sqlite --> pkg_session + pkg_session_query_sqlite --> pkg_session_persistence + pkg_session_query_sqlite --> pkg_session_query pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_llm pkg_agent_loop --> pkg_scope @@ -372,6 +376,7 @@ flowchart TD | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | +| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 54e59f7aa1..a3eb41b7b0 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -11,7 +11,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 | | [Claude Code and Codex subagent backends (out-of-process delegation to external coding agents)](proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md) | 2026-07-07 | | [Interactive side sessions and merge-back](proposed/feature/2026-07-08-interactive-side-sessions.md) | 2026-07-08 | -| [SQLite FTS5 session search](proposed/feature/2026-07-10-sqlite-session-query-provider.md) | 2026-07-10 | | [Stream workflow progress through tool calls](proposed/feature/2026-07-13-stream-workflow-progress-through-tool-calls.md) | 2026-07-13 | ### Simplification @@ -77,6 +76,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Repeat-tool-call guard plugin](implemented/feature/2026-07-08-repeat-tool-guard.md) | 2026-07-08 | | [The self-referential cordis toolset](implemented/feature/2026-07-08-self-referential-cordis-toolset.md) | 2026-07-08 | | [Exact session query service](implemented/feature/2026-07-10-session-query-service.md) | 2026-07-10 | +| [SQLite FTS5 session search](implemented/feature/2026-07-10-sqlite-session-query-provider.md) | 2026-07-10 | | [Configure subagent persona, tool visibility, and depth](implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) | 2026-07-12 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md index 7e13256669..be496311cc 100644 --- a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md +++ b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md @@ -6,11 +6,11 @@ Status: implemented Session history exists in two places: current `SessionStore` objects and an optional persistence backend. Consumers that need exact inspection would otherwise duplicate live-versus-persisted precedence, persistence lifecycle handling, raw-event surface classification, and defensive cloning. Durable state can lag the live log between checkpoints, so persistence alone is not a truthful current source. -Full-text search is related but materially larger. Designing provider registration, extraction, synchronization, invalidation, ranking, and cursor contracts before a real backend exists creates two speculative state machines: one in the interface service and another in the eventual database package. +Full-text search is related but materially larger. Putting provider coordination, synchronization, invalidation, ranking, and cursor state into the exact-read service would create a second state machine beside the concrete database owner. ## Decision -`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a small trusted exact-read service over one logical corpus. It exposes `listSessions()`, `listEvents(sessionId)`, and bounded `readEvent(request)`. It does not expose filters, lineage or provenance traversals, text extractors, search requests, provider registration, or derived-index synchronization. +`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a small trusted exact-read service over one logical corpus. It exposes `listSessions()`, `listEvents(sessionId)`, provider-independent `filterEvents(sessionId, filters)`, and bounded `readEvent(request)`. The package also declares the separate abstract `ctx.sessionSearch` contract and shared semantic extraction used by the [SQLite search decision](2026-07-10-sqlite-session-query-provider.md), but `ctx.sessionQuery` does not coordinate providers or synchronize a derived index. The service observes the optional `ctx.sessionPersistence` binding dynamically but retains no persisted cache or invalidation listener. Each cross-corpus list asks the active backend for authoritative metadata, then overlays a fresh live-store list. Matching ids become one `SessionRecord`: the live header wins and `live`/`persisted` independently report source availability. Immutable header disagreement is `SESSION_QUERY_SOURCE_CONFLICT`. @@ -31,11 +31,11 @@ The service is context-wide trusted infrastructure, not an authorization layer. - **Put logical-corpus resolution directly in every consumer** — rejected because source precedence, conflicts, optional-service lifecycle, cloning, and surface classification are shared correctness rules. - **Query only persistence** — rejected because checkpoints can lag the current live log. - **Cache persisted metadata and listen for writes/removals** — rejected because exact reads can ask the authoritative sources directly, while cache invalidation adds lifecycle and concurrency state before scale requires it. -- **Define a provider-neutral search protocol now** — rejected because no provider consumes it. The first SQLite FTS package should own one reconciliation/transaction state machine; a smaller shared seam can be extracted later only when a second implementation proves the boundary. -- **Include lineage, provenance, and generic filters in phase one** — rejected because no current consumer requires them and canonical logs remain sufficient to add them with evidence later. +- **Put provider registration into the exact-read service** — rejected because the SQLite package owns one reconciliation/transaction lifecycle; a registry would split that state without a second provider to justify it. +- **Include lineage and provenance traversal** — rejected because canonical logs remain sufficient to add those higher-level views when a concrete consumer requires them. ## Consequences -Phase one has one source-resolution state variable: the currently mounted persistence service. There are no provider queues, fingerprints, extractor registries, observation generations, or derived index updates. Exact reads remain usable in live-only deployments and deterministic when persistence is present. +Exact reads have one source-resolution state variable: the currently mounted persistence service. There are no provider queues, fingerprints, extractor registries, observation generations, or derived index updates in `ctx.sessionQuery`. Exact reads and semantic scans remain usable in live-only deployments and deterministic when persistence is present. -Cross-corpus listing and persisted exact reads perform backend I/O on each call. That is deliberate: correctness comes from current authoritative state, and scale-oriented search belongs to the phase-two database. Full-text search is unavailable until that package defines and implements its complete contract. +Cross-corpus listing and persisted exact reads perform backend I/O on each call. That is deliberate: correctness comes from current authoritative state, while scale-oriented full-text search uses the separately owned SQLite derived index. diff --git a/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md b/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md new file mode 100644 index 0000000000..d620ece108 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md @@ -0,0 +1,57 @@ +# RFC: SQLite FTS5 session search + +Status: implemented + +## Problem + +The exact-read `ctx.sessionQuery` service deliberately has no derived index. Large persisted histories need full-text search without scanning every event on every query, while current live sessions need an overlay newer than the last durability checkpoint. Search also needs concrete ranking, snippets, filters, pagination, cancellation, and rebuild behavior. + +Splitting those concerns across a provider coordinator and a database implementation would create two coupled reconciliation state machines. The first implementation needs to own source observation, extraction, SQLite transactions, generations, and query execution as one lifecycle while still exposing a small provider-neutral call contract. + +## Decision + +`@deepseek-ai/dsh-session-query` declares an independent abstract `ctx.sessionSearch` service without changing the exact-read `ctx.sessionQuery` key. `searchSessions(request, exec?)` returns cursor-paginated `SessionSearchHit`s grouped by each session's strongest matching event; `searchEvents(request, exec?)` returns `SessionEventSearchHit`s within one logical session. Both requests require `query`, accept `limit` and an opaque `cursor`, and support an optional abort signal. Session search accepts `sessionFilters` plus event metadata filters; event search accepts event metadata filters. Results expose bounded plain-text snippets but no provider identifier or numeric relevance score. + +`@deepseek-ai/dsh-session-query-sqlite` is the sole concrete owner of `ctx.sessionSearch`. It depends on live `ctx.sessions`, observes optional `ctx.sessionPersistence` dynamically, and owns a dedicated derived SQLite database. There is no search-provider registry, coordinator, persistence event, or agent-loop integration. + +The interface package also owns shared first-party semantic extraction and provider-independent filtering. `SessionResultFilter` covers id, nullable cwd, created-at range, nullable parent, and availability. `SessionEventResultFilter` covers seq/time ranges, event type, surface, and literal semantic text. Arrays are ANDed and list values are ORed. The text clause escapes caller input into a Unicode case-insensitive regular expression whose whitespace runs match one or more whitespace characters; it is available through `ctx.sessionQuery.filterEvents()` and is not delegated to an FTS provider. + +## Search semantics + +Each semantic event is one FTS document carrying session metadata, event metadata, surface classification, and extracted text. All `current`, `shadowed`, and `log-only` documents participate unless a surface filter narrows them. Metadata filters compile to parameterized SQL before ranking. Session results partition matching documents by session and retain the strongest one. + +Ordering is deterministic: BM25 ascending, event time descending, session id ascending for the cross-session scope, and seq descending. Provider scores remain private. Snippets normalize whitespace and are bounded by Unicode code points. Opaque cursors bind to the service instance, scope, canonical normalized request, offset, and relevant generation. Any corpus change invalidates cross-session cursors; a within-session cursor changes only when its target source/generation changes, so unrelated sessions do not invalidate it. Reopening creates a new service instance and invalidates old cursors. + +Queries are trimmed, whitespace-normalized, and quoted as one literal FTS5 phrase. Embedded quotes are doubled before binding, so MATCH operators such as `OR`, `NEAR`, quotes, parentheses, and `*` remain data rather than executable query syntax. Phrase matching follows tokenizer tokens rather than arbitrary substrings. + +## Tokenizer choice + +Both persistent and live FTS5 tables use `unicode61`. The implementation experiment found that this tokenizer supports the two-character token `AI` and produces an index about 2.1× smaller than the trigram alternative. The accepted limitation is token/phrase recall: `AI` does not match the larger token `BRAID`, and arbitrary substring search uses the provider-independent text scan instead. + +## Extraction and reconciliation + +The shared extractor includes message text, reasoning, nested tool-call/result content, tool names and arguments, blocked-prompt reasons, todo status/content, and error or terminal status detail. Structural boundaries, stream chunks, request headers, successful completion markers, and unknown declaration-merged event/content variants produce no document. Surface classification reuses `foldSurface()` so search agrees with model-history derivation. + +One serialized operation observes complete persisted and live sources, computes stable fingerprints, reconciles rows in one transaction, and executes the query. Unchanged persisted sessions retain their rows and generation. New, changed, and deleted persisted sessions update on the next search. A source or extraction failure cannot mark a row current, and a transaction failure rolls back so a later search retries. + +Persisted documents survive restarts. Live sessions use connection-local TEMP tables, shadow the persisted base for the same id, and reveal that base on detach. Closing the database drops live rows. Unmounting persistence hides durable rows without treating absence as authoritative deletion; remounting observes and reconciles the backend again. Conflicting immutable live and durable headers fail rather than combining sources. + +The derived schema has its own application id and monotonic schema version. A recognized incompatible version resets only this derived database. A database with a foreign application id or unrecognized user tables is refused, which prevents an accidentally configured canonical session database from being reset. + +Cancellation rejects queued operations and caller waits around asynchronous source observation without committing an aborted observation. Node's synchronous `DatabaseSync` MATCH call cannot be interrupted once it is executing on the JavaScript thread, so the service checks the signal at serialized boundaries but does not promise mid-statement preemption. + +## Alternatives considered + +- **Add FTS tables to the canonical persistence database** — rejected because a rebuildable index must not share the authoritative log's schema, reset, or failure boundary. +- **Add a phase-one provider registry and coordinator** — rejected because one implementation provides no evidence for registration semantics and would split one reconciliation lifecycle across two owners. +- **Persist live overrides immediately** — rejected because live events are not canonical until the existing checkpoint commits. +- **Use the FTS5 trigram tokenizer** — rejected because it omits useful queries shorter than three characters and measured about 2.1× the index size of `unicode61`; literal substring filtering remains available through the scan path. +- **Return BM25 scores** — rejected because provider-specific numeric scales are unstable across corpus changes. + +## Consequences + +Search has a small provider-neutral API while its only backend owns every derived-index state transition. The separate database adds configuration and a reconciliation read before queries, but index corruption, reset, and tokenizer changes cannot endanger canonical logs. Persistent fingerprints avoid rewriting unchanged sessions; TEMP live overlays preserve current-session truth without making uncheckpointed events durable. + +The chosen tokenizer supports short tokens with a smaller index but does not promise substring recall. Literal phrases make query syntax safe and predictable at the cost of excluding boolean/full MATCH expressions. Cancellation is effective while queued or awaiting sources, but synchronous SQLite execution remains a non-preemptible section. + +Unit coverage pins extraction, filters, both search scopes, all default surfaces, metadata-before-ranking, snippets, literal escaping, deterministic ties, complete pagination, scoped cursor invalidation, dynamic persistence mount/unmount, restart reconciliation, live shadow/reveal/reopen, schema safety, rollback retry, and queued/in-flight source-wait cancellation. A keyless real-Loader-path test combines the package with the real SQLite persistence backend. diff --git a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md b/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md deleted file mode 100644 index acfdf23bee..0000000000 --- a/docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md +++ /dev/null @@ -1,51 +0,0 @@ -# RFC: SQLite FTS5 session search - -Status: proposed - -## Problem - -The exact-read `ctx.sessionQuery` service deliberately has no derived index. Large persisted histories need full-text search without scanning every event on every query, while current live sessions need an overlay newer than the last durability checkpoint. Search also needs concrete ranking, snippets, filters, pagination, cancellation, and rebuild behavior. - -Splitting those concerns across a speculative provider coordinator and a database implementation would create two coupled reconciliation state machines. The first real implementation should own the source observation, extraction, SQLite transaction, generation, and query as one lifecycle. - -## Proposal - -Add `@deepseek-ai/dsh-session-query-sqlite` beside the exact-read package. The package will expose a search service or extend the family with the smallest API required by its actual consumers; phase one does not pre-commit a provider-registration protocol. It will depend on `ctx.sessions` and optional `ctx.sessionPersistence`, own a separate derived SQLite database, and reuse the canonical `foldSurface()` classification. - -The implementation owns one serialized reconciliation/DB transaction state machine. A transaction observes authoritative persisted metadata and live snapshots, extracts semantic documents, updates derived tables, advances relevant cursor generations, and executes or enables the corresponding query. No second service maintains parallel fingerprints, dirty flags, live-id sets, or invalidation generations. - -Persisted documents survive restarts. Live overrides are connection-local and shadow the persisted rows for the same session, then disappear when the live owner or database closes. The derived database remains separate from canonical persistence so index reset, corruption, tokenizer changes, and schema churn cannot endanger durable conversation logs. - -## Search semantics to decide with implementation - -The implementation must define both cross-session and within-session scopes from executable use cases. Each searchable event is one document with session metadata, event metadata, surface classification, normalized semantic text, and a bounded plain-text snippet. Session results group by their strongest matching event; numeric backend scores remain private. - -Filters compile to parameterized SQL before ranking. Query syntax is treated as data. Ordering includes stable tie fields. Opaque cursors bind to normalized request shape and the smallest relevant generation; unrelated session changes should not invalidate a within-session cursor. Cancellation must stop caller waiting and interrupt SQLite work where the runtime permits. - -Tokenizer choice remains an implementation experiment. FTS5 trigram supports substring recall but rejects useful terms shorter than three characters and increases index size; the proposal must benchmark that tradeoff against the default Unicode tokenizer before making it contract. - -## Extraction and reconciliation - -The package starts with first-party semantic extraction for messages, reasoning, tool calls/results, blocked prompts, context, steering, todos, and error/status detail. Structural events and stream chunks contribute no document. Unknown declaration-merged event/content types remain non-searchable unless a real extension consumer demonstrates the need for a public extractor registry. - -Reconciliation may use stable fingerprints to avoid rewriting unchanged persisted sessions, but the database package owns their calculation and storage. It must never report a row current when source observation or extraction failed. Provider-schema mismatch may reset only the derived database; ordinary source changes use transactional upsert/delete. Mounted but unreadable persistence fails affected searches without affecting canonical writes or known live exact reads. - -## Alternatives considered - -- **Add FTS tables to the canonical persistence database** — rejected because a rebuildable index must not share the authoritative log's schema/reset/failure boundary. -- **Reintroduce phase-one provider coordination** — rejected because there is one planned implementation and no evidence for a stable multi-provider seam. -- **Persist live overrides immediately** — rejected because live events are not canonical until the existing checkpoint commits. -- **Return BM25 scores** — rejected because provider-specific numeric scales are unstable across corpus changes. - -## Acceptance criteria - -- Restart tests cover unchanged, new, changed, and deleted persisted sessions without rebuilding the whole index. -- Reopening preserves persisted rows and removes live rows; live rows shadow and then reveal their persisted base. -- Tests cover both search scopes, metadata filters, surface defaults, snippets, escaping, deterministic ties, pagination, scoped stale cursors, cancellation, dynamic persistence mount/unmount, and recovery after a failed transaction. -- A schema mismatch resets only the derived database. -- A keyless end-to-end test combines a real persistence backend with the real SQLite search package. -- The RFC is amended to the measured tokenizer and public API actually implemented before moving to `implemented/`. - -## Risks - -A single owner is simpler but initially less reusable than a provider-neutral seam. That is intentional: a second real backend can reveal what to extract. SQLite runtime differences can affect FTS ranking and snippets, so tests must pin only contract-controlled ordering and presentation. The separate database adds configuration and lifecycle work, but preserves the canonical store's safety boundary. diff --git a/packages/README.md b/packages/README.md index d3dd6818df..f494b12222 100644 --- a/packages/README.md +++ b/packages/README.md @@ -25,7 +25,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | -| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface | +| [`session-query/`](session-query/README.md) | Session retrieval family: exact reads, semantic filtering, and SQLite full-text search | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface | | [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded` primitive) | Support — small, stable, harness-dep-free | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 06bf895e96..2859353cfa 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -157,6 +157,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ 'listSessions(): Promise', 'async listEvents(sessionId: SessionId): Promise', + 'async filterEvents( sessionId: SessionId, filters: readonly SessionEventResultFilter[], ): Promise', 'async readEvent(request: SessionEventReadRequest): Promise', ], }, @@ -174,6 +175,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session', ], }, + { + key: 'sessionSearch', + summary: 'Abstract full-text search service implemented by one concrete backend.', + methods: [ + 'abstract searchSessions( request: SessionSearchRequest, exec?: SessionSearchExecContext, ): Promise>', + 'abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise>', + ], + }, { key: 'skills', summary: 'Registry of skill providers.', @@ -787,6 +796,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SendOptions', declaration: 'export interface SendOptions {\n source?: MessageSource;\n}', }, + { + name: 'SessionAvailability', + declaration: 'export type SessionAvailability = \'live\' | \'persisted\';', + }, { name: 'SessionEvent', declaration: 'export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', @@ -795,6 +808,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventMap', declaration: 'export interface SessionEventMap {\n \'turn/start\': {\n turn: number;\n trigger: TurnTrigger;\n };\n \'turn/end\': {\n turn: number;\n reason: TurnEndReason;\n };\n \'step/start\': {\n turn: number;\n step: number;\n };\n \'step/end\': {\n turn: number;\n step: number;\n };\n \'user/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'prompt/blocked\': {\n content: ContentBlock[];\n source: MessageSource;\n reason: string;\n };\n \'context/message\': {\n content: ContentBlock[];\n source: MessageSource;\n };\n \'assistant/chunk\': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n \'assistant/message\': {\n turn: number;\n step: number;\n content: ContentBlock[];\n usage?: TokenUsage;\n };\n \'tool/call\': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n \'tool/result\': {\n turn: number;\n step: number;\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: {\n name: string;\n code: string;\n };\n meta?: unknown;\n };\n \'steering/message\': {\n turn: number;\n content: ContentBlock[];\n source: MessageSource;\n };\n \'todo/write\': {\n todos: TodoItem[];\n };\n \'request/header\': {\n header: E /* …truncated — full shape in source */', }, + { + name: 'SessionEventMetadataFilter', + declaration: 'export type SessionEventMetadataFilter = Exclude;', + }, { name: 'SessionEventReadRequest', declaration: 'export interface SessionEventReadRequest {\n sessionId: SessionId;\n seq: number;\n before?: number;\n after?: number;\n}', @@ -803,6 +820,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventRecord', declaration: 'export interface SessionEventRecord {\n sessionId: SessionId;\n seq: number;\n type: SessionEventType;\n time: number;\n surface: SessionEventSurface;\n}', }, + { + name: 'SessionEventResultFilter', + declaration: 'export type SessionEventResultFilter = ({\n kind: \'seq\';\n} & SessionResultRange) | ({\n kind: \'time\';\n} & SessionResultRange) | {\n kind: \'type\';\n values: readonly SessionEventType[];\n} | {\n kind: \'surface\';\n values: readonly SessionEventSurface[];\n} | {\n kind: \'text\';\n text: string;\n};', + }, + { + name: 'SessionEventSearchDocument', + declaration: 'export interface SessionEventSearchDocument extends SessionEventRecord {\n text: string;\n}', + }, + { + name: 'SessionEventSearchHit', + declaration: 'export interface SessionEventSearchHit extends SessionEventRecord {\n snippet: string;\n}', + }, + { + name: 'SessionEventSearchRequest', + declaration: 'export interface SessionEventSearchRequest {\n sessionId: SessionId;\n query: string;\n filters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: string;\n}', + }, { name: 'SessionEventSurface', declaration: 'export type SessionEventSurface = \'current\' | \'shadowed\' | \'log-only\';', @@ -831,6 +864,30 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionRecord', declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}', }, + { + name: 'SessionResultFilter', + declaration: 'export type SessionResultFilter = {\n kind: \'id\';\n values: readonly SessionId[];\n} | {\n kind: \'cwd\';\n values: readonly (string | null)[];\n} | ({\n kind: \'created-at\';\n} & SessionResultRange) | {\n kind: \'parent\';\n values: readonly (SessionId | null)[];\n} | {\n kind: \'availability\';\n values: readonly SessionAvailability[];\n};', + }, + { + name: 'SessionResultRange', + declaration: 'export interface SessionResultRange {\n from?: number;\n to?: number;\n}', + }, + { + name: 'SessionSearchExecContext', + declaration: 'export interface SessionSearchExecContext {\n signal?: AbortSignal;\n}', + }, + { + name: 'SessionSearchHit', + declaration: 'export interface SessionSearchHit extends SessionRecord {\n bestMatch: SessionEventSearchHit;\n}', + }, + { + name: 'SessionSearchPage', + declaration: 'export interface SessionSearchPage {\n items: readonly T[];\n nextCursor?: string;\n}', + }, + { + name: 'SessionSearchRequest', + declaration: 'export interface SessionSearchRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: string;\n}', + }, { name: 'SkillCandidate', declaration: 'export interface SkillCandidate extends SkillSummary {\n readonly rank: number;\n readonly locator: unknown;\n readonly path?: string;\n readonly metadata?: Readonly>;\n}', diff --git a/packages/session-query/README.md b/packages/session-query/README.md index 8b0c06a30c..0622858294 100644 --- a/packages/session-query/README.md +++ b/packages/session-query/README.md @@ -1,9 +1,10 @@ # session-query/ — session retrieval capability family -Trusted exact reads over live and durable session logs. Phase one contains one interface package that owns `ctx.sessionQuery`, logical-corpus precedence, surface classification, and bounded event reads. +Trusted exact reads, provider-independent semantic filtering, and SQLite full-text search over live and durable session logs. | Package | Role | ctx key | |---|---|---| -| [`session-query/`](session-query/README.md) | Logical-corpus and exact-event read service | `ctx.sessionQuery` | +| [`session-query/`](session-query/README.md) | Logical-corpus reads, semantic extraction/filtering, and the abstract search seam | `ctx.sessionQuery`, `ctx.sessionSearch` | +| [`session-query-sqlite/`](session-query-sqlite/README.md) | SQLite FTS5 search with persistent bases and live overlays | `ctx.sessionSearch` | -The family is independent of compaction: it reads the canonical session log but does not participate in compaction policy or execution. Full-text search remains proposed as a phase-two SQLite package rather than a speculative provider seam in this interface package. +The family is independent of compaction: it reads the canonical session log but does not participate in compaction policy or execution. Search uses one abstract service and one concrete owner, not a provider registry or coordinator. diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md new file mode 100644 index 0000000000..1f3344887f --- /dev/null +++ b/packages/session-query/session-query-sqlite/README.md @@ -0,0 +1,35 @@ +# @deepseek-ai/dsh-session-query-sqlite + +SQLite FTS5 implementation of `ctx.sessionSearch`. The service searches the live-preferred logical session corpus, groups cross-session results by their strongest event, and keeps provider-specific BM25 scores private. + +## Search contract + +`searchSessions(request, exec?)` returns `SessionSearchHit` pages across the corpus; `searchEvents(request, exec?)` returns `SessionEventSearchHit` pages within one session. Queries are required, trimmed, whitespace-normalized literal phrases. FTS5 syntax such as quotes, `OR`, `NEAR`, and `*` is treated as data rather than executable MATCH syntax. Metadata filters are parameterized SQL predicates applied before ranking. + +Ordering is deterministic: relevance first, then event time, session id where applicable, and seq. Cross-session results expose the selected event as `bestMatch`; both scopes return plain-text snippets bounded in Unicode code points. Cursors are opaque, bind to the normalized request and service instance, and fail when the relevant generation changes. A within-session cursor survives unrelated-session changes; a cross-session cursor does not. + +All three surfaces (`current`, `shadowed`, and `log-only`) are searchable by default. Pass a surface filter to narrow them. + +## Source and index lifecycle + +The service requires `ctx.sessions` and observes optional `ctx.sessionPersistence` dynamically. One serialized state machine observes complete sources, extracts shared semantic documents, reconciles changes transactionally, and runs the query. Stable fingerprints preserve unchanged persisted rows and generations; new, changed, and deleted durable sessions reconcile on the next search. Source or transaction failure commits nothing, and the next search retries. + +Persisted FTS rows live in a dedicated derived database. Connection-local TEMP tables hold live rows, which shadow the durable base for the same session and reveal it when the live owner disappears. Unmounting persistence hides durable rows without discarding the cache; remounting reconciles it. Closing or reopening the database drops every live overlay while retaining persisted rows. + +The database is disposable but reset is guarded: a recognized incompatible search schema rebuilds in place, while an unrelated or canonical database is refused. Never point `path` at the session-persistence database. + +## Configuration + +| Key | Default | Contract | +|---|---:|---| +| `path` | required | Dedicated derived-index SQLite path; `:memory:` is supported. | +| `journalMode` | `wal` | `wal`, `delete`, `truncate`, or `persist`. | +| `defaultLimit` | `20` | Page size when a request omits `limit`. | +| `maxLimit` | `100` | Largest accepted request page size. | +| `snippetChars` | `240` | Maximum snippet length in Unicode code points. | + +## Tokenizer and limits + +The index uses FTS5 `unicode61`. In the implementation experiment it supported the two-character query `AI` and produced an index about 2.1× smaller than the trigram alternative. The trade-off is token/phrase recall rather than arbitrary substring recall: `AI` does not match the token `BRAID`. Use `ctx.sessionQuery.filterEvents()` with a `text` clause when a literal whitespace-flexible substring scan is required. + +Abort signals stop queued work and caller waits around asynchronous source observation. Node's synchronous `DatabaseSync` API cannot interrupt a MATCH statement already executing on the JavaScript thread; the signal is checked immediately before and after the serialized observation/reconciliation boundary. diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json new file mode 100644 index 0000000000..de5677fd68 --- /dev/null +++ b/packages/session-query/session-query-sqlite/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-session-query-sqlite", + "description": "SQLite FTS5 implementation of ctx.sessionSearch", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-session-query": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-session-persistence": { + "optional": true + } + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts new file mode 100644 index 0000000000..a6cba8d866 --- /dev/null +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -0,0 +1,765 @@ +/** + * SQLite FTS5 search over the live-preferred logical session corpus. + * + * @module @deepseek-ai/dsh-session-query-sqlite + */ + +import { createHash, randomUUID } from 'node:crypto' +import { DatabaseSync } from 'node:sqlite' +import { Context } 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 { + SessionQueryError, + SessionSearchService, + assertSessionHeadersCompatible, + buildSessionEventSearchDocuments, +} from '@deepseek-ai/dsh-session-query' +import type { + SessionEventSearchDocument, + SessionEventSearchHit, + SessionEventSearchRequest, + SessionSearchExecContext, + SessionSearchHit, + SessionSearchPage, + SessionSearchRequest, +} from '@deepseek-ai/dsh-session-query' +import { + type JournalMode, + openSearchDatabase, +} from './schema.ts' +import { + type NormalizedEventRequest, + type NormalizedSessionRequest, + buildEventWhere, + buildSessionWhere, + makeSnippet, + normalizeEventRequest, + normalizeSessionRequest, + quoteFtsData, + requestFingerprint, +} from './query.ts' + +export { + SESSION_QUERY_SQLITE_APPLICATION_ID, + SESSION_QUERY_SQLITE_SCHEMA_VERSION, + type JournalMode, +} from './schema.ts' + +/** Default result page size. */ +export const SESSION_QUERY_SQLITE_DEFAULT_LIMIT = 20 +/** Maximum accepted result page size. */ +export const SESSION_QUERY_SQLITE_MAX_LIMIT = 100 +/** Default maximum snippet length in Unicode code points. */ +export const SESSION_QUERY_SQLITE_SNIPPET_CHARS = 240 + +/** SQLite session-search configuration. */ +export interface Config { + /** Dedicated derived-index path; `:memory:` is supported for tests. */ + path: string + /** SQLite journal mode. Defaults to `wal`. */ + journalMode?: JournalMode + /** Page size when a request omits `limit`. Defaults to 20. */ + defaultLimit?: number + /** Largest accepted page size. Defaults to 100. */ + maxLimit?: number + /** Maximum snippet length in Unicode code points. Defaults to 240. */ + snippetChars?: number +} + +interface ResolvedConfig { + path: string + journalMode: JournalMode + defaultLimit: number + maxLimit: number + snippetChars: number +} + +interface ObservedSession { + header: SessionHeader + events: SessionEvent[] + documents: SessionEventSearchDocument[] + fingerprint: string +} + +interface Observation { + persistence: SessionPersistence | undefined + persistenceRevision: number + persisted: Map + live: Map +} + +interface IndexedRow { + id: string + fingerprint: string + generation: number +} + +interface SearchRow { + session_id: string + version: number + created_at: number + cwd: string | null + parent_session: string | null + seed_length: number | null + live: number + persisted: number + seq: number + type: string + time: number + surface: string + text: string + score: number +} + +interface CursorPayload { + version: 1 + instance: string + scope: 'sessions' | 'events' + fingerprint: string + generation: string + offset: number +} + +/** Concrete SQLite owner of `ctx.sessionSearch`. */ +export class SessionSearchSqlite extends SessionSearchService { + static inject = ['sessions'] + + static Config: z = z.object({ + path: z.string().required(), + journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'), + defaultLimit: z.number().step(1).min(1).default(SESSION_QUERY_SQLITE_DEFAULT_LIMIT), + maxLimit: z.number().step(1).min(1).default(SESSION_QUERY_SQLITE_MAX_LIMIT), + snippetChars: z.number().step(1).min(1).default(SESSION_QUERY_SQLITE_SNIPPET_CHARS), + }) + + /** Validated and defaulted backend configuration. */ + readonly config: ResolvedConfig + + private readonly _instance = randomUUID() + private readonly _ready: Promise + private _db: DatabaseSync | undefined + private _persistence: SessionPersistence | undefined + private _persistenceBinding: object | undefined + private _persistenceRevision = 0 + private _lastPersistenceRevision: number | undefined + private _persistenceEpoch = 0 + private _globalGeneration = 0 + private _localGeneration = 0 + private _tail: Promise = Promise.resolve() + private _closed = false + + 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 + 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.optionalPersistence') + ctx.effect(() => async () => this.close(), 'sessionSearchSqlite.close') + } + + override async searchSessions( + request: SessionSearchRequest, + exec?: SessionSearchExecContext, + ): Promise> { + 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 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({ + version: 1, + instance: this._instance, + scope: 'sessions', + fingerprint, + generation, + offset: cursorOffset, + }), offset) + }) + } + + override async searchEvents( + request: SessionEventSearchRequest, + exec?: SessionSearchExecContext, + ): Promise> { + 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 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({ + version: 1, + instance: this._instance, + scope: 'events', + fingerprint, + generation, + offset: cursorOffset, + }), offset) + }) + } + + /** Close the database after every accepted operation reaches quiescence. */ + async close(): Promise { + if (this._closed) return + this._closed = true + await this._tail + try { + await this._ready + } catch { + // Opening already closed a partially-created handle; disposal only waits. + } + this._db?.close() + this._db = undefined + } + + private async _open(): Promise { + this._db = await openSearchDatabase(this.config.path, this.config.journalMode) + const state = this._db.prepare( + 'SELECT global_generation FROM search_state WHERE singleton = 1', + ).get() as { global_generation: number } + this._globalGeneration = state.global_generation + this._localGeneration = state.global_generation + } + + private async _ensureReady(signal: AbortSignal | undefined): Promise { + try { + await waitWithAbort(this._ready, signal) + } catch (error: unknown) { + if (isAbort(error)) throw error + throw new SessionQueryError( + `session-search SQLite index failed to open: ${errorMessage(error)}`, + 'SESSION_QUERY_INDEX_FAILED', + { cause: error }, + ) + } + } + + private async _serialized(signal: AbortSignal | undefined, operation: () => Promise): Promise { + if (this._isClosed()) throw indexClosed() + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const prior = this._tail + this._tail = prior.then(() => gate) + try { + await waitWithAbort(prior, signal) + } catch (error: unknown) { + release() + throw error + } + if (this._isClosed()) { + release() + throw indexClosed() + } + try { + assertNotAborted(signal) + return await operation() + } finally { + release() + } + } + + private async _reconcile(signal: AbortSignal | undefined): Promise { + 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[] + const liveRows = db.prepare( + 'SELECT id, fingerprint, generation FROM temp.live_sessions', + ).all() as unknown as IndexedRow[] + 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 persistentChanges = observation.persistence === undefined + ? [] + : [...observation.persisted.values()].filter(entry => persistedById.get(entry.header.id)?.fingerprint !== entry.fingerprint) + const persistentDeletes = observation.persistence === undefined + ? [] + : persistedRows.filter(row => !observation.persisted.has(row.id as SessionId)) + const liveChanges = [...observation.live.values()].filter(entry => liveById.get(entry.header.id)?.fingerprint !== entry.fingerprint) + const liveDeletes = liveRows.filter(row => !observation.live.has(row.id as SessionId)) + const pointerChanged = this._lastPersistenceRevision !== undefined + && this._lastPersistenceRevision !== observation.persistenceRevision + const hasWrites = persistentChanges.length > 0 + || persistentDeletes.length > 0 + || liveChanges.length > 0 + || liveDeletes.length > 0 + + let nextMainGeneration = this._mainGeneration() + let nextLocalGeneration = this._localGeneration + if (persistentChanges.length > 0 || persistentDeletes.length > 0) nextMainGeneration += 1 + const liveReplacements = liveChanges.map((entry) => { + nextLocalGeneration = Math.max(nextLocalGeneration, nextMainGeneration) + 1 + return { entry, generation: nextLocalGeneration } + }) + + if (hasWrites) { + let began = false + try { + 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) + 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) + } + db.exec('COMMIT') + } catch (error: unknown) { + /* v8 ignore next -- a BEGIN failure has no transaction to roll back; the common wrapper still reports it. */ + if (began) { + /* v8 ignore next 5 -- ROLLBACK failure requires a SQLite double fault; the original failure remains actionable. */ + try { + db.exec('ROLLBACK') + } catch { + // The original SQLite failure remains the actionable cause. + } + } + throw new SessionQueryError( + `session-search reconciliation failed: ${errorMessage(error)}`, + 'SESSION_QUERY_INDEX_FAILED', + { cause: error }, + ) + } + } + + if (hasWrites || pointerChanged) this._globalGeneration += 1 + if (pointerChanged) this._persistenceEpoch += 1 + this._localGeneration = nextLocalGeneration + this._lastPersistenceRevision = observation.persistenceRevision + } + + private async _observeStable(signal: AbortSignal | undefined): Promise { + for (;;) { + assertNotAborted(signal) + const persistence = this._persistence + const persistenceRevision = this._persistenceRevision + const persisted = new Map() + 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)) + } + } catch (error: unknown) { + if (error instanceof SessionQueryError) throw error + throw new SessionQueryError( + `session-search persistence observation failed: ${errorMessage(error)}`, + 'SESSION_QUERY_PERSISTENCE_FAILED', + { cause: error }, + ) + } + } + const live = new Map() + for (const session of this.ctx.sessions.list()) { + const observed = observeLive(session) + const durable = persisted.get(session.id) + if (durable !== undefined) assertSessionHeadersCompatible(observed.header, durable.header) + live.set(session.id, observed) + } + if (this._persistenceRevision === persistenceRevision) { + return { persistence, persistenceRevision, persisted, live } + } + } + } + + private _mainGeneration(): number { + const row = this._requireDb().prepare( + 'SELECT global_generation FROM search_state WHERE singleton = 1', + ).get() as { global_generation: number } + return row.global_generation + } + + private _deleteSession(source: 'persisted' | 'live', id: SessionId): void { + const db = this._requireDb() + if (source === 'persisted') { + db.prepare('DELETE FROM persisted_docs WHERE session_id = ?').run(id) + db.prepare('DELETE FROM persisted_sessions WHERE id = ?').run(id) + } else { + db.prepare('DELETE FROM temp.live_docs WHERE session_id = ?').run(id) + db.prepare('DELETE FROM temp.live_sessions WHERE id = ?').run(id) + } + } + + private _replaceSession(source: 'persisted' | 'live', entry: ObservedSession, generation: number): void { + this._deleteSession(source, 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} + (id, version, created_at, cwd, parent_session, seed_length, fingerprint, generation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `).run( + entry.header.id, + entry.header.version, + entry.header.createdAt, + entry.header.cwd ?? null, + entry.header.parentSession ?? null, + entry.header.seedLength ?? null, + entry.fingerprint, + generation, + ) + const insert = db.prepare(` + INSERT INTO ${docsTable} (text, session_id, seq, type, time, surface) + VALUES (?, ?, ?, ?, ?, ?) + `) + for (const document of entry.documents) { + insert.run(document.text, document.sessionId, document.seq, document.type, document.time, document.surface) + } + } + + private _querySessions(request: NormalizedSessionRequest, offset: number): SearchRow[] { + const selected = selectedDocumentsSql() + const sessionWhere = buildSessionWhere(request.sessionFilters) + const eventWhere = buildEventWhere(request.eventFilters) + const where = [sessionWhere.sql, eventWhere.sql].filter(Boolean).join(' AND ') + return this._requireDb().prepare(` + ${selected.sql}, + filtered AS ( + SELECT * FROM matched ${where.length === 0 ? '' : `WHERE ${where}`} + ), + ranked AS ( + SELECT *, ROW_NUMBER() OVER ( + PARTITION BY session_id + ORDER BY score 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 + LIMIT ? OFFSET ? + `).all( + quoteFtsData(request.query), + this._persistence === undefined ? 0 : 1, + this._persistence === undefined ? 0 : 1, + quoteFtsData(request.query), + ...sessionWhere.params, + ...eventWhere.params, + request.limit + 1, + offset, + ) as unknown as SearchRow[] + } + + private _queryEvents(request: NormalizedEventRequest, offset: number): SearchRow[] { + const selected = selectedDocumentsSql() + const eventWhere = buildEventWhere(request.filters) + const where = ['session_id = ?', eventWhere.sql].filter(Boolean).join(' AND ') + return this._requireDb().prepare(` + ${selected.sql} + SELECT * FROM matched + WHERE ${where} + ORDER BY score ASC, time DESC, seq DESC + LIMIT ? OFFSET ? + `).all( + quoteFtsData(request.query), + this._persistence === undefined ? 0 : 1, + this._persistence === undefined ? 0 : 1, + quoteFtsData(request.query), + request.sessionId, + ...eventWhere.params, + request.limit + 1, + offset, + ) as unknown as SearchRow[] + } + + private _targetGeneration(sessionId: SessionId): string { + const db = this._requireDb() + const live = db.prepare( + 'SELECT generation FROM temp.live_sessions WHERE id = ?', + ).get(sessionId) as { generation: number } | undefined + if (live !== undefined) return `live:${live.generation}` + if (this._persistence !== undefined) { + const persisted = db.prepare( + 'SELECT generation FROM persisted_sessions WHERE id = ?', + ).get(sessionId) as { generation: number } | undefined + if (persisted !== undefined) return `persisted:${this._persistenceEpoch}:${persisted.generation}` + } + throw new SessionQueryError( + `session "${sessionId}" not found`, + 'SESSION_QUERY_SESSION_NOT_FOUND', + ) + } + + private _sessionHit(row: SearchRow, query: string): SessionSearchHit { + return { + header: rowHeader(row), + live: row.live === 1, + persisted: row.persisted === 1, + bestMatch: this._eventHit(row, query), + } + } + + private _eventHit(row: SearchRow, query: string): 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), + } + } + + private _requireDb(): DatabaseSync { + /* v8 ignore next -- callers await `_ready`; this guards lifecycle misuse */ + if (this._db === undefined) throw indexClosed() + return this._db + } + + private _isClosed(): boolean { + return this._closed + } +} + +function selectedDocumentsSql(): { sql: string } { + return { + sql: `WITH matched AS ( + SELECT + pd.session_id AS session_id, + ps.version AS version, + ps.created_at AS created_at, + ps.cwd AS cwd, + ps.parent_session AS parent_session, + ps.seed_length AS seed_length, + 0 AS live, + 1 AS persisted, + CAST(pd.seq AS INTEGER) AS seq, + pd.type AS type, + CAST(pd.time AS INTEGER) AS time, + pd.surface AS surface, + pd.text AS text, + bm25(persisted_docs) AS score + FROM persisted_docs AS pd + JOIN persisted_sessions AS ps ON ps.id = pd.session_id + WHERE persisted_docs MATCH ? + AND ? = 1 + AND NOT EXISTS (SELECT 1 FROM temp.live_sessions AS ls WHERE ls.id = pd.session_id) + UNION ALL + SELECT + ld.session_id AS session_id, + ls.version AS version, + ls.created_at AS created_at, + ls.cwd AS cwd, + ls.parent_session AS parent_session, + ls.seed_length AS seed_length, + 1 AS live, + CASE WHEN ? = 1 AND EXISTS ( + SELECT 1 FROM persisted_sessions AS ps WHERE ps.id = ld.session_id + ) THEN 1 ELSE 0 END AS persisted, + CAST(ld.seq AS INTEGER) AS seq, + ld.type AS type, + CAST(ld.time AS INTEGER) AS time, + ld.surface AS surface, + ld.text AS text, + bm25(live_docs) AS score + FROM temp.live_docs AS ld + JOIN temp.live_sessions AS ls ON ls.id = ld.session_id + WHERE live_docs MATCH ? + )`, + } +} + +function observeLive(session: Session): ObservedSession { + return observeSession( + structuredClone(session.header), + session.events.map(event => structuredClone(event)), + ) +} + +function observeSession(header: SessionHeader, events: readonly SessionEvent[]): ObservedSession { + const detachedHeader = structuredClone(header) + 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 })) + .digest('base64url'), + } +} + +function rowHeader(row: SearchRow): SessionHeader { + return { + version: row.version, + id: row.session_id as SessionId, + createdAt: row.created_at, + ...row.cwd === null ? {} : { cwd: row.cwd }, + ...row.parent_session === null ? {} : { parentSession: row.parent_session as SessionId }, + ...row.seed_length === null ? {} : { seedLength: row.seed_length }, + } +} + +function page( + rows: readonly Row[], + limit: number, + convert: (row: Row) => Item, + nextCursor: (offset: number) => string, + offset: number, +): SessionSearchPage { + const hasMore = rows.length > limit + return { + items: rows.slice(0, limit).map(convert), + ...hasMore ? { nextCursor: nextCursor(offset + limit) } : {}, + } +} + +function encodeCursor(payload: CursorPayload): string { + return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') +} + +function decodeCursor( + cursor: string, + instance: string, + scope: CursorPayload['scope'], + fingerprint: string, + generation: string, +): number { + let decoded: Partial + try { + decoded = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as Partial + } catch (error: unknown) { + throw invalidCursor(error) + } + if ( + decoded.version !== 1 + || decoded.instance !== instance + || decoded.scope !== scope + || decoded.fingerprint !== fingerprint + || !Number.isInteger(decoded.offset) + || decoded.offset === undefined + || decoded.offset < 0 + ) { + throw invalidCursor(new Error('cursor does not belong to this normalized request')) + } + if (decoded.generation !== generation) { + throw new SessionQueryError( + 'session-search cursor is stale because its relevant corpus changed', + 'SESSION_QUERY_STALE_CURSOR', + ) + } + return decoded.offset +} + +function invalidCursor(cause: unknown): SessionQueryError { + return new SessionQueryError( + 'session-search cursor is invalid', + 'SESSION_QUERY_INVALID_CURSOR', + { cause }, + ) +} + +function resolveConfig(config: Config): ResolvedConfig { + const resolved: ResolvedConfig = { + path: config.path, + journalMode: config.journalMode ?? 'wal', + defaultLimit: config.defaultLimit ?? SESSION_QUERY_SQLITE_DEFAULT_LIMIT, + maxLimit: config.maxLimit ?? SESSION_QUERY_SQLITE_MAX_LIMIT, + snippetChars: config.snippetChars ?? SESSION_QUERY_SQLITE_SNIPPET_CHARS, + } + if (typeof resolved.path !== 'string' || resolved.path.trim().length === 0) { + throw invalidConfig('path must not be blank') + } + assertPositiveInteger('defaultLimit', resolved.defaultLimit) + assertPositiveInteger('maxLimit', resolved.maxLimit) + assertPositiveInteger('snippetChars', resolved.snippetChars) + if (resolved.defaultLimit > resolved.maxLimit) { + throw invalidConfig('defaultLimit must be less than or equal to maxLimit') + } + const journalModes: readonly string[] = ['wal', 'delete', 'truncate', 'persist'] + if (!journalModes.includes(resolved.journalMode)) throw invalidConfig('journalMode is not supported') + return resolved +} + +function assertPositiveInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 1) throw invalidConfig(`${name} must be a positive integer`) +} + +function invalidConfig(detail: string): SessionQueryError { + return new SessionQueryError( + `session-search SQLite config: ${detail}`, + 'SESSION_QUERY_INVALID_CONFIG', + ) +} + +function indexClosed(): SessionQueryError { + return new SessionQueryError('session-search SQLite index is closed', 'SESSION_QUERY_INDEX_FAILED') +} + +function assertNotAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new SessionQueryError('session-search aborted', 'SESSION_QUERY_ABORTED') + } +} + +function waitWithAbort(promise: Promise, signal: AbortSignal | undefined): Promise { + if (signal === undefined) return promise + if (signal.aborted) return Promise.reject(new SessionQueryError('session-search aborted', 'SESSION_QUERY_ABORTED')) + return new Promise((resolve, reject) => { + const onAbort = () => { + reject(new SessionQueryError('session-search aborted', 'SESSION_QUERY_ABORTED')) + } + signal.addEventListener('abort', onAbort, { once: true }) + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort) + reject(asError(error)) + }, + ) + }) +} + +function isAbort(error: unknown): boolean { + return error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED' +} + +function asError(error: unknown): Error { + return error instanceof Error + ? error + : new Error('session-search dependency rejected with a non-Error value', { cause: error }) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'unknown error' +} + +export default SessionSearchSqlite diff --git a/packages/session-query/session-query-sqlite/src/query.ts b/packages/session-query/session-query-sqlite/src/query.ts new file mode 100644 index 0000000000..fd2b5156e6 --- /dev/null +++ b/packages/session-query/session-query-sqlite/src/query.ts @@ -0,0 +1,312 @@ +/** Request normalization, parameterized predicates, and result presentation. */ + +import { + SessionQueryError, + filterSessionEventDocuments, + filterSessionResults, +} from '@deepseek-ai/dsh-session-query' +import type { + SessionEventMetadataFilter, + SessionEventSearchRequest, + SessionResultFilter, + SessionSearchRequest, +} from '@deepseek-ai/dsh-session-query' + +/** Limit defaults needed to normalize a search request. */ +export interface QueryLimits { + /** Page size used when the request omits one. */ + defaultLimit: number + /** Largest accepted page size. */ + maxLimit: number +} + +/** Normalized cross-session request. */ +export interface NormalizedSessionRequest { + query: string + sessionFilters: readonly SessionResultFilter[] + eventFilters: readonly SessionEventMetadataFilter[] + limit: number + cursor?: string +} + +/** Normalized within-session request. */ +export interface NormalizedEventRequest { + sessionId: SessionEventSearchRequest['sessionId'] + query: string + filters: readonly SessionEventMetadataFilter[] + limit: number + cursor?: string +} + +/** Parameterized SQL predicate fragment. */ +export interface SqlWhere { + /** SQL without the leading `WHERE`. */ + sql: string + /** Bindings in placeholder order. */ + params: Array +} + +/** + * Validate and canonicalize a cross-session request. + * @param request - caller-provided query, filters, limit, and cursor. + * @param limits - configured default and maximum page sizes. + * @returns normalized request with explicit arrays and limit. + */ +export function normalizeSessionRequest( + request: SessionSearchRequest, + limits: QueryLimits, +): NormalizedSessionRequest { + const sessionFilters = request.sessionFilters ?? [] + const eventFilters = request.eventFilters ?? [] + filterSessionResults([], sessionFilters) + filterSessionEventDocuments([], eventFilters) + return { + query: normalizeQuery(request.query), + sessionFilters, + eventFilters, + limit: normalizeLimit(request.limit, limits), + ...request.cursor === undefined ? {} : { cursor: request.cursor }, + } +} + +/** + * Validate and canonicalize a within-session request. + * @param request - caller-provided target, query, filters, limit, and cursor. + * @param limits - configured default and maximum page sizes. + * @returns normalized request with an explicit filter array and limit. + */ +export function normalizeEventRequest( + request: SessionEventSearchRequest, + limits: QueryLimits, +): NormalizedEventRequest { + const filters = request.filters ?? [] + filterSessionEventDocuments([], filters) + return { + sessionId: request.sessionId, + query: normalizeQuery(request.query), + filters, + limit: normalizeLimit(request.limit, limits), + ...request.cursor === undefined ? {} : { cursor: request.cursor }, + } +} + +/** + * Compile logical-session predicates against selected-document columns. + * @param filters - validated ANDed logical-session clauses. + * @returns parameterized SQL fragment and ordered bindings. + */ +export function buildSessionWhere(filters: readonly SessionResultFilter[]): SqlWhere { + const clauses: string[] = [] + const params: Array = [] + for (const filter of filters) { + switch (filter.kind) { + case 'id': + addList(clauses, params, 'session_id', filter.values) + break + case 'cwd': + addNullableList(clauses, params, 'cwd', filter.values) + break + case 'created-at': + addRange(clauses, params, 'created_at', filter) + break + case 'parent': + addNullableList(clauses, params, 'parent_session', filter.values) + break + 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`) + break + } + } + } + return { sql: clauses.join(' AND '), params } +} + +/** + * Compile event metadata predicates against selected-document columns. + * @param filters - validated ANDed event metadata clauses. + * @returns parameterized SQL fragment and ordered bindings. + */ +export function buildEventWhere(filters: readonly SessionEventMetadataFilter[]): SqlWhere { + const clauses: string[] = [] + const params: Array = [] + for (const filter of filters) { + switch (filter.kind) { + case 'seq': + addRange(clauses, params, 'seq', filter) + break + case 'time': + addRange(clauses, params, 'time', filter) + break + case 'type': + addList(clauses, params, 'type', filter.values) + break + case 'surface': + addList(clauses, params, 'surface', filter.values) + break + } + } + return { sql: clauses.join(' AND '), params } +} + +/** + * Quote caller text as one FTS5 phrase so query syntax remains inert data. + * @param query - normalized caller query. + * @returns FTS5 expression containing one escaped literal phrase. + */ +export function quoteFtsData(query: string): string { + return `"${query.replaceAll('"', '""')}"` +} + +/** + * Build the stable normalized request identity stored in opaque cursors. + * @param request - normalized request whose filter ordering is canonicalized. + * @returns deterministic JSON identity for cursor binding. + */ +export function requestFingerprint(request: NormalizedSessionRequest | NormalizedEventRequest): string { + if ('sessionId' in request) { + return JSON.stringify({ + scope: 'events', + sessionId: request.sessionId, + query: request.query, + filters: canonicalFilters(request.filters), + limit: request.limit, + }) + } + return JSON.stringify({ + scope: 'sessions', + query: request.query, + sessionFilters: canonicalFilters(request.sessionFilters), + eventFilters: canonicalFilters(request.eventFilters), + limit: request.limit, + }) +} + +/** + * 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 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() + 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 prefix = start > 0 ? '…' : '' + let suffix = '…' + let contentLength = maxChars - prefix.length - suffix.length + if (contentLength < 1) { + start = 0 + prefix = '' + contentLength = maxChars - 1 + } + let end = Math.min(characters.length, start + contentLength) + if (end === characters.length) { + suffix = '' + contentLength = maxChars - prefix.length + start = Math.max(0, end - contentLength) + } + end = Math.min(characters.length, start + contentLength) + return `${prefix}${characters.slice(start, end).join('')}${suffix}` +} + +function normalizeQuery(value: string): string { + if (typeof value !== 'string') { + throw new SessionQueryError('session-search query must be text', 'SESSION_QUERY_INVALID_QUERY') + } + const query = value.trim().replace(/\s+/gu, ' ') + if (query.length === 0) { + throw new SessionQueryError( + 'session-search query must contain non-whitespace text', + 'SESSION_QUERY_INVALID_QUERY', + ) + } + return query +} + +function normalizeLimit(value: number | undefined, limits: QueryLimits): number { + const limit = value ?? limits.defaultLimit + if (!Number.isInteger(limit) || limit < 1 || limit > limits.maxLimit) { + throw new SessionQueryError( + `session-search limit must be an integer between 1 and ${limits.maxLimit}`, + 'SESSION_QUERY_INVALID_LIMIT', + ) + } + return limit +} + +function addList( + clauses: string[], + params: Array, + column: string, + values: readonly (string | number)[], +): void { + if (values.length === 0) { + clauses.push('0') + return + } + clauses.push(`${column} IN (${values.map(() => '?').join(', ')})`) + params.push(...values) +} + +function addNullableList( + clauses: string[], + params: Array, + column: string, + values: readonly (string | null)[], +): void { + if (values.length === 0) { + clauses.push('0') + return + } + const concrete = values.filter((value): value is string => value !== null) + const parts: string[] = [] + if (concrete.length > 0) { + parts.push(`${column} IN (${concrete.map(() => '?').join(', ')})`) + params.push(...concrete) + } + if (values.includes(null)) parts.push(`${column} IS NULL`) + clauses.push(`(${parts.join(' OR ')})`) +} + +function addRange( + clauses: string[], + params: Array, + column: string, + range: { from?: number; to?: number }, +): void { + if (range.from !== undefined) { + clauses.push(`CAST(${column} AS INTEGER) >= ?`) + params.push(range.from) + } + if (range.to !== undefined) { + clauses.push(`CAST(${column} AS INTEGER) <= ?`) + params.push(range.to) + } +} + +function canonicalFilters(filters: readonly (SessionResultFilter | SessionEventMetadataFilter)[]): unknown[] { + return filters.map((filter) => { + if ('values' in filter) { + return { ...filter, values: [...filter.values].sort(compareNullable) } + } + return { + kind: filter.kind, + from: filter.from ?? null, + to: filter.to ?? null, + } + }).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) +} + +function compareNullable(a: string | null, b: string | null): number { + if (a === b) return 0 + if (a === null) return -1 + if (b === null) return 1 + return a.localeCompare(b) +} diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts new file mode 100644 index 0000000000..1c9bd98791 --- /dev/null +++ b/packages/session-query/session-query-sqlite/src/schema.ts @@ -0,0 +1,127 @@ +/** SQLite schema for the disposable session full-text read model. */ + +import { DatabaseSync } from 'node:sqlite' +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 + +/** SQLite application id protecting unrelated databases from derived resets. */ +export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851 + +/** Supported SQLite journal modes. */ +export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' + +/** + * Open, validate, and initialize persistent and connection-local schemas. + * @param path - dedicated derived-index path or `:memory:`. + * @param journalMode - validated SQLite journal mode. + * @returns initialized database handle owned by the search service. + */ +export async function openSearchDatabase(path: string, journalMode: JournalMode): Promise { + const actual = path === ':memory:' ? path : resolve(path) + 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) + if (applicationId !== 0 && applicationId !== SESSION_QUERY_SQLITE_APPLICATION_ID) { + throw new Error(`session-search database at "${actual}" belongs to another application`) + } + if (applicationId === 0 && userTables.length > 0) { + throw new Error(`session-search database at "${actual}" is not an empty or recognized derived index`) + } + if (applicationId === SESSION_QUERY_SQLITE_APPLICATION_ID && version !== SESSION_QUERY_SQLITE_SCHEMA_VERSION) { + resetDerivedSchema(db) + } + ensurePersistentSchema(db) + ensureTemporarySchema(db) + return db + } catch (error: unknown) { + db.close() + throw error + } +} + +function listUserTables(db: DatabaseSync): string[] { + const rows = db.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + ).all() as Array<{ name: string }> + return rows.map(row => row.name) +} + +function resetDerivedSchema(db: DatabaseSync): void { + for (const name of listUserTables(db)) { + db.exec(`DROP TABLE IF EXISTS ${quoteIdentifier(name)}`) + } + db.exec('PRAGMA user_version = 0') +} + +function ensurePersistentSchema(db: DatabaseSync): void { + db.exec(`PRAGMA application_id = ${SESSION_QUERY_SQLITE_APPLICATION_ID}`) + db.exec(` + CREATE TABLE IF NOT EXISTS search_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + global_generation INTEGER NOT NULL + ) STRICT + `) + db.exec('INSERT OR IGNORE INTO search_state (singleton, global_generation) VALUES (1, 0)') + db.exec(` + CREATE TABLE IF NOT EXISTS persisted_sessions ( + id TEXT PRIMARY KEY, + version INTEGER NOT NULL, + created_at INTEGER NOT NULL, + cwd TEXT, + parent_session TEXT, + seed_length INTEGER, + fingerprint TEXT NOT NULL, + generation INTEGER NOT NULL + ) STRICT + `) + db.exec(` + CREATE VIRTUAL TABLE IF NOT EXISTS persisted_docs USING fts5( + text, + session_id UNINDEXED, + seq UNINDEXED, + type UNINDEXED, + time UNINDEXED, + surface UNINDEXED, + tokenize = 'unicode61' + ) + `) + db.exec(`PRAGMA user_version = ${SESSION_QUERY_SQLITE_SCHEMA_VERSION}`) +} + +function ensureTemporarySchema(db: DatabaseSync): void { + db.exec(` + CREATE TEMP TABLE IF NOT EXISTS live_sessions ( + id TEXT PRIMARY KEY, + version INTEGER NOT NULL, + created_at INTEGER NOT NULL, + cwd TEXT, + parent_session TEXT, + seed_length INTEGER, + fingerprint TEXT NOT NULL, + generation INTEGER NOT NULL + ) STRICT + `) + db.exec(` + CREATE VIRTUAL TABLE IF NOT EXISTS temp.live_docs USING fts5( + text, + session_id UNINDEXED, + seq UNINDEXED, + type UNINDEXED, + time UNINDEXED, + surface UNINDEXED, + tokenize = 'unicode61' + ) + `) +} + +function quoteIdentifier(value: string): string { + return `"${value.replaceAll('"', '""')}"` +} diff --git a/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts new file mode 100644 index 0000000000..c20a501964 --- /dev/null +++ b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts @@ -0,0 +1,60 @@ +/** + * Keyless real-Loader-path smoke for the SQLite session-search service. + * + * @module @deepseek-ai/dsh-session-query-sqlite/tests/load-path + */ + +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore from '@deepseek-ai/dsh-session' +import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' +import SessionSearchSqlite, * as searchModule from '@deepseek-ai/dsh-session-query-sqlite' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await rm(directory, { recursive: true, force: true }) + } +}) + +async function temporaryPath(name: string): Promise { + const directory = await mkdtemp(join(tmpdir(), 'dsh-session-search-loader-')) + temporaryDirectories.push(directory) + return join(directory, name) +} + +describe('dsh-session-query-sqlite real Loader path', () => { + it('unwraps, mounts, and searches the real persistence backend', async () => { + const persistencePath = await temporaryPath('canonical.db') + const searchPath = await temporaryPath('derived.db') + const ctx = new Context() + await ctx.plugin(SessionStore) + const persistence = await ctx.plugin(SessionPersistenceSqlite, { path: persistencePath }) + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(searchModule) as Parameters[0] + expect(unwrapped).toBe(SessionSearchSqlite) + const search = await ctx.plugin(unwrapped, { path: searchPath }) + + const id = SessionId('loader-path') + await ctx.sessionPersistence.create({ version: SESSION_FORMAT_VERSION, id, createdAt: 10 }) + await ctx.sessionPersistence.append(id, [{ + type: 'user/message', + seq: 0, + time: 10, + data: { content: [{ type: 'text', text: 'real Loader needle' }], source: { kind: 'user' } }, + surfaceOp: 'append', + }]) + + await expect(ctx.sessionSearch.searchSessions({ query: 'Loader needle' })) + .resolves.toMatchObject({ items: [{ header: { id }, persisted: true, live: false }] }) + await search.dispose() + await persistence.dispose() + }) +}) diff --git a/packages/session-query/session-query-sqlite/tests/query.spec.ts b/packages/session-query/session-query-sqlite/tests/query.spec.ts new file mode 100644 index 0000000000..0faced72e4 --- /dev/null +++ b/packages/session-query/session-query-sqlite/tests/query.spec.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query' +import { + buildEventWhere, + buildSessionWhere, + makeSnippet, + normalizeEventRequest, + normalizeSessionRequest, + quoteFtsData, + requestFingerprint, + type NormalizedEventRequest, + type NormalizedSessionRequest, +} from '../src/query.ts' + +const limits = { defaultLimit: 2, maxLimit: 3 } + +function expectCode(code: SessionQueryErrorCode): Error { + return expect.objectContaining({ code }) as Error +} + +describe('SQLite search request normalization', () => { + it('normalizes both scopes, defaults arrays and limits, and preserves cursors', () => { + expect(normalizeSessionRequest({ query: ' alpha\n beta ' }, limits)).toEqual({ + query: 'alpha beta', + sessionFilters: [], + eventFilters: [], + limit: 2, + }) + expect(normalizeSessionRequest({ + query: 'needle', + sessionFilters: [{ kind: 'availability', values: ['live'] }], + eventFilters: [{ kind: 'surface', values: ['current'] }], + limit: 3, + cursor: 'next', + }, limits)).toEqual({ + query: 'needle', + sessionFilters: [{ kind: 'availability', values: ['live'] }], + eventFilters: [{ kind: 'surface', values: ['current'] }], + limit: 3, + cursor: 'next', + }) + expect(normalizeEventRequest({ sessionId: SessionId('s'), query: 'needle' }, limits)).toEqual({ + sessionId: SessionId('s'), + query: 'needle', + filters: [], + limit: 2, + }) + expect(normalizeEventRequest({ + sessionId: SessionId('s'), + query: 'needle', + filters: [{ kind: 'seq', from: 1 }], + cursor: 'next', + }, limits)).toEqual({ + sessionId: SessionId('s'), + query: 'needle', + filters: [{ kind: 'seq', from: 1 }], + limit: 2, + cursor: 'next', + }) + }) + + it('rejects non-text, blank, non-integer, non-positive, and oversized requests', () => { + expect(() => normalizeSessionRequest({ query: 1 as never }, limits)) + .toThrow(expectCode('SESSION_QUERY_INVALID_QUERY')) + expect(() => normalizeSessionRequest({ query: ' \n ' }, limits)) + .toThrow(expectCode('SESSION_QUERY_INVALID_QUERY')) + for (const limit of [1.5, 0, 4]) { + expect(() => normalizeEventRequest({ sessionId: SessionId('s'), query: 'x', limit }, limits)) + .toThrow(expectCode('SESSION_QUERY_INVALID_LIMIT')) + } + }) +}) + +describe('SQLite search predicate compilation', () => { + it('compiles all logical-session clauses including empty and nullable values', () => { + expect(buildSessionWhere([])).toEqual({ sql: '', params: [] }) + expect(buildSessionWhere([{ kind: 'id', values: [] }])).toEqual({ sql: '0', params: [] }) + expect(buildSessionWhere([{ kind: 'id', values: [SessionId('a'), SessionId('b')] }])).toEqual({ + sql: 'session_id IN (?, ?)', + params: [SessionId('a'), SessionId('b')], + }) + expect(buildSessionWhere([{ kind: 'cwd', values: [] }])).toEqual({ sql: '0', params: [] }) + expect(buildSessionWhere([{ kind: 'cwd', values: [null] }])).toEqual({ + sql: '(cwd IS NULL)', + params: [], + }) + expect(buildSessionWhere([{ kind: 'cwd', values: ['/a'] }])).toEqual({ + sql: '(cwd IN (?))', + params: ['/a'], + }) + expect(buildSessionWhere([{ kind: 'parent', values: [SessionId('p'), null] }])).toEqual({ + sql: '(parent_session IN (?) OR parent_session IS NULL)', + params: [SessionId('p')], + }) + expect(buildSessionWhere([ + { kind: 'created-at', from: 1, to: 2 }, + { kind: 'availability', values: [] }, + { kind: 'availability', values: ['live', 'live'] }, + { kind: 'availability', values: ['live', 'persisted'] }, + ])).toEqual({ + sql: 'CAST(created_at AS INTEGER) >= ? AND CAST(created_at AS INTEGER) <= ? AND 0 AND live = 1', + params: [1, 2], + }) + expect(buildSessionWhere([{ kind: 'created-at' }])).toEqual({ sql: '', params: [] }) + }) + + it('compiles every event clause and empty lists', () => { + expect(buildEventWhere([ + { kind: 'seq', from: 1 }, + { kind: 'time', to: 9 }, + { kind: 'type', values: ['user/message'] }, + { kind: 'surface', values: ['current', 'log-only'] }, + ])).toEqual({ + sql: 'CAST(seq AS INTEGER) >= ? AND CAST(time AS INTEGER) <= ? AND type IN (?) AND surface IN (?, ?)', + params: [1, 9, 'user/message', 'current', 'log-only'], + }) + expect(buildEventWhere([ + { kind: 'type', values: [] }, + { kind: 'surface', values: [] }, + ])).toEqual({ sql: '0 AND 0', params: [] }) + }) +}) + +describe('SQLite query identity and presentation', () => { + it('quotes all caller MATCH syntax as data', () => { + expect(quoteFtsData('say "needle" OR *')).toBe('"say ""needle"" OR *"') + }) + + it('canonicalizes request and filter ordering in both scopes', () => { + const sessionA: NormalizedSessionRequest = { + query: 'needle', + limit: 2, + sessionFilters: [ + { kind: 'cwd', values: ['/b', '/a'] }, + { kind: 'parent', values: [null, SessionId('p')] }, + { kind: 'id', values: [SessionId('same'), SessionId('same')] }, + { kind: 'created-at', from: 1 }, + ], + eventFilters: [{ kind: 'time', to: 9 }], + } + const sessionB: NormalizedSessionRequest = { + query: 'needle', + limit: 2, + sessionFilters: [ + { kind: 'created-at', from: 1 }, + { kind: 'id', values: [SessionId('same'), SessionId('same')] }, + { kind: 'parent', values: [SessionId('p'), null] }, + { kind: 'cwd', values: ['/a', '/b'] }, + ], + eventFilters: [{ kind: 'time', to: 9 }], + } + expect(requestFingerprint(sessionA)).toBe(requestFingerprint(sessionB)) + + const eventA: NormalizedEventRequest = { + sessionId: SessionId('s'), + query: 'needle', + limit: 2, + filters: [{ kind: 'seq' }, { kind: 'surface', values: ['shadowed', 'current'] }], + } + const eventB: NormalizedEventRequest = { + sessionId: SessionId('s'), + query: 'needle', + limit: 2, + filters: [{ kind: 'surface', values: ['current', 'shadowed'] }, { kind: 'seq' }], + } + expect(requestFingerprint(eventA)).toBe(requestFingerprint(eventB)) + expect(requestFingerprint(eventA)).not.toBe(requestFingerprint({ ...eventB, sessionId: SessionId('other') })) + }) + + it('normalizes, bounds, and positions snippets by Unicode code point', () => { + expect(makeSnippet(' short\ntext ', 'absent', 20)).toBe('short text') + expect(makeSnippet('abcdef', 'f', 1)).toBe('…') + expect(makeSnippet('abcdefghij', 'absent', 5)).toBe('abcd…') + expect(makeSnippet('abcdefghij', 'c', 5)).toBe('…bcd…') + expect(makeSnippet('abcdef', 'f', 2)).toBe('a…') + expect(makeSnippet('abcdef', 'f', 5)).toBe('…cdef') + }) +}) diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts new file mode 100644 index 0000000000..77d1e0125d --- /dev/null +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -0,0 +1,594 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { DatabaseSync } from 'node:sqlite' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' +import SessionPersistence from '@deepseek-ai/dsh-session-persistence' +import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' +import SessionSearchSqlite, { + SESSION_QUERY_SQLITE_APPLICATION_ID, + SESSION_QUERY_SQLITE_SCHEMA_VERSION, +} from '@deepseek-ai/dsh-session-query-sqlite' +import type { SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query' + +const temporaryDirectories: string[] = [] + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await rm(directory, { recursive: true, force: true }) + } +}) + +async function temporaryPath(name = 'search.db'): Promise { + const directory = await mkdtemp(join(tmpdir(), 'dsh-session-search-')) + temporaryDirectories.push(directory) + return join(directory, name) +} + +function header(id: string, createdAt = 1, extra: Partial = {}): SessionHeader { + return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra } +} + +function messageEvents(text: string, time = 1): SessionEvent[] { + return [{ + type: 'user/message', + seq: 0, + time, + data: { content: [{ type: 'text', text }], source: { kind: 'user' } }, + surfaceOp: 'append', + }] +} + +function expectCode(code: SessionQueryErrorCode): Error { + return expect.objectContaining({ code }) as Error +} + +class TestPersistence extends SessionPersistence { + static entries = new Map() + static listGate: Promise | undefined + static listStarted: (() => void) | undefined + static failure: unknown + + static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void { + this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)])) + this.listGate = undefined + this.listStarted = undefined + this.failure = undefined + } + + create(meta: SessionHeader): Promise { + TestPersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] }) + return Promise.resolve() + } + + append(id: SessionIdType, events: readonly SessionEvent[]): Promise { + const entry = TestPersistence.entries.get(id) + if (entry === undefined) return Promise.reject(new Error('missing test session')) + entry.events.push(...structuredClone(events)) + return Promise.resolve() + } + + async load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + if (TestPersistence.failure !== undefined) throw TestPersistence.failure + const entry = TestPersistence.entries.get(id) + if (entry === undefined) throw new Error('missing test session') + return structuredClone(entry) + } + + async list(): Promise { + TestPersistence.listStarted?.() + await TestPersistence.listGate + if (TestPersistence.failure !== undefined) throw TestPersistence.failure + return [...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta)) + } +} + +async function liveContext(config: ConstructorParameters[1] = { path: ':memory:' }): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionSearchSqlite, config) + return ctx +} + +describe('SQLite session search', () => { + it('searches two-character Unicode61 tokens in live-only sessions', async () => { + const ctx = await liveContext({ path: ':memory:', snippetChars: 20 }) + const session = ctx.sessions.create(SessionId('live'), { meta: { cwd: '/work', createdAt: 10, seedLength: 1 } }) + session.append( + 'user/message', + { content: [{ type: 'text', text: 'An AI helper' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + + await expect(ctx.sessionSearch.searchEvents({ sessionId: session.id, query: 'AI' })) + .resolves.toMatchObject({ items: [{ sessionId: session.id, seq: 0, snippet: 'An AI helper' }] }) + await expect(ctx.sessionSearch.searchSessions({ query: 'AI' })) + .resolves.toMatchObject({ items: [{ header: { ...session.header, seedLength: 1 }, live: true, persisted: false }] }) + }) + + it('searches all surfaces by default and applies metadata before ranking', async () => { + const ctx = await liveContext({ path: ':memory:', defaultLimit: 10, maxLimit: 20 }) + const parent = SessionId('parent') + const events: SessionEvent[] = [ + { type: 'user/message', seq: 0, time: 10, data: { content: [{ type: 'text', text: 'needle original' }], source: { kind: 'user' } }, surfaceOp: 'append' }, + { type: 'assistant/chunk', seq: 1, time: 11, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'needle raw' } } }, + { type: 'user/message', seq: 2, time: 12, data: { content: [{ type: 'text', text: 'needle summary' }], source: { kind: 'plugin', plugin: 'test' } }, surfaceOp: { op: 'replace', start: 0, end: 0 } }, + { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'error', step: 1, message: 'needle failure' } } }, + ] + ctx.sessions.create(SessionId('a'), { seed: events, meta: { cwd: '/a', parentSession: parent, createdAt: 20 } }) + ctx.sessions.create(SessionId('b'), { seed: messageEvents('needle peer', 12), meta: { createdAt: 20 } }) + + const all = await ctx.sessionSearch.searchEvents({ sessionId: SessionId('a'), query: 'needle' }) + expect(new Set(all.items.map(item => item.surface))).toEqual(new Set(['current', 'shadowed', 'log-only'])) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: SessionId('a'), + query: 'needle', + filters: [ + { kind: 'seq', from: 2, to: 2 }, + { kind: 'time', from: 12, to: 12 }, + { kind: 'type', values: ['user/message'] }, + { kind: 'surface', values: ['current'] }, + ], + })).resolves.toMatchObject({ items: [{ seq: 2, surface: 'current' }] }) + + const grouped = await ctx.sessionSearch.searchSessions({ + query: 'needle', + sessionFilters: [ + { kind: 'id', values: [SessionId('a')] }, + { kind: 'cwd', values: ['/a'] }, + { kind: 'created-at', from: 20, to: 20 }, + { kind: 'parent', values: [parent] }, + { kind: 'availability', values: ['live'] }, + ], + eventFilters: [{ kind: 'surface', values: ['shadowed'] }], + }) + expect(grouped.items).toHaveLength(1) + expect(grouped.items[0]).toMatchObject({ + header: { id: SessionId('a'), cwd: '/a', parentSession: parent }, + live: true, + persisted: false, + bestMatch: { seq: 0, surface: 'shadowed' }, + }) + }) + + it('uses literal phrase tokens, stable ties, and bounded Unicode snippets', async () => { + const ctx = await liveContext({ path: ':memory:', defaultLimit: 10, maxLimit: 10, snippetChars: 5 }) + ctx.sessions.create(SessionId('a'), { seed: messageEvents('😀😀 alpha beta BRAID 😀😀', 10), meta: { createdAt: 1 } }) + ctx.sessions.create(SessionId('b'), { seed: messageEvents('alpha beta', 10), meta: { createdAt: 1 } }) + ctx.sessions.create(SessionId('c'), { seed: messageEvents('alpha middle beta', 10), meta: { createdAt: 1 } }) + ctx.sessions.create(SessionId('d'), { seed: messageEvents('alpha beta', 10), meta: { createdAt: 1 } }) + ctx.sessions.create(SessionId('operator'), { seed: messageEvents('needle OR absent', 10), meta: { createdAt: 1 } }) + ctx.sessions.create(SessionId('only'), { seed: messageEvents('needle only', 10), meta: { createdAt: 1 } }) + ctx.sessions.create(SessionId('quote'), { seed: messageEvents('say "needle" exactly', 10), meta: { createdAt: 1 } }) + + const phrase = await ctx.sessionSearch.searchSessions({ query: 'alpha beta' }) + expect(phrase.items.map(item => item.header.id)).toEqual([SessionId('b'), SessionId('d'), SessionId('a')]) + expect(phrase.items.every(item => Array.from(item.bestMatch.snippet).length <= 5)).toBe(true) + await expect(ctx.sessionSearch.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] }) + await expect(ctx.sessionSearch.searchSessions({ query: 'needle OR absent' })) + .resolves.toMatchObject({ items: [{ header: { id: SessionId('operator') } }] }) + await expect(ctx.sessionSearch.searchSessions({ query: 'say "needle"' })) + .resolves.toMatchObject({ items: [{ header: { id: SessionId('quote') } }] }) + await expect(ctx.sessionSearch.searchSessions({ query: '*' })).resolves.toEqual({ items: [] }) + }) + + it('binds cursors to requests and only invalidates within-session pages for target changes', async () => { + const ctx = await liveContext({ path: ':memory:', defaultLimit: 1, maxLimit: 5 }) + const target = ctx.sessions.create(SessionId('target'), { + seed: [ + ...messageEvents('needle one', 10), + { ...messageEvents('needle two', 11)[0]!, seq: 1 }, + { ...messageEvents('needle three', 12)[0]!, seq: 2 }, + ], + }) + ctx.sessions.create(SessionId('other'), { seed: messageEvents('needle other', 10) }) + + const eventPage = await ctx.sessionSearch.searchEvents({ sessionId: target.id, query: 'needle', limit: 1 }) + const sessionPage = await ctx.sessionSearch.searchSessions({ query: 'needle', limit: 1 }) + expect(eventPage.nextCursor).toEqual(expect.any(String)) + expect(sessionPage.nextCursor).toEqual(expect.any(String)) + if (eventPage.nextCursor === undefined || sessionPage.nextCursor === undefined) throw new Error('expected cursors') + + const eventKeys = eventPage.items.map(item => `${item.sessionId}:${item.seq}`) + let eventCursor: string | undefined = eventPage.nextCursor + while (eventCursor !== undefined) { + const next = await ctx.sessionSearch.searchEvents({ + sessionId: target.id, + query: 'needle', + limit: 1, + cursor: eventCursor, + }) + eventKeys.push(...next.items.map(item => `${item.sessionId}:${item.seq}`)) + eventCursor = next.nextCursor + } + expect(eventKeys).toHaveLength(3) + expect(new Set(eventKeys).size).toBe(eventKeys.length) + + const sessionIds = sessionPage.items.map(item => item.header.id) + let sessionCursor: string | undefined = sessionPage.nextCursor + while (sessionCursor !== undefined) { + const next = await ctx.sessionSearch.searchSessions({ query: 'needle', limit: 1, cursor: sessionCursor }) + sessionIds.push(...next.items.map(item => item.header.id)) + sessionCursor = next.nextCursor + } + expect(sessionIds).toHaveLength(2) + expect(new Set(sessionIds).size).toBe(sessionIds.length) + + ctx.sessions.create(SessionId('unrelated'), { seed: messageEvents('needle unrelated', 20) }) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: target.id, + query: 'needle', + limit: 1, + cursor: eventPage.nextCursor, + })).resolves.toMatchObject({ items: [{ sessionId: target.id }] }) + await expect(ctx.sessionSearch.searchSessions({ query: 'needle', limit: 1, cursor: sessionPage.nextCursor })) + .rejects.toThrow(expectCode('SESSION_QUERY_STALE_CURSOR')) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: target.id, + query: 'different', + limit: 1, + cursor: eventPage.nextCursor, + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CURSOR')) + + target.append('user/message', { content: [{ type: 'text', text: 'needle four' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: target.id, + query: 'needle', + limit: 1, + cursor: eventPage.nextCursor, + })).rejects.toThrow(expectCode('SESSION_QUERY_STALE_CURSOR')) + }) + + it('rejects invalid requests, filters, cursors, and direct config', async () => { + const ctx = await liveContext({ path: ':memory:', defaultLimit: 2, maxLimit: 3 }) + const session = ctx.sessions.create(SessionId('valid'), { seed: messageEvents('needle') }) + for (const request of [ + { sessionId: session.id, query: '' }, + { sessionId: session.id, query: 'needle', limit: 0 }, + { sessionId: session.id, query: 'needle', limit: 4 }, + { sessionId: session.id, query: 'needle', filters: [{ kind: 'seq', from: 2, to: 1 }] }, + { sessionId: session.id, query: 'needle', filters: [{ kind: 'surface', values: ['future'] }] }, + ] as const) { + await expect(ctx.sessionSearch.searchEvents(request as never)).rejects.toBeInstanceOf(Error) + } + await expect(ctx.sessionSearch.searchSessions({ + query: 'needle', + sessionFilters: [{ kind: 'availability', values: ['remote' as never] }], + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + await expect(ctx.sessionSearch.searchEvents({ sessionId: session.id, query: 'needle', cursor: 'not-json' })) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CURSOR')) + await expect(ctx.sessionSearch.searchEvents({ sessionId: SessionId('absent'), query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) + + for (const config of [ + { path: '' }, + { path: ':memory:', defaultLimit: 0 }, + { path: ':memory:', maxLimit: 0 }, + { path: ':memory:', snippetChars: 0 }, + { path: ':memory:', defaultLimit: 3, maxLimit: 2 }, + { path: ':memory:', journalMode: 'memory' }, + ]) { + const direct = new Context() + await direct.plugin(SessionStore) + expect(() => new SessionSearchSqlite(direct, config as never)) + .toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG')) + } + }) +}) + +describe('SQLite reconciliation and source lifecycle', () => { + it('mounts persistence dynamically, shadows with TEMP live rows, reveals, and hides on unmount', async () => { + const shared = header('shared', 10, { cwd: '/work' }) + const durable = header('durable', 5) + TestPersistence.reset([ + { meta: shared, events: messageEvents('persisted needle') }, + { meta: durable, events: messageEvents('durable needle') }, + ]) + const ctx = await liveContext() + await expect(ctx.sessionSearch.searchSessions({ query: 'durable' })).resolves.toEqual({ items: [] }) + const persistenceFiber = await ctx.plugin(TestPersistence) + + await expect(ctx.sessionSearch.searchSessions({ query: 'durable' })) + .resolves.toMatchObject({ items: [{ header: durable, live: false, persisted: true }] }) + const live = ctx.sessions.prepare(shared.id, { meta: { createdAt: 10, cwd: '/work' } }) + live.append('user/message', { content: [{ type: 'text', text: 'live needle' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const detach = ctx.sessions.enter(live) + ctx.sessions.announce(live) + + await expect(ctx.sessionSearch.searchSessions({ query: 'persisted' })).resolves.toEqual({ items: [] }) + await expect(ctx.sessionSearch.searchSessions({ query: 'live' })) + .resolves.toMatchObject({ items: [{ header: shared, live: true, persisted: true }] }) + detach() + await expect(ctx.sessionSearch.searchSessions({ query: 'persisted' })) + .resolves.toMatchObject({ items: [{ header: shared, live: false, persisted: true }] }) + + await persistenceFiber.dispose() + await expect(ctx.sessionSearch.searchSessions({ query: 'durable' })).resolves.toEqual({ items: [] }) + await expect(ctx.sessionSearch.searchEvents({ sessionId: durable.id, query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) + }) + + it('restarts observation when persistence unmounts during an asynchronous list', async () => { + const durable = header('racing') + TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) + const ctx = await liveContext() + const persistenceFiber = await ctx.plugin(TestPersistence) + let release!: () => void + TestPersistence.listGate = new Promise((resolve) => { release = resolve }) + let markStarted!: () => void + const started = new Promise((resolve) => { markStarted = resolve }) + TestPersistence.listStarted = () => { + TestPersistence.listStarted = undefined + markStarted() + } + + const search = ctx.sessionSearch.searchSessions({ query: 'needle' }) + await started + await persistenceFiber.dispose() + release() + await expect(search).resolves.toEqual({ items: [] }) + }) + + it('rejects immutable header conflicts between live and persisted sources', async () => { + const shared = header('conflict', 10) + TestPersistence.reset([{ meta: shared, events: messageEvents('persisted needle') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + ctx.sessions.create(shared.id, { seed: messageEvents('live needle'), meta: { createdAt: 11 } }) + + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) + }) + + it('preserves unchanged persisted generations while reconciling new, changed, and deleted rows', async () => { + const path = await temporaryPath() + const unchanged = header('unchanged') + const changed = header('changed') + const deleted = header('deleted') + TestPersistence.reset([ + { meta: unchanged, events: messageEvents('unchanged needle') }, + { meta: changed, events: messageEvents('old needle') }, + { meta: deleted, events: messageEvents('deleted needle') }, + ]) + const first = new Context() + await first.plugin(SessionStore) + const firstPersistence = await first.plugin(TestPersistence) + const firstSearch = await first.plugin(SessionSearchSqlite, { path }) + await first.sessionSearch.searchSessions({ query: 'needle' }) + await firstSearch.dispose() + await firstPersistence.dispose() + + const beforeDb = new DatabaseSync(path) + const beforeRows = beforeDb.prepare('SELECT id, generation FROM persisted_sessions ORDER BY id').all() as Array<{ id: string; generation: number }> + beforeDb.close() + const before = new Map(beforeRows.map(row => [row.id, row.generation])) + + const added = header('added') + TestPersistence.entries.delete(deleted.id) + TestPersistence.entries.set(changed.id, { meta: changed, events: messageEvents('changed needle') }) + TestPersistence.entries.set(added.id, { meta: added, events: messageEvents('added needle') }) + const second = new Context() + await second.plugin(SessionStore) + const secondPersistence = await second.plugin(TestPersistence) + const secondSearch = await second.plugin(SessionSearchSqlite, { path }) + const result = await second.sessionSearch.searchSessions({ query: 'needle' }) + expect(result.items.map(item => item.header.id).sort()).toEqual([added.id, changed.id, unchanged.id].sort()) + await secondSearch.dispose() + await secondPersistence.dispose() + + const afterDb = new DatabaseSync(path) + const afterRows = afterDb.prepare('SELECT id, generation FROM persisted_sessions ORDER BY id').all() as Array<{ id: string; generation: number }> + afterDb.close() + const after = new Map(afterRows.map(row => [row.id, row.generation])) + expect(after.get(unchanged.id)).toBe(before.get(unchanged.id)) + expect(after.get(changed.id)).toBeGreaterThan(before.get(changed.id)!) + expect(after.has(deleted.id)).toBe(false) + expect(after.has(added.id)).toBe(true) + }) + + it('drops connection-local live overlays on reopen and retains persistent bases', async () => { + const path = await temporaryPath() + const shared = header('shared', 10) + TestPersistence.reset([{ meta: shared, events: messageEvents('persisted needle') }]) + const first = new Context() + await first.plugin(SessionStore) + const persistence = await first.plugin(TestPersistence) + const live = first.sessions.create(shared.id, { seed: messageEvents('live needle'), meta: { createdAt: 10 } }) + const search = await first.plugin(SessionSearchSqlite, { path }) + await expect(first.sessionSearch.searchEvents({ sessionId: live.id, query: 'live' })).resolves.toMatchObject({ items: [{}] }) + await search.dispose() + await persistence.dispose() + + const second = new Context() + await second.plugin(SessionStore) + const persistenceAgain = await second.plugin(TestPersistence) + const searchAgain = await second.plugin(SessionSearchSqlite, { path }) + await expect(second.sessionSearch.searchSessions({ query: 'live' })).resolves.toEqual({ items: [] }) + await expect(second.sessionSearch.searchSessions({ query: 'persisted' })) + .resolves.toMatchObject({ items: [{ header: shared, live: false, persisted: true }] }) + await searchAgain.dispose() + await persistenceAgain.dispose() + }) + + it('recovers on the next search after source and SQLite transaction failures', async () => { + TestPersistence.reset([{ meta: header('durable'), events: messageEvents('durable needle') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + TestPersistence.failure = 'offline' + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + const signal = new AbortController().signal + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' }, { signal })) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + TestPersistence.failure = new Error('still offline') + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' }, { signal })) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + TestPersistence.failure = undefined + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })).resolves.toMatchObject({ items: [{}] }) + + const live = ctx.sessions.create(SessionId('live'), { seed: messageEvents('base') }) + await ctx.sessionSearch.searchEvents({ sessionId: live.id, query: 'base' }) + const db = (ctx.sessionSearch as unknown as { _db: DatabaseSync })._db + db.exec('PRAGMA query_only = ON') + live.append('user/message', { content: [{ type: 'text', text: 'retry needle' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + await expect(ctx.sessionSearch.searchEvents({ sessionId: live.id, query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + db.exec('PRAGMA query_only = OFF') + await expect(ctx.sessionSearch.searchEvents({ sessionId: live.id, query: 'needle' })) + .resolves.toMatchObject({ items: [{ seq: 1 }] }) + }) +}) + +describe('SQLite schema, cancellation, and real persistence integration', () => { + it('resets a recognized incompatible derived schema but refuses a foreign database', async () => { + const stalePath = await temporaryPath('stale.db') + const stale = new DatabaseSync(stalePath) + stale.exec(`PRAGMA application_id = ${SESSION_QUERY_SQLITE_APPLICATION_ID}`) + stale.exec('PRAGMA user_version = 999') + stale.exec('CREATE TABLE stale(value TEXT)') + stale.close() + const staleCtx = await liveContext({ path: stalePath }) + staleCtx.sessions.create(SessionId('live'), { seed: messageEvents('needle') }) + await staleCtx.sessionSearch.searchSessions({ query: 'needle' }) + await (staleCtx.sessionSearch as SessionSearchSqlite).close() + const rebuilt = new DatabaseSync(stalePath) + expect((rebuilt.prepare('PRAGMA user_version').get() as { user_version: number }).user_version) + .toBe(SESSION_QUERY_SQLITE_SCHEMA_VERSION) + expect(rebuilt.prepare("SELECT name FROM sqlite_master WHERE name = 'stale'").get()).toBeUndefined() + rebuilt.close() + + const foreignPath = await temporaryPath('foreign.db') + const foreign = new DatabaseSync(foreignPath) + foreign.exec('CREATE TABLE canonical(value TEXT)') + foreign.exec("INSERT INTO canonical VALUES ('safe')") + foreign.close() + const foreignCtx = await liveContext({ path: foreignPath }) + await expect(foreignCtx.sessionSearch.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + const stillForeign = new DatabaseSync(foreignPath) + expect(stillForeign.prepare('SELECT value FROM canonical').get()).toEqual({ value: 'safe' }) + stillForeign.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.sessionSearch.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + }) + + it('cancels both queued and in-flight source waits without committing them', async () => { + TestPersistence.reset() + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + + const boundaryController = new AbortController() + const boundary = ctx.sessionSearch.searchSessions({ query: 'needle' }, { signal: boundaryController.signal }) + queueMicrotask(() => { boundaryController.abort() }) + await expect(boundary).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + + const readyController = new AbortController() + readyController.abort() + const internals = ctx.sessionSearch as unknown as { + _ensureReady(signal: AbortSignal): Promise + } + await expect(internals._ensureReady(readyController.signal)) + .rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + + let releaseBlocking!: () => void + TestPersistence.listGate = new Promise((resolve) => { releaseBlocking = resolve }) + let markBlockingStarted!: () => void + const blockingStarted = new Promise((resolve) => { markBlockingStarted = resolve }) + TestPersistence.listStarted = () => { + TestPersistence.listStarted = undefined + markBlockingStarted() + } + const blocking = ctx.sessionSearch.searchSessions({ query: 'needle' }) + await blockingStarted + + const queuedController = new AbortController() + const queued = ctx.sessionSearch.searchSessions({ query: 'needle' }, { signal: queuedController.signal }) + queuedController.abort() + await expect(queued).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + + releaseBlocking() + await expect(blocking).resolves.toEqual({ items: [] }) + + TestPersistence.entries.set(SessionId('uncommitted'), { + meta: header('uncommitted'), + events: messageEvents('durable needle'), + }) + let releaseActive!: () => void + TestPersistence.listGate = new Promise((resolve) => { releaseActive = resolve }) + let markActiveStarted!: () => void + const activeStarted = new Promise((resolve) => { markActiveStarted = resolve }) + TestPersistence.listStarted = () => { + TestPersistence.listStarted = undefined + markActiveStarted() + } + const activeController = new AbortController() + const active = ctx.sessionSearch.searchSessions({ query: 'needle' }, { signal: activeController.signal }) + await activeStarted + activeController.abort() + await expect(active).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + releaseActive() + + const db = (ctx.sessionSearch as unknown as { _db: DatabaseSync })._db + expect(db.prepare('SELECT COUNT(*) AS count FROM persisted_sessions').get()).toEqual({ count: 0 }) + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + .resolves.toMatchObject({ items: [{ header: { id: SessionId('uncommitted') } }] }) + }) + + it('rejects queued and future work when close waits for an accepted operation', async () => { + TestPersistence.reset() + let release!: () => void + TestPersistence.listGate = new Promise((resolve) => { release = resolve }) + let markStarted!: () => void + const started = new Promise((resolve) => { markStarted = resolve }) + TestPersistence.listStarted = () => { + TestPersistence.listStarted = undefined + markStarted() + } + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const search = ctx.sessionSearch as SessionSearchSqlite + const accepted = search.searchSessions({ query: 'needle' }) + await started + const queued = search.searchSessions({ query: 'needle' }) + const closing = search.close() + release() + + await expect(accepted).resolves.toEqual({ items: [] }) + await expect(queued).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + await closing + await expect(search.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + await search.close() + }) + + it('combines the real SQLite persistence backend with the real search service keylessly', async () => { + const persistencePath = await temporaryPath('canonical.db') + const searchPath = await temporaryPath('derived.db') + const ctx = new Context() + await ctx.plugin(SessionStore) + const persistence = await ctx.plugin(SessionPersistenceSqlite, { path: persistencePath }) + const search = await ctx.plugin(SessionSearchSqlite, { path: searchPath }) + const meta = header('real', 10, { cwd: '/work' }) + await ctx.sessionPersistence.create(meta) + await ctx.sessionPersistence.append(meta.id, messageEvents('real SQLite needle')) + + await expect(ctx.sessionSearch.searchSessions({ query: 'SQLite needle' })) + .resolves.toMatchObject({ items: [{ header: meta, persisted: true, live: false }] }) + await expect(ctx.sessionSearch.searchEvents({ sessionId: meta.id, query: 'SQLite needle' })) + .resolves.toMatchObject({ items: [{ sessionId: meta.id, seq: 0 }] }) + await expect(ctx.sessionSearch.searchEvents({ sessionId: SessionId('absent'), query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) + await search.dispose() + await expect(ctx.sessionPersistence.load(meta.id)).resolves.toMatchObject({ meta, events: [{ seq: 0 }] }) + await persistence.dispose() + }) +}) diff --git a/packages/session-query/session-query-sqlite/tsconfig.json b/packages/session-query/session-query-sqlite/tsconfig.json new file mode 100644 index 0000000000..ea16cdbe96 --- /dev/null +++ b/packages/session-query/session-query-sqlite/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session-persistence/session-persistence" + }, + { + "path": "../session-query" + } + ] +} diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 55f9b32fcd..6f9e9bbd7a 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-session-query -Exact session-history retrieval through `ctx.sessionQuery`. The service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`. +Session-history query contracts and provider-independent helpers. The concrete `ctx.sessionQuery` service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus for exact reads and semantic scans. The abstract `ctx.sessionSearch` service defines full-text search without introducing a provider registry. This is trusted context-wide infrastructure. It performs no caller authorization; a future model tool or UI must constrain which sessions its caller may inspect. @@ -8,11 +8,24 @@ This is trusted context-wide infrastructure. It performs no caller authorization - `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order. - `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold. +- `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order. - `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`. Persistence is optional and may mount or unmount dynamically. A cross-corpus list fails with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted exact reads list before loading, and reject a metadata mismatch rather than combining inconsistent observations. -`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`. +## Filtering and extraction + +`SessionResultFilter` covers id, nullable cwd, created-at range, nullable parent, and source availability. `SessionEventResultFilter` covers seq/time ranges, event type, surface, and semantic text. Filter arrays are ANDed; values within one list clause are ORed. Empty list values match nothing, ranges are inclusive, and malformed ranges or closed-union values fail with `SESSION_QUERY_INVALID_FILTER`. + +The text clause is deliberately independent of FTS providers: caller text is escaped into a Unicode, case-insensitive regular expression, and each whitespace run matches one or more whitespace characters. It is a literal semantic-text scan, not a full-text query. `extractSessionEventText()` and `buildSessionEventSearchDocuments()` define the shared first-party document projection; structural boundaries, stream chunks, request headers, and unknown declaration-merged variants produce no document. + +## Full-text seam + +`SessionSearchService` owns the independent `ctx.sessionSearch` key. `searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. Both return opaque cursor pages, accept optional cancellation, and expose snippets without provider-specific numeric scores. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above. + +The package has no provider coordinator or registration protocol. A concrete backend owns observation, reconciliation, ranking, cursor generations, and query execution as one lifecycle; the first implementation is [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md). + +`SessionQueryError.code` is a closed union covering request validation, missing targets, malformed surfaces, source conflicts, persistence/index failures, cancellation, and invalid or stale cursors; the exact literals are defined in [`src/config.ts`](src/config.ts). ## Configuration @@ -20,4 +33,4 @@ Persistence is optional and may mount or unmount dynamically. A cross-corpus lis |---|---:|---| | `readWindowMax` | `50` | Maximum `before` or `after` raw-event count. | -This phase deliberately has no filters, lineage/provenance traversal, extraction registry, search-provider protocol, index synchronization, or model-facing tool. Full-text search belongs beside its first real implementation; the proposed SQLite package and its single transaction/reconciliation owner are described in the [phase-two RFC](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md). +The package deliberately has no lineage/provenance traversal, extractor registry, search-provider registry, index synchronization, caller authorization, or model-facing tool. The SQLite ownership and tokenizer decisions are recorded in the [implemented search RFC](../../../docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md). diff --git a/packages/session-query/session-query/src/config.ts b/packages/session-query/session-query/src/config.ts index 2736f68cbd..296d3830c6 100644 --- a/packages/session-query/session-query/src/config.ts +++ b/packages/session-query/session-query/src/config.ts @@ -1,4 +1,4 @@ -/** Public configuration and typed failures for session-query. */ +/** Public configuration and typed failures for session-query and search. */ import { HarnessError } from '@deepseek-ai/dsh-llm' @@ -11,14 +11,21 @@ export interface Config { readWindowMax?: number } -/** Stable machine-routable failure taxonomy for exact session reads. */ +/** Stable machine-routable failure taxonomy for session reads and search. */ export type SessionQueryErrorCode = + | 'SESSION_QUERY_ABORTED' | 'SESSION_QUERY_EVENT_NOT_FOUND' + | 'SESSION_QUERY_INDEX_FAILED' | 'SESSION_QUERY_INVALID_CONFIG' + | 'SESSION_QUERY_INVALID_CURSOR' + | 'SESSION_QUERY_INVALID_FILTER' + | 'SESSION_QUERY_INVALID_LIMIT' + | 'SESSION_QUERY_INVALID_QUERY' | 'SESSION_QUERY_INVALID_SURFACE' | 'SESSION_QUERY_INVALID_WINDOW' | 'SESSION_QUERY_PERSISTENCE_FAILED' | 'SESSION_QUERY_SESSION_NOT_FOUND' + | 'SESSION_QUERY_STALE_CURSOR' | 'SESSION_QUERY_SOURCE_CONFLICT' /** Typed session-query failure whose `code` is one closed taxonomy member. */ diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index ebc3d92577..c8ddca3caa 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -5,6 +5,7 @@ import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek- import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' import type { SessionRecord } from './types.ts' import { SessionQueryError } from './config.ts' +import { assertSessionHeadersCompatible } from './sources.ts' /** Detached source selected for one exact read. */ export interface LogicalSession { @@ -45,7 +46,7 @@ export class SessionCorpus { } for (const session of this._ctx.sessions.list()) { const durable = records.get(session.id) - if (durable !== undefined) assertCompatibleHeaders(session.header, durable.header) + if (durable !== undefined) assertSessionHeadersCompatible(session.header, durable.header) records.set(session.id, { header: structuredClone(session.header), live: true, @@ -80,7 +81,7 @@ export class SessionCorpus { { cause: error }, ) } - assertCompatibleHeaders(loaded.meta, listed) + assertSessionHeadersCompatible(loaded.meta, listed) return { header: structuredClone(loaded.meta), events: loaded.events.map(event => structuredClone(event)), @@ -107,22 +108,6 @@ function snapshotLive(session: Session): LogicalSession { } } -function assertCompatibleHeaders(a: SessionHeader, b: SessionHeader): void { - if ( - a.version !== b.version - || a.id !== b.id - || a.createdAt !== b.createdAt - || a.cwd !== b.cwd - || a.parentSession !== b.parentSession - || a.seedLength !== b.seedLength - ) { - throw new SessionQueryError( - `live and persisted headers conflict for session "${a.id}"`, - 'SESSION_QUERY_SOURCE_CONFLICT', - ) - } -} - function compareSessions(a: SessionRecord, b: SessionRecord): number { return b.header.createdAt - a.header.createdAt || a.header.id.localeCompare(b.header.id) } diff --git a/packages/session-query/session-query/src/documents.ts b/packages/session-query/session-query/src/documents.ts new file mode 100644 index 0000000000..f58029ae67 --- /dev/null +++ b/packages/session-query/session-query/src/documents.ts @@ -0,0 +1,74 @@ +/** Shared event metadata and semantic-document projection. */ + +import { foldSurface } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEventRecord, SessionEventSearchDocument, SessionEventSurface } from './types.ts' +import { SessionQueryError } from './config.ts' +import { extractSessionEventText } from './extraction.ts' + +/** + * Project a raw log into lightweight surface-aware event records. + * @param sessionId - session that owns the log. + * @param events - complete contiguous raw event log. + * @returns one record per event in ascending seq order. + */ +export function buildSessionEventRecords( + sessionId: SessionId, + events: readonly SessionEvent[], +): SessionEventRecord[] { + const surfaceBySeq = classifySurface(events) + return events.map(event => ({ + sessionId, + seq: event.seq, + type: event.type, + time: event.time, + surface: surfaceBySeq.get(event.seq) ?? 'log-only', + })) +} + +/** + * Build first-party semantic documents for one complete raw event log. + * @param sessionId - session that owns the log. + * @param events - complete contiguous raw event log. + * @returns searchable documents in ascending seq order; structural events are omitted. + */ +export function buildSessionEventSearchDocuments( + sessionId: SessionId, + events: readonly SessionEvent[], +): SessionEventSearchDocument[] { + const surfaceBySeq = classifySurface(events) + const documents: SessionEventSearchDocument[] = [] + for (const event of events) { + const text = extractSessionEventText(event) + if (text.length === 0) continue + documents.push({ + sessionId, + seq: event.seq, + type: event.type, + time: event.time, + surface: surfaceBySeq.get(event.seq) ?? 'log-only', + text, + }) + } + return documents +} + +function classifySurface(events: readonly SessionEvent[]): Map { + let folded: ReturnType + try { + folded = foldSurface(events) + } catch (error: unknown) { + throw new SessionQueryError( + /* v8 ignore next -- foldSurface throws Error instances */ + `invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`, + 'SESSION_QUERY_INVALID_SURFACE', + { cause: error }, + ) + } + const result = new Map() + for (const node of folded.nodes) result.set(node.seq, 'current') + for (const replacement of folded.replacements) { + for (const seq of replacement.shadowedSeqs) result.set(seq, 'shadowed') + } + return result +} diff --git a/packages/session-query/session-query/src/extraction.ts b/packages/session-query/session-query/src/extraction.ts new file mode 100644 index 0000000000..96a0af247b --- /dev/null +++ b/packages/session-query/session-query/src/extraction.ts @@ -0,0 +1,93 @@ +/** First-party semantic text extraction for session-query consumers. */ + +import type { SessionEvent } from '@deepseek-ai/dsh-session' + +/** + * Extract searchable semantic text from one first-party session event. + * + * Structural boundaries, raw stream chunks, request envelopes, and unknown + * declaration-merged events contribute no text. + * @param event - event to inspect. + * @returns newline-joined semantic text, or an empty string when non-searchable. + */ +export function extractSessionEventText(event: SessionEvent): string { + switch (event.type) { + case 'user/message': + case 'assistant/message': + case 'context/message': + case 'steering/message': + return contentText(event.data.content) + case 'prompt/blocked': + return joinText([contentText(event.data.content), event.data.reason]) + case 'tool/call': + return joinText([event.data.name, event.data.arguments]) + case 'tool/result': + return joinText([ + contentText(event.data.content), + event.data.error?.name ?? '', + event.data.error?.code ?? '', + ]) + case 'todo/write': + return joinText(event.data.todos.flatMap(todo => [todo.status, todo.content])) + case 'turn/end': + return turnEndText(event.data.reason) + case 'turn/start': + case 'step/start': + case 'step/end': + case 'assistant/chunk': + case 'request/header': + case 'request/header-delta': + return '' + // SessionEventMap is merge-extensible. Unknown events remain + // non-searchable until a concrete first-party consumer defines semantics. + default: + return '' + } +} + +function turnEndText(reason: SessionEvent<'turn/end'>['data']['reason']): string { + switch (reason.kind) { + case 'error': + return joinText(['error', reason.message, reason.code ?? '']) + case 'aborted': + return joinText(['aborted', reason.reason ?? '']) + case 'rejected': + return joinText(['rejected', reason.reason]) + case 'disposed': + case 'max-tokens': + case 'interrupted': + return reason.kind + case 'completed': + return '' + // TurnEndReasonMap is merge-extensible. Unknown outcomes stay out until + // their owner defines which detail is semantic rather than structural. + default: + return '' + } +} + +type SessionContentBlock = SessionEvent<'user/message'>['data']['content'][number] + +function contentText(content: readonly SessionContentBlock[]): string { + return joinText(content.flatMap(blockText)) +} + +function blockText(block: SessionContentBlock): string[] { + switch (block.type) { + case 'text': + case 'reasoning': + return [block.text] + case 'tool-call': + return [block.name, block.arguments] + case 'tool-result': + return block.content.flatMap(blockText) + // ContentBlockMap is merge-extensible. Unknown blocks do not become + // searchable merely because their payload happens to contain strings. + default: + return [] + } +} + +function joinText(parts: readonly string[]): string { + return parts.map(part => part.trim()).filter(Boolean).join('\n') +} diff --git a/packages/session-query/session-query/src/filters.ts b/packages/session-query/session-query/src/filters.ts new file mode 100644 index 0000000000..c7a7b40dd4 --- /dev/null +++ b/packages/session-query/session-query/src/filters.ts @@ -0,0 +1,132 @@ +/** Pure provider-independent predicates for logical sessions and event text. */ + +import type { SessionRecord, SessionEventSearchDocument, SessionEventResultFilter, SessionResultFilter, SessionResultRange } from './types.ts' +import { SessionQueryError } from './config.ts' + +/** + * Apply ANDed logical-session filters while preserving input order. + * @param records - detached logical-session records to inspect. + * @param filters - clauses whose list values are ORed within each clause. + * @returns records accepted by every clause. + */ +export function filterSessionResults( + records: readonly T[], + filters: readonly SessionResultFilter[] = [], +): T[] { + const predicates = filters.map(sessionPredicate) + return records.filter(record => predicates.every(predicate => predicate(record))) +} + +/** + * Apply ANDed event filters to extracted semantic documents. + * @param documents - semantic documents produced by {@link buildSessionEventSearchDocuments}. + * @param filters - metadata and literal-text predicates. + * @returns documents accepted by every clause, in input order. + */ +export function filterSessionEventDocuments( + documents: readonly T[], + filters: readonly SessionEventResultFilter[] = [], +): T[] { + const predicates = filters.map(eventPredicate) + return documents.filter(document => predicates.every(predicate => predicate(document))) +} + +/** + * Compile a literal case-insensitive, whitespace-flexible semantic-text match. + * @param text - caller-provided literal text. + * @returns Unicode-aware regular expression safe from regex injection. + */ +export function compileSessionTextFilter(text: string): RegExp { + const trimmed = text.trim() + if (trimmed.length === 0) { + throw new SessionQueryError( + 'session text filter must contain non-whitespace text', + 'SESSION_QUERY_INVALID_FILTER', + ) + } + const pattern = trimmed + .split(/\s+/u) + .map(part => part.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')) + .join('\\s+') + return new RegExp(pattern, 'iu') +} + +function sessionPredicate(filter: SessionResultFilter): (record: SessionRecord) => boolean { + switch (filter.kind) { + case 'id': + return record => filter.values.includes(record.header.id) + case 'cwd': + return record => filter.values.includes(record.header.cwd ?? null) + case 'created-at': { + const range = validateRange(filter.kind, filter) + return record => matchesRange(record.header.createdAt, range) + } + case 'parent': + return record => filter.values.includes(record.header.parentSession ?? null) + case 'availability': + assertAllowedValues(filter.kind, filter.values, ['live', 'persisted']) + return record => filter.values.some(value => value === 'live' ? record.live : record.persisted) + } +} + +function eventPredicate(filter: SessionEventResultFilter): (document: SessionEventSearchDocument) => boolean { + switch (filter.kind) { + case 'seq': { + const range = validateRange(filter.kind, filter) + return document => matchesRange(document.seq, range) + } + case 'time': { + const range = validateRange(filter.kind, filter) + return document => matchesRange(document.time, range) + } + case 'type': + return document => filter.values.includes(document.type) + case 'surface': + assertAllowedValues(filter.kind, filter.values, ['current', 'shadowed', 'log-only']) + return document => filter.values.includes(document.surface) + case 'text': { + const pattern = compileSessionTextFilter(filter.text) + return document => pattern.test(document.text) + } + } +} + +function assertAllowedValues( + name: string, + values: readonly string[], + allowed: readonly string[], +): void { + for (const value of values) { + if (!allowed.includes(value)) { + throw new SessionQueryError( + `session ${name} filter contains unknown value "${value}"`, + 'SESSION_QUERY_INVALID_FILTER', + ) + } + } +} + +function validateRange(name: string, range: SessionResultRange): SessionResultRange { + if (range.from !== undefined && !Number.isFinite(range.from)) { + throw invalidRange(name, 'from must be finite') + } + if (range.to !== undefined && !Number.isFinite(range.to)) { + throw invalidRange(name, 'to must be finite') + } + if (range.from !== undefined && range.to !== undefined && range.from > range.to) { + throw invalidRange(name, 'from must be less than or equal to to') + } + return range +} + +function matchesRange(value: number, range: SessionResultRange): boolean { + return (range.from === undefined || value >= range.from) + && (range.to === undefined || value <= range.to) +} + +function invalidRange(name: string, detail: string): SessionQueryError { + return new SessionQueryError( + `session ${name} filter ${detail}`, + 'SESSION_QUERY_INVALID_FILTER', + ) +} diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 828fe2ec88..243c86746b 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -6,13 +6,20 @@ import { Context, Service } from 'cordis' import z from 'schemastery' -import { foldSurface } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' import type { + SessionEventResultFilter, SessionEventReadRequest, SessionEventRecord, + SessionEventSearchHit, + SessionEventSearchDocument, + SessionEventSearchRequest, SessionEventWindow, SessionRecord, + SessionSearchExecContext, + SessionSearchHit, + SessionSearchPage, + SessionSearchRequest, } from './types.ts' import { SESSION_QUERY_READ_WINDOW_MAX, @@ -20,17 +27,58 @@ import { type Config, } from './config.ts' import { SessionCorpus } from './corpus.ts' +import { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts' +import { filterSessionEventDocuments } from './filters.ts' export type * from './types.ts' export type { Config, SessionQueryErrorCode } from './config.ts' export { SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError } from './config.ts' +export { extractSessionEventText } from './extraction.ts' +export { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts' +export { compileSessionTextFilter, filterSessionEventDocuments, filterSessionResults } from './filters.ts' +export { assertSessionHeadersCompatible } from './sources.ts' declare module 'cordis' { interface Context { sessionQuery: SessionQueryService + sessionSearch: SessionSearchService } } +/** + * Abstract full-text search service implemented by one concrete backend. + * + * The implementation owns source observation, reconciliation, cursor + * generations, ranking, and query execution as one lifecycle. + */ +export abstract class SessionSearchService extends Service { + constructor(ctx: Context) { + super(ctx, 'sessionSearch') + } + + /** + * Search the live-preferred logical corpus and group by session. + * @param request - query text, metadata filters, page size, and cursor. + * @param exec - optional cancellation control. + * @returns session hits ranked by their strongest matching event. + */ + abstract searchSessions( + request: SessionSearchRequest, + exec?: SessionSearchExecContext, + ): Promise> + + /** + * Search events within one live-preferred logical session. + * @param request - target session, query text, filters, page size, and cursor. + * @param exec - optional cancellation control. + * @returns matching event hits in deterministic relevance order. + */ + abstract searchEvents( + request: SessionEventSearchRequest, + exec?: SessionSearchExecContext, + ): Promise> +} + /** Live-preferred logical-corpus and exact-event read service. */ export class SessionQueryService extends Service { static inject = ['sessions'] @@ -68,7 +116,22 @@ export class SessionQueryService extends Service { */ async listEvents(sessionId: SessionId): Promise { const loaded = await this._corpus.load(sessionId) - return eventRecords(sessionId, loaded.events) + return buildSessionEventRecords(sessionId, loaded.events) + } + + /** + * Scan first-party semantic event documents with provider-independent filters. + * @param sessionId - live-preferred session id to scan. + * @param filters - ANDed metadata and literal-text predicates. + * @returns matching semantic documents in ascending seq order. + */ + async filterEvents( + sessionId: SessionId, + filters: readonly SessionEventResultFilter[], + ): Promise { + const loaded = await this._corpus.load(sessionId) + const documents = buildSessionEventSearchDocuments(sessionId, loaded.events) + return filterSessionEventDocuments(documents, filters) } /** @@ -110,27 +173,4 @@ export class SessionQueryService extends Service { } } -function eventRecords(sessionId: SessionId, events: readonly SessionEvent[]): SessionEventRecord[] { - let folded: ReturnType - try { - folded = foldSurface(events) - } catch (error: unknown) { - throw new SessionQueryError( - /* v8 ignore next -- foldSurface throws Error instances */ - `invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`, - 'SESSION_QUERY_INVALID_SURFACE', - { cause: error }, - ) - } - const current = new Set(folded.nodes.map(node => node.seq)) - const shadowed = new Set(folded.replacements.flatMap(replacement => replacement.shadowedSeqs)) - return events.map(event => ({ - sessionId, - seq: event.seq, - type: event.type, - time: event.time, - surface: current.has(event.seq) ? 'current' : shadowed.has(event.seq) ? 'shadowed' : 'log-only', - })) -} - export default SessionQueryService diff --git a/packages/session-query/session-query/src/sources.ts b/packages/session-query/session-query/src/sources.ts new file mode 100644 index 0000000000..00b08eae4e --- /dev/null +++ b/packages/session-query/session-query/src/sources.ts @@ -0,0 +1,25 @@ +/** Shared immutable-header checks for logical session source observers. */ + +import type { SessionHeader } from '@deepseek-ai/dsh-session' +import { SessionQueryError } from './config.ts' + +/** + * Reject incompatible observations of one logical session source. + * @param a - first live, listed, or loaded header observation. + * @param b - second header observation expected to identify the same source. + */ +export function assertSessionHeadersCompatible(a: SessionHeader, b: SessionHeader): void { + if ( + a.version !== b.version + || a.id !== b.id + || a.createdAt !== b.createdAt + || a.cwd !== b.cwd + || a.parentSession !== b.parentSession + || a.seedLength !== b.seedLength + ) { + throw new SessionQueryError( + `session source headers conflict for session "${a.id}"`, + 'SESSION_QUERY_SOURCE_CONFLICT', + ) + } +} diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts index 5c49695dda..d0de0dd48a 100644 --- a/packages/session-query/session-query/src/types.ts +++ b/packages/session-query/session-query/src/types.ts @@ -58,3 +58,99 @@ export interface SessionEventWindow { /** Last seq included in `events`. */ endSeq: number } + +/** Inclusive numeric interval used by time and sequence filters. */ +export interface SessionResultRange { + /** Inclusive lower bound. */ + from?: number + /** Inclusive upper bound. */ + to?: number +} + +/** Source availability predicates understood by logical-session filters. */ +export type SessionAvailability = 'live' | 'persisted' + +/** + * One logical-session predicate. A filter array is ANDed; `values` within a + * clause are ORed. + */ +export type SessionResultFilter = + | { kind: 'id'; values: readonly SessionId[] } + | { kind: 'cwd'; values: readonly (string | null)[] } + | ({ kind: 'created-at' } & SessionResultRange) + | { kind: 'parent'; values: readonly (SessionId | null)[] } + | { kind: 'availability'; values: readonly SessionAvailability[] } + +/** + * One event predicate. A filter array is ANDed; list-valued clauses are ORed. + * Text is a literal, case-insensitive, whitespace-flexible semantic-text scan. + */ +export type SessionEventResultFilter = + | ({ kind: 'seq' } & SessionResultRange) + | ({ kind: 'time' } & SessionResultRange) + | { kind: 'type'; values: readonly SessionEventType[] } + | { kind: 'surface'; values: readonly SessionEventSurface[] } + | { kind: 'text'; text: string } + +/** Event predicates a full-text provider can apply before relevance ranking. */ +export type SessionEventMetadataFilter = Exclude + +/** Searchable semantic document derived from one session event. */ +export interface SessionEventSearchDocument extends SessionEventRecord { + /** First-party semantic text used by scan filters and full-text indexes. */ + text: string +} + +/** One cursor-paginated result page. */ +export interface SessionSearchPage { + /** Results for this page in contract-defined order. */ + items: readonly T[] + /** Opaque continuation cursor, absent on the final page. */ + nextCursor?: string +} + +/** Controls shared by cross-session and within-session search calls. */ +export interface SessionSearchExecContext { + /** Abort caller waiting and interrupt provider work where supported. */ + signal?: AbortSignal +} + +/** Cross-session full-text search request. */ +export interface SessionSearchRequest { + /** Full-text query interpreted as data, never executable FTS syntax. */ + query: string + /** Logical-session predicates applied before event ranking. */ + sessionFilters?: readonly SessionResultFilter[] + /** Event predicates applied before event ranking. */ + eventFilters?: readonly SessionEventMetadataFilter[] + /** Maximum sessions in this page. */ + limit?: number + /** Opaque cursor returned for the identical normalized request. */ + cursor?: string +} + +/** Within-session full-text search request. */ +export interface SessionEventSearchRequest { + /** Session whose live-preferred logical log is searched. */ + sessionId: SessionId + /** Full-text query interpreted as data, never executable FTS syntax. */ + query: string + /** Event predicates applied before ranking. */ + filters?: readonly SessionEventMetadataFilter[] + /** Maximum events in this page. */ + limit?: number + /** Opaque cursor returned for the identical normalized request. */ + cursor?: string +} + +/** One event full-text search hit with a bounded plain-text excerpt. */ +export interface SessionEventSearchHit extends SessionEventRecord { + /** Plain text excerpt selected around the match. */ + snippet: string +} + +/** One grouped cross-session hit, ranked by its strongest matching event. */ +export interface SessionSearchHit extends SessionRecord { + /** Strongest matching event for this session. */ + bestMatch: SessionEventSearchHit +} diff --git a/packages/session-query/session-query/tests/search-helpers.spec.ts b/packages/session-query/session-query/tests/search-helpers.spec.ts new file mode 100644 index 0000000000..e9cf857608 --- /dev/null +++ b/packages/session-query/session-query/tests/search-helpers.spec.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId } from '@deepseek-ai/dsh-llm' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import SessionQueryService, { + buildSessionEventRecords, + buildSessionEventSearchDocuments, + compileSessionTextFilter, + extractSessionEventText, + filterSessionEventDocuments, + filterSessionResults, + SessionSearchService, + type SessionEventSearchHit, + type SessionEventSearchRequest, + type SessionQueryErrorCode, + type SessionSearchExecContext, + type SessionSearchHit, + type SessionSearchPage, + type SessionSearchRequest, +} from '@deepseek-ai/dsh-session-query' + +const id = SessionId('session') + +function header(value: string, extra: Partial = {}): SessionHeader { + return { version: SESSION_FORMAT_VERSION, id: SessionId(value), createdAt: 10, ...extra } +} + +function expectCode(code: SessionQueryErrorCode): Error { + return expect.objectContaining({ code }) as Error +} + +describe('session-query semantic extraction', () => { + it('extracts first-party message, tool, todo, and failure detail', () => { + const callId = CallId('call') + const messageContent: SessionEvent<'user/message'>['data']['content'] = [ + { type: 'text', text: ' visible ' }, + { type: 'reasoning', text: 'thought' }, + { type: 'tool-call', id: callId, name: 'read', arguments: '{"path":"a"}' }, + { + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'text', text: 'nested' }], + isError: false, + }, + { type: 'future-content', payload: 'hidden' } as never, + ] + const events: SessionEvent[] = [ + { type: 'user/message', seq: 0, time: 1, data: { content: messageContent, source: { kind: 'user' } }, surfaceOp: 'append' }, + { type: 'assistant/message', seq: 1, time: 2, data: { turn: 1, step: 1, content: messageContent }, surfaceOp: 'append' }, + { type: 'context/message', seq: 2, time: 3, data: { content: messageContent, source: { kind: 'plugin', plugin: 'test' } }, surfaceOp: 'append' }, + { type: 'steering/message', seq: 3, time: 4, data: { turn: 1, content: messageContent, source: { kind: 'user' } }, surfaceOp: 'append' }, + { type: 'prompt/blocked', seq: 4, time: 5, data: { content: [{ type: 'text', text: 'unsafe' }], source: { kind: 'user' }, reason: 'policy' } }, + { type: 'tool/call', seq: 5, time: 6, data: { turn: 1, step: 1, callId, name: 'bash', arguments: '{"cmd":"pwd"}' } }, + { type: 'tool/result', seq: 6, time: 7, data: { turn: 1, step: 1, callId, content: [{ type: 'text', text: 'failed' }], isError: true, error: { name: 'Oops', code: 'E_OOPS' } }, surfaceOp: 'append' }, + { type: 'tool/result', seq: 7, time: 8, data: { turn: 1, step: 1, callId, content: [], isError: false }, surfaceOp: 'append' }, + { type: 'todo/write', seq: 8, time: 9, data: { todos: [{ status: 'in_progress', content: 'ship search' }] } }, + ] + + for (const event of events.slice(0, 4)) { + expect(extractSessionEventText(event)).toBe('visible\nthought\nread\n{"path":"a"}\nnested') + } + expect(extractSessionEventText(events[4]!)).toBe('unsafe\npolicy') + expect(extractSessionEventText(events[5]!)).toBe('bash\n{"cmd":"pwd"}') + expect(extractSessionEventText(events[6]!)).toBe('failed\nOops\nE_OOPS') + expect(extractSessionEventText(events[7]!)).toBe('') + expect(extractSessionEventText(events[8]!)).toBe('in_progress\nship search') + }) + + it('extracts meaningful turn outcomes and skips structural or unknown events', () => { + const reasons: Array<[SessionEvent<'turn/end'>['data']['reason'], string]> = [ + [{ kind: 'error', step: 2, message: 'boom', code: 'E' }, 'error\nboom\nE'], + [{ kind: 'error', step: 2, message: 'boom' }, 'error\nboom'], + [{ kind: 'aborted', reason: 'cancelled' }, 'aborted\ncancelled'], + [{ kind: 'aborted' }, 'aborted'], + [{ kind: 'rejected', reason: 'denied' }, 'rejected\ndenied'], + [{ kind: 'disposed' }, 'disposed'], + [{ kind: 'max-tokens' }, 'max-tokens'], + [{ kind: 'interrupted' }, 'interrupted'], + [{ kind: 'completed' }, ''], + [{ kind: 'future-status' } as never, ''], + ] + for (const [reason, text] of reasons) { + expect(extractSessionEventText({ type: 'turn/end', seq: 0, time: 1, data: { turn: 1, reason } })).toBe(text) + } + const structural: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } }, + { type: 'step/end', seq: 2, time: 1, data: { turn: 1, step: 1 } }, + { type: 'assistant/chunk', seq: 3, time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } }, + { type: 'request/header', seq: 4, time: 1, data: { header: { config: { model: 'test' } }, reason: 'initial' } }, + { type: 'request/header-delta', seq: 5, time: 1, data: {} }, + { type: 'future/event', seq: 6, time: 1, data: { text: 'hidden' } } as never, + ] + expect(structural.map(extractSessionEventText)).toEqual(['', '', '', '', '', '', '']) + }) +}) + +describe('session-query document and filter helpers', () => { + const events: SessionEvent[] = [ + { type: 'user/message', seq: 0, time: 10, data: { content: [{ type: 'text', text: 'Hello\n(AI)+' }], source: { kind: 'user' } }, surfaceOp: 'append' }, + { type: 'assistant/chunk', seq: 1, time: 11, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } }, + { type: 'assistant/message', seq: 2, time: 12, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] }, surfaceOp: { op: 'replace', start: 0, end: 0 } }, + { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'interrupted' } } }, + ] + + it('classifies every event and omits non-semantic documents', () => { + expect(buildSessionEventRecords(id, events).map(record => record.surface)) + .toEqual(['shadowed', 'log-only', 'current', 'log-only']) + const documents = buildSessionEventSearchDocuments(id, events) + expect(documents.map(document => [document.seq, document.text, document.surface])).toEqual([ + [0, 'Hello\n(AI)+', 'shadowed'], + [2, 'replacement', 'current'], + [3, 'interrupted', 'log-only'], + ]) + }) + + it('applies every session clause with OR values and validates closed values', () => { + const parent = SessionId('parent') + const records = [ + { header: header('a', { cwd: '/a', parentSession: parent }), live: true, persisted: false, marker: 1 }, + { header: header('b', { createdAt: 20 }), live: false, persisted: true, marker: 2 }, + ] + expect(filterSessionResults(records, [ + { kind: 'id', values: [SessionId('a'), SessionId('x')] }, + { kind: 'cwd', values: ['/a', null] }, + { kind: 'created-at', from: 5, to: 15 }, + { kind: 'parent', values: [parent, null] }, + { kind: 'availability', values: ['live'] }, + ])).toEqual([records[0]]) + expect(filterSessionResults(records, [{ kind: 'cwd', values: [null] }])).toEqual([records[1]]) + expect(filterSessionResults(records, [{ kind: 'parent', values: [null] }])).toEqual([records[1]]) + expect(filterSessionResults(records, [{ kind: 'availability', values: ['persisted'] }])).toEqual([records[1]]) + expect(() => filterSessionResults(records, [{ kind: 'availability', values: ['remote' as never] }])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + }) + + it('applies event metadata and safe literal text clauses', () => { + const documents = buildSessionEventSearchDocuments(id, events).map((document, marker) => ({ ...document, marker })) + expect(filterSessionEventDocuments(documents, [ + { kind: 'seq', from: 0, to: 1 }, + { kind: 'time', from: 9, to: 11 }, + { kind: 'type', values: ['user/message', 'tool/result'] }, + { kind: 'surface', values: ['shadowed'] }, + { kind: 'text', text: 'hello (ai)+' }, + ])).toEqual([documents[0]]) + expect(compileSessionTextFilter('CAFÉ').test('café')).toBe(true) + expect(filterSessionEventDocuments(documents)).toEqual(documents) + expect(filterSessionEventDocuments(documents, [{ kind: 'surface', values: [] }])).toEqual([]) + expect(() => filterSessionEventDocuments(documents, [{ kind: 'surface', values: ['future' as never] }])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => compileSessionTextFilter(' \n ')).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + }) + + it('rejects malformed range filters and malformed surfaces', () => { + const documents = buildSessionEventSearchDocuments(id, events) + for (const filter of [ + { kind: 'seq', from: Number.NaN }, + { kind: 'seq', to: Number.POSITIVE_INFINITY }, + { kind: 'time', from: 2, to: 1 }, + ] as const) { + expect(() => filterSessionEventDocuments(documents, [filter])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + } + expect(() => filterSessionResults([], [{ kind: 'created-at', from: Number.NaN }])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => filterSessionResults([{ header: header('x'), live: true, persisted: false }], [ + { kind: 'created-at', from: Number.NaN }, + ])).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + const malformed: SessionEvent[] = [{ + type: 'assistant/message', + seq: 0, + time: 1, + data: { turn: 1, step: 1, content: [{ type: 'text', text: 'bad' }] }, + surfaceOp: { op: 'replace', start: 9, end: 9 }, + }] + expect(() => buildSessionEventRecords(id, malformed)).toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) + }) + + it('exposes the scan path on the concrete exact-read service', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionQueryService) + const session = ctx.sessions.create(id) + session.append('user/message', { content: [{ type: 'text', text: 'Alpha\n beta' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + session.append('user/message', { content: [{ type: 'text', text: 'other' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + await expect(ctx.sessionQuery.filterEvents(id, [{ kind: 'text', text: 'alpha beta' }])) + .resolves.toMatchObject([{ seq: 0, text: 'Alpha\n beta' }]) + }) +}) + +class TestSearchService extends SessionSearchService { + searchSessions(_request: SessionSearchRequest, _exec?: SessionSearchExecContext): Promise> { + return Promise.resolve({ items: [] }) + } + + searchEvents(_request: SessionEventSearchRequest, _exec?: SessionSearchExecContext): Promise> { + return Promise.resolve({ items: [] }) + } +} + +it('registers the abstract search seam under its independent ctx key', async () => { + const ctx = new Context() + const fiber = await ctx.plugin(TestSearchService) + await expect(ctx.sessionSearch.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] }) + await expect(ctx.sessionSearch.searchEvents({ sessionId: id, query: 'AI' })).resolves.toEqual({ items: [] }) + await fiber.dispose() + expect(ctx.sessionSearch).toBeUndefined() +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 456ce0896b..997f00ff5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -778,6 +778,31 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/session-query/session-query-sqlite: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-persistence-sqlite': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-sqlite + '@deepseek-ai/dsh-session-query': + specifier: workspace:^ + version: link:../session-query + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + packages/skill/skill: dependencies: schemastery: diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 05eaaed337..413917aa9a 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -93,7 +93,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'session', title: 'In-memory session store', mode: 'core', - consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'subagent-inprocess', 'invariants'], + consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants'], note: 'Owns append-only Session instances and emits the durable session event feed.', }, { @@ -102,7 +102,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Durable session persistence seam', mode: 'seam', implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'], - consumers: ['agent-loop', 'acp', 'session-query'], + consumers: ['agent-loop', 'acp', 'session-query', 'session-query-sqlite'], note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.', }, { @@ -110,7 +110,15 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'session-query', title: 'Exact session-history reads', mode: 'seam', - note: 'Resolves live and optional persisted logs into one logical corpus for exact reads.', + note: 'Resolves live and optional persisted logs into one logical corpus for exact reads and semantic scans.', + }, + { + key: 'sessionSearch', + pkg: 'session-query', + title: 'Full-text session search', + mode: 'seam', + implementations: ['session-query-sqlite'], + note: 'The concrete backend owns source reconciliation, ranking, snippets, and cursor generations as one lifecycle.', }, { key: 'systemPrompt', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 7b83f1004e..0d0a0b871d 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -49,6 +49,14 @@ { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionResultFilter", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventResultFilter", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchDocument", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchRequest", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchRequest", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchPage", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchHit", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchHit", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryErrorCode", "source": "packages/session-query/session-query/src/config.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventReadRequest", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventWindow", "source": "packages/session-query/session-query/src/types.ts" }, diff --git a/tsconfig.build.json b/tsconfig.build.json index 7797998a30..d2470e0ff5 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -19,6 +19,7 @@ { "path": "./packages/session-persistence/session-persistence-jsonl" }, { "path": "./packages/session-persistence/session-persistence-sqlite" }, { "path": "./packages/session-query/session-query" }, + { "path": "./packages/session-query/session-query-sqlite" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/ui/user-interaction" }, diff --git a/tsconfig.json b/tsconfig.json index 778a26ff8a..c62cc70518 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,7 @@ { "path": "./packages/session-persistence/session-persistence-jsonl" }, { "path": "./packages/session-persistence/session-persistence-sqlite" }, { "path": "./packages/session-query/session-query" }, + { "path": "./packages/session-query/session-query-sqlite" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, { "path": "./packages/ui/user-interaction" }, From f88ca85ffdd5c3b86ff859bda2c9f05967ade11e Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 12:10:24 +0800 Subject: [PATCH 02/19] fix(session-query): harden SQLite search reconciliation --- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 8 +- docs/core-data-structures/persistence.md | 19 +- docs/core-data-structures/session-query.md | 12 +- docs/module-graph.md | 6 +- .../2026-07-10-session-query-service.md | 2 +- ...026-07-10-sqlite-session-query-provider.md | 16 +- .../cordis/tool-cordis/src/api-catalog.ts | 20 +- .../session-persistence-jsonl/README.md | 1 + .../session-persistence-jsonl/src/index.ts | 40 +- .../session-persistence-sqlite/README.md | 3 +- .../session-persistence-sqlite/src/index.ts | 24 +- .../session-persistence-sqlite/src/schema.ts | 19 +- .../tests/sqlite.spec.ts | 17 +- .../session-persistence/README.md | 7 +- .../session-persistence/package.json | 2 + .../session-persistence/src/coordinator.ts | 4 +- .../session-persistence/src/index.ts | 19 + .../session-persistence/src/revision.ts | 15 + .../session-persistence/tests/contract.ts | 22 +- .../tests/persistence.spec.ts | 12 +- .../session-persistence/tsconfig.json | 3 + .../session-query-sqlite/README.md | 10 +- .../session-query-sqlite/src/index.ts | 326 ++++++++++++---- .../session-query-sqlite/src/query.ts | 151 ++++++- .../session-query-sqlite/src/schema.ts | 11 +- .../session-query-sqlite/tests/query.spec.ts | 65 +++- .../session-query-sqlite/tests/sqlite.spec.ts | 367 +++++++++++++++++- .../session-query/session-query/README.md | 3 +- .../session-query/session-query/package.json | 2 + .../session-query/session-query/src/corpus.ts | 21 +- .../session-query/session-query/src/cursor.ts | 15 + .../session-query/src/filters.ts | 121 +++++- .../session-query/session-query/src/index.ts | 62 ++- .../session-query/session-query/src/types.ts | 9 +- .../tests/search-helpers.spec.ts | 27 ++ .../session-query/tests/session-query.spec.ts | 67 +++- .../session-query/session-query/tsconfig.json | 3 + pnpm-lock.yaml | 6 + scripts/type-equiv.manifest.json | 3 + 40 files changed, 1315 insertions(+), 227 deletions(-) create mode 100644 packages/session-persistence/session-persistence/src/revision.ts create mode 100644 packages/session-query/session-query/src/cursor.ts diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3a802231d7..e8a06e469e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -613,7 +613,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-query/session-query-sqlite/src/index.ts:58`](../packages/session-query/session-query-sqlite/src/index.ts) +Source: [`packages/session-query/session-query-sqlite/src/index.ts:67`](../packages/session-query/session-query-sqlite/src/index.ts) ## `@deepseek-ai/dsh-skill` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index fef691aae1..e83b96bd11 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -195,11 +195,12 @@ abstract create(meta: SessionHeader): Promise abstract append(id: SessionId, events: readonly SessionEvent[]): Promise abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> abstract list(): Promise +abstract listSnapshots(): Promise ``` Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:102`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:112`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` @@ -207,12 +208,13 @@ Live-preferred logical-corpus and exact-event read service. ```ts cordis-catalog listSessions(): Promise +async filterSessions(filters: readonly SessionResultFilter[]): Promise async listEvents(sessionId: SessionId): Promise async filterEvents( sessionId: SessionId, filters: readonly SessionEventResultFilter[], ): Promise async readEvent(request: SessionEventReadRequest): Promise ``` -Source: [`packages/session-query/session-query/src/index.ts:83`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:96`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessions` — `SessionStore` @@ -244,7 +246,7 @@ abstract searchSessions( request: SessionSearchRequest, exec?: SessionSearchExec abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise> ``` -Source: [`packages/session-query/session-query/src/index.ts:54`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:67`](../../packages/session-query/session-query/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 7bf102924b..9535686f86 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -2,7 +2,7 @@ The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. The event vocabulary the log carries is enumerated, member by member, in the generated [persistence log event catalog](../persistence-catalog.md). -The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining create/append/load/list over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md). +The seam is a textbook [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining create/append/load plus lightweight list/snapshot observation over the existing `SessionEvent` — **no parallel persisted event type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence RFC](../rfc/implemented/architecture/2026-06-14-session-persistence.md). ## The flush checkpoint @@ -78,9 +78,24 @@ interface CreateSessionOptions { Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resuming a *persisted* session into a live agent is `ctx.agents.resume({ resumeSessionId })`. +## Lightweight source revisions + +Consumers of derived state compare a cheap opaque revision before loading a full event log. The persistence backend owns its representation and changes it transactionally with append or mutating load repair; callers compare it only for equality. + +```ts type-equiv +export type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'> +``` + +```ts type-equiv +export interface SessionPersistenceSnapshot { + header: SessionHeader + revision: SessionPersistenceRevision +} +``` + ## The backends -Both implement the same abstract `SessionPersistence` (create/append/load/list over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: +Both implement the same abstract `SessionPersistence` (create/append/load/list/listSnapshots over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: - **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)** — an append-only JSONL log per session with crash-safe atomic writes, the interrupted-turn crash recovery above, and a read/replay path. - **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data, source_event_seqs, surface_op)` maps 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync. diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index 27ef9959de..d1ed41b775 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -58,19 +58,23 @@ export interface SessionEventSearchDocument extends SessionEventRecord { } ``` -`ctx.sessionQuery.filterEvents(sessionId, filters)` returns these documents in ascending seq order. Messages, reasoning, tool calls/results, blocked prompts, todos, and failure/status detail contribute semantic text; structural events and stream chunks do not. +`ctx.sessionQuery.filterSessions(filters)` applies `SessionResultFilter` to the complete logical corpus; `ctx.sessionQuery.filterEvents(sessionId, filters)` returns matching documents in ascending seq order. Messages, reasoning, tool calls/results, blocked prompts, todos, and failure/status detail contribute semantic text; structural events and stream chunks do not. ## Full-text search pages The independent `ctx.sessionSearch` seam has two scopes. `searchSessions()` groups the corpus by strongest matching event; `searchEvents()` searches one session. Requests bind an opaque cursor to the normalized query, metadata filters, and limit. The event text scan is intentionally absent from provider metadata filters. +```ts type-equiv +export type SessionSearchCursor = Branded<'SessionSearchCursor'> +``` + ```ts type-equiv export interface SessionSearchRequest { query: string sessionFilters?: readonly SessionResultFilter[] eventFilters?: readonly SessionEventMetadataFilter[] limit?: number - cursor?: string + cursor?: SessionSearchCursor } ``` @@ -80,14 +84,14 @@ export interface SessionEventSearchRequest { query: string filters?: readonly SessionEventMetadataFilter[] limit?: number - cursor?: string + cursor?: SessionSearchCursor } ``` ```ts type-equiv export interface SessionSearchPage { items: readonly T[] - nextCursor?: string + nextCursor?: SessionSearchCursor } ``` diff --git a/docs/module-graph.md b/docs/module-graph.md index 6fcec53dd4..7ad4cd8c70 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -151,6 +151,7 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_web_search_exa --> pkg_web pkg_web_search_perplexity --> pkg_web + pkg_session_persistence --> pkg_brand pkg_session_persistence --> pkg_session pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session @@ -168,6 +169,7 @@ flowchart TD pkg_session_persistence_jsonl --> pkg_session_persistence pkg_session_persistence_sqlite --> pkg_session pkg_session_persistence_sqlite --> pkg_session_persistence + pkg_session_query --> pkg_brand pkg_session_query --> pkg_llm pkg_session_query --> pkg_session pkg_session_query --> pkg_session_persistence @@ -361,7 +363,7 @@ flowchart TD | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`web`](../packages/web/web) | -| [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`session`](../packages/core/session) | +| [`session-persistence`](../packages/session-persistence/session-persistence) | `session-persistence` | [`brand`](../packages/util/brand), [`session`](../packages/core/session) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`timeout`](../packages/util/timeout) | @@ -369,7 +371,7 @@ flowchart TD | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | `session-persistence` | [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | -| [`session-query`](../packages/session-query/session-query) | `session-query` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | +| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) | | [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | diff --git a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md index be496311cc..10baa3e2c3 100644 --- a/docs/rfc/implemented/feature/2026-07-10-session-query-service.md +++ b/docs/rfc/implemented/feature/2026-07-10-session-query-service.md @@ -10,7 +10,7 @@ Full-text search is related but materially larger. Putting provider coordination ## Decision -`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a small trusted exact-read service over one logical corpus. It exposes `listSessions()`, `listEvents(sessionId)`, provider-independent `filterEvents(sessionId, filters)`, and bounded `readEvent(request)`. The package also declares the separate abstract `ctx.sessionSearch` contract and shared semantic extraction used by the [SQLite search decision](2026-07-10-sqlite-session-query-provider.md), but `ctx.sessionQuery` does not coordinate providers or synchronize a derived index. +`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a small trusted exact-read service over one logical corpus. It exposes `listSessions()`, provider-independent `filterSessions(filters)`, `listEvents(sessionId)`, `filterEvents(sessionId, filters)`, and bounded `readEvent(request)`. The package also declares the separate abstract `ctx.sessionSearch` contract and shared semantic extraction used by the [SQLite search decision](2026-07-10-sqlite-session-query-provider.md), but `ctx.sessionQuery` does not coordinate providers or synchronize a derived index. The service observes the optional `ctx.sessionPersistence` binding dynamically but retains no persisted cache or invalidation listener. Each cross-corpus list asks the active backend for authoritative metadata, then overlays a fresh live-store list. Matching ids become one `SessionRecord`: the live header wins and `live`/`persisted` independently report source availability. Immutable header disagreement is `SESSION_QUERY_SOURCE_CONFLICT`. diff --git a/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md b/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md index d620ece108..fafa7f3569 100644 --- a/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md +++ b/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md @@ -10,19 +10,19 @@ Splitting those concerns across a provider coordinator and a database implementa ## Decision -`@deepseek-ai/dsh-session-query` declares an independent abstract `ctx.sessionSearch` service without changing the exact-read `ctx.sessionQuery` key. `searchSessions(request, exec?)` returns cursor-paginated `SessionSearchHit`s grouped by each session's strongest matching event; `searchEvents(request, exec?)` returns `SessionEventSearchHit`s within one logical session. Both requests require `query`, accept `limit` and an opaque `cursor`, and support an optional abort signal. Session search accepts `sessionFilters` plus event metadata filters; event search accepts event metadata filters. Results expose bounded plain-text snippets but no provider identifier or numeric relevance score. +`@deepseek-ai/dsh-session-query` declares an independent abstract `ctx.sessionSearch` service without changing the exact-read `ctx.sessionQuery` key. `searchSessions(request, exec?)` returns cursor-paginated `SessionSearchHit`s grouped by each session's strongest matching event; `searchEvents(request, exec?)` returns `SessionEventSearchHit`s within one logical session. Both requests require `query`, accept `limit` and an owned branded `SessionSearchCursor`, and support an optional abort signal. Session search accepts `sessionFilters` plus event metadata filters; event search accepts event metadata filters. Results expose bounded plain-text snippets but no provider identifier or numeric relevance score. `@deepseek-ai/dsh-session-query-sqlite` is the sole concrete owner of `ctx.sessionSearch`. It depends on live `ctx.sessions`, observes optional `ctx.sessionPersistence` dynamically, and owns a dedicated derived SQLite database. There is no search-provider registry, coordinator, persistence event, or agent-loop integration. -The interface package also owns shared first-party semantic extraction and provider-independent filtering. `SessionResultFilter` covers id, nullable cwd, created-at range, nullable parent, and availability. `SessionEventResultFilter` covers seq/time ranges, event type, surface, and literal semantic text. Arrays are ANDed and list values are ORed. The text clause escapes caller input into a Unicode case-insensitive regular expression whose whitespace runs match one or more whitespace characters; it is available through `ctx.sessionQuery.filterEvents()` and is not delegated to an FTS provider. +The interface package also owns shared first-party semantic extraction and provider-independent filtering. `SessionResultFilter` covers id, nullable cwd, created-at range, nullable parent, and availability; `ctx.sessionQuery.filterSessions()` applies it without an FTS provider. `SessionEventResultFilter` covers seq/time ranges, event type, surface, and literal semantic text. Arrays are ANDed and list values are ORed. The text clause escapes caller input into a Unicode case-insensitive regular expression whose whitespace runs match one or more whitespace characters; it is available through `ctx.sessionQuery.filterEvents()` and is not delegated to an FTS provider. ## Search semantics Each semantic event is one FTS document carrying session metadata, event metadata, surface classification, and extracted text. All `current`, `shadowed`, and `log-only` documents participate unless a surface filter narrows them. Metadata filters compile to parameterized SQL before ranking. Session results partition matching documents by session and retain the strongest one. -Ordering is deterministic: BM25 ascending, event time descending, session id ascending for the cross-session scope, and seq descending. Provider scores remain private. Snippets normalize whitespace and are bounded by Unicode code points. Opaque cursors bind to the service instance, scope, canonical normalized request, offset, and relevant generation. Any corpus change invalidates cross-session cursors; a within-session cursor changes only when its target source/generation changes, so unrelated sessions do not invalidate it. Reopening creates a new service instance and invalidates old cursors. +Ordering is deterministic and comparable across the persistent and TEMP FTS tables: actual FTS5 highlighted-match span count descending, indexed document code-point length ascending, event time descending, session id ascending for the cross-session scope, and seq descending. Snippets use those actual highlight positions, strip the reserved markers, normalize whitespace, and bound by Unicode code points. Opaque cursors bind to the service instance, scope, canonical normalized request, offset, and relevant generation. Any corpus change invalidates cross-session cursors; a within-session cursor changes only when its target source/generation changes, so unrelated sessions do not invalidate it. Reopening creates a new service instance and invalidates old cursors. -Queries are trimmed, whitespace-normalized, and quoted as one literal FTS5 phrase. Embedded quotes are doubled before binding, so MATCH operators such as `OR`, `NEAR`, quotes, parentheses, and `*` remain data rather than executable query syntax. Phrase matching follows tokenizer tokens rather than arbitrary substrings. +Queries are trimmed, whitespace-normalized, and quoted as one literal FTS5 phrase. Embedded quotes are doubled before binding, so MATCH operators such as `OR`, `NEAR`, quotes, parentheses, and `*` remain data rather than executable query syntax. NUL is rejected before SQLite execution. Reserved highlight noncharacters and NUL in documents are normalized before indexing, making inserted presentation markers collision-free. Phrase matching follows tokenizer tokens rather than arbitrary substrings. ## Tokenizer choice @@ -32,11 +32,11 @@ Both persistent and live FTS5 tables use `unicode61`. The implementation experim The shared extractor includes message text, reasoning, nested tool-call/result content, tool names and arguments, blocked-prompt reasons, todo status/content, and error or terminal status detail. Structural boundaries, stream chunks, request headers, successful completion markers, and unknown declaration-merged event/content variants produce no document. Surface classification reuses `foldSurface()` so search agrees with model-history derivation. -One serialized operation observes complete persisted and live sources, computes stable fingerprints, reconciles rows in one transaction, and executes the query. Unchanged persisted sessions retain their rows and generation. New, changed, and deleted persisted sessions update on the next search. A source or extraction failure cannot mark a row current, and a transaction failure rolls back so a later search retries. +One serialized operation reads the provider-neutral `SessionPersistence` snapshot listing, compares each opaque revision with the revision stored beside the indexed session, loads only new or changed logs, reconciles rows in one transaction, and executes the query. Observation repeats when listing changes during a load; this incorporates a mutating load repair's refreshed revision before commit. Repeated queries and unchanged reopen load no full persisted logs. New, changed, and deleted sessions update on the next stable search. A source or extraction failure cannot mark a row current, and a transaction failure rolls back so a later search retries. Persisted documents survive restarts. Live sessions use connection-local TEMP tables, shadow the persisted base for the same id, and reveal that base on detach. Closing the database drops live rows. Unmounting persistence hides durable rows without treating absence as authoritative deletion; remounting observes and reconciles the backend again. Conflicting immutable live and durable headers fail rather than combining sources. -The derived schema has its own application id and monotonic schema version. A recognized incompatible version resets only this derived database. A database with a foreign application id or unrecognized user tables is refused, which prevents an accidentally configured canonical session database from being reset. +The derived schema has its own application id and monotonic schema version. A recognized incompatible version resets only this derived database. A database with a foreign application id or unrecognized user tables is refused before journal-mode mutation, which prevents an accidentally configured canonical session database from being changed. One service in one process exclusively owns a derived-index path; cross-process writers are unsupported because generations and live TEMP shadow state are connection-owned. Cancellation rejects queued operations and caller waits around asynchronous source observation without committing an aborted observation. Node's synchronous `DatabaseSync` MATCH call cannot be interrupted once it is executing on the JavaScript thread, so the service checks the signal at serialized boundaries but does not promise mid-statement preemption. @@ -46,11 +46,11 @@ Cancellation rejects queued operations and caller waits around asynchronous sour - **Add a phase-one provider registry and coordinator** — rejected because one implementation provides no evidence for registration semantics and would split one reconciliation lifecycle across two owners. - **Persist live overrides immediately** — rejected because live events are not canonical until the existing checkpoint commits. - **Use the FTS5 trigram tokenizer** — rejected because it omits useful queries shorter than three characters and measured about 2.1× the index size of `unicode61`; literal substring filtering remains available through the scan path. -- **Return BM25 scores** — rejected because provider-specific numeric scales are unstable across corpus changes. +- **Use FTS5 BM25 independently in each table** — rejected because scores from differently populated persistent and TEMP corpora are not comparable; actual matched spans and document length have one shared scale. ## Consequences -Search has a small provider-neutral API while its only backend owns every derived-index state transition. The separate database adds configuration and a reconciliation read before queries, but index corruption, reset, and tokenizer changes cannot endanger canonical logs. Persistent fingerprints avoid rewriting unchanged sessions; TEMP live overlays preserve current-session truth without making uncheckpointed events durable. +Search has a small provider-neutral API while its only backend owns every derived-index state transition. The separate database adds configuration and a lightweight snapshot read before queries, but index corruption, reset, and tokenizer changes cannot endanger canonical logs. Durable revisions avoid full-log reads and rewrites for unchanged sessions; TEMP live overlays preserve current-session truth without making uncheckpointed events durable. The chosen tokenizer supports short tokens with a smaller index but does not promise substring recall. Literal phrases make query syntax safe and predictable at the cost of excluding boolean/full MATCH expressions. Cancellation is effective while queued or awaiting sources, but synchronous SQLite execution remains a non-preemptible section. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2859353cfa..7322698cdd 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -149,6 +149,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'abstract append(id: SessionId, events: readonly SessionEvent[]): Promise', 'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', 'abstract list(): Promise', + 'abstract listSnapshots(): Promise', ], }, { @@ -156,6 +157,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Live-preferred logical-corpus and exact-event read service.', methods: [ 'listSessions(): Promise', + 'async filterSessions(filters: readonly SessionResultFilter[]): Promise', 'async listEvents(sessionId: SessionId): Promise', 'async filterEvents( sessionId: SessionId, filters: readonly SessionEventResultFilter[], ): Promise', 'async readEvent(request: SessionEventReadRequest): Promise', @@ -834,7 +836,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEventSearchRequest', - declaration: 'export interface SessionEventSearchRequest {\n sessionId: SessionId;\n query: string;\n filters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: string;\n}', + declaration: 'export interface SessionEventSearchRequest {\n sessionId: SessionId;\n query: string;\n filters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: SessionSearchCursor;\n}', }, { name: 'SessionEventSurface', @@ -860,6 +862,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionId', declaration: 'export type SessionId = Branded<\'SessionId\'>;', }, + { + name: 'SessionPersistenceRevision', + declaration: 'export type SessionPersistenceRevision = Branded<\'SessionPersistenceRevision\'>;', + }, + { + name: 'SessionPersistenceSnapshot', + declaration: 'export interface SessionPersistenceSnapshot {\n header: SessionHeader;\n revision: SessionPersistenceRevision;\n}', + }, { name: 'SessionRecord', declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}', @@ -872,6 +882,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionResultRange', declaration: 'export interface SessionResultRange {\n from?: number;\n to?: number;\n}', }, + { + name: 'SessionSearchCursor', + declaration: 'export type SessionSearchCursor = Branded<\'SessionSearchCursor\'>;', + }, { name: 'SessionSearchExecContext', declaration: 'export interface SessionSearchExecContext {\n signal?: AbortSignal;\n}', @@ -882,11 +896,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionSearchPage', - declaration: 'export interface SessionSearchPage {\n items: readonly T[];\n nextCursor?: string;\n}', + declaration: 'export interface SessionSearchPage {\n items: readonly T[];\n nextCursor?: SessionSearchCursor;\n}', }, { name: 'SessionSearchRequest', - declaration: 'export interface SessionSearchRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: string;\n}', + declaration: 'export interface SessionSearchRequest {\n query: string;\n sessionFilters?: readonly SessionResultFilter[];\n eventFilters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: SessionSearchCursor;\n}', }, { name: 'SkillCandidate', diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 990ad2fcc4..0be2c785b3 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -25,6 +25,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence - **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`. - **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). - **Contiguous-seq.** `load` rejects a mid-log parse error or `seq` gap (unloadable); `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. +- **Lightweight revisions.** `listSnapshots()` reads each header and returns an opaque identity from the file device/inode, size, and nanosecond mtime/ctime. Under the append-only single-writer contract, unchanged files retain revisions while append, repair, or replacement changes them without parsing event lines. - **Format version.** Only the current `SESSION_FORMAT_VERSION` (v0) is supported; `load` rejects any other version. While the harness is unreleased the on-disk format is pre-release/unstable: a breaking format change is absorbed at v0 (no bump until the first tagged release) and non-current logs are rejected — there is no migration (no persisted user data to preserve). ## Write path diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index bd31e0ae47..0aea7927d1 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -11,7 +11,7 @@ * (the `session/event` → buffer → `session/flush` drain, per-session * serialization, write cursors, fork-seed persistence, HMR live-adoption, * crash-repair sequencing, dispose quiescence) lives in the backend-agnostic - * {@link PersistenceCoordinator} this class composes. The four public + * {@link PersistenceCoordinator} this class composes. The stateful public * {@link SessionPersistence} methods delegate to the coordinator. * * @module @deepseek-ai/dsh-session-persistence-jsonl @@ -19,12 +19,12 @@ import { Context } from 'cordis' import z from 'schemastery' -import { open, mkdir, readFile, readdir, link, rm, truncate } from 'node:fs/promises' +import { open, mkdir, readFile, readdir, link, rm, stat, truncate } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { - SessionPersistence, PersistenceCoordinator, - type PersistenceBackend, type StoredPrefix, + SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, + type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -179,20 +179,44 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi /** List all stored sessions' metadata (header line only — no full-log parse). */ async list(): Promise { - const metas: SessionHeader[] = [] + return (await this.listArtifacts()).map(artifact => artifact.header) + } + + /** List metadata plus a stat-derived identity for each append-only log. */ + async listSnapshots(): Promise { + const snapshots: SessionPersistenceSnapshot[] = [] + for (const artifact of await this.listArtifacts()) { + const identity = await stat(artifact.path, { bigint: true }) + snapshots.push({ + header: artifact.header, + revision: SessionPersistenceRevision([ + identity.dev, + identity.ino, + identity.size, + identity.mtimeNs, + identity.ctimeNs, + ].join(':')), + }) + } + return snapshots + } + + private async listArtifacts(): Promise> { + const artifacts: Array<{ header: SessionHeader; path: string }> = [] for (const dir of await this.listCwdDirs()) { for (const name of await this.listJsonl(dir)) { // Read ONLY the header line, not the whole log: a session picker must // scale with the number of sessions, not the total size of every // conversation (the log persists every assistant/chunk verbatim). - const first = await this.readFirstLine(`${dir}/${name}`) + const path = `${dir}/${name}` + const first = await this.readFirstLine(path) if (first === undefined) continue // empty/half-written file const meta = parseHeaderMeta(first) if (meta === undefined) continue // not a session header - metas.push(meta) + artifacts.push({ header: meta, path }) } } - return metas + return artifacts } // --- materialization / append / repair (file mechanics) --- diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 082b60af88..6e6006dc6b 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -6,7 +6,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i ## Storage model -Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) lives in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row), so no separate column is needed. +Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) and a monotonic per-log revision live in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). The repo's `engines.node` is `^22.19.0 || >=24.0.0` (Node 22.19+ or 24+), matching the LTS floor required by the installed Pi adapter dependency; `node:sqlite` itself ships without the `--experimental-sqlite` flag from Node 22.13 (LTS) and 23.4 / 24 (Current) on. The range deliberately excludes Node 23 because that line is non-LTS/EOL and still has flagged runtime features before 23.6. The database opens with `foreign_keys = ON` (so `ON DELETE CASCADE` drops a session's events with its row) and the configured `journal_mode` (default `wal`; pick a rollback-journal mode like `delete` on filesystems where WAL's shared-memory files do not work, e.g. network mounts). The table-layout version is stored in `PRAGMA user_version` and checked on open: a fresh database is stamped with the current `SCHEMA_VERSION`; a database written by any other, incompatible build (a non-current `user_version`, older or newer) is rejected rather than opened against an unknown layout — there is no migration (unreleased software). @@ -14,6 +14,7 @@ The repo's `engines.node` is `^22.19.0 || >=24.0.0` (Node 22.19+ or 24+), matchi - **Append = a transaction.** `append` runs `BEGIN`/`COMMIT` around the batch: it materializes the `sessions` row (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event's `seq` must equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (`load()` already balanced the stored log, so `append` never has to repair a crash tail.) - **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row). +- **Lightweight revisions.** `listSnapshots()` reads the monotonic revision stored beside each session header. Append and mutating load repair increment it in the same transaction as their event changes, so unchanged observations are stable and no full-log count or parse is required. - **Interrupted-turn close on load.** `load()` reads every stored event ordered by `seq` and finds the longest seq-contiguous, parseable prefix — INCLUDING the real events of an interrupted final turn after the last `turn/end` (the loop only flushes at `turn/end`, so a process killed mid-turn leaves real, fully-written rows past it). A single turn can be huge in a long-horizon task, so those events are **preserved, never truncated**: `load()` CLOSES the orphaned turn by durably appending the minimal synthetic boundary events (an error `tool/result` for every assistant tool call left unanswered, a `step/end` if a step was open, then a `turn/end` carrying `{ kind: 'interrupted' }`), inside one transaction that also DELETEs any never-fully-written torn tail row. `load()` is therefore mutating — after it the stored rows are balanced and the cursor is truthful, so the next `append` continues cleanly. The boundary (last `turn/end`, torn-tail detection) is computed from the `seq`/`type` columns so a malformed `data` in a torn tail row is never parsed (discarded, not unloadable). A parse error or `seq` gap inside the committed region (at or before the last real `turn/end`) makes the session unloadable. A session whose only turn never closed keeps its metadata row and stays present in `list()` — the same as the JSONL backend, whose file likewise survives a first append that never reached `turn/end`. ## Configuration (schemastery) diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 30387b4837..3a1c7ffbcc 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -11,7 +11,7 @@ * Like the JSONL backend it supplies ONLY the storage primitives (the * {@link PersistenceBackend} hooks below — INSERT/DELETE/SELECT inside * transactions); all the write-path orchestration lives in the backend-agnostic - * {@link PersistenceCoordinator} this class composes. The four public + * {@link PersistenceCoordinator} this class composes. The stateful public * {@link SessionPersistence} methods delegate to the coordinator. * * @module @deepseek-ai/dsh-session-persistence-sqlite @@ -23,8 +23,8 @@ import { DatabaseSync } from 'node:sqlite' import { mkdir } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { - SessionPersistence, PersistenceCoordinator, - type PersistenceBackend, type StoredPrefix, + SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, + type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { Session, SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -181,6 +181,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers const [surfaceSeqs, surfaceOp] = surfaceBindings(event) insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp) } + this.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(meta.id) this.db.exec('COMMIT') } catch (error) { this.db.exec('ROLLBACK') @@ -209,6 +210,9 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp) } } + if (tornMarker !== undefined || closers.length > 0) { + this.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(meta.id) + } this.db.exec('COMMIT') } catch (error) { // The DELETE+INSERT cannot collide (a row at a closer's seq is preserved or @@ -230,6 +234,16 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return rows.map(rowToMeta) } + /** List metadata with an append-only event-count revision per session. */ + async listSnapshots(): Promise { + await this.ready + const rows = this.db.prepare('SELECT * FROM sessions').all() as unknown as SessionRow[] + return rows.map(row => ({ + header: rowToMeta(row), + revision: SessionPersistenceRevision(`revision:${row.revision}`), + })) + } + /** Close the database handle (awaited by the coordinator's dispose, post-drain). */ async close(): Promise { await this.ready @@ -250,8 +264,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers */ private writeRow(meta: SessionHeader): void { this.db.prepare(` - INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, revision) + VALUES (?, ?, ?, ?, ?, ?, 0) ON CONFLICT(id) DO UPDATE SET version = excluded.version, created_at = excluded.created_at, diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 2dacbe04a0..2f238b0131 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -15,7 +15,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 4 +export const SCHEMA_VERSION = 5 /** * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}). @@ -31,6 +31,8 @@ export interface SessionRow { cwd: string | null parent_session: string | null seed_length: number | null + /** Monotonic log-change token incremented in each mutating transaction. */ + revision: number } /** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */ @@ -67,15 +69,9 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' * current {@link SCHEMA_VERSION}; an existing database whose version is NOT the * current one (written by a different, incompatible build — older or newer) is * REJECTED rather than opened against a layout this build does not understand. - * There are no migrations: an earlier layout is not upgraded in place — it is - * rejected. v1 had a different `sessions` shape; v2 lacked all of - * `seed_length`/`source_event_seqs`/`surface_op`. v3 is SKIPPED: two unmerged - * branches each shipped a DISTINCT v3 (one adding only `seed_length`, the other - * adding only the surface columns), so an on-disk v3 is ambiguous — it could be - * either sibling layout, neither of which has all of this build's columns. v4 - * is the merged layout carrying every column; bumping past the collided v3 - * makes the version check reject both sibling v3 databases instead of opening - * one against columns it does not have. + * There are no migrations: an incompatible layout is rejected. The current + * sessions row carries every header field plus its monotonic snapshot revision; + * the events row carries the complete surface metadata. * @param path - the SQLite database file to open (created when absent). * @param journalMode - the journal pragma to apply — a closed in-code union, validated by the plugin Config. * @returns the open handle with pragmas applied and both tables ensured. @@ -105,7 +101,8 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy created_at INTEGER NOT NULL, cwd TEXT, parent_session TEXT, - seed_length INTEGER + seed_length INTEGER, + revision INTEGER NOT NULL ) STRICT `) db.exec(` diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index a6eda72685..98924ca691 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -258,11 +258,11 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { it('rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns', async () => { // Two unmerged branches each shipped a DISTINCT layout under user_version 3 // (one added only `seed_length`, the other only the surface columns). The - // merged build is v4; an on-disk v3 is ambiguous and is missing at least one + // the current build rejects every older layout; an on-disk v3 is ambiguous and is missing at least one // of this build's columns, so it MUST be rejected, not opened. Stamp a v3 // database and confirm the version check refuses it. const path = await freshDbPath() - openDatabase(path, 'wal').close() // creates + stamps user_version = SCHEMA_VERSION (4) + openDatabase(path, 'wal').close() // creates + stamps user_version = SCHEMA_VERSION const db = openDatabase(path, 'wal') db.exec('PRAGMA user_version = 3') db.close() @@ -338,7 +338,18 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(4) + expect(SCHEMA_VERSION).toBe(5) + }) + + it('keeps the revision stable for an empty repair hook', async () => { + const b = await backend() + const m = meta('empty-repair') + await b.ctx.sessionPersistence.create(m) + await b.ctx.sessionPersistence.append(m.id, oneTurnLog()) + const before = await b.ctx.sessionPersistence.listSnapshots() + await (b.ctx.sessionPersistence as SessionPersistenceSqlite).commitRepair(m, undefined, []) + expect(await b.ctx.sessionPersistence.listSnapshots()).toEqual(before) + await b.dispose() }) }) diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index bf7da03757..32958bfaaa 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -12,6 +12,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `append(id, events): Promise` | Durably persist a batch (from the `session/flush` drain). Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic closers — an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. | | `list(): Promise` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. | +| `listSnapshots(): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log is unchanged and changes after append or mutating load repair. | ## Invariants every backend must honor @@ -24,7 +25,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l The two first-party backends were byte-identical (or same-algorithm) for ALL of their write-path orchestration — the in-memory bookkeeping (per-id state, write-behind buffers, per-id serialization chains, per-session init promises), the `session/event` → buffer → `session/flush` drain, lazy materialization, crash-tail repair on load, the four `session/created` adoption cases (new / HMR-adopt / collision / ownerless-claim), and dispose-time quiescence. Only the STORAGE primitives differed (write bytes vs. INSERT rows). -`PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates its four public service methods to the coordinator. This keeps the duplicated, correctness-heavy orchestration in a single place (it used to receive the same fixes twice). +`PersistenceCoordinator` owns that orchestration once. A first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements the small `PersistenceBackend` hook interface, and delegates the stateful write/read methods to the coordinator. Lightweight snapshot listing remains a backend storage primitive because its revision identity is backend-owned. The `PersistenceBackend` hooks (the only seam between the coordinator and storage): @@ -38,11 +39,11 @@ The `PersistenceBackend` hooks (the only seam between the coordinato | `list()` | List all stored metadata. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | -The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). The public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator. See [the write-coordinator RFC](../../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). +The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must also provide trustworthy lightweight snapshot revisions. See [the write-coordinator RFC](../../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). ## Testing backends -Import `runPersistenceContract` from `tests/contract.ts` (the public-API contract) and `runCoordinatorContract` from `tests/coordinator-contract.ts` (the shared write-path orchestration: adoption, HMR, collision, dispose-drain, crash-tail repair) and call each with a fixture for your backend. Every backend is held to the same append-only / contiguous-seq / lazy-materialization / serializability semantics AND the same orchestration, so a backend's own spec is left with only storage-mechanics tests (path sanitization, fsync rollback; schema version, transaction rollback) on top. +Import `runPersistenceContract` from `tests/contract.ts` (the public API, including stable/change-sensitive lightweight revisions) and `runCoordinatorContract` from `tests/coordinator-contract.ts` (the shared write-path orchestration: adoption, HMR, collision, dispose-drain, crash-tail repair) and call each with a fixture for your backend. Every backend is held to the same append-only / contiguous-seq / lazy-materialization / serializability semantics AND the same orchestration, so a backend's own spec is left with only storage-mechanics tests (path sanitization, fsync rollback; schema version, transaction rollback) on top. Three backends run these suites: an in-memory reference (in `tests/`), `dsh-session-persistence-jsonl` (append-only file log) and `dsh-session-persistence-sqlite` (`node:sqlite`, each `SessionEvent` one row `(session_id, seq, type, time, data, source_event_seqs, surface_op)`). All passing the same contract + coordinator suite is the proof that the seam is genuinely backend-agnostic — lazy materialization, crash-tail-on-load, and contiguous-seq hold identically over file bytes and over a transactional store. diff --git a/packages/session-persistence/session-persistence/package.json b/packages/session-persistence/session-persistence/package.json index ed6c80dfd9..8b7140e221 100644 --- a/packages/session-persistence/session-persistence/package.json +++ b/packages/session-persistence/session-persistence/package.json @@ -22,10 +22,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "cordis": "^4.0.0-rc.6" } diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index b1fc118a21..7857b57a3c 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -14,7 +14,7 @@ * {@link PersistenceBackend} hook object. * * The abstract {@link SessionPersistence} service's public API is independent of - * this: a backend IS a `SessionPersistence` (its four public methods delegate to + * this: a backend IS a `SessionPersistence` (its write/read methods delegate to * a coordinator it composes), so a third-party backend MAY implement the service * directly without using the coordinator at all. * @@ -146,7 +146,7 @@ async function settledErrors(promises: Iterable>): Promise + + /** + * List materialized sessions with cheap per-log change tokens. + * + * Repeated observations of an unchanged log return the same revision. A + * successful mutating {@link load} repair changes the next listed revision. + * @returns one header and opaque revision per materialized session without loading full logs. + */ + abstract listSnapshots(): Promise } export default SessionPersistence diff --git a/packages/session-persistence/session-persistence/src/revision.ts b/packages/session-persistence/session-persistence/src/revision.ts new file mode 100644 index 0000000000..41378eb3e4 --- /dev/null +++ b/packages/session-persistence/session-persistence/src/revision.ts @@ -0,0 +1,15 @@ +/** Opaque revision identity for lightweight persistence observations. */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Backend-owned token that changes whenever one persisted session log changes. */ +export type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'> + +/** + * Brand a backend revision for the provider-neutral persistence contract. + * @param value - backend-owned opaque revision representation. + * @returns the same runtime string with persistence-revision identity. + */ +export function SessionPersistenceRevision(value: string): SessionPersistenceRevision { + return value as SessionPersistenceRevision +} diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 789aa72c91..d1699c58a5 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -102,11 +102,16 @@ export function runPersistenceContract(name: string, make: () => Promise snapshot.header.id === m.id)?.revision // load PRESERVES the interrupted turn's events (a turn can be huge — they // must not be truncated) and closes the orphaned turn with synthetic // boundary events: step/end (the step was open) then turn/end {interrupted}. const loaded = await persistence.load(m.id) + const afterRepair = (await persistence.listSnapshots()) + .find(snapshot => snapshot.header.id === m.id)?.revision + expect(afterRepair).not.toBe(beforeRepair) expect(loaded.events.map(e => e.type)).toEqual([ 'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1 'turn/start', 'step/start', 'step/end', 'turn/end', // turn 2: real events + synthetic closers @@ -173,18 +178,33 @@ export function runPersistenceContract(name: string, make: () => Promise m.id)).not.toContain(SessionId('empty')) + expect((await persistence.listSnapshots()).map(snapshot => snapshot.header.id)) + .not.toContain(SessionId('empty')) } finally { await dispose() } }) - it('list() includes a session once it has events', async () => { + it('lists stable lightweight revisions that change after an append', async () => { const { persistence, dispose } = await make() try { const m = meta('s2') await persistence.create(m) await persistence.append(m.id, oneTurnLog()) expect((await persistence.list()).map(x => x.id)).toContain(m.id) + const first = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id) + const repeated = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id) + expect(first).toBeDefined() + expect(repeated?.revision).toBe(first?.revision) + + await persistence.append(m.id, [{ + type: 'turn/start', + seq: 6, + time: 7, + data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }, + }]) + const changed = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id) + expect(changed?.revision).not.toBe(first?.revision) } finally { await dispose() } diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 9c766d4b66..00e9863bb8 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -3,8 +3,8 @@ import { Context } from 'cordis' import SessionStore, { SessionId, isJsonValue } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { - SessionPersistence, PersistenceCoordinator, assertSerializable, seedCoversPrefix, - type PersistenceBackend, type StoredPrefix, + SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, assertSerializable, seedCoversPrefix, + type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix, } from '../src/index.ts' import { runPersistenceContract, meta, oneTurnLog } from './contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-contract.ts' @@ -109,6 +109,14 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend async list(): Promise { return [...this.store.values()].map(e => structuredClone(e.meta)) } + + + async listSnapshots(): Promise { + return [...this.store.values()].map(entry => ({ + header: structuredClone(entry.meta), + revision: SessionPersistenceRevision(`events:${entry.events.length}`), + })) + } } // Run the shared contract against the in-memory backend. diff --git a/packages/session-persistence/session-persistence/tsconfig.json b/packages/session-persistence/session-persistence/tsconfig.json index e817086a6a..84c6f5ccb0 100644 --- a/packages/session-persistence/session-persistence/tsconfig.json +++ b/packages/session-persistence/session-persistence/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../util/brand" + }, { "path": "../../core/session" } diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index 1f3344887f..001ea87d7d 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -1,22 +1,22 @@ # @deepseek-ai/dsh-session-query-sqlite -SQLite FTS5 implementation of `ctx.sessionSearch`. The service searches the live-preferred logical session corpus, groups cross-session results by their strongest event, and keeps provider-specific BM25 scores private. +SQLite FTS5 implementation of `ctx.sessionSearch`. The service searches the live-preferred logical session corpus and groups cross-session results by their strongest event. ## Search contract `searchSessions(request, exec?)` returns `SessionSearchHit` pages across the corpus; `searchEvents(request, exec?)` returns `SessionEventSearchHit` pages within one session. Queries are required, trimmed, whitespace-normalized literal phrases. FTS5 syntax such as quotes, `OR`, `NEAR`, and `*` is treated as data rather than executable MATCH syntax. Metadata filters are parameterized SQL predicates applied before ranking. -Ordering is deterministic: relevance first, then event time, session id where applicable, and seq. Cross-session results expose the selected event as `bestMatch`; both scopes return plain-text snippets bounded in Unicode code points. Cursors are opaque, bind to the normalized request and service instance, and fail when the relevant generation changes. A within-session cursor survives unrelated-session changes; a cross-session cursor does not. +Relevance is source-comparable across persistent and TEMP tables: actual FTS5 highlighted-match span count descending, then stored document code-point length ascending. Event time, session id where applicable, and seq break remaining ties. Cross-session results expose the selected event as `bestMatch`; both scopes derive whitespace-normalized plain text from FTS5 highlight positions and bound it in Unicode code points. Cursors are opaque branded values, bind to the normalized request and service instance, and fail when the relevant generation changes. A within-session cursor survives unrelated-session changes; a cross-session cursor does not. All three surfaces (`current`, `shadowed`, and `log-only`) are searchable by default. Pass a surface filter to narrow them. ## Source and index lifecycle -The service requires `ctx.sessions` and observes optional `ctx.sessionPersistence` dynamically. One serialized state machine observes complete sources, extracts shared semantic documents, reconciles changes transactionally, and runs the query. Stable fingerprints preserve unchanged persisted rows and generations; new, changed, and deleted durable sessions reconcile on the next search. Source or transaction failure commits nothing, and the next search retries. +The service requires `ctx.sessions` and observes optional `ctx.sessionPersistence` dynamically. One serialized state machine compares lightweight durable snapshot revisions, loads only new or changed logs, extracts shared semantic documents, reconciles changes transactionally, and runs the query. Repeated queries and unchanged reopen load no full durable logs; new, changed, deleted, or load-repaired sources reconcile on the next stable observation. Source or transaction failure commits nothing, and the next search retries. Persisted FTS rows live in a dedicated derived database. Connection-local TEMP tables hold live rows, which shadow the durable base for the same session and reveal it when the live owner disappears. Unmounting persistence hides durable rows without discarding the cache; remounting reconciles it. Closing or reopening the database drops every live overlay while retaining persisted rows. -The database is disposable but reset is guarded: a recognized incompatible search schema rebuilds in place, while an unrelated or canonical database is refused. Never point `path` at the session-persistence database. +The database is disposable but reset is guarded: a recognized incompatible search schema rebuilds in place, while an unrelated or canonical database is refused before mutating journal mode. Never point `path` at the session-persistence database. Exactly one service in one process owns a derived-index path; external writers or a second process are unsupported because generations and TEMP shadow state are connection-owned. ## Configuration @@ -30,6 +30,6 @@ The database is disposable but reset is guarded: a recognized incompatible searc ## Tokenizer and limits -The index uses FTS5 `unicode61`. In the implementation experiment it supported the two-character query `AI` and produced an index about 2.1× smaller than the trigram alternative. The trade-off is token/phrase recall rather than arbitrary substring recall: `AI` does not match the token `BRAID`. Use `ctx.sessionQuery.filterEvents()` with a `text` clause when a literal whitespace-flexible substring scan is required. +The index uses FTS5 `unicode61`. In the implementation experiment it supported the two-character query `AI` and produced an index about 2.1× smaller than the trigram alternative. The trade-off is token/phrase recall rather than arbitrary substring recall: `AI` does not match the token `BRAID`. Use `ctx.sessionQuery.filterEvents()` with a `text` clause when a literal whitespace-flexible substring scan is required. NUL is rejected in queries; reserved highlight markers and NUL in documents are normalized before indexing so presentation markers cannot collide with source text. Abort signals stop queued work and caller waits around asynchronous source observation. Node's synchronous `DatabaseSync` API cannot interrupt a MATCH statement already executing on the JavaScript thread; the signal is checked immediately before and after the serialized observation/reconciliation boundary. diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index a6cba8d866..dd1ddb532a 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -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 + persisted: Map live: Map } -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 = Promise.resolve() private _closed = false + private _closePromise: Promise | 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> { 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> { 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 { - if (this._closed) return + close(): Promise { + this._closePromise ??= this._close() + return this._closePromise + } + + private async _close(): Promise { this._closed = true await this._tail try { @@ -287,20 +319,20 @@ export class SessionSearchSqlite extends SessionSearchService { } private async _reconcile(signal: AbortSignal | undefined): Promise { - 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 { + private async _observeStable( + indexed: ReadonlyMap, + signal: AbortSignal | undefined, + ): Promise { for (;;) { assertNotAborted(signal) const persistence = this._persistence const persistenceRevision = this._persistenceRevision - const persisted = new Map() + let persisted = new Map() 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 { + 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 { + if (!isRuntimeArray(snapshots)) throw new Error('persistence snapshots must be an array') + const result = new Map() + 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, + after: ReadonlyMap, +): 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( rows: readonly Row[], limit: number, convert: (row: Row) => Item, - nextCursor: (offset: number) => string, + nextCursor: (offset: number) => SessionSearchCursorValue, offset: number, ): SessionSearchPage { const hasMore = rows.length > limit @@ -639,12 +799,12 @@ function page( } } -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 diff --git a/packages/session-query/session-query-sqlite/src/query.ts b/packages/session-query/session-query-sqlite/src/query.ts index fd2b5156e6..9654f6ae70 100644 --- a/packages/session-query/session-query-sqlite/src/query.ts +++ b/packages/session-query/session-query-sqlite/src/query.ts @@ -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', + ) +} diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts index 1c9bd98791..8e5c85f676 100644 --- a/packages/session-query/session-query-sqlite/src/schema.ts +++ b/packages/session-query/session-query-sqlite/src/schema.ts @@ -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' ) `) diff --git a/packages/session-query/session-query-sqlite/tests/query.spec.ts b/packages/session-query/session-query-sqlite/tests/query.spec.ts index 0faced72e4..f9c0a5d19e 100644 --- a/packages/session-query/session-query-sqlite/tests/query.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/query.spec.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query' +import { SessionSearchCursor, type SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query' import { buildEventWhere, buildSessionWhere, + FTS_HIGHLIGHT_END, + FTS_HIGHLIGHT_START, makeSnippet, normalizeEventRequest, normalizeSessionRequest, @@ -32,13 +34,13 @@ describe('SQLite search request normalization', () => { sessionFilters: [{ kind: 'availability', values: ['live'] }], eventFilters: [{ kind: 'surface', values: ['current'] }], limit: 3, - cursor: 'next', + cursor: SessionSearchCursor('next'), }, limits)).toEqual({ query: 'needle', sessionFilters: [{ kind: 'availability', values: ['live'] }], eventFilters: [{ kind: 'surface', values: ['current'] }], limit: 3, - cursor: 'next', + cursor: SessionSearchCursor('next'), }) expect(normalizeEventRequest({ sessionId: SessionId('s'), query: 'needle' }, limits)).toEqual({ sessionId: SessionId('s'), @@ -50,13 +52,13 @@ describe('SQLite search request normalization', () => { sessionId: SessionId('s'), query: 'needle', filters: [{ kind: 'seq', from: 1 }], - cursor: 'next', + cursor: SessionSearchCursor('next'), }, limits)).toEqual({ sessionId: SessionId('s'), query: 'needle', filters: [{ kind: 'seq', from: 1 }], limit: 2, - cursor: 'next', + cursor: SessionSearchCursor('next'), }) }) @@ -65,11 +67,39 @@ describe('SQLite search request normalization', () => { .toThrow(expectCode('SESSION_QUERY_INVALID_QUERY')) expect(() => normalizeSessionRequest({ query: ' \n ' }, limits)) .toThrow(expectCode('SESSION_QUERY_INVALID_QUERY')) + expect(() => normalizeSessionRequest({ query: 'bad\0query' }, limits)) + .toThrow(expectCode('SESSION_QUERY_INVALID_QUERY')) + expect(() => normalizeEventRequest({ sessionId: 1 as never, query: 'x' }, limits)) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => normalizeEventRequest({ + sessionId: SessionId('s'), + query: 'x', + cursor: 1 as never, + }, limits)).toThrow(expectCode('SESSION_QUERY_INVALID_CURSOR')) + expect(() => normalizeSessionRequest({ + query: 'x', + eventFilters: [{ kind: 'text', text: 'x' } as never], + }, limits)).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => normalizeSessionRequest({ + query: 'x', + eventFilters: [{} as never], + }, limits)).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) for (const limit of [1.5, 0, 4]) { expect(() => normalizeEventRequest({ sessionId: SessionId('s'), query: 'x', limit }, limits)) .toThrow(expectCode('SESSION_QUERY_INVALID_LIMIT')) } }) + + it('materializes owned filter values during normalization', () => { + const values = ['live'] as Array<'live' | 'persisted'> + const filter = { kind: 'availability' as const, values } + const request = { query: 'needle', sessionFilters: [filter] } + const normalized = normalizeSessionRequest(request, limits) + + values[0] = 'persisted' + request.sessionFilters.push({ kind: 'availability', values: ['persisted'] }) + expect(normalized.sessionFilters).toEqual([{ kind: 'availability', values: ['live'] }]) + }) }) describe('SQLite search predicate compilation', () => { @@ -120,6 +150,17 @@ describe('SQLite search predicate compilation', () => { { kind: 'surface', values: [] }, ])).toEqual({ sql: '0 AND 0', params: [] }) }) + + it('rejects runtime-unknown filter discriminants in both SQL builders', () => { + expect(() => buildSessionWhere([{ kind: 'future' } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => buildEventWhere([{ kind: 'future' } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => buildSessionWhere([{ kind: 'availability', values: ['future'] } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => buildSessionWhere([{} as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + }) }) describe('SQLite query identity and presentation', () => { @@ -169,11 +210,13 @@ describe('SQLite query identity and presentation', () => { }) it('normalizes, bounds, and positions snippets by Unicode code point', () => { - expect(makeSnippet(' short\ntext ', 'absent', 20)).toBe('short text') - expect(makeSnippet('abcdef', 'f', 1)).toBe('…') - expect(makeSnippet('abcdefghij', 'absent', 5)).toBe('abcd…') - expect(makeSnippet('abcdefghij', 'c', 5)).toBe('…bcd…') - expect(makeSnippet('abcdef', 'f', 2)).toBe('a…') - expect(makeSnippet('abcdef', 'f', 5)).toBe('…cdef') + expect(makeSnippet(' short\ntext ', 20)).toBe('short text') + expect(makeSnippet(`abcde${FTS_HIGHLIGHT_START}f${FTS_HIGHLIGHT_END}`, 1)).toBe('…') + expect(makeSnippet('abcdefghij', 5)).toBe('abcd…') + expect(makeSnippet(`ab${FTS_HIGHLIGHT_START}c${FTS_HIGHLIGHT_END}defghij`, 5)).toBe('…bcd…') + expect(makeSnippet(`abcde${FTS_HIGHLIGHT_START}f${FTS_HIGHLIGHT_END}`, 2)).toBe('a…') + expect(makeSnippet(`abcde${FTS_HIGHLIGHT_START}f${FTS_HIGHLIGHT_END}`, 5)).toBe('…cdef') + expect(makeSnippet(` x—${FTS_HIGHLIGHT_START}café${FTS_HIGHLIGHT_END}\n y `, 20)) + .toBe('x—café y') }) }) 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 77d1e0125d..f4904e17d8 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -1,18 +1,25 @@ -import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context, type Fiber } from 'cordis' import { DatabaseSync } from 'node:sqlite' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' -import SessionPersistence from '@deepseek-ai/dsh-session-persistence' +import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' +import type { SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' import SessionSearchSqlite, { SESSION_QUERY_SQLITE_APPLICATION_ID, SESSION_QUERY_SQLITE_SCHEMA_VERSION, } from '@deepseek-ai/dsh-session-query-sqlite' -import type { SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query' +import { + SessionQueryError, + SessionSearchCursor, + type SessionAvailability, + type SessionQueryErrorCode, + type SessionSearchRequest, +} from '@deepseek-ai/dsh-session-query' const temporaryDirectories: string[] = [] @@ -48,19 +55,36 @@ function expectCode(code: SessionQueryErrorCode): Error { class TestPersistence extends SessionPersistence { static entries = new Map() + static revisions = new Map() + static nextRevision = 0 + static loads = new Map() + static loadEffect: ((entry: { meta: SessionHeader; events: SessionEvent[] }) => void) | undefined static listGate: Promise | undefined static listStarted: (() => void) | undefined + static snapshotEffect: (() => void | Promise) | undefined + static snapshotOverride: (() => SessionPersistenceSnapshot[]) | undefined static failure: unknown static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void { - this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)])) + this.entries = new Map() + this.revisions = new Map() + this.loads = new Map() + this.loadEffect = undefined + for (const entry of entries) this.set(entry) this.listGate = undefined this.listStarted = undefined + this.snapshotEffect = undefined + this.snapshotOverride = undefined this.failure = undefined } + static set(entry: { meta: SessionHeader; events: SessionEvent[] }): void { + this.entries.set(entry.meta.id, structuredClone(entry)) + this.revisions.set(entry.meta.id, ++this.nextRevision) + } + create(meta: SessionHeader): Promise { - TestPersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] }) + TestPersistence.set({ meta, events: [] }) return Promise.resolve() } @@ -68,13 +92,21 @@ class TestPersistence extends SessionPersistence { const entry = TestPersistence.entries.get(id) if (entry === undefined) return Promise.reject(new Error('missing test session')) entry.events.push(...structuredClone(events)) + TestPersistence.revisions.set(id, ++TestPersistence.nextRevision) return Promise.resolve() } async load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + TestPersistence.loads.set(id, (TestPersistence.loads.get(id) ?? 0) + 1) if (TestPersistence.failure !== undefined) throw TestPersistence.failure const entry = TestPersistence.entries.get(id) if (entry === undefined) throw new Error('missing test session') + if (TestPersistence.loadEffect !== undefined) { + const effect = TestPersistence.loadEffect + TestPersistence.loadEffect = undefined + effect(entry) + TestPersistence.revisions.set(id, ++TestPersistence.nextRevision) + } return structuredClone(entry) } @@ -84,6 +116,20 @@ class TestPersistence extends SessionPersistence { if (TestPersistence.failure !== undefined) throw TestPersistence.failure return [...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta)) } + + + async listSnapshots(): Promise { + TestPersistence.listStarted?.() + await TestPersistence.listGate + if (TestPersistence.failure !== undefined) throw TestPersistence.failure + const snapshots = TestPersistence.snapshotOverride?.() + ?? [...TestPersistence.entries.values()].map(entry => ({ + header: structuredClone(entry.meta), + revision: SessionPersistenceRevision(`test:${TestPersistence.revisions.get(entry.meta.id)}`), + })) + await TestPersistence.snapshotEffect?.() + return snapshots + } } async function liveContext(config: ConstructorParameters[1] = { path: ':memory:' }): Promise { @@ -175,6 +221,44 @@ describe('SQLite session search', () => { await expect(ctx.sessionSearch.searchSessions({ query: '*' })).resolves.toEqual({ items: [] }) }) + it('ranks live and persisted matches on one source-comparable contract', async () => { + const persisted = header('z-persisted') + TestPersistence.reset([ + { meta: persisted, events: messageEvents('needle needle', 10) }, + ...Array.from({ length: 12 }, (_, index) => ({ + meta: header(`filler-${index}`), + events: messageEvents('needle', 10), + })), + ]) + const ctx = await liveContext() + const persistence = await ctx.plugin(TestPersistence) + ctx.sessions.create(SessionId('a-live'), { + seed: messageEvents('needle needle', 10), + meta: { createdAt: persisted.createdAt }, + }) + + const result = await ctx.sessionSearch.searchSessions({ + query: 'needle', + sessionFilters: [{ kind: 'id', values: [SessionId('a-live'), persisted.id] }], + }) + expect(result.items.map(item => item.header.id)).toEqual([SessionId('a-live'), persisted.id]) + await persistence.dispose() + }) + + it('positions snippets from FTS5 matches across diacritics and punctuation', async () => { + const ctx = await liveContext({ path: ':memory:', snippetChars: 14 }) + const session = ctx.sessions.create(SessionId('snippet'), { + seed: messageEvents('long long long—café,\nnext value', 10), + }) + + const page = await ctx.sessionSearch.searchEvents({ sessionId: session.id, query: 'CAFE' }) + expect(page.items).toHaveLength(1) + expect(page.items[0]!.snippet).toContain('café') + expect(page.items[0]!.snippet).toContain('—') + expect(page.items[0]!.snippet).not.toContain('\n') + expect(Array.from(page.items[0]!.snippet).length).toBeLessThanOrEqual(14) + }) + it('binds cursors to requests and only invalidates within-session pages for target changes', async () => { const ctx = await liveContext({ path: ':memory:', defaultLimit: 1, maxLimit: 5 }) const target = ctx.sessions.create(SessionId('target'), { @@ -193,7 +277,7 @@ describe('SQLite session search', () => { if (eventPage.nextCursor === undefined || sessionPage.nextCursor === undefined) throw new Error('expected cursors') const eventKeys = eventPage.items.map(item => `${item.sessionId}:${item.seq}`) - let eventCursor: string | undefined = eventPage.nextCursor + let eventCursor: ReturnType | undefined = eventPage.nextCursor while (eventCursor !== undefined) { const next = await ctx.sessionSearch.searchEvents({ sessionId: target.id, @@ -208,7 +292,7 @@ describe('SQLite session search', () => { expect(new Set(eventKeys).size).toBe(eventKeys.length) const sessionIds = sessionPage.items.map(item => item.header.id) - let sessionCursor: string | undefined = sessionPage.nextCursor + let sessionCursor: ReturnType | undefined = sessionPage.nextCursor while (sessionCursor !== undefined) { const next = await ctx.sessionSearch.searchSessions({ query: 'needle', limit: 1, cursor: sessionCursor }) sessionIds.push(...next.items.map(item => item.header.id)) @@ -251,6 +335,7 @@ describe('SQLite session search', () => { { sessionId: session.id, query: 'needle', limit: 4 }, { sessionId: session.id, query: 'needle', filters: [{ kind: 'seq', from: 2, to: 1 }] }, { sessionId: session.id, query: 'needle', filters: [{ kind: 'surface', values: ['future'] }] }, + { sessionId: session.id, query: 'bad\0query' }, ] as const) { await expect(ctx.sessionSearch.searchEvents(request as never)).rejects.toBeInstanceOf(Error) } @@ -258,7 +343,24 @@ describe('SQLite session search', () => { query: 'needle', sessionFilters: [{ kind: 'availability', values: ['remote' as never] }], })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) - await expect(ctx.sessionSearch.searchEvents({ sessionId: session.id, query: 'needle', cursor: 'not-json' })) + await expect(ctx.sessionSearch.searchSessions({ + query: 'needle', + sessionFilters: [{ kind: 'future' } as never], + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + await expect(ctx.sessionSearch.searchSessions({ + query: 'needle', + eventFilters: [{ kind: 'future' } as never], + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: session.id, + query: 'needle', + filters: [{ kind: 'future' } as never], + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: session.id, + query: 'needle', + cursor: SessionSearchCursor('not-json'), + })) .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CURSOR')) await expect(ctx.sessionSearch.searchEvents({ sessionId: SessionId('absent'), query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) @@ -280,6 +382,37 @@ describe('SQLite session search', () => { }) describe('SQLite reconciliation and source lifecycle', () => { + it('owns queued request and filter values before waiting for the serializer', async () => { + const durable = header('owned') + TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) + const ctx = await liveContext() + const persistence = await ctx.plugin(TestPersistence) + let release!: () => void + TestPersistence.listGate = new Promise((resolve) => { release = resolve }) + let markStarted!: () => void + const started = new Promise((resolve) => { markStarted = resolve }) + TestPersistence.listStarted = () => { + TestPersistence.listStarted = undefined + markStarted() + } + const blocking = ctx.sessionSearch.searchSessions({ query: 'needle' }) + await started + + const availability: SessionAvailability[] = ['persisted'] + const request: SessionSearchRequest = { + query: 'needle', + sessionFilters: [{ kind: 'availability', values: availability }], + } + const queued = ctx.sessionSearch.searchSessions(request) + request.query = 'absent' + availability[0] = 'live' + release() + + await expect(blocking).resolves.toMatchObject({ items: [{ header: durable }] }) + await expect(queued).resolves.toMatchObject({ items: [{ header: durable }] }) + await persistence.dispose() + }) + it('mounts persistence dynamically, shadows with TEMP live rows, reveals, and hides on unmount', async () => { const shared = header('shared', 10, { cwd: '/work' }) const durable = header('durable', 5) @@ -311,7 +444,7 @@ describe('SQLite reconciliation and source lifecycle', () => { .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) }) - it('restarts observation when persistence unmounts during an asynchronous list', async () => { + it('discards a stale list rejection when persistence unmounts during observation', async () => { const durable = header('racing') TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) const ctx = await liveContext() @@ -328,10 +461,140 @@ describe('SQLite reconciliation and source lifecycle', () => { const search = ctx.sessionSearch.searchSessions({ query: 'needle' }) await started await persistenceFiber.dispose() + TestPersistence.failure = new Error('stale backend rejection') release() await expect(search).resolves.toEqual({ items: [] }) }) + it('retries against a replacement after the prior binding rejects', async () => { + const durable = header('replacement') + TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) + const ctx = await liveContext() + const prior = await ctx.plugin(TestPersistence) + let rejectPrior!: (reason: unknown) => void + TestPersistence.listGate = new Promise((_resolve, reject) => { rejectPrior = reject }) + let markStarted!: () => void + const started = new Promise((resolve) => { markStarted = resolve }) + TestPersistence.listStarted = () => { + TestPersistence.listStarted = undefined + markStarted() + } + + const search = ctx.sessionSearch.searchSessions({ query: 'needle' }) + await started + await prior.dispose() + TestPersistence.listGate = undefined + const replacement = await ctx.plugin(TestPersistence) + rejectPrior(new Error('stale prior binding')) + await expect(search).resolves.toMatchObject({ items: [{ header: durable }] }) + await replacement.dispose() + }) + + it('reloads a replacement source even when its opaque revisions collide', async () => { + const durable = header('colliding-replacement') + TestPersistence.reset([{ meta: durable, events: messageEvents('old content') }]) + const revision = TestPersistence.revisions.get(durable.id)! + const ctx = await liveContext() + const prior = await ctx.plugin(TestPersistence) + await expect(ctx.sessionSearch.searchSessions({ query: 'old' })) + .resolves.toMatchObject({ items: [{ header: durable }] }) + await prior.dispose() + + TestPersistence.set({ meta: durable, events: messageEvents('new needle') }) + TestPersistence.revisions.set(durable.id, revision) + const replacement = await ctx.plugin(TestPersistence) + const internals = ctx.sessionSearch as unknown as { + _lastPersistenceRevision: number + _persistenceRevision: number + } + expect(internals._persistenceRevision).not.toBe(internals._lastPersistenceRevision) + const page = await ctx.sessionSearch.searchSessions({ query: 'new needle' }) + expect(TestPersistence.loads.get(durable.id)).toBe(2) + expect(page).toMatchObject({ items: [{ header: durable }] }) + await expect(ctx.sessionSearch.searchSessions({ query: 'old' })).resolves.toEqual({ items: [] }) + expect(TestPersistence.loads.get(durable.id)).toBe(2) + await replacement.dispose() + }) + + it('retries when a successful observation belongs to a source unmounted during listing', async () => { + const durable = header('successful-unmount') + TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) + const ctx = await liveContext() + const persistence = await ctx.plugin(TestPersistence) + let lists = 0 + TestPersistence.snapshotEffect = async () => { + lists += 1 + if (lists === 2) await persistence.dispose() + } + + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })).resolves.toEqual({ items: [] }) + expect(lists).toBe(2) + }) + + it('retries when the snapshot population changes during observation', async () => { + const first = header('first') + const added = header('added-during-list') + TestPersistence.reset([{ meta: first, events: messageEvents('first needle') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + TestPersistence.snapshotEffect = () => { + TestPersistence.snapshotEffect = undefined + TestPersistence.set({ meta: added, events: messageEvents('added needle') }) + } + + const page = await ctx.sessionSearch.searchSessions({ query: 'needle' }) + expect(page.items.map(item => item.header.id).sort()).toEqual([added.id, first.id].sort()) + expect(TestPersistence.loads.get(first.id)).toBe(2) + expect(TestPersistence.loads.get(added.id)).toBe(1) + }) + + it('retries if the source revision changes while live sessions are observed', async () => { + const durable = header('live-boundary-retry') + TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const internals = ctx.sessionSearch as unknown as { _persistenceRevision: number } + const originalList = ctx.sessions.list.bind(ctx.sessions) + let bumped = false + const list = vi.spyOn(ctx.sessions, 'list').mockImplementation(() => { + if (!bumped) { + bumped = true + internals._persistenceRevision += 1 + } + return originalList() + }) + + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + .resolves.toMatchObject({ items: [{ header: durable }] }) + expect(TestPersistence.loads.get(durable.id)).toBe(2) + list.mockRestore() + }) + + it('rejects malformed snapshots and preserves typed persistence failures', async () => { + const durable = header('invalid-snapshot') + TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + + TestPersistence.snapshotOverride = () => 'not-an-array' as never + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + TestPersistence.snapshotOverride = () => [{ header: durable, revision: 1 as never }] + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + TestPersistence.snapshotOverride = () => [ + { header: durable, revision: SessionPersistenceRevision('duplicate:1') }, + { header: durable, revision: SessionPersistenceRevision('duplicate:2') }, + ] + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + + TestPersistence.snapshotOverride = undefined + const typed = new SessionQueryError('typed persistence failure', 'SESSION_QUERY_PERSISTENCE_FAILED') + TestPersistence.failure = typed + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })).rejects.toBe(typed) + }) + it('rejects immutable header conflicts between live and persisted sources', async () => { const shared = header('conflict', 10) TestPersistence.reset([{ meta: shared, events: messageEvents('persisted needle') }]) @@ -358,6 +621,9 @@ describe('SQLite reconciliation and source lifecycle', () => { const firstPersistence = await first.plugin(TestPersistence) const firstSearch = await first.plugin(SessionSearchSqlite, { path }) await first.sessionSearch.searchSessions({ query: 'needle' }) + expect(Object.fromEntries(TestPersistence.loads)).toEqual({ unchanged: 1, changed: 1, deleted: 1 }) + await first.sessionSearch.searchSessions({ query: 'needle' }) + expect(Object.fromEntries(TestPersistence.loads)).toEqual({ unchanged: 1, changed: 1, deleted: 1 }) await firstSearch.dispose() await firstPersistence.dispose() @@ -368,14 +634,20 @@ describe('SQLite reconciliation and source lifecycle', () => { const added = header('added') TestPersistence.entries.delete(deleted.id) - TestPersistence.entries.set(changed.id, { meta: changed, events: messageEvents('changed needle') }) - TestPersistence.entries.set(added.id, { meta: added, events: messageEvents('added needle') }) + TestPersistence.set({ meta: changed, events: messageEvents('changed needle') }) + TestPersistence.set({ meta: added, events: messageEvents('added needle') }) const second = new Context() await second.plugin(SessionStore) const secondPersistence = await second.plugin(TestPersistence) const secondSearch = await second.plugin(SessionSearchSqlite, { path }) const result = await second.sessionSearch.searchSessions({ query: 'needle' }) expect(result.items.map(item => item.header.id).sort()).toEqual([added.id, changed.id, unchanged.id].sort()) + expect(Object.fromEntries(TestPersistence.loads)).toEqual({ + unchanged: 1, + changed: 2, + deleted: 1, + added: 1, + }) await secondSearch.dispose() await secondPersistence.dispose() @@ -409,10 +681,27 @@ describe('SQLite reconciliation and source lifecycle', () => { await expect(second.sessionSearch.searchSessions({ query: 'live' })).resolves.toEqual({ items: [] }) await expect(second.sessionSearch.searchSessions({ query: 'persisted' })) .resolves.toMatchObject({ items: [{ header: shared, live: false, persisted: true }] }) + expect(TestPersistence.loads.get(shared.id)).toBe(1) await searchAgain.dispose() await persistenceAgain.dispose() }) + it('refreshes the stored revision after a mutating load repair', async () => { + const durable = header('repair') + TestPersistence.reset([{ meta: durable, events: messageEvents('before repair') }]) + TestPersistence.loadEffect = (entry) => { + entry.events = messageEvents('repaired needle') + } + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + + await expect(ctx.sessionSearch.searchSessions({ query: 'repaired' })) + .resolves.toMatchObject({ items: [{ header: durable }] }) + expect(TestPersistence.loads.get(durable.id)).toBe(2) + await ctx.sessionSearch.searchSessions({ query: 'repaired' }) + expect(TestPersistence.loads.get(durable.id)).toBe(2) + }) + it('recovers on the next search after source and SQLite transaction failures', async () => { TestPersistence.reset([{ meta: header('durable'), events: messageEvents('durable needle') }]) const ctx = await liveContext() @@ -462,15 +751,18 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const foreignPath = await temporaryPath('foreign.db') const foreign = new DatabaseSync(foreignPath) + foreign.exec('PRAGMA journal_mode = WAL') foreign.exec('CREATE TABLE canonical(value TEXT)') foreign.exec("INSERT INTO canonical VALUES ('safe')") foreign.close() - const foreignCtx = await liveContext({ path: foreignPath }) + const foreignCtx = await liveContext({ path: foreignPath, journalMode: 'delete' }) await expect(foreignCtx.sessionSearch.searchSessions({ query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) 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.sessionSearch as SessionSearchSqlite).close() const otherAppPath = await temporaryPath('other-app.db') const otherApp = new DatabaseSync(otherAppPath) @@ -479,6 +771,25 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const otherAppCtx = await liveContext({ path: otherAppPath }) await expect(otherAppCtx.sessionSearch.searchSessions({ query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + await (otherAppCtx.sessionSearch as SessionSearchSqlite).close() + }) + + it('observes asynchronous open rejection even when no query is made', async () => { + const path = await temporaryPath('never-queried.db') + const foreign = new DatabaseSync(path) + foreign.exec('CREATE TABLE canonical(value TEXT)') + foreign.close() + const unhandled: unknown[] = [] + const onUnhandled = (reason: unknown) => { unhandled.push(reason) } + process.on('unhandledRejection', onUnhandled) + try { + const ctx = await liveContext({ path }) + await new Promise((resolve) => { setImmediate(resolve) }) + expect(unhandled).toEqual([]) + await (ctx.sessionSearch as SessionSearchSqlite).close() + } finally { + process.off('unhandledRejection', onUnhandled) + } }) it('cancels both queued and in-flight source waits without committing them', async () => { @@ -518,7 +829,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => releaseBlocking() await expect(blocking).resolves.toEqual({ items: [] }) - TestPersistence.entries.set(SessionId('uncommitted'), { + TestPersistence.set({ meta: header('uncommitted'), events: messageEvents('durable needle'), }) @@ -560,14 +871,38 @@ describe('SQLite schema, cancellation, and real persistence integration', () => await started const queued = search.searchSessions({ query: 'needle' }) const closing = search.close() + const repeatedClose = search.close() + expect(repeatedClose).toBe(closing) release() await expect(accepted).resolves.toEqual({ items: [] }) await expect(queued).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) - await closing + await Promise.all([closing, repeatedClose]) await expect(search.searchSessions({ query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) - await search.close() + expect(search.close()).toBe(closing) + }) + + it('awaits optional-persistence child-fiber quiescence on disposal', async () => { + TestPersistence.reset() + const ctx = new Context() + await ctx.plugin(SessionStore) + const search = await ctx.plugin(SessionSearchSqlite, { path: ':memory:' }) + const persistence = await ctx.plugin(TestPersistence) + const optional = (ctx.sessionSearch as unknown as { + _optionalPersistenceFiber: Fiber + })._optionalPersistenceFiber + let release!: () => void + const cleanup = new Promise((resolve) => { release = resolve }) + optional.ctx.effect(() => () => cleanup) + + let settled = false + const disposing = search.dispose().then(() => { settled = true }) + await Promise.resolve() + expect(settled).toBe(false) + release() + await disposing + await persistence.dispose() }) it('combines the real SQLite persistence backend with the real search service keylessly', async () => { diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 6f9e9bbd7a..917984f0cd 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -7,6 +7,7 @@ This is trusted context-wide infrastructure. It performs no caller authorization ## Reads - `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order. +- `filterSessions(filters)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus. - `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold. - `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order. - `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`. @@ -21,7 +22,7 @@ The text clause is deliberately independent of FTS providers: caller text is esc ## Full-text seam -`SessionSearchService` owns the independent `ctx.sessionSearch` key. `searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. Both return opaque cursor pages, accept optional cancellation, and expose snippets without provider-specific numeric scores. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above. +`SessionSearchService` owns the independent `ctx.sessionSearch` key. `searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. Both return pages whose continuation is an owned branded `SessionSearchCursor`, accept optional cancellation, and expose snippets without provider-specific numeric scores. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above. The package has no provider coordinator or registration protocol. A concrete backend owns observation, reconciliation, ranking, cursor generations, and query execution as one lifecycle; the first implementation is [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md). diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 9f78d4f1db..449610a5a4 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -22,6 +22,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", @@ -36,6 +37,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index c8ddca3caa..4a27c34e58 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -1,6 +1,6 @@ /** Live/persisted logical-corpus resolution for session-query. */ -import type { Context } from 'cordis' +import type { Context, Fiber } from 'cordis' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' import type { SessionRecord } from './types.ts' @@ -18,18 +18,19 @@ export interface LogicalSession { /** Resolves a live-preferred corpus against the persistence service mounted now. */ export class SessionCorpus { private _persistence: SessionPersistence | undefined + private readonly _optionalPersistenceFiber: Fiber constructor(private readonly _ctx: Context) { + this._optionalPersistenceFiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => { + const service = childCtx.sessionPersistence + this._persistence = service + childCtx.effect(() => () => { + /* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */ + if (this._persistence === service) this._persistence = undefined + }, 'sessionQuery.persistenceBinding') + }) _ctx.effect(() => { - const fiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => { - const service = childCtx.sessionPersistence - this._persistence = service - childCtx.effect(() => () => { - /* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */ - if (this._persistence === service) this._persistence = undefined - }, 'sessionQuery.persistenceBinding') - }) - return () => void fiber.dispose() + return () => this._optionalPersistenceFiber.dispose() }, 'sessionQuery.optionalPersistence') } diff --git a/packages/session-query/session-query/src/cursor.ts b/packages/session-query/session-query/src/cursor.ts new file mode 100644 index 0000000000..8ee2a6660d --- /dev/null +++ b/packages/session-query/session-query/src/cursor.ts @@ -0,0 +1,15 @@ +/** Opaque cursor identity for session-search pagination. */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Provider-owned opaque continuation token returned by session search. */ +export type SessionSearchCursor = Branded<'SessionSearchCursor'> + +/** + * Brand an encoded provider cursor for the public search contract. + * @param value - opaque encoded cursor value. + * @returns the same runtime string with session-search cursor identity. + */ +export function SessionSearchCursor(value: string): SessionSearchCursor { + return value as SessionSearchCursor +} diff --git a/packages/session-query/session-query/src/filters.ts b/packages/session-query/session-query/src/filters.ts index c7a7b40dd4..91ae640615 100644 --- a/packages/session-query/session-query/src/filters.ts +++ b/packages/session-query/session-query/src/filters.ts @@ -1,6 +1,12 @@ /** Pure provider-independent predicates for logical sessions and event text. */ -import type { SessionRecord, SessionEventSearchDocument, SessionEventResultFilter, SessionResultFilter, SessionResultRange } from './types.ts' +import type { + SessionEventResultFilter, + SessionEventSearchDocument, + SessionRecord, + SessionResultFilter, + SessionResultRange, +} from './types.ts' import { SessionQueryError } from './config.ts' /** @@ -31,6 +37,66 @@ export function filterSessionEventDocuments predicates.every(predicate => predicate(document))) } +/** + * Copy and validate logical-session filters before an asynchronous boundary. + * @param filters - caller-owned clauses to materialize. + * @returns detached validated clauses. + */ +export function materializeSessionResultFilters( + filters: readonly SessionResultFilter[], +): SessionResultFilter[] { + assertArray(filters) + return filters.map((filter) => { + switch (filter.kind) { + case 'id': + return { kind: filter.kind, values: copyStrings(filter.kind, filter.values) } + case 'cwd': + return { kind: filter.kind, values: copyNullableStrings(filter.kind, filter.values) } + case 'created-at': + return copyRange(filter.kind, filter) + case 'parent': + return { kind: filter.kind, values: copyNullableStrings(filter.kind, filter.values) } + case 'availability': { + const values = copyStrings(filter.kind, filter.values) + assertAllowedValues(filter.kind, values, ['live', 'persisted']) + return { kind: filter.kind, values } + } + default: + return unknownFilter(filter) + } + }) +} + +/** + * Copy and validate event filters before an asynchronous boundary. + * @param filters - caller-owned clauses to materialize. + * @returns detached validated clauses. + */ +export function materializeSessionEventResultFilters( + filters: readonly SessionEventResultFilter[], +): SessionEventResultFilter[] { + assertArray(filters) + return filters.map((filter) => { + switch (filter.kind) { + case 'seq': + case 'time': + return copyRange(filter.kind, filter) + case 'type': + return { kind: filter.kind, values: copyStrings(filter.kind, filter.values) } + case 'surface': { + const values = copyStrings(filter.kind, filter.values) + assertAllowedValues(filter.kind, values, ['current', 'shadowed', 'log-only']) + return { kind: filter.kind, values } + } + case 'text': + if (typeof filter.text !== 'string') throw invalidFilter('text filter text must be a string') + return { kind: filter.kind, text: filter.text } + default: + return unknownFilter(filter) + } + }) +} + /** * Compile a literal case-insensitive, whitespace-flexible semantic-text match. * @param text - caller-provided literal text. @@ -66,6 +132,8 @@ function sessionPredicate(filter: SessionResultFilter): (record: SessionRecord) case 'availability': assertAllowedValues(filter.kind, filter.values, ['live', 'persisted']) return record => filter.values.some(value => value === 'live' ? record.live : record.persisted) + default: + return unknownFilter(filter) } } @@ -88,9 +156,47 @@ function eventPredicate(filter: SessionEventResultFilter): (document: SessionEve const pattern = compileSessionTextFilter(filter.text) return document => pattern.test(document.text) } + default: + return unknownFilter(filter) } } +function copyStrings(name: string, values: readonly T[]): T[] { + if (!isRuntimeArray(values) || values.some(value => typeof value !== 'string')) { + throw invalidFilter(`${name} filter values must be an array of strings`) + } + return [...values] +} + +function assertArray(value: unknown): void { + if (!Array.isArray(value)) throw invalidFilter('filters must be an array') +} + +function copyNullableStrings(name: string, values: readonly (T | null)[]): Array { + if (!isRuntimeArray(values) || values.some(value => value !== null && typeof value !== 'string')) { + throw invalidFilter(`${name} filter values must be an array of strings or null`) + } + return [...values] +} + +function copyRange( + kind: K, + range: SessionResultRange, +): { kind: K } & SessionResultRange { + const copy = { + kind, + ...range.from === undefined ? {} : { from: range.from }, + ...range.to === undefined ? {} : { to: range.to }, + } + validateRange(kind, copy) + return copy +} + +function unknownFilter(filter: never): never { + const kind = (filter as { kind?: unknown }).kind + throw invalidFilter(`unknown filter kind ${typeof kind === 'string' ? `"${kind}"` : '(missing)'}`) +} + function assertAllowedValues( name: string, values: readonly string[], @@ -125,8 +231,13 @@ function matchesRange(value: number, range: SessionResultRange): boolean { } function invalidRange(name: string, detail: string): SessionQueryError { - return new SessionQueryError( - `session ${name} filter ${detail}`, - 'SESSION_QUERY_INVALID_FILTER', - ) + return invalidFilter(`${name} filter ${detail}`) +} + +function invalidFilter(detail: string): SessionQueryError { + return new SessionQueryError(`session ${detail}`, 'SESSION_QUERY_INVALID_FILTER') +} + +function isRuntimeArray(value: unknown): boolean { + return Array.isArray(value) } diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 243c86746b..2c2155eebc 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -16,6 +16,7 @@ import type { SessionEventSearchRequest, SessionEventWindow, SessionRecord, + SessionResultFilter, SessionSearchExecContext, SessionSearchHit, SessionSearchPage, @@ -28,14 +29,26 @@ import { } from './config.ts' import { SessionCorpus } from './corpus.ts' import { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts' -import { filterSessionEventDocuments } from './filters.ts' +import { + filterSessionEventDocuments, + filterSessionResults, + materializeSessionEventResultFilters, + materializeSessionResultFilters, +} from './filters.ts' export type * from './types.ts' +export { SessionSearchCursor } from './cursor.ts' export type { Config, SessionQueryErrorCode } from './config.ts' export { SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError } from './config.ts' export { extractSessionEventText } from './extraction.ts' export { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts' -export { compileSessionTextFilter, filterSessionEventDocuments, filterSessionResults } from './filters.ts' +export { + compileSessionTextFilter, + filterSessionEventDocuments, + filterSessionResults, + materializeSessionEventResultFilters, + materializeSessionResultFilters, +} from './filters.ts' export { assertSessionHeadersCompatible } from './sources.ts' declare module 'cordis' { @@ -109,6 +122,16 @@ export class SessionQueryService extends Service { return this._corpus.listSessions() } + /** + * Filter the complete logical corpus with provider-independent predicates. + * @param filters - ANDed session metadata and availability clauses. + * @returns matching cloned records in deterministic newest-first order. + */ + async filterSessions(filters: readonly SessionResultFilter[]): Promise { + const ownedFilters = materializeSessionResultFilters(filters) + return this._filterSessions(ownedFilters) + } + /** * List lightweight raw-log event records for one logical session. * @param sessionId - live-preferred session id to read. @@ -128,6 +151,18 @@ export class SessionQueryService extends Service { async filterEvents( sessionId: SessionId, filters: readonly SessionEventResultFilter[], + ): Promise { + const ownedFilters = materializeSessionEventResultFilters(filters) + return this._filterEvents(sessionId, ownedFilters) + } + + private async _filterSessions(filters: readonly SessionResultFilter[]): Promise { + return filterSessionResults(await this._corpus.listSessions(), filters) + } + + private async _filterEvents( + sessionId: SessionId, + filters: readonly SessionEventResultFilter[], ): Promise { const loaded = await this._corpus.load(sessionId) const documents = buildSessionEventSearchDocuments(sessionId, loaded.events) @@ -142,16 +177,27 @@ export class SessionQueryService extends Service { async readEvent(request: SessionEventReadRequest): Promise { const before = this._readWindow('before', request.before) const after = this._readWindow('after', request.after) - const loaded = await this._corpus.load(request.sessionId) - const target = loaded.events[request.seq] - if (target === undefined || target.seq !== request.seq) { + const sessionId = request.sessionId + const seq = request.seq + return this._readEvent(sessionId, seq, before, after) + } + + private async _readEvent( + sessionId: SessionId, + seq: number, + before: number, + after: number, + ): Promise { + const loaded = await this._corpus.load(sessionId) + const target = loaded.events[seq] + if (target === undefined || target.seq !== seq) { throw new SessionQueryError( - `session "${request.sessionId}" has no event at seq ${request.seq}`, + `session "${sessionId}" has no event at seq ${seq}`, 'SESSION_QUERY_EVENT_NOT_FOUND', ) } - const startSeq = Math.max(0, request.seq - before) - const endSeq = Math.min(loaded.events.length - 1, request.seq + after) + const startSeq = Math.max(0, seq - before) + const endSeq = Math.min(loaded.events.length - 1, seq + after) return { session: loaded.header, target, diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts index d0de0dd48a..a223084f56 100644 --- a/packages/session-query/session-query/src/types.ts +++ b/packages/session-query/session-query/src/types.ts @@ -5,6 +5,9 @@ */ import type { SessionEvent, SessionEventType, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionSearchCursor } from './cursor.ts' + +export type { SessionSearchCursor } from './cursor.ts' /** Whether an event is current model context, replaced context, or raw-log-only. */ export type SessionEventSurface = 'current' | 'shadowed' | 'log-only' @@ -106,7 +109,7 @@ export interface SessionSearchPage { /** Results for this page in contract-defined order. */ items: readonly T[] /** Opaque continuation cursor, absent on the final page. */ - nextCursor?: string + nextCursor?: SessionSearchCursor } /** Controls shared by cross-session and within-session search calls. */ @@ -126,7 +129,7 @@ export interface SessionSearchRequest { /** Maximum sessions in this page. */ limit?: number /** Opaque cursor returned for the identical normalized request. */ - cursor?: string + cursor?: SessionSearchCursor } /** Within-session full-text search request. */ @@ -140,7 +143,7 @@ export interface SessionEventSearchRequest { /** Maximum events in this page. */ limit?: number /** Opaque cursor returned for the identical normalized request. */ - cursor?: string + cursor?: SessionSearchCursor } /** One event full-text search hit with a bounded plain-text excerpt. */ diff --git a/packages/session-query/session-query/tests/search-helpers.spec.ts b/packages/session-query/session-query/tests/search-helpers.spec.ts index e9cf857608..8ee9cf1ccf 100644 --- a/packages/session-query/session-query/tests/search-helpers.spec.ts +++ b/packages/session-query/session-query/tests/search-helpers.spec.ts @@ -10,6 +10,8 @@ import SessionQueryService, { extractSessionEventText, filterSessionEventDocuments, filterSessionResults, + materializeSessionEventResultFilters, + materializeSessionResultFilters, SessionSearchService, type SessionEventSearchHit, type SessionEventSearchRequest, @@ -177,6 +179,31 @@ describe('session-query document and filter helpers', () => { expect(() => buildSessionEventRecords(id, malformed)).toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE')) }) + it('owns filters and rejects malformed runtime filter shapes deterministically', () => { + expect(materializeSessionResultFilters([{ kind: 'created-at', to: 2 }])) + .toEqual([{ kind: 'created-at', to: 2 }]) + expect(() => materializeSessionResultFilters('not-an-array' as never)) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => materializeSessionResultFilters([{ kind: 'id', values: 'bad' } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => materializeSessionResultFilters([{ kind: 'id', values: [1] } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => materializeSessionResultFilters([{ kind: 'cwd', values: 'bad' } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => materializeSessionResultFilters([{ kind: 'parent', values: [1] } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => materializeSessionResultFilters([{} as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => materializeSessionEventResultFilters([{ kind: 'text', text: 1 } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => materializeSessionEventResultFilters([{ kind: 'future' } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => filterSessionResults([], [{ kind: 'future' } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + expect(() => filterSessionEventDocuments([], [{ kind: 'future' } as never])) + .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + }) + it('exposes the scan path on the concrete exact-read service', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 3b50feee45..e1432722f2 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context, type Fiber } from 'cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' -import SessionPersistence from '@deepseek-ai/dsh-session-persistence' +import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' import SessionQueryService, { + type SessionEventSurface, type SessionQueryErrorCode, } from '@deepseek-ai/dsh-session-query' @@ -59,6 +60,14 @@ class TestPersistence extends SessionPersistence { TestPersistence.afterList?.() return Promise.resolve(headers) } + + + async listSnapshots() { + return [...TestPersistence.entries.values()].map(entry => ({ + header: structuredClone(entry.meta), + revision: SessionPersistenceRevision(`events:${entry.events.length}`), + })) + } } async function liveContext(config: ConstructorParameters[1] = {}): Promise { @@ -94,6 +103,38 @@ describe('session-query exact reads', () => { expect(older.header.createdAt).toBe(1) }) + it('filters sessions symmetrically and owns mutable filter values immediately', async () => { + const durable = header('durable-filter', 1) + TestPersistence.reset([{ meta: durable, events: eventLog('durable') }]) + const ctx = await liveContext() + const live = ctx.sessions.create(SessionId('live-filter'), { meta: { createdAt: 2 } }) + live.append( + 'user/message', + { content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + const persistence = await ctx.plugin(TestPersistence) + + const ids = [durable.id] + const filtered = ctx.sessionQuery.filterSessions([{ kind: 'id', values: ids }]) + ids[0] = live.id + await expect(filtered).resolves.toEqual([{ + header: durable, + live: false, + persisted: true, + }]) + + const surfaces: SessionEventSurface[] = ['current'] + const events = ctx.sessionQuery.filterEvents(live.id, [{ kind: 'surface', values: surfaces }]) + surfaces[0] = 'shadowed' + await expect(events).resolves.toMatchObject([{ sessionId: live.id, surface: 'current', text: 'live' }]) + await expect(ctx.sessionQuery.filterSessions([{ kind: 'future' } as never])) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + await expect(ctx.sessionQuery.filterEvents(live.id, [{ kind: 'future' } as never])) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + await persistence.dispose() + }) + it('classifies current, shadowed, and raw-log-only events through foldSurface', async () => { const ctx = await liveContext() const session = ctx.sessions.create(SessionId('surface')) @@ -267,4 +308,26 @@ describe('session-query exact reads', () => { await fiber.dispose() expect(ctx.sessionQuery).toBeUndefined() }) + + it('awaits optional-persistence child-fiber quiescence on disposal', async () => { + TestPersistence.reset() + const ctx = new Context() + await ctx.plugin(SessionStore) + const query = await ctx.plugin(SessionQueryService) + const persistence = await ctx.plugin(TestPersistence) + const optional = (ctx.sessionQuery as unknown as { + _corpus: { _optionalPersistenceFiber: Fiber } + })._corpus._optionalPersistenceFiber + let release!: () => void + const cleanup = new Promise((resolve) => { release = resolve }) + optional.ctx.effect(() => () => cleanup) + + let settled = false + const disposing = query.dispose().then(() => { settled = true }) + await Promise.resolve() + expect(settled).toBe(false) + release() + await disposing + await persistence.dispose() + }) }) diff --git a/packages/session-query/session-query/tsconfig.json b/packages/session-query/session-query/tsconfig.json index 7153dae8bb..1a254e5379 100644 --- a/packages/session-query/session-query/tsconfig.json +++ b/packages/session-query/session-query/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../util/brand" + }, { "path": "../../llm/llm" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 997f00ff5c..902c362355 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -720,6 +720,9 @@ importers: packages/session-persistence/session-persistence: devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -765,6 +768,9 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 0d0a0b871d..ba2d75a8ff 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -45,6 +45,8 @@ { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionPersistenceRevision", "source": "packages/session-persistence/session-persistence/src/revision.ts" }, + { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionPersistenceSnapshot", "source": "packages/session-persistence/session-persistence/src/index.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" }, @@ -52,6 +54,7 @@ { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionResultFilter", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventResultFilter", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchDocument", "source": "packages/session-query/session-query/src/types.ts" }, + { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchCursor", "source": "packages/session-query/session-query/src/cursor.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchRequest", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchRequest", "source": "packages/session-query/session-query/src/types.ts" }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchPage", "source": "packages/session-query/session-query/src/types.ts" }, From 35edf2a825b40e4bac80d3e38d7c5334dbe1dd85 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 12:32:18 +0800 Subject: [PATCH 03/19] fix(session-query): qualify persistence revisions by store --- docs/config-catalog.md | 2 +- ...026-07-10-sqlite-session-query-provider.md | 2 +- .../session-persistence-jsonl/README.md | 2 +- .../tests/jsonl.spec.ts | 23 ++++++++ .../session-persistence-sqlite/README.md | 4 +- .../session-persistence-sqlite/src/index.ts | 35 ++++++++--- .../session-persistence-sqlite/src/schema.ts | 38 +++++++++--- .../tests/sqlite.spec.ts | 59 +++++++++++++++++-- .../session-persistence/README.md | 2 +- .../session-persistence/src/index.ts | 4 +- .../session-persistence/src/revision.ts | 5 +- .../session-query-sqlite/README.md | 2 +- .../session-query-sqlite/tests/sqlite.spec.ts | 45 ++++++++++++++ 13 files changed, 192 insertions(+), 31 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e8a06e469e..aa37295146 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -574,7 +574,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:50`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:51`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query` diff --git a/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md b/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md index fafa7f3569..b77279949b 100644 --- a/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md +++ b/docs/rfc/implemented/feature/2026-07-10-sqlite-session-query-provider.md @@ -32,7 +32,7 @@ Both persistent and live FTS5 tables use `unicode61`. The implementation experim The shared extractor includes message text, reasoning, nested tool-call/result content, tool names and arguments, blocked-prompt reasons, todo status/content, and error or terminal status detail. Structural boundaries, stream chunks, request headers, successful completion markers, and unknown declaration-merged event/content variants produce no document. Surface classification reuses `foldSurface()` so search agrees with model-history derivation. -One serialized operation reads the provider-neutral `SessionPersistence` snapshot listing, compares each opaque revision with the revision stored beside the indexed session, loads only new or changed logs, reconciles rows in one transaction, and executes the query. Observation repeats when listing changes during a load; this incorporates a mutating load repair's refreshed revision before commit. Repeated queries and unchanged reopen load no full persisted logs. New, changed, and deleted sessions update on the next stable search. A source or extraction failure cannot mark a row current, and a transaction failure rolls back so a later search retries. +One serialized operation reads the provider-neutral `SessionPersistence` snapshot listing, compares each source-qualified opaque revision with the revision stored beside the indexed session, loads only new or changed logs, reconciles rows in one transaction, and executes the query. A revision identifies its backing persistence store as well as the backend-local log revision, so reopening against the same store reuses indexed rows while switching to an independent store cannot collide on a session id and local counter. Observation repeats when listing changes during a load; this incorporates a mutating load repair's refreshed revision before commit. Repeated queries and unchanged reopen load no full persisted logs. New, changed, and deleted sessions update on the next stable search. A source or extraction failure cannot mark a row current, and a transaction failure rolls back so a later search retries. Persisted documents survive restarts. Live sessions use connection-local TEMP tables, shadow the persisted base for the same id, and reveal that base on detach. Closing the database drops live rows. Unmounting persistence hides durable rows without treating absence as authoritative deletion; remounting observes and reconciles the backend again. Conflicting immutable live and durable headers fail rather than combining sources. diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 0be2c785b3..2043967cb8 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -25,7 +25,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence - **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`. - **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See [session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md). - **Contiguous-seq.** `load` rejects a mid-log parse error or `seq` gap (unloadable); `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. -- **Lightweight revisions.** `listSnapshots()` reads each header and returns an opaque identity from the file device/inode, size, and nanosecond mtime/ctime. Under the append-only single-writer contract, unchanged files retain revisions while append, repair, or replacement changes them without parsing event lines. +- **Lightweight revisions.** `listSnapshots()` reads each header and returns an opaque identity from the file device/inode, size, and nanosecond mtime/ctime. Under the append-only single-writer contract, unchanged files retain revisions while append, repair, replacement, or switching to an independent root changes them without parsing event lines. - **Format version.** Only the current `SESSION_FORMAT_VERSION` (v0) is supported; `load` rejects any other version. While the harness is unreleased the on-disk format is pre-release/unstable: a breaking format change is absorbed at v0 (no bump until the first tagged release) and non-current logs are rejected — there is no migration (no persisted user data to preserve). ## Write path diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 757ba03aac..6f5aa81ee0 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -147,6 +147,29 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { expect(loaded.events).toEqual(log) // chunks preserved, contiguous seqs }) + it('source-qualifies revisions across roots while preserving same-log reopen identity', async () => { + const m = meta('revision-source') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const revision = (await ctx.sessionPersistence.listSnapshots())[0]?.revision + + const reopenedCtx = new Context() + await reopenedCtx.plugin(SessionStore) + await reopenedCtx.plugin(SessionPersistenceJsonl, { root }) + expect((await reopenedCtx.sessionPersistence.listSnapshots())[0]?.revision).toBe(revision) + + const otherRoot = await freshRoot() + const otherCtx = new Context() + await otherCtx.plugin(SessionStore) + await otherCtx.plugin(SessionPersistenceJsonl, { root: otherRoot }) + await otherCtx.sessionPersistence.create(m) + await otherCtx.sessionPersistence.append(m.id, oneTurnLog()) + expect((await otherCtx.sessionPersistence.listSnapshots())[0]?.revision).not.toBe(revision) + + await reopenedCtx.fiber.dispose() + await otherCtx.fiber.dispose() + }) + it('persists a forked child seed through the existing session write path', async () => { const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } }) appendClosedTurn(source) diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 6e6006dc6b..e45f1d2371 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -6,7 +6,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i ## Storage model -Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) and a monotonic per-log revision live in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). +Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). The repo's `engines.node` is `^22.19.0 || >=24.0.0` (Node 22.19+ or 24+), matching the LTS floor required by the installed Pi adapter dependency; `node:sqlite` itself ships without the `--experimental-sqlite` flag from Node 22.13 (LTS) and 23.4 / 24 (Current) on. The range deliberately excludes Node 23 because that line is non-LTS/EOL and still has flagged runtime features before 23.6. The database opens with `foreign_keys = ON` (so `ON DELETE CASCADE` drops a session's events with its row) and the configured `journal_mode` (default `wal`; pick a rollback-journal mode like `delete` on filesystems where WAL's shared-memory files do not work, e.g. network mounts). The table-layout version is stored in `PRAGMA user_version` and checked on open: a fresh database is stamped with the current `SCHEMA_VERSION`; a database written by any other, incompatible build (a non-current `user_version`, older or newer) is rejected rather than opened against an unknown layout — there is no migration (unreleased software). @@ -14,7 +14,7 @@ The repo's `engines.node` is `^22.19.0 || >=24.0.0` (Node 22.19+ or 24+), matchi - **Append = a transaction.** `append` runs `BEGIN`/`COMMIT` around the batch: it materializes the `sessions` row (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event's `seq` must equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (`load()` already balanced the stored log, so `append` never has to repair a crash tail.) - **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row). -- **Lightweight revisions.** `listSnapshots()` reads the monotonic revision stored beside each session header. Append and mutating load repair increment it in the same transaction as their event changes, so unchanged observations are stable and no full-log count or parse is required. +- **Lightweight revisions.** `listSnapshots()` combines the database's immutable random store id and physical file identity with the monotonic revision stored beside each session header; an in-memory database uses the store id alone. Append and mutating load repair increment the local counter in the same transaction as their event changes, so unchanged same-file observations are stable, independent stores and file replacements cannot collide on a local counter, and no full-log count or parse is required. - **Interrupted-turn close on load.** `load()` reads every stored event ordered by `seq` and finds the longest seq-contiguous, parseable prefix — INCLUDING the real events of an interrupted final turn after the last `turn/end` (the loop only flushes at `turn/end`, so a process killed mid-turn leaves real, fully-written rows past it). A single turn can be huge in a long-horizon task, so those events are **preserved, never truncated**: `load()` CLOSES the orphaned turn by durably appending the minimal synthetic boundary events (an error `tool/result` for every assistant tool call left unanswered, a `step/end` if a step was open, then a `turn/end` carrying `{ kind: 'interrupted' }`), inside one transaction that also DELETEs any never-fully-written torn tail row. `load()` is therefore mutating — after it the stored rows are balanced and the cursor is truthful, so the next `append` continues cleanly. The boundary (last `turn/end`, torn-tail detection) is computed from the `seq`/`type` columns so a malformed `data` in a torn tail row is never parsed (discarded, not unloadable). A parse error or `seq` gap inside the committed region (at or before the last real `turn/end`) makes the session unloadable. A session whose only turn never closed keeps its metadata row and stays present in `list()` — the same as the JSONL backend, whose file likewise survives a first append that never reached `turn/end`. ## Configuration (schemastery) diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 3a1c7ffbcc..546f285824 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -19,6 +19,7 @@ import { Context } from 'cordis' import z from 'schemastery' +import { statSync } from 'node:fs' import { DatabaseSync } from 'node:sqlite' import { mkdir } from 'node:fs/promises' import { dirname, resolve } from 'node:path' @@ -84,6 +85,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers override readonly name = 'session-persistence-sqlite' private db!: DatabaseSync + private storeIdentity!: string private ready: Promise private coordinator: PersistenceCoordinator @@ -98,12 +100,29 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers } private async openDb(path: string, journalMode: JournalMode): Promise { - if (path !== ':memory:') { - const abs = resolve(path) - await mkdir(dirname(abs), { recursive: true, mode: 0o700 }) - this.db = openDatabase(abs, journalMode) - } else { - this.db = openDatabase(path, journalMode) + const actual = path === ':memory:' ? path : resolve(path) + if (actual !== ':memory:') await mkdir(dirname(actual), { recursive: true, mode: 0o700 }) + this.db = openDatabase(actual, journalMode) + try { + const row = this.db.prepare( + 'SELECT store_id FROM persistence_state WHERE singleton = 1', + ).get() as { store_id: string } | undefined + /* v8 ignore next -- openDatabase inserts the singleton before returning. */ + if (row === undefined) { + throw new Error(`session database at "${actual}" has no store identity`) + } + if (row.store_id.length === 0) { + throw new Error(`session database at "${actual}" has no valid store identity`) + } + if (actual !== ':memory:') { + const identity = statSync(actual, { bigint: true }) + this.storeIdentity = `file:${identity.dev}:${identity.ino}:${identity.birthtimeNs}:store:${row.store_id}` + } else { + this.storeIdentity = `memory:store:${row.store_id}` + } + } catch (error: unknown) { + this.db.close() + throw error } } @@ -234,13 +253,13 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return rows.map(rowToMeta) } - /** List metadata with an append-only event-count revision per session. */ + /** List metadata with a source-qualified monotonic revision per session. */ async listSnapshots(): Promise { await this.ready const rows = this.db.prepare('SELECT * FROM sessions').all() as unknown as SessionRow[] return rows.map(row => ({ header: rowToMeta(row), - revision: SessionPersistenceRevision(`revision:${row.revision}`), + revision: SessionPersistenceRevision(`${this.storeIdentity}:revision:${row.revision}`), })) } diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 2f238b0131..caf1766f22 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -1,12 +1,14 @@ /** * Schema + load-time helpers for the SQLite session-persistence backend: the - * DDL (a `sessions` metadata table and a 1:1 `events` row per `SessionEvent`), - * the database open/configure step, and the last-`turn/end` cut that gives the - * SQLite backend the SAME crash-tail-on-load semantics as the JSONL backend. + * DDL (a store-identity row, `sessions` metadata, and a 1:1 `events` row per + * `SessionEvent`), the database open/configure step, and the last-`turn/end` + * cut that gives the SQLite backend the SAME crash-tail-on-load semantics as + * the JSONL backend. * * @module dsh-session-persistence-sqlite/schema */ +import { randomUUID } from 'node:crypto' import { DatabaseSync } from 'node:sqlite' import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepseek-ai/dsh-session' @@ -15,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 5 +export const SCHEMA_VERSION = 6 /** * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}). @@ -70,14 +72,25 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' * current one (written by a different, incompatible build — older or newer) is * REJECTED rather than opened against a layout this build does not understand. * There are no migrations: an incompatible layout is rejected. The current - * sessions row carries every header field plus its monotonic snapshot revision; - * the events row carries the complete surface metadata. + * persistence-state row carries an immutable random store id, the sessions row + * carries every header field plus its monotonic snapshot revision, and the + * events row carries the complete surface metadata. * @param path - the SQLite database file to open (created when absent). * @param journalMode - the journal pragma to apply — a closed in-code union, validated by the plugin Config. - * @returns the open handle with pragmas applied and both tables ensured. + * @returns the open handle with pragmas applied and all three tables ensured. */ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync { const db = new DatabaseSync(path) + try { + configureDatabase(db, path, journalMode) + return db + } catch (error: unknown) { + db.close() + throw error + } +} + +function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalMode): void { db.exec('PRAGMA foreign_keys = ON') // journalMode is a closed in-code union (validated by the plugin Config), not // user-controlled SQL — safe to interpolate (PRAGMA takes no bound params). @@ -85,7 +98,6 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy // `PRAGMA user_version` always returns exactly one row { user_version }. const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number } if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) { - db.close() throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`) } if (onDisk === 0) { @@ -94,6 +106,15 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy // constant (SCHEMA_VERSION is a trusted in-code number, not user input). db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`) } + db.exec(` + CREATE TABLE IF NOT EXISTS persistence_state ( + singleton INTEGER PRIMARY KEY CHECK (singleton = 1), + store_id TEXT NOT NULL + ) STRICT + `) + db.prepare( + 'INSERT OR IGNORE INTO persistence_state (singleton, store_id) VALUES (1, ?)', + ).run(randomUUID()) db.exec(` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, @@ -117,7 +138,6 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy PRIMARY KEY (session_id, seq) ) STRICT `) - return db } /** diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 98924ca691..5b85f65908 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { existsSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, rm, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -245,12 +245,12 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { dbNewer.close() expect(() => openDatabase(path, 'wal')).toThrow(/incompatible with this build/) - // A stale OLDER version (e.g. a pre-summary-drop v1 DB) is also rejected — - // we do not migrate (unreleased software, no backward-compat). + // The immediately preceding layout lacks the required store identity and is + // rejected rather than migrated (unreleased software, no backward-compat). const olderPath = await freshDbPath() openDatabase(olderPath, 'wal').close() const dbOlder = openDatabase(olderPath, 'wal') - dbOlder.exec('PRAGMA user_version = 1') + dbOlder.exec(`PRAGMA user_version = ${SCHEMA_VERSION - 1}`) dbOlder.close() expect(() => openDatabase(olderPath, 'wal')).toThrow(/incompatible with this build/) }) @@ -337,8 +337,46 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { await fiber2.dispose() }) + it('source-qualifies revisions across stores while preserving same-file reopen identity', async () => { + const pathA = await freshDbPath() + const pathB = await freshDbPath() + const m = meta('revision-source') + const a = await backend(pathA) + await a.ctx.sessionPersistence.create(m) + await a.ctx.sessionPersistence.append(m.id, oneTurnLog()) + const revisionA = (await a.ctx.sessionPersistence.listSnapshots())[0]?.revision + await a.dispose() + + const probeA = openDatabase(pathA, 'wal') + const storeIdA = (probeA.prepare( + 'SELECT store_id FROM persistence_state WHERE singleton = 1', + ).get() as { store_id: string }).store_id + probeA.close() + + const aliasA = `${pathA}.alias` + await symlink(pathA, aliasA) + const reopenedA = await backend(aliasA) + expect((await reopenedA.ctx.sessionPersistence.listSnapshots())[0]?.revision).toBe(revisionA) + await reopenedA.dispose() + + const b = await backend(pathB) + await b.ctx.sessionPersistence.create(m) + await b.ctx.sessionPersistence.append(m.id, oneTurnLog()) + const revisionB = (await b.ctx.sessionPersistence.listSnapshots())[0]?.revision + const probeB = openDatabase(pathB, 'wal') + const storeIdB = (probeB.prepare( + 'SELECT store_id FROM persistence_state WHERE singleton = 1', + ).get() as { store_id: string }).store_id + probeB.close() + expect(storeIdB).not.toBe(storeIdA) + expect(revisionB).not.toBe(revisionA) + expect(String(revisionA)).toMatch(/:revision:1$/) + expect(String(revisionB)).toMatch(/:revision:1$/) + await b.dispose() + }) + it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(5) + expect(SCHEMA_VERSION).toBe(6) }) it('keeps the revision stable for an empty repair hook', async () => { @@ -354,6 +392,17 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) describe('SessionPersistenceSqlite: edge cases', () => { + it('rejects and closes a current-schema database with an invalid store identity', async () => { + const path = await freshDbPath() + const db = openDatabase(path, 'wal') + db.exec("UPDATE persistence_state SET store_id = '' WHERE singleton = 1") + db.close() + + const b = await backend(path) + await expect(b.ctx.sessionPersistence.listSnapshots()).rejects.toThrow(/no valid store identity/) + await expect(b.dispose()).resolves.toBeUndefined() + }) + it('append rolls back and rethrows when an event INSERT fails inside the transaction', async () => { const path = await freshDbPath() const m = meta('rollback-insert') diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 32958bfaaa..d16bfcda40 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -12,7 +12,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `append(id, events): Promise` | Durably persist a batch (from the `session/flush` drain). Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic closers — an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. | | `list(): Promise` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. | -| `listSnapshots(): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log is unchanged and changes after append or mutating load repair. | +| `listSnapshots(): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. | ## Invariants every backend must honor diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 84ab0f321a..b647befeee 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -34,7 +34,7 @@ export { SessionPersistenceRevision } from './revision.ts' export interface SessionPersistenceSnapshot { /** Detached metadata for one materialized session. */ header: SessionHeader - /** Opaque token that changes whenever this stored log changes. */ + /** Opaque source-qualified token that changes whenever this stored log changes. */ revision: SessionPersistenceRevision } @@ -172,6 +172,8 @@ export abstract class SessionPersistence extends Service { * * Repeated observations of an unchanged log return the same revision. A * successful mutating {@link load} repair changes the next listed revision. + * Revisions also distinguish independently backed stores so backend-local + * counters cannot compare equal across different persistence sources. * @returns one header and opaque revision per materialized session without loading full logs. */ abstract listSnapshots(): Promise diff --git a/packages/session-persistence/session-persistence/src/revision.ts b/packages/session-persistence/session-persistence/src/revision.ts index 41378eb3e4..cb037ffafc 100644 --- a/packages/session-persistence/session-persistence/src/revision.ts +++ b/packages/session-persistence/session-persistence/src/revision.ts @@ -2,7 +2,10 @@ import type { Branded } from '@deepseek-ai/dsh-brand' -/** Backend-owned token that changes whenever one persisted session log changes. */ +/** + * Backend-owned token that identifies both one storage source and one revision + * of a persisted session log. + */ export type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'> /** diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index 001ea87d7d..d2f337cafd 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -12,7 +12,7 @@ All three surfaces (`current`, `shadowed`, and `log-only`) are searchable by def ## Source and index lifecycle -The service requires `ctx.sessions` and observes optional `ctx.sessionPersistence` dynamically. One serialized state machine compares lightweight durable snapshot revisions, loads only new or changed logs, extracts shared semantic documents, reconciles changes transactionally, and runs the query. Repeated queries and unchanged reopen load no full durable logs; new, changed, deleted, or load-repaired sources reconcile on the next stable observation. Source or transaction failure commits nothing, and the next search retries. +The service requires `ctx.sessions` and observes optional `ctx.sessionPersistence` dynamically. One serialized state machine compares source-qualified lightweight durable snapshot revisions, loads only new or changed logs, extracts shared semantic documents, reconciles changes transactionally, and runs the query. Repeated queries and unchanged same-store reopen load no full durable logs; switching stores, or observing new, changed, deleted, or load-repaired sources, reconciles on the next stable observation. Source or transaction failure commits nothing, and the next search retries. Persisted FTS rows live in a dedicated derived database. Connection-local TEMP tables hold live rows, which shadow the durable base for the same session and reveal it when the live owner disappears. Unmounting persistence hides durable rows without discarding the cache; remounting reconciles it. Closing or reopening the database drops every live overlay while retaining persisted rows. 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 f4904e17d8..9897331549 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -926,4 +926,49 @@ describe('SQLite schema, cancellation, and real persistence integration', () => await expect(ctx.sessionPersistence.load(meta.id)).resolves.toMatchObject({ meta, events: [{ seq: 0 }] }) await persistence.dispose() }) + + it('reconciles colliding local revisions when a derived index reopens against another SQLite store', async () => { + const persistencePathA = await temporaryPath('canonical-a.db') + const persistencePathB = await temporaryPath('canonical-b.db') + const searchPath = await temporaryPath('derived-collision.db') + const shared = header('same-id', 10) + + const first = new Context() + await first.plugin(SessionStore) + const persistenceA = await first.plugin(SessionPersistenceSqlite, { path: persistencePathA }) + await first.sessionPersistence.create(shared) + await first.sessionPersistence.append(shared.id, messageEvents('alpha source')) + const loadA = vi.spyOn(first.sessionPersistence, 'load') + const searchA = await first.plugin(SessionSearchSqlite, { path: searchPath }) + await expect(first.sessionSearch.searchSessions({ query: 'alpha' })) + .resolves.toMatchObject({ items: [{ header: shared }] }) + expect(loadA).toHaveBeenCalledTimes(1) + await searchA.dispose() + await persistenceA.dispose() + + const reopened = new Context() + await reopened.plugin(SessionStore) + const persistenceAAgain = await reopened.plugin(SessionPersistenceSqlite, { path: persistencePathA }) + const reopenedLoad = vi.spyOn(reopened.sessionPersistence, 'load') + const searchAAgain = await reopened.plugin(SessionSearchSqlite, { path: searchPath }) + await expect(reopened.sessionSearch.searchSessions({ query: 'alpha' })) + .resolves.toMatchObject({ items: [{ header: shared }] }) + expect(reopenedLoad).not.toHaveBeenCalled() + await searchAAgain.dispose() + await persistenceAAgain.dispose() + + const second = new Context() + await second.plugin(SessionStore) + const persistenceB = await second.plugin(SessionPersistenceSqlite, { path: persistencePathB }) + await second.sessionPersistence.create(shared) + await second.sessionPersistence.append(shared.id, messageEvents('bravo source')) + const loadB = vi.spyOn(second.sessionPersistence, 'load') + const searchB = await second.plugin(SessionSearchSqlite, { path: searchPath }) + await expect(second.sessionSearch.searchSessions({ query: 'bravo' })) + .resolves.toMatchObject({ items: [{ header: shared }] }) + await expect(second.sessionSearch.searchSessions({ query: 'alpha' })).resolves.toEqual({ items: [] }) + expect(loadB).toHaveBeenCalledTimes(1) + await searchB.dispose() + await persistenceB.dispose() + }) }) From 9eb49f61a9334429837325849d34979f95fdd368 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 12:41:46 +0800 Subject: [PATCH 04/19] chore(knip): include session query loader e2e --- knip.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/knip.json b/knip.json index 825980f205..03fcd1b30d 100644 --- a/knip.json +++ b/knip.json @@ -45,6 +45,10 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/session-query/session-query-sqlite": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/code-runtime/code-runtime-worker": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] From f1426511be657e9ca45663f9724678ffe3a79ca4 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 15 Jul 2026 16:35:17 +0800 Subject: [PATCH 05/19] test(hooks): wait for SubagentStop marker output --- packages/hooks/hooks-claude/tests/coverage.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index f376708688..9632d075dd 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync } from 'node:fs' +import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' @@ -612,7 +612,6 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server await waitForIdle(ctx, handle.agent as ReactLoopAgent) expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir - const { readFileSync } = await import('node:fs') const where = readFileSync(marker, 'utf8').trim() // `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames. expect(where.endsWith(sessionDir.split('/').pop()!)).toBe(true) @@ -643,9 +642,10 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server const childHandle = await ctx.agents.create({ agentId: AgentId('child-stop'), sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } }) ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' }) - await waitFor(() => existsSync(marker)) + // Redirection creates the marker before `pwd` writes it, so wait for the + // trailing newline that marks the command's complete output. + await waitFor(() => existsSync(marker) && readFileSync(marker, 'utf8').endsWith('\n')) expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir - const { readFileSync } = await import('node:fs') const where = readFileSync(marker, 'utf8').trim() // `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames. expect(where.endsWith(childDir.split('/').pop()!)).toBe(true) From 4505c6c55a515b5eed9a22a7ae112de5de413ba6 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 13:55:23 +0800 Subject: [PATCH 06/19] refactor(session-query): simplify persistence binding (round 1) --- .../session-query-sqlite/src/index.ts | 58 +++++++++---------- .../session-query-sqlite/tests/sqlite.spec.ts | 29 +++++++--- 2 files changed, 48 insertions(+), 39 deletions(-) diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index dd1ddb532a..4a510e5324 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -97,9 +97,12 @@ interface ObservedPersistedSession { loaded?: ObservedSession } +interface PersistenceBinding { + readonly service?: SessionPersistence +} + interface Observation { - persistence: SessionPersistence | undefined - persistenceRevision: number + persistenceBinding: PersistenceBinding persisted: Map live: Map } @@ -161,10 +164,8 @@ export class SessionSearchSqlite extends SessionSearchService { private readonly _instance = randomUUID() private readonly _ready: Promise private _db: DatabaseSync | undefined - private _persistence: SessionPersistence | undefined - private _persistenceBinding: object | undefined - private _persistenceRevision = 0 - private _lastPersistenceRevision: number | undefined + private _persistenceBinding: PersistenceBinding = {} + private _lastPersistenceBinding: PersistenceBinding | undefined private _persistenceEpoch = 0 private _globalGeneration = 0 private _localGeneration = 0 @@ -182,16 +183,12 @@ export class SessionSearchSqlite extends SessionSearchService { void this._ready.catch(() => undefined) this._optionalPersistenceFiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => { const service = childCtx.sessionPersistence - const binding = {} + const binding = { service } 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 + this._persistenceBinding = {} }, 'sessionSearchSqlite.persistenceBinding') }) ctx.effect(() => { @@ -330,16 +327,16 @@ export class SessionSearchSqlite extends SessionSearchService { 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 + const persistentChanges = observation.persistenceBinding.service === undefined ? [] : [...observation.persisted.values()].filter(entry => entry.loaded !== undefined) - const persistentDeletes = observation.persistence === undefined + const persistentDeletes = observation.persistenceBinding.service === undefined ? [] : persistedRows.filter(row => !observation.persisted.has(row.id as SessionId)) const liveChanges = [...observation.live.values()].filter(entry => liveById.get(entry.header.id)?.fingerprint !== entry.fingerprint) const liveDeletes = liveRows.filter(row => !observation.live.has(row.id as SessionId)) - const pointerChanged = this._lastPersistenceRevision !== undefined - && this._lastPersistenceRevision !== observation.persistenceRevision + const pointerChanged = this._lastPersistenceBinding !== undefined + && this._lastPersistenceBinding !== observation.persistenceBinding const hasWrites = persistentChanges.length > 0 || persistentDeletes.length > 0 || liveChanges.length > 0 @@ -393,7 +390,7 @@ export class SessionSearchSqlite extends SessionSearchService { if (hasWrites || pointerChanged) this._globalGeneration += 1 if (pointerChanged) this._persistenceEpoch += 1 this._localGeneration = nextLocalGeneration - this._lastPersistenceRevision = observation.persistenceRevision + this._lastPersistenceBinding = observation.persistenceBinding } private async _observeStable( @@ -402,13 +399,13 @@ export class SessionSearchSqlite extends SessionSearchService { ): Promise { for (;;) { assertNotAborted(signal) - const persistence = this._persistence - const persistenceRevision = this._persistenceRevision + const persistenceBinding = this._persistenceBinding + const persistence = persistenceBinding.service let persisted = new Map() if (persistence !== undefined) { try { - const canReuseIndexed = this._lastPersistenceRevision === undefined - || this._lastPersistenceRevision === persistenceRevision + const canReuseIndexed = this._lastPersistenceBinding === undefined + || this._lastPersistenceBinding === persistenceBinding const before = await waitWithAbort(persistence.listSnapshots(), signal) persisted = materializePersistenceSnapshots(before) for (const entry of persisted.values()) { @@ -421,14 +418,14 @@ export class SessionSearchSqlite extends SessionSearchService { await waitWithAbort(persistence.listSnapshots(), signal), ) if (!samePersistenceSnapshots(persisted, after)) continue - if (this._persistenceRevision !== persistenceRevision) continue + if (this._persistenceBinding !== persistenceBinding) 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 (this._persistenceBinding !== persistenceBinding) continue if (error instanceof SessionQueryError) throw error throw new SessionQueryError( `session-search persistence observation failed: ${errorMessage(error)}`, @@ -444,8 +441,8 @@ export class SessionSearchSqlite extends SessionSearchService { if (durable !== undefined) assertSessionHeadersCompatible(observed.header, durable.header) live.set(session.id, observed) } - if (this._persistenceRevision === persistenceRevision) { - return { persistence, persistenceRevision, persisted, live } + if (this._persistenceBinding === persistenceBinding) { + return { persistenceBinding, persisted, live } } } } @@ -564,7 +561,7 @@ export class SessionSearchSqlite extends SessionSearchService { ORDER BY match_count DESC, document_length ASC, time DESC, session_id ASC, seq DESC LIMIT ? OFFSET ? `).all( - ...selectedDocumentsParams(request.query, this._persistence !== undefined), + ...selectedDocumentsParams(request.query, this._persistenceBinding.service !== undefined), ...sessionWhere.params, ...eventWhere.params, request.limit + 1, @@ -583,7 +580,7 @@ export class SessionSearchSqlite extends SessionSearchService { ORDER BY match_count DESC, document_length ASC, time DESC, seq DESC LIMIT ? OFFSET ? `).all( - ...selectedDocumentsParams(request.query, this._persistence !== undefined), + ...selectedDocumentsParams(request.query, this._persistenceBinding.service !== undefined), request.sessionId, ...eventWhere.params, request.limit + 1, @@ -597,7 +594,7 @@ export class SessionSearchSqlite extends SessionSearchService { 'SELECT generation FROM temp.live_sessions WHERE id = ?', ).get(sessionId) as { generation: number } | undefined if (live !== undefined) return `live:${live.generation}` - if (this._persistence !== undefined) { + if (this._persistenceBinding.service !== undefined) { const persisted = db.prepare( 'SELECT generation FROM persisted_sessions WHERE id = ?', ).get(sessionId) as { generation: number } | undefined @@ -713,10 +710,7 @@ function selectedDocumentsParams(query: string, persistenceVisible: boolean): Ar } function observeLive(session: Session): ObservedSession { - return observeSession( - structuredClone(session.header), - session.events.map(event => structuredClone(event)), - ) + return observeSession(session.header, session.events) } function observeSession(header: SessionHeader, events: readonly SessionEvent[]): ObservedSession { 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 9897331549..3de325b35f 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -326,6 +326,24 @@ describe('SQLite session search', () => { })).rejects.toThrow(expectCode('SESSION_QUERY_STALE_CURSOR')) }) + it('invalidates session cursors after transient persistence topology changes', async () => { + TestPersistence.reset() + const ctx = await liveContext({ path: ':memory:', defaultLimit: 1, maxLimit: 5 }) + ctx.sessions.create(SessionId('first'), { seed: messageEvents('needle first') }) + ctx.sessions.create(SessionId('second'), { seed: messageEvents('needle second') }) + const page = await ctx.sessionSearch.searchSessions({ query: 'needle', limit: 1 }) + if (page.nextCursor === undefined) throw new Error('expected cursor') + + const persistence = await ctx.plugin(TestPersistence) + await persistence.dispose() + + await expect(ctx.sessionSearch.searchSessions({ + query: 'needle', + limit: 1, + cursor: page.nextCursor, + })).rejects.toThrow(expectCode('SESSION_QUERY_STALE_CURSOR')) + }) + it('rejects invalid requests, filters, cursors, and direct config', async () => { const ctx = await liveContext({ path: ':memory:', defaultLimit: 2, maxLimit: 3 }) const session = ctx.sessions.create(SessionId('valid'), { seed: messageEvents('needle') }) @@ -503,11 +521,6 @@ describe('SQLite reconciliation and source lifecycle', () => { TestPersistence.set({ meta: durable, events: messageEvents('new needle') }) TestPersistence.revisions.set(durable.id, revision) const replacement = await ctx.plugin(TestPersistence) - const internals = ctx.sessionSearch as unknown as { - _lastPersistenceRevision: number - _persistenceRevision: number - } - expect(internals._persistenceRevision).not.toBe(internals._lastPersistenceRevision) const page = await ctx.sessionSearch.searchSessions({ query: 'new needle' }) expect(TestPersistence.loads.get(durable.id)).toBe(2) expect(page).toMatchObject({ items: [{ header: durable }] }) @@ -553,13 +566,15 @@ describe('SQLite reconciliation and source lifecycle', () => { TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) const ctx = await liveContext() await ctx.plugin(TestPersistence) - const internals = ctx.sessionSearch as unknown as { _persistenceRevision: number } + const internals = ctx.sessionSearch as unknown as { + _persistenceBinding: { service?: SessionPersistence } + } const originalList = ctx.sessions.list.bind(ctx.sessions) let bumped = false const list = vi.spyOn(ctx.sessions, 'list').mockImplementation(() => { if (!bumped) { bumped = true - internals._persistenceRevision += 1 + internals._persistenceBinding = { ...internals._persistenceBinding } } return originalList() }) From de863aab9ab674a1528f575695fd62d1b151966a Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 14:04:49 +0800 Subject: [PATCH 07/19] fix(session-query): release stale persistence binding (round 2) --- .../session-query-sqlite/src/index.ts | 19 ++++++++++--------- .../session-query-sqlite/tests/sqlite.spec.ts | 7 +++++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 4a510e5324..1590a1d61c 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -98,6 +98,7 @@ interface ObservedPersistedSession { } interface PersistenceBinding { + readonly identity: symbol readonly service?: SessionPersistence } @@ -164,8 +165,8 @@ export class SessionSearchSqlite extends SessionSearchService { private readonly _instance = randomUUID() private readonly _ready: Promise private _db: DatabaseSync | undefined - private _persistenceBinding: PersistenceBinding = {} - private _lastPersistenceBinding: PersistenceBinding | undefined + private _persistenceBinding: PersistenceBinding = { identity: Symbol() } + private _lastPersistenceIdentity: symbol | undefined private _persistenceEpoch = 0 private _globalGeneration = 0 private _localGeneration = 0 @@ -183,12 +184,12 @@ export class SessionSearchSqlite extends SessionSearchService { void this._ready.catch(() => undefined) this._optionalPersistenceFiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => { const service = childCtx.sessionPersistence - const binding = { service } + const binding = { identity: Symbol(), service } this._persistenceBinding = binding childCtx.effect(() => () => { /* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */ if (this._persistenceBinding !== binding) return - this._persistenceBinding = {} + this._persistenceBinding = { identity: Symbol() } }, 'sessionSearchSqlite.persistenceBinding') }) ctx.effect(() => { @@ -335,8 +336,8 @@ export class SessionSearchSqlite extends SessionSearchService { : persistedRows.filter(row => !observation.persisted.has(row.id as SessionId)) const liveChanges = [...observation.live.values()].filter(entry => liveById.get(entry.header.id)?.fingerprint !== entry.fingerprint) const liveDeletes = liveRows.filter(row => !observation.live.has(row.id as SessionId)) - const pointerChanged = this._lastPersistenceBinding !== undefined - && this._lastPersistenceBinding !== observation.persistenceBinding + const pointerChanged = this._lastPersistenceIdentity !== undefined + && this._lastPersistenceIdentity !== observation.persistenceBinding.identity const hasWrites = persistentChanges.length > 0 || persistentDeletes.length > 0 || liveChanges.length > 0 @@ -390,7 +391,7 @@ export class SessionSearchSqlite extends SessionSearchService { if (hasWrites || pointerChanged) this._globalGeneration += 1 if (pointerChanged) this._persistenceEpoch += 1 this._localGeneration = nextLocalGeneration - this._lastPersistenceBinding = observation.persistenceBinding + this._lastPersistenceIdentity = observation.persistenceBinding.identity } private async _observeStable( @@ -404,8 +405,8 @@ export class SessionSearchSqlite extends SessionSearchService { let persisted = new Map() if (persistence !== undefined) { try { - const canReuseIndexed = this._lastPersistenceBinding === undefined - || this._lastPersistenceBinding === persistenceBinding + const canReuseIndexed = this._lastPersistenceIdentity === undefined + || this._lastPersistenceIdentity === persistenceBinding.identity const before = await waitWithAbort(persistence.listSnapshots(), signal) persisted = materializePersistenceSnapshots(before) for (const entry of persisted.values()) { 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 3de325b35f..0a62fff1b3 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -567,14 +567,17 @@ describe('SQLite reconciliation and source lifecycle', () => { const ctx = await liveContext() await ctx.plugin(TestPersistence) const internals = ctx.sessionSearch as unknown as { - _persistenceBinding: { service?: SessionPersistence } + _persistenceBinding: { identity: symbol; service?: SessionPersistence } } const originalList = ctx.sessions.list.bind(ctx.sessions) let bumped = false const list = vi.spyOn(ctx.sessions, 'list').mockImplementation(() => { if (!bumped) { bumped = true - internals._persistenceBinding = { ...internals._persistenceBinding } + internals._persistenceBinding = { + ...internals._persistenceBinding, + identity: Symbol(), + } } return originalList() }) From 92fd92fa697639ece53fcbc8175daf8589b263e4 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 16 Jul 2026 14:12:50 +0800 Subject: [PATCH 08/19] test(session-query): name binding retry precisely (round 3) --- .../session-query/session-query-sqlite/tests/sqlite.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0a62fff1b3..b4a69acbbd 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -561,7 +561,7 @@ describe('SQLite reconciliation and source lifecycle', () => { expect(TestPersistence.loads.get(added.id)).toBe(1) }) - it('retries if the source revision changes while live sessions are observed', async () => { + it('retries if the persistence binding changes while live sessions are observed', async () => { const durable = header('live-boundary-retry') TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) const ctx = await liveContext() From 75e9958f11e941ef33753eba037915f6d92be6ec Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 17 Jul 2026 09:39:39 +0800 Subject: [PATCH 09/19] fix(session-query): close review edge cases (round 4) --- docs/config-catalog.md | 8 +-- .../session-persistence-jsonl/src/index.ts | 26 ++++---- .../tests/jsonl.spec.ts | 33 ++++++++++ .../session-persistence-sqlite/README.md | 4 +- .../session-persistence-sqlite/src/index.ts | 11 +++- .../session-persistence-sqlite/src/schema.ts | 5 +- .../tests/sqlite.spec.ts | 25 +++++++- .../session-query-sqlite/README.md | 4 +- .../session-query-sqlite/src/index.ts | 62 +++++++++++++------ .../session-query-sqlite/src/query.ts | 25 +++++--- .../session-query-sqlite/tests/query.spec.ts | 10 ++- .../session-query-sqlite/tests/sqlite.spec.ts | 51 +++++++++++++++ 12 files changed, 213 insertions(+), 51 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c4d5ecc523..6d141185ca 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -627,7 +627,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:39`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:40`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query` @@ -654,9 +654,9 @@ export interface Config { path: string /** SQLite journal mode. Defaults to `wal`. */ journalMode?: JournalMode - /** Page size when a request omits `limit`. Defaults to 20. */ + /** Page size when a request omits `limit`. At most `Number.MAX_SAFE_INTEGER - 1`; defaults to 20. */ defaultLimit?: number - /** Largest accepted page size. Defaults to 100. */ + /** Largest accepted page size. At most `Number.MAX_SAFE_INTEGER - 1`; defaults to 100. */ maxLimit?: number /** Maximum snippet length in Unicode code points. Defaults to 240. */ snippetChars?: number @@ -666,7 +666,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-query/session-query-sqlite/src/index.ts:67`](../packages/session-query/session-query-sqlite/src/index.ts) +Source: [`packages/session-query/session-query-sqlite/src/index.ts:72`](../packages/session-query/session-query-sqlite/src/index.ts) ## `@deepseek-ai/dsh-skill` diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index d45f31e3df..b47766e186 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -145,17 +145,21 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi async listSnapshots(): Promise { const snapshots: SessionPersistenceSnapshot[] = [] for (const artifact of await this.listArtifacts()) { - const identity = await stat(artifact.path, { bigint: true }) - snapshots.push({ - header: artifact.header, - revision: SessionPersistenceRevision([ - identity.dev, - identity.ino, - identity.size, - identity.mtimeNs, - identity.ctimeNs, - ].join(':')), - }) + try { + const identity = await stat(artifact.path, { bigint: true }) + snapshots.push({ + header: artifact.header, + revision: SessionPersistenceRevision([ + identity.dev, + identity.ino, + identity.size, + identity.mtimeNs, + identity.ctimeNs, + ].join(':')), + }) + } catch (error: unknown) { + if (!isENOENT(error)) throw error + } } return snapshots } diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 0cb4ed3fea..3c826db2eb 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -179,6 +179,39 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { await otherCtx.fiber.dispose() }) + it('omits a snapshot artifact removed after discovery', async () => { + const m = meta('vanishing-snapshot') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as unknown as { + listArtifacts(): Promise> + } + const listArtifacts = persistence.listArtifacts.bind(persistence) + const discovery = vi.spyOn(persistence, 'listArtifacts').mockImplementation(async () => { + const artifacts = await listArtifacts() + await rm(artifacts[0]!.path) + return artifacts + }) + + await expect(ctx.sessionPersistence.listSnapshots()).resolves.toEqual([]) + discovery.mockRestore() + }) + + it('surfaces non-ENOENT snapshot stat failures after discovery', async () => { + const blocker = join(root, 'snapshot-not-a-directory') + await writeFile(blocker, 'x') + const persistence = ctx.sessionPersistence as unknown as { + listArtifacts(): Promise> + } + const discovery = vi.spyOn(persistence, 'listArtifacts').mockResolvedValue([{ + header: meta('snapshot-stat-failure'), + path: join(blocker, 'session.jsonl'), + }]) + + await expect(ctx.sessionPersistence.listSnapshots()).rejects.toThrow(/ENOTDIR/) + discovery.mockRestore() + }) + it('persists a forked child seed through the existing session write path', async () => { const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } }) appendClosedTurn(source) diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index a4a0e645c4..cae28d2bd4 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -6,7 +6,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i ## Storage model -Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). +Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. @@ -15,7 +15,7 @@ The repository's Node range supports unflagged `node:sqlite`. The database enabl - **Append = a transaction.** `append` runs `BEGIN`/`COMMIT` around the batch: it materializes the `sessions` row (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event's `seq` must equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (`load()` already balanced the stored log, so `append` never has to repair a crash tail.) - **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row). - **Interrupted-turn close on load.** `load()` implements the shared [crash-recovery contract](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md): preserve the valid interrupted turn, append its synthetic closing events in one transaction, and remove only a torn tail row. Committed parse errors or sequence gaps make the session unloadable. Because recovery mutates stored rows, the next append starts from a balanced log and accurate cursor. -- **Lightweight revisions.** `listSnapshots()` combines an immutable store identity, the database file identity, and a per-session counter incremented in each mutating transaction. This keeps unchanged observations stable without parsing event rows and prevents independent stores from sharing a revision accidentally. +- **Lightweight revisions.** `listSnapshots()` combines the immutable store and database-file identity, a per-materialization incarnation id, and a per-session counter incremented in each mutating transaction. This keeps unchanged observations stable without parsing event rows and distinguishes independent stores and recreated same-id logs. ## Configuration (schemastery) diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 6759704d7f..2f3b941f8b 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -7,6 +7,7 @@ import { Context } from 'cordis' import z from 'schemastery' +import { randomUUID } from 'node:crypto' import { statSync } from 'node:fs' import { DatabaseSync } from 'node:sqlite' import { mkdir } from 'node:fs/promises' @@ -235,7 +236,9 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers const rows = this.db.prepare('SELECT * FROM sessions').all() as unknown as SessionRow[] return rows.map(row => ({ header: rowToMeta(row), - revision: SessionPersistenceRevision(`${this.storeIdentity}:revision:${row.revision}`), + revision: SessionPersistenceRevision( + `${this.storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`, + ), })) } @@ -259,8 +262,9 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers */ private writeRow(meta: SessionHeader): void { this.db.prepare(` - INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, revision) - VALUES (?, ?, ?, ?, ?, ?, 0) + INSERT INTO sessions + (id, version, created_at, cwd, parent_session, seed_length, incarnation, revision) + VALUES (?, ?, ?, ?, ?, ?, ?, 0) ON CONFLICT(id) DO UPDATE SET version = excluded.version, created_at = excluded.created_at, @@ -274,6 +278,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers meta.cwd ?? null, meta.parentSession ?? null, meta.seedLength ?? null, + randomUUID(), ) } } diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index fe7db15d67..fc397ff742 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 6 +export const SCHEMA_VERSION = 7 /** * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}). @@ -33,6 +33,8 @@ export interface SessionRow { cwd: string | null parent_session: string | null seed_length: number | null + /** Stable identity assigned when this log is materialized. */ + incarnation: string /** Monotonic log-change token incremented in each mutating transaction. */ revision: number } @@ -108,6 +110,7 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM cwd TEXT, parent_session TEXT, seed_length INTEGER, + incarnation TEXT NOT NULL, revision INTEGER NOT NULL ) STRICT `) diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 26b39e22d4..c1d4f8e74f 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -378,8 +378,31 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { await b.dispose() }) + it('changes revisions when a deleted session id is materialized again in the same database', async () => { + const path = await freshDbPath() + const m = meta('recreated-revision') + const first = await backend(path) + await first.ctx.sessionPersistence.create(m) + await first.ctx.sessionPersistence.append(m.id, oneTurnLog()) + const before = (await first.ctx.sessionPersistence.listSnapshots())[0]?.revision + await first.dispose() + + const cleanup = openDatabase(path, 'wal') + cleanup.prepare('DELETE FROM sessions WHERE id = ?').run(m.id) + cleanup.close() + + const second = await backend(path) + await second.ctx.sessionPersistence.create(m) + await second.ctx.sessionPersistence.append(m.id, oneTurnLog()) + const after = (await second.ctx.sessionPersistence.listSnapshots())[0]?.revision + expect(after).not.toBe(before) + expect(String(before)).toMatch(/:revision:1$/) + expect(String(after)).toMatch(/:revision:1$/) + await second.dispose() + }) + it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(6) + expect(SCHEMA_VERSION).toBe(7) }) it('keeps the revision stable for an empty repair hook', async () => { diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index 101f03eeca..dba2132f43 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -24,8 +24,8 @@ The database is disposable but reset is guarded: a recognized incompatible searc |---|---:|---| | `path` | required | Dedicated derived-index SQLite path; `:memory:` is supported. | | `journalMode` | `wal` | `wal`, `delete`, `truncate`, or `persist`. | -| `defaultLimit` | `20` | Page size when a request omits `limit`. | -| `maxLimit` | `100` | Largest accepted request page size. | +| `defaultLimit` | `20` | Page size when a request omits `limit`; at most `Number.MAX_SAFE_INTEGER - 1`. | +| `maxLimit` | `100` | Largest accepted request page size; at most `Number.MAX_SAFE_INTEGER - 1`. | | `snippetChars` | `240` | Maximum snippet length in Unicode code points. | ## Tokenizer and limits diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 1590a1d61c..4265405098 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -48,6 +48,7 @@ import { quoteFtsData, requestFingerprint, sanitizeFtsText, + SQLITE_MAX_PAGE_LIMIT, } from './query.ts' export { @@ -63,15 +64,19 @@ export const SESSION_QUERY_SQLITE_MAX_LIMIT = 100 /** Default maximum snippet length in Unicode code points. */ export const SESSION_QUERY_SQLITE_SNIPPET_CHARS = 240 +// A serialized search tolerates one transient source change; repeated churn +// fails instead of monopolizing the operation queue. +const STABLE_OBSERVATION_ATTEMPTS = 2 + /** SQLite session-search configuration. */ export interface Config { /** Dedicated derived-index path; `:memory:` is supported for tests. */ path: string /** SQLite journal mode. Defaults to `wal`. */ journalMode?: JournalMode - /** Page size when a request omits `limit`. Defaults to 20. */ + /** Page size when a request omits `limit`. At most `Number.MAX_SAFE_INTEGER - 1`; defaults to 20. */ defaultLimit?: number - /** Largest accepted page size. Defaults to 100. */ + /** Largest accepted page size. At most `Number.MAX_SAFE_INTEGER - 1`; defaults to 100. */ maxLimit?: number /** Maximum snippet length in Unicode code points. Defaults to 240. */ snippetChars?: number @@ -154,8 +159,8 @@ export class SessionSearchSqlite extends SessionSearchService { static Config: z = z.object({ path: z.string().required(), journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'), - defaultLimit: z.number().step(1).min(1).default(SESSION_QUERY_SQLITE_DEFAULT_LIMIT), - maxLimit: z.number().step(1).min(1).default(SESSION_QUERY_SQLITE_MAX_LIMIT), + defaultLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(SESSION_QUERY_SQLITE_DEFAULT_LIMIT), + maxLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(SESSION_QUERY_SQLITE_MAX_LIMIT), snippetChars: z.number().step(1).min(1).default(SESSION_QUERY_SQLITE_SNIPPET_CHARS), }) @@ -206,14 +211,14 @@ export class SessionSearchSqlite extends SessionSearchService { const signal = exec?.signal return this._serialized(signal, async () => { await this._ensureReady(signal) - await this._reconcile(signal) + const persistenceBinding = 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) + const rows = this._querySessions(normalized, offset, persistenceBinding) return page(rows, normalized.limit, row => this._sessionHit(row), cursorOffset => encodeCursor({ version: 1, instance: this._instance, @@ -233,14 +238,14 @@ export class SessionSearchSqlite extends SessionSearchService { const signal = exec?.signal return this._serialized(signal, async () => { await this._ensureReady(signal) - await this._reconcile(signal) + const persistenceBinding = await this._reconcile(signal) assertNotAborted(signal) - const generation = this._targetGeneration(normalized.sessionId) + const generation = this._targetGeneration(normalized.sessionId, persistenceBinding) const fingerprint = requestFingerprint(normalized) const offset = normalized.cursor === undefined ? 0 : decodeCursor(normalized.cursor, this._instance, 'events', fingerprint, generation) - const rows = this._queryEvents(normalized, offset) + const rows = this._queryEvents(normalized, offset, persistenceBinding) return page(rows, normalized.limit, row => this._eventHit(row), cursorOffset => encodeCursor({ version: 1, instance: this._instance, @@ -316,7 +321,7 @@ export class SessionSearchSqlite extends SessionSearchService { } } - private async _reconcile(signal: AbortSignal | undefined): Promise { + private async _reconcile(signal: AbortSignal | undefined): Promise { const db = this._requireDb() const persistedRows = db.prepare( 'SELECT id, revision, generation FROM persisted_sessions', @@ -392,13 +397,14 @@ export class SessionSearchSqlite extends SessionSearchService { if (pointerChanged) this._persistenceEpoch += 1 this._localGeneration = nextLocalGeneration this._lastPersistenceIdentity = observation.persistenceBinding.identity + return observation.persistenceBinding } private async _observeStable( indexed: ReadonlyMap, signal: AbortSignal | undefined, ): Promise { - for (;;) { + for (let attempt = 0; attempt < STABLE_OBSERVATION_ATTEMPTS; attempt += 1) { assertNotAborted(signal) const persistenceBinding = this._persistenceBinding const persistence = persistenceBinding.service @@ -446,6 +452,10 @@ export class SessionSearchSqlite extends SessionSearchService { return { persistenceBinding, persisted, live } } } + throw new SessionQueryError( + 'session-search persistence observation did not stabilize after one retry', + 'SESSION_QUERY_PERSISTENCE_FAILED', + ) } private _mainGeneration(): number { @@ -540,7 +550,11 @@ export class SessionSearchSqlite extends SessionSearchService { } } - private _querySessions(request: NormalizedSessionRequest, offset: number): SearchRow[] { + private _querySessions( + request: NormalizedSessionRequest, + offset: number, + persistenceBinding: PersistenceBinding, + ): SearchRow[] { const selected = selectedDocumentsSql() const sessionWhere = buildSessionWhere(request.sessionFilters) const eventWhere = buildEventWhere(request.eventFilters) @@ -562,7 +576,7 @@ export class SessionSearchSqlite extends SessionSearchService { ORDER BY match_count DESC, document_length ASC, time DESC, session_id ASC, seq DESC LIMIT ? OFFSET ? `).all( - ...selectedDocumentsParams(request.query, this._persistenceBinding.service !== undefined), + ...selectedDocumentsParams(request.query, persistenceBinding.service !== undefined), ...sessionWhere.params, ...eventWhere.params, request.limit + 1, @@ -570,7 +584,11 @@ export class SessionSearchSqlite extends SessionSearchService { ) as unknown as SearchRow[] } - private _queryEvents(request: NormalizedEventRequest, offset: number): SearchRow[] { + private _queryEvents( + request: NormalizedEventRequest, + offset: number, + persistenceBinding: PersistenceBinding, + ): SearchRow[] { const selected = selectedDocumentsSql() const eventWhere = buildEventWhere(request.filters) const where = ['session_id = ?', eventWhere.sql].filter(Boolean).join(' AND ') @@ -581,7 +599,7 @@ export class SessionSearchSqlite extends SessionSearchService { ORDER BY match_count DESC, document_length ASC, time DESC, seq DESC LIMIT ? OFFSET ? `).all( - ...selectedDocumentsParams(request.query, this._persistenceBinding.service !== undefined), + ...selectedDocumentsParams(request.query, persistenceBinding.service !== undefined), request.sessionId, ...eventWhere.params, request.limit + 1, @@ -589,13 +607,13 @@ export class SessionSearchSqlite extends SessionSearchService { ) as unknown as SearchRow[] } - private _targetGeneration(sessionId: SessionId): string { + private _targetGeneration(sessionId: SessionId, persistenceBinding: PersistenceBinding): string { const db = this._requireDb() const live = db.prepare( 'SELECT generation FROM temp.live_sessions WHERE id = ?', ).get(sessionId) as { generation: number } | undefined if (live !== undefined) return `live:${live.generation}` - if (this._persistenceBinding.service !== undefined) { + if (persistenceBinding.service !== undefined) { const persisted = db.prepare( 'SELECT generation FROM persisted_sessions WHERE id = ?', ).get(sessionId) as { generation: number } | undefined @@ -850,8 +868,8 @@ function resolveConfig(config: Config): ResolvedConfig { if (typeof resolved.path !== 'string' || resolved.path.trim().length === 0) { throw invalidConfig('path must not be blank') } - assertPositiveInteger('defaultLimit', resolved.defaultLimit) - assertPositiveInteger('maxLimit', resolved.maxLimit) + assertPageLimit('defaultLimit', resolved.defaultLimit) + assertPageLimit('maxLimit', resolved.maxLimit) assertPositiveInteger('snippetChars', resolved.snippetChars) if (resolved.defaultLimit > resolved.maxLimit) { throw invalidConfig('defaultLimit must be less than or equal to maxLimit') @@ -865,6 +883,12 @@ function assertPositiveInteger(name: string, value: number): void { if (!Number.isInteger(value) || value < 1) throw invalidConfig(`${name} must be a positive integer`) } +function assertPageLimit(name: string, value: number): void { + if (!Number.isSafeInteger(value) || value < 1 || value > SQLITE_MAX_PAGE_LIMIT) { + throw invalidConfig(`${name} must be an integer between 1 and ${SQLITE_MAX_PAGE_LIMIT}`) + } +} + function invalidConfig(detail: string): SessionQueryError { return new SessionQueryError( `session-search SQLite config: ${detail}`, diff --git a/packages/session-query/session-query-sqlite/src/query.ts b/packages/session-query/session-query-sqlite/src/query.ts index 9654f6ae70..5eb3ed8380 100644 --- a/packages/session-query/session-query-sqlite/src/query.ts +++ b/packages/session-query/session-query-sqlite/src/query.ts @@ -20,6 +20,9 @@ export const FTS_HIGHLIGHT_START = '\uFDD0' /** Collision-free marker inserted after an FTS5 match by `highlight()`. */ export const FTS_HIGHLIGHT_END = '\uFDD1' +/** Largest page size whose internal lookahead remains an exact SQLite integer binding. */ +export const SQLITE_MAX_PAGE_LIMIT = Number.MAX_SAFE_INTEGER - 1 + /** Limit defaults needed to normalize a search request. */ export interface QueryLimits { /** Page size used when the request omits one. */ @@ -232,14 +235,17 @@ export function makeSnippet(markedText: string, maxChars: number): string { const characters = Array.from(clean) if (characters.length <= maxChars) return clean if (maxChars === 1) return '…' - let start = Math.max(0, matchStart - Math.floor(maxChars / 3)) - let prefix = start > 0 ? '…' : '' + const matchedIndex = Math.min(matchStart, characters.length - 1) + let start = Math.max(0, matchedIndex - Math.floor(maxChars / 3)) + const prefix = start > 0 ? '…' : '' let suffix = '…' let contentLength = maxChars - prefix.length - suffix.length if (contentLength < 1) { - start = 0 - prefix = '' - contentLength = maxChars - 1 + start = matchedIndex + suffix = '' + contentLength = maxChars - prefix.length - suffix.length + } else if (matchedIndex >= start + contentLength) { + start = matchedIndex - contentLength + 1 } let end = Math.min(characters.length, start + contentLength) if (end === characters.length) { @@ -326,9 +332,14 @@ function materializeMetadataFilters( function normalizeLimit(value: number | undefined, limits: QueryLimits): number { const limit = value ?? limits.defaultLimit - if (!Number.isInteger(limit) || limit < 1 || limit > limits.maxLimit) { + const maxLimit = Math.min(limits.maxLimit, SQLITE_MAX_PAGE_LIMIT) + if ( + !Number.isSafeInteger(limit) + || limit < 1 + || limit > maxLimit + ) { throw new SessionQueryError( - `session-search limit must be an integer between 1 and ${limits.maxLimit}`, + `session-search limit must be an integer between 1 and ${maxLimit}`, 'SESSION_QUERY_INVALID_LIMIT', ) } diff --git a/packages/session-query/session-query-sqlite/tests/query.spec.ts b/packages/session-query/session-query-sqlite/tests/query.spec.ts index f9c0a5d19e..14e84aae75 100644 --- a/packages/session-query/session-query-sqlite/tests/query.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/query.spec.ts @@ -11,6 +11,7 @@ import { normalizeSessionRequest, quoteFtsData, requestFingerprint, + SQLITE_MAX_PAGE_LIMIT, type NormalizedEventRequest, type NormalizedSessionRequest, } from '../src/query.ts' @@ -88,6 +89,12 @@ describe('SQLite search request normalization', () => { expect(() => normalizeEventRequest({ sessionId: SessionId('s'), query: 'x', limit }, limits)) .toThrow(expectCode('SESSION_QUERY_INVALID_LIMIT')) } + expect(() => normalizeEventRequest({ + sessionId: SessionId('s'), + query: 'x', + limit: SQLITE_MAX_PAGE_LIMIT + 1, + }, { defaultLimit: 1, maxLimit: SQLITE_MAX_PAGE_LIMIT + 1 })) + .toThrow(expectCode('SESSION_QUERY_INVALID_LIMIT')) }) it('materializes owned filter values during normalization', () => { @@ -214,7 +221,8 @@ describe('SQLite query identity and presentation', () => { expect(makeSnippet(`abcde${FTS_HIGHLIGHT_START}f${FTS_HIGHLIGHT_END}`, 1)).toBe('…') expect(makeSnippet('abcdefghij', 5)).toBe('abcd…') expect(makeSnippet(`ab${FTS_HIGHLIGHT_START}c${FTS_HIGHLIGHT_END}defghij`, 5)).toBe('…bcd…') - expect(makeSnippet(`abcde${FTS_HIGHLIGHT_START}f${FTS_HIGHLIGHT_END}`, 2)).toBe('a…') + expect(makeSnippet(`ab${FTS_HIGHLIGHT_START}c${FTS_HIGHLIGHT_END}defghij`, 3)).toBe('…c…') + expect(makeSnippet(`abcde${FTS_HIGHLIGHT_START}f${FTS_HIGHLIGHT_END}`, 2)).toBe('…f') expect(makeSnippet(`abcde${FTS_HIGHLIGHT_START}f${FTS_HIGHLIGHT_END}`, 5)).toBe('…cdef') expect(makeSnippet(` x—${FTS_HIGHLIGHT_START}café${FTS_HIGHLIGHT_END}\n y `, 20)) .toBe('x—café y') 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 b4a69acbbd..0895b6f08e 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -387,6 +387,8 @@ describe('SQLite session search', () => { { path: '' }, { path: ':memory:', defaultLimit: 0 }, { path: ':memory:', maxLimit: 0 }, + { path: ':memory:', defaultLimit: 1e100 }, + { path: ':memory:', maxLimit: 1e100 }, { path: ':memory:', snippetChars: 0 }, { path: ':memory:', defaultLimit: 3, maxLimit: 2 }, { path: ':memory:', journalMode: 'memory' }, @@ -462,6 +464,39 @@ describe('SQLite reconciliation and source lifecycle', () => { .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) }) + it('uses the reconciled persistence binding through the query boundary', async () => { + const durable = header('post-reconcile-unmount') + TestPersistence.reset([{ meta: durable, events: [ + ...messageEvents('durable needle', 1), + { ...messageEvents('durable needle again', 2)[0]!, seq: 1 }, + ] }]) + const ctx = await liveContext({ path: ':memory:', defaultLimit: 1, maxLimit: 2 }) + const persistence = await ctx.plugin(TestPersistence) + const internals = ctx.sessionSearch as unknown as { + _reconcile(signal: AbortSignal | undefined): Promise<{ + identity: symbol + service?: SessionPersistence + }> + } + const reconcile = internals._reconcile.bind(internals) + const boundary = vi.spyOn(internals, '_reconcile').mockImplementation(async (signal) => { + const binding = await reconcile(signal) + await persistence.dispose() + return binding + }) + + const page = await ctx.sessionSearch.searchEvents({ + sessionId: durable.id, + query: 'needle', + limit: 1, + }) + expect(page.items).toMatchObject([{ sessionId: durable.id }]) + expect(page.nextCursor).toEqual(expect.any(String)) + boundary.mockRestore() + await expect(ctx.sessionSearch.searchEvents({ sessionId: durable.id, query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) + }) + it('discards a stale list rejection when persistence unmounts during observation', async () => { const durable = header('racing') TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) @@ -561,6 +596,22 @@ describe('SQLite reconciliation and source lifecycle', () => { expect(TestPersistence.loads.get(added.id)).toBe(1) }) + it('fails after one retry when persistence snapshots keep changing', async () => { + const durable = header('continuous-mutation') + TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + let lists = 0 + TestPersistence.snapshotEffect = () => { + lists += 1 + TestPersistence.set({ meta: durable, events: messageEvents(`durable needle ${lists}`) }) + } + + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) + expect(lists).toBe(4) + }) + it('retries if the persistence binding changes while live sessions are observed', async () => { const durable = header('live-boundary-retry') TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) From 220076e5e2e74248f56bb8b4bf23de9b792f72bc Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 17 Jul 2026 10:15:48 +0800 Subject: [PATCH 10/19] fix(session-query): preserve typed query failures (round 5) --- .../session-query-sqlite/README.md | 2 +- .../session-query-sqlite/src/index.ts | 47 +++++++++++++------ .../session-query-sqlite/tests/sqlite.spec.ts | 46 ++++++++++++++++++ 3 files changed, 79 insertions(+), 16 deletions(-) diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index dba2132f43..1a5ee97e29 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -4,7 +4,7 @@ SQLite FTS5 implementation of `ctx.sessionSearch`. The service searches the live ## Search contract -`searchSessions(request, exec?)` returns `SessionSearchHit` pages across the corpus; `searchEvents(request, exec?)` returns `SessionEventSearchHit` pages within one session. Queries are required, trimmed, whitespace-normalized literal phrases. FTS5 syntax such as quotes, `OR`, `NEAR`, and `*` is treated as data rather than executable MATCH syntax. Metadata filters are parameterized SQL predicates applied before ranking. +`searchSessions(request, exec?)` returns `SessionSearchHit` pages across the corpus; `searchEvents(request, exec?)` returns `SessionEventSearchHit` pages within one session. Queries are required, trimmed, whitespace-normalized literal phrases. FTS5 syntax such as quotes, `OR`, `NEAR`, and `*` is treated as data rather than executable MATCH syntax. Metadata filters are parameterized SQL predicates applied before ranking. A request exceeding SQLite's portable limit of 32,766 total bindings, including fixed query and pagination values, fails with `SESSION_QUERY_INVALID_FILTER` before statement preparation. Relevance is source-comparable across persistent and TEMP tables: actual FTS5 highlighted-match span count descending, then stored document code-point length ascending. Event time, session id where applicable, and seq break remaining ties. Cross-session results expose the selected event as `bestMatch`; both scopes derive whitespace-normalized plain text from FTS5 highlight positions and bound it in Unicode code points. Cursors are opaque branded values, bind to the normalized request and service instance, and fail when the relevant generation changes. A within-session cursor survives unrelated-session changes; a cross-session cursor does not. diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 4265405098..4428d7ca00 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -559,6 +559,14 @@ export class SessionSearchSqlite extends SessionSearchService { const sessionWhere = buildSessionWhere(request.sessionFilters) const eventWhere = buildEventWhere(request.eventFilters) const where = [sessionWhere.sql, eventWhere.sql].filter(Boolean).join(' AND ') + const bindings = [ + ...selectedDocumentsParams(request.query, persistenceBinding.service !== undefined), + ...sessionWhere.params, + ...eventWhere.params, + request.limit + 1, + offset, + ] + assertPortableBindingCount(bindings) return this._requireDb().prepare(` ${selected.sql}, filtered AS ( @@ -575,13 +583,7 @@ export class SessionSearchSqlite extends SessionSearchService { WHERE event_rank = 1 ORDER BY match_count DESC, document_length ASC, time DESC, session_id ASC, seq DESC LIMIT ? OFFSET ? - `).all( - ...selectedDocumentsParams(request.query, persistenceBinding.service !== undefined), - ...sessionWhere.params, - ...eventWhere.params, - request.limit + 1, - offset, - ) as unknown as SearchRow[] + `).all(...bindings) as unknown as SearchRow[] } private _queryEvents( @@ -592,19 +594,21 @@ export class SessionSearchSqlite extends SessionSearchService { const selected = selectedDocumentsSql() const eventWhere = buildEventWhere(request.filters) const where = ['session_id = ?', eventWhere.sql].filter(Boolean).join(' AND ') + const bindings = [ + ...selectedDocumentsParams(request.query, persistenceBinding.service !== undefined), + request.sessionId, + ...eventWhere.params, + request.limit + 1, + offset, + ] + assertPortableBindingCount(bindings) return this._requireDb().prepare(` ${selected.sql} SELECT * FROM matched WHERE ${where} ORDER BY match_count DESC, document_length ASC, time DESC, seq DESC LIMIT ? OFFSET ? - `).all( - ...selectedDocumentsParams(request.query, persistenceBinding.service !== undefined), - request.sessionId, - ...eventWhere.params, - request.limit + 1, - offset, - ) as unknown as SearchRow[] + `).all(...bindings) as unknown as SearchRow[] } private _targetGeneration(sessionId: SessionId, persistenceBinding: PersistenceBinding): string { @@ -728,6 +732,19 @@ function selectedDocumentsParams(query: string, persistenceVisible: boolean): Ar ] } +// SQLite builds may raise this ceiling; supported modern versions share 32,766 +// as the portable host-parameter limit. +const SQLITE_PORTABLE_VARIABLE_LIMIT = 32_766 + +function assertPortableBindingCount(bindings: readonly (string | number)[]): void { + if (bindings.length > SQLITE_PORTABLE_VARIABLE_LIMIT) { + throw new SessionQueryError( + `session-search request requires ${bindings.length} SQLite bindings; reduce filters to stay within the portable ${SQLITE_PORTABLE_VARIABLE_LIMIT}-variable limit`, + 'SESSION_QUERY_INVALID_FILTER', + ) + } +} + function observeLive(session: Session): ObservedSession { return observeSession(session.header, session.events) } @@ -834,7 +851,7 @@ function decodeCursor( || decoded.instance !== instance || decoded.scope !== scope || decoded.fingerprint !== fingerprint - || !Number.isInteger(decoded.offset) + || !Number.isSafeInteger(decoded.offset) || decoded.offset === undefined || decoded.offset < 0 ) { 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 0895b6f08e..4b875931c4 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -53,6 +53,16 @@ function expectCode(code: SessionQueryErrorCode): Error { return expect.objectContaining({ code }) as Error } +function replaceCursorOffset( + cursor: ReturnType, + offset: number, +): ReturnType { + const payload = JSON.parse( + Buffer.from(cursor, 'base64url').toString('utf8'), + ) as Record + return SessionSearchCursor(Buffer.from(JSON.stringify({ ...payload, offset }), 'utf8').toString('base64url')) +} + class TestPersistence extends SessionPersistence { static entries = new Map() static revisions = new Map() @@ -276,6 +286,14 @@ describe('SQLite session search', () => { expect(sessionPage.nextCursor).toEqual(expect.any(String)) if (eventPage.nextCursor === undefined || sessionPage.nextCursor === undefined) throw new Error('expected cursors') + const unsafeOffsetCursor = replaceCursorOffset(eventPage.nextCursor, 1e100) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: target.id, + query: 'needle', + limit: 1, + cursor: unsafeOffsetCursor, + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CURSOR')) + const eventKeys = eventPage.items.map(item => `${item.sessionId}:${item.seq}`) let eventCursor: ReturnType | undefined = eventPage.nextCursor while (eventCursor !== undefined) { @@ -399,6 +417,34 @@ describe('SQLite session search', () => { .toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG')) } }) + + it('rejects aggregate filter bindings above SQLite\'s portable variable limit', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('binding-limit'), { seed: messageEvents('needle') }) + // Each clause is below the ceiling; combined with its sibling and fixed + // query bindings, the complete statement is not portable. + const halfPortableLimit = 16_383 + const ids = Array.from( + { length: halfPortableLimit }, + (_, index) => SessionId(`binding-${index}`), + ) + const types = Array.from({ length: halfPortableLimit }, () => 'user/message' as const) + const surfaces = Array.from({ length: halfPortableLimit }, () => 'current' as const) + + await expect(ctx.sessionSearch.searchSessions({ + query: 'needle', + sessionFilters: [{ kind: 'id', values: ids }], + eventFilters: [{ kind: 'type', values: types }], + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: session.id, + query: 'needle', + filters: [ + { kind: 'type', values: types }, + { kind: 'surface', values: surfaces }, + ], + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + }) }) describe('SQLite reconciliation and source lifecycle', () => { From f401528941e39c4b96957a9d910af5ced7ab08de Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 17 Jul 2026 10:29:49 +0800 Subject: [PATCH 11/19] fix(session-query): preflight SQLite bindings (round 6) --- .../session-query-sqlite/src/index.ts | 21 +++--------- .../session-query-sqlite/src/query.ts | 33 ++++++++++++++++--- .../session-query-sqlite/tests/sqlite.spec.ts | 13 ++++++++ 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 4428d7ca00..05b5edace4 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -40,6 +40,7 @@ import { type NormalizedSessionRequest, FTS_HIGHLIGHT_END, FTS_HIGHLIGHT_START, + assertPortableBindingCount, buildEventWhere, buildSessionWhere, makeSnippet, @@ -64,8 +65,7 @@ export const SESSION_QUERY_SQLITE_MAX_LIMIT = 100 /** Default maximum snippet length in Unicode code points. */ export const SESSION_QUERY_SQLITE_SNIPPET_CHARS = 240 -// A serialized search tolerates one transient source change; repeated churn -// fails instead of monopolizing the operation queue. +// One transient source change gets a retry; repeated churn fails rather than monopolizing the queue. const STABLE_OBSERVATION_ATTEMPTS = 2 /** SQLite session-search configuration. */ @@ -566,7 +566,7 @@ export class SessionSearchSqlite extends SessionSearchService { request.limit + 1, offset, ] - assertPortableBindingCount(bindings) + assertPortableBindingCount(bindings.length) return this._requireDb().prepare(` ${selected.sql}, filtered AS ( @@ -601,7 +601,7 @@ export class SessionSearchSqlite extends SessionSearchService { request.limit + 1, offset, ] - assertPortableBindingCount(bindings) + assertPortableBindingCount(bindings.length) return this._requireDb().prepare(` ${selected.sql} SELECT * FROM matched @@ -732,19 +732,6 @@ function selectedDocumentsParams(query: string, persistenceVisible: boolean): Ar ] } -// SQLite builds may raise this ceiling; supported modern versions share 32,766 -// as the portable host-parameter limit. -const SQLITE_PORTABLE_VARIABLE_LIMIT = 32_766 - -function assertPortableBindingCount(bindings: readonly (string | number)[]): void { - if (bindings.length > SQLITE_PORTABLE_VARIABLE_LIMIT) { - throw new SessionQueryError( - `session-search request requires ${bindings.length} SQLite bindings; reduce filters to stay within the portable ${SQLITE_PORTABLE_VARIABLE_LIMIT}-variable limit`, - 'SESSION_QUERY_INVALID_FILTER', - ) - } -} - function observeLive(session: Session): ObservedSession { return observeSession(session.header, session.events) } diff --git a/packages/session-query/session-query-sqlite/src/query.ts b/packages/session-query/session-query-sqlite/src/query.ts index 5eb3ed8380..409c20f028 100644 --- a/packages/session-query/session-query-sqlite/src/query.ts +++ b/packages/session-query/session-query-sqlite/src/query.ts @@ -23,6 +23,22 @@ export const FTS_HIGHLIGHT_END = '\uFDD1' /** Largest page size whose internal lookahead remains an exact SQLite integer binding. */ export const SQLITE_MAX_PAGE_LIMIT = Number.MAX_SAFE_INTEGER - 1 +/** Portable host-parameter ceiling shared by predicate and statement builders. */ +export const SQLITE_PORTABLE_VARIABLE_LIMIT = 32_766 + +/** + * Reject prospective SQLite binding growth beyond the portable ceiling. + * @param count - binding count at the current construction boundary. + */ +export function assertPortableBindingCount(count: number): void { + if (count > SQLITE_PORTABLE_VARIABLE_LIMIT) { + throw new SessionQueryError( + `session-search request exceeds SQLite's portable ${SQLITE_PORTABLE_VARIABLE_LIMIT}-variable limit; reduce filter values`, + 'SESSION_QUERY_INVALID_FILTER', + ) + } +} + /** Limit defaults needed to normalize a search request. */ export interface QueryLimits { /** Page size used when the request omits one. */ @@ -356,8 +372,7 @@ function addList( clauses.push('0') return } - clauses.push(`${column} IN (${values.map(() => '?').join(', ')})`) - params.push(...values) + clauses.push(`${column} IN (${appendListBindings(params, values)})`) } function addNullableList( @@ -373,8 +388,7 @@ function addNullableList( const concrete = values.filter((value): value is string => value !== null) const parts: string[] = [] if (concrete.length > 0) { - parts.push(`${column} IN (${concrete.map(() => '?').join(', ')})`) - params.push(...concrete) + parts.push(`${column} IN (${appendListBindings(params, concrete)})`) } if (values.includes(null)) parts.push(`${column} IS NULL`) clauses.push(`(${parts.join(' OR ')})`) @@ -387,15 +401,26 @@ function addRange( range: { from?: number; to?: number }, ): void { if (range.from !== undefined) { + assertPortableBindingCount(params.length + 1) clauses.push(`CAST(${column} AS INTEGER) >= ?`) params.push(range.from) } if (range.to !== undefined) { + assertPortableBindingCount(params.length + 1) clauses.push(`CAST(${column} AS INTEGER) <= ?`) params.push(range.to) } } +function appendListBindings( + params: Array, + values: readonly (string | number)[], +): string { + assertPortableBindingCount(params.length + values.length) + for (const value of values) params.push(value) + return values.map(() => '?').join(', ') +} + function canonicalFilters(filters: readonly (SessionResultFilter | SessionEventMetadataFilter)[]): unknown[] { return filters.map((filter) => { if ('values' in filter) { 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 4b875931c4..8db1df5666 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -445,6 +445,19 @@ describe('SQLite session search', () => { ], })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) }) + + it('rejects one 125,000-value filter list with a typed error', async () => { + const ctx = await liveContext() + const ids = Array.from( + { length: 125_000 }, + (_, index) => SessionId(`oversized-binding-${index}`), + ) + + await expect(ctx.sessionSearch.searchSessions({ + query: 'needle', + sessionFilters: [{ kind: 'id', values: ids }], + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + }) }) describe('SQLite reconciliation and source lifecycle', () => { From e8abfd6482b6d7050e161915de5cb98486e3e14a Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 17 Jul 2026 11:13:03 +0800 Subject: [PATCH 12/19] fix(session-query): guard FTS predicate planning (round 7) --- docs/config-catalog.md | 2 +- .../session-query-sqlite/README.md | 2 +- .../session-query-sqlite/src/index.ts | 3 + .../session-query-sqlite/src/query.ts | 24 +++++++- .../session-query-sqlite/tests/query.spec.ts | 42 ++++++++++++-- .../session-query-sqlite/tests/sqlite.spec.ts | 55 +++++++++++++++++++ 6 files changed, 119 insertions(+), 9 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6d141185ca..de1a316cc6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -666,7 +666,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-query/session-query-sqlite/src/index.ts:72`](../packages/session-query/session-query-sqlite/src/index.ts) +Source: [`packages/session-query/session-query-sqlite/src/index.ts:73`](../packages/session-query/session-query-sqlite/src/index.ts) ## `@deepseek-ai/dsh-skill` diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index 1a5ee97e29..1beb479d5e 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -4,7 +4,7 @@ SQLite FTS5 implementation of `ctx.sessionSearch`. The service searches the live ## Search contract -`searchSessions(request, exec?)` returns `SessionSearchHit` pages across the corpus; `searchEvents(request, exec?)` returns `SessionEventSearchHit` pages within one session. Queries are required, trimmed, whitespace-normalized literal phrases. FTS5 syntax such as quotes, `OR`, `NEAR`, and `*` is treated as data rather than executable MATCH syntax. Metadata filters are parameterized SQL predicates applied before ranking. A request exceeding SQLite's portable limit of 32,766 total bindings, including fixed query and pagination values, fails with `SESSION_QUERY_INVALID_FILTER` before statement preparation. +`searchSessions(request, exec?)` returns `SessionSearchHit` pages across the corpus; `searchEvents(request, exec?)` returns `SessionEventSearchHit` pages within one session. Queries are required, trimmed, whitespace-normalized literal phrases. FTS5 syntax such as quotes, `OR`, `NEAR`, and `*` is treated as data rather than executable MATCH syntax. Metadata filters are parameterized SQL predicates applied before ranking. To keep SQLite FTS5 MATCH in a supported outer-predicate context, cross-session requests may compile at most 14 combined session and event filter predicates; within-session requests may compile at most 13 filter predicates because the fixed target-session predicate consumes one slot. Each range endpoint compiles as one predicate. A request exceeding either predicate budget or SQLite's portable limit of 32,766 total bindings, including fixed query and pagination values, fails with `SESSION_QUERY_INVALID_FILTER` before statement preparation. Relevance is source-comparable across persistent and TEMP tables: actual FTS5 highlighted-match span count descending, then stored document code-point length ascending. Event time, session id where applicable, and seq break remaining ties. Cross-session results expose the selected event as `bestMatch`; both scopes derive whitespace-normalized plain text from FTS5 highlight positions and bound it in Unicode code points. Cursors are opaque branded values, bind to the normalized request and service instance, and fail when the relevant generation changes. A within-session cursor survives unrelated-session changes; a cross-session cursor does not. diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 05b5edace4..2bccb6f269 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -40,6 +40,7 @@ import { type NormalizedSessionRequest, FTS_HIGHLIGHT_END, FTS_HIGHLIGHT_START, + assertFts5OuterPredicateCount, assertPortableBindingCount, buildEventWhere, buildSessionWhere, @@ -558,6 +559,7 @@ export class SessionSearchSqlite extends SessionSearchService { const selected = selectedDocumentsSql() const sessionWhere = buildSessionWhere(request.sessionFilters) const eventWhere = buildEventWhere(request.eventFilters) + assertFts5OuterPredicateCount(sessionWhere.predicateCount + eventWhere.predicateCount) const where = [sessionWhere.sql, eventWhere.sql].filter(Boolean).join(' AND ') const bindings = [ ...selectedDocumentsParams(request.query, persistenceBinding.service !== undefined), @@ -593,6 +595,7 @@ export class SessionSearchSqlite extends SessionSearchService { ): SearchRow[] { const selected = selectedDocumentsSql() const eventWhere = buildEventWhere(request.filters) + assertFts5OuterPredicateCount(1 + eventWhere.predicateCount) const where = ['session_id = ?', eventWhere.sql].filter(Boolean).join(' AND ') const bindings = [ ...selectedDocumentsParams(request.query, persistenceBinding.service !== undefined), diff --git a/packages/session-query/session-query-sqlite/src/query.ts b/packages/session-query/session-query-sqlite/src/query.ts index 409c20f028..5a67653911 100644 --- a/packages/session-query/session-query-sqlite/src/query.ts +++ b/packages/session-query/session-query-sqlite/src/query.ts @@ -26,6 +26,9 @@ export const SQLITE_MAX_PAGE_LIMIT = Number.MAX_SAFE_INTEGER - 1 /** Portable host-parameter ceiling shared by predicate and statement builders. */ export const SQLITE_PORTABLE_VARIABLE_LIMIT = 32_766 +/** Supported outer-predicate budget that keeps SQLite FTS5 MATCH usable. */ +export const SQLITE_FTS5_OUTER_PREDICATE_LIMIT = 14 + /** * Reject prospective SQLite binding growth beyond the portable ceiling. * @param count - binding count at the current construction boundary. @@ -39,6 +42,19 @@ export function assertPortableBindingCount(count: number): void { } } +/** + * Reject compiled outer predicates beyond the supported FTS5 planner budget. + * @param count - predicate count including fixed statement predicates. + */ +export function assertFts5OuterPredicateCount(count: number): void { + if (count > SQLITE_FTS5_OUTER_PREDICATE_LIMIT) { + throw new SessionQueryError( + `session-search request exceeds the supported SQLite FTS5 outer-predicate budget of ${SQLITE_FTS5_OUTER_PREDICATE_LIMIT}; reduce filters`, + 'SESSION_QUERY_INVALID_FILTER', + ) + } +} + /** Limit defaults needed to normalize a search request. */ export interface QueryLimits { /** Page size used when the request omits one. */ @@ -71,6 +87,8 @@ export interface SqlWhere { sql: string /** Bindings in placeholder order. */ params: Array + /** Number of compiled predicates in `sql`. */ + predicateCount: number } /** @@ -163,7 +181,8 @@ export function buildSessionWhere(filters: readonly SessionResultFilter[]): SqlW unknownFilter(filter) } } - return { sql: clauses.join(' AND '), params } + assertFts5OuterPredicateCount(clauses.length) + return { sql: clauses.join(' AND '), params, predicateCount: clauses.length } } /** @@ -192,7 +211,8 @@ export function buildEventWhere(filters: readonly SessionEventMetadataFilter[]): unknownFilter(filter) } } - return { sql: clauses.join(' AND '), params } + assertFts5OuterPredicateCount(clauses.length) + return { sql: clauses.join(' AND '), params, predicateCount: clauses.length } } /** diff --git a/packages/session-query/session-query-sqlite/tests/query.spec.ts b/packages/session-query/session-query-sqlite/tests/query.spec.ts index 14e84aae75..b40489d429 100644 --- a/packages/session-query/session-query-sqlite/tests/query.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/query.spec.ts @@ -11,6 +11,7 @@ import { normalizeSessionRequest, quoteFtsData, requestFingerprint, + SQLITE_FTS5_OUTER_PREDICATE_LIMIT, SQLITE_MAX_PAGE_LIMIT, type NormalizedEventRequest, type NormalizedSessionRequest, @@ -111,24 +112,36 @@ describe('SQLite search request normalization', () => { describe('SQLite search predicate compilation', () => { it('compiles all logical-session clauses including empty and nullable values', () => { - expect(buildSessionWhere([])).toEqual({ sql: '', params: [] }) - expect(buildSessionWhere([{ kind: 'id', values: [] }])).toEqual({ sql: '0', params: [] }) + expect(buildSessionWhere([])).toEqual({ sql: '', params: [], predicateCount: 0 }) + expect(buildSessionWhere([{ kind: 'id', values: [] }])).toEqual({ + sql: '0', + params: [], + predicateCount: 1, + }) expect(buildSessionWhere([{ kind: 'id', values: [SessionId('a'), SessionId('b')] }])).toEqual({ sql: 'session_id IN (?, ?)', params: [SessionId('a'), SessionId('b')], + predicateCount: 1, + }) + expect(buildSessionWhere([{ kind: 'cwd', values: [] }])).toEqual({ + sql: '0', + params: [], + predicateCount: 1, }) - expect(buildSessionWhere([{ kind: 'cwd', values: [] }])).toEqual({ sql: '0', params: [] }) expect(buildSessionWhere([{ kind: 'cwd', values: [null] }])).toEqual({ sql: '(cwd IS NULL)', params: [], + predicateCount: 1, }) expect(buildSessionWhere([{ kind: 'cwd', values: ['/a'] }])).toEqual({ sql: '(cwd IN (?))', params: ['/a'], + predicateCount: 1, }) expect(buildSessionWhere([{ kind: 'parent', values: [SessionId('p'), null] }])).toEqual({ sql: '(parent_session IN (?) OR parent_session IS NULL)', params: [SessionId('p')], + predicateCount: 1, }) expect(buildSessionWhere([ { kind: 'created-at', from: 1, to: 2 }, @@ -138,8 +151,13 @@ describe('SQLite search predicate compilation', () => { ])).toEqual({ sql: 'CAST(created_at AS INTEGER) >= ? AND CAST(created_at AS INTEGER) <= ? AND 0 AND live = 1', params: [1, 2], + predicateCount: 4, + }) + expect(buildSessionWhere([{ kind: 'created-at' }])).toEqual({ + sql: '', + params: [], + predicateCount: 0, }) - expect(buildSessionWhere([{ kind: 'created-at' }])).toEqual({ sql: '', params: [] }) }) it('compiles every event clause and empty lists', () => { @@ -151,11 +169,25 @@ describe('SQLite search predicate compilation', () => { ])).toEqual({ sql: 'CAST(seq AS INTEGER) >= ? AND CAST(time AS INTEGER) <= ? AND type IN (?) AND surface IN (?, ?)', params: [1, 9, 'user/message', 'current', 'log-only'], + predicateCount: 4, }) expect(buildEventWhere([ { kind: 'type', values: [] }, { kind: 'surface', values: [] }, - ])).toEqual({ sql: '0 AND 0', params: [] }) + ])).toEqual({ sql: '0 AND 0', params: [], predicateCount: 2 }) + }) + + it('rejects predicate builders above the supported FTS5 outer budget', () => { + const filters = Array.from( + { length: SQLITE_FTS5_OUTER_PREDICATE_LIMIT }, + () => ({ kind: 'id' as const, values: [SessionId('safe')] }), + ) + + expect(buildSessionWhere(filters).predicateCount).toBe(SQLITE_FTS5_OUTER_PREDICATE_LIMIT) + expect(() => buildSessionWhere([ + ...filters, + { kind: 'id', values: [SessionId('over')] }, + ])).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) }) it('rejects runtime-unknown filter discriminants in both SQL builders', () => { 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 8db1df5666..043bf3cf6b 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -210,6 +210,61 @@ describe('SQLite session search', () => { }) }) + it('searches at the supported FTS5 outer-predicate boundary in both scopes', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('predicate-boundary'), { + seed: messageEvents('needle'), + meta: { cwd: '/work' }, + }) + const sessionFilters = Array.from( + { length: 14 }, + () => ({ kind: 'cwd' as const, values: ['/work', null] }), + ) + const eventFilters = Array.from( + { length: 13 }, + () => ({ kind: 'type' as const, values: ['user/message' as const] }), + ) + + await expect(ctx.sessionSearch.searchSessions({ query: 'needle', sessionFilters })) + .resolves.toMatchObject({ items: [{ header: { id: session.id } }] }) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: session.id, + query: 'needle', + filters: eventFilters, + })).resolves.toMatchObject({ items: [{ sessionId: session.id, seq: 0 }] }) + }) + + it('rejects unsupported FTS5 outer-predicate counts with typed errors', async () => { + const ctx = await liveContext() + const session = ctx.sessions.create(SessionId('predicate-limit'), { seed: messageEvents('needle') }) + const sessionFilters = Array.from( + { length: 1_100 }, + () => ({ kind: 'id' as const, values: [session.id] }), + ) + const eventFilters = Array.from( + { length: 1_100 }, + () => ({ kind: 'type' as const, values: ['user/message' as const] }), + ) + + await expect(ctx.sessionSearch.searchSessions({ query: 'needle', sessionFilters })) + .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: session.id, + query: 'needle', + filters: eventFilters, + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + await expect(ctx.sessionSearch.searchSessions({ + query: 'needle', + sessionFilters: sessionFilters.slice(0, 7), + eventFilters: eventFilters.slice(0, 8), + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + await expect(ctx.sessionSearch.searchEvents({ + sessionId: session.id, + query: 'needle', + filters: eventFilters.slice(0, 14), + })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) + }) + it('uses literal phrase tokens, stable ties, and bounded Unicode snippets', async () => { const ctx = await liveContext({ path: ':memory:', defaultLimit: 10, maxLimit: 10, snippetChars: 5 }) ctx.sessions.create(SessionId('a'), { seed: messageEvents('😀😀 alpha beta BRAID 😀😀', 10), meta: { createdAt: 1 } }) From 931e0e22c75fa26c9e0d7f05966b6ed914aa40ca Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 23 Jul 2026 14:02:37 +0800 Subject: [PATCH 13/19] test(ui-sidebar): cover expanded search control --- packages/client/ui-sidebar/tests/sidebar-root.spec.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index b0e8a9f769..197dbe2b9a 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -205,6 +205,14 @@ describe('SidebarRoot', () => { } }) + it('expanded search control focuses the field without toggling the sidebar', () => { + const { onToggleSidebar } = mount(...projectData()) + const input = screen.getByPlaceholderText('Search name, keywords...') + act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) }) + expect(document.activeElement).toBe(input) + expect(onToggleSidebar).not.toHaveBeenCalled() + }) + it('the search query survives a collapse/expand round trip', () => { vi.useFakeTimers() try { From a2a89bf3000ff965c34bfcfeadcc85875cf89167 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 23 Jul 2026 19:36:05 +0800 Subject: [PATCH 14/19] fix(session-query): protect derived index permissions --- ...026-07-10-sqlite-session-query-provider.md | 2 +- docs/config-catalog.md | 6 +- .../session-query-sqlite/README.md | 4 +- .../session-query-sqlite/src/index.ts | 6 +- .../session-query-sqlite/src/schema.ts | 22 +++++++- .../session-query-sqlite/tests/sqlite.spec.ts | 55 ++++++++++++++++++- 6 files changed, 85 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md index a57348b9da..ecaca27894 100644 --- a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md +++ b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md @@ -36,7 +36,7 @@ One serialized operation reads the provider-neutral `SessionPersistence` snapsho Persisted documents survive restarts. Live sessions use connection-local TEMP tables, shadow the persisted base for the same id, and reveal that base on detach. Closing the database drops live rows. Unmounting persistence hides durable rows without treating absence as authoritative deletion; remounting observes and reconciles the backend again. Conflicting immutable live and durable headers fail rather than combining sources. -The derived schema has its own application id and monotonic schema version. A recognized incompatible version resets only this derived database. A database with a foreign application id or unrecognized user tables is refused before journal-mode mutation, which prevents an accidentally configured canonical session database from being changed. One service in one process exclusively owns a derived-index path; cross-process writers are unsupported because generations and live TEMP shadow state are connection-owned. +The derived schema has its own application id and monotonic schema version. A recognized incompatible version resets only this derived database. A database with a foreign application id or unrecognized user tables is refused before journal-mode mutation, which prevents an accidentally configured canonical session database from being changed. On POSIX filesystems, missing directories and database files are created owner-only so new SQLite sidecars inherit that mode; existing modes are preserved. One service in one process exclusively owns a derived-index path; cross-process writers are unsupported because generations and live TEMP shadow state are connection-owned. Cancellation rejects queued operations and caller waits around asynchronous source observation without committing an aborted observation. Node's synchronous `DatabaseSync` MATCH call cannot be interrupted once it is executing on the JavaScript thread, so the service checks the signal at serialized boundaries but does not promise mid-statement preemption. diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 17d7b6b7b2..eb7f166e07 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1018,7 +1018,11 @@ Requires: `sessions` ```ts config-catalog /** SQLite session-search configuration. */ export interface Config { - /** Dedicated derived-index path; `:memory:` is supported for tests. */ + /** + * Dedicated derived-index path; `:memory:` is supported for tests. Missing + * directories and database files are created owner-only on POSIX filesystems; + * existing modes are preserved. + */ path: string /** SQLite journal mode. Defaults to `wal`. */ journalMode?: JournalMode diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index 6429a30fac..82a8899095 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -16,13 +16,13 @@ The service requires `ctx.sessions` and observes optional `ctx.sessionPersistenc Persisted FTS rows live in a dedicated derived database. Connection-local TEMP tables hold live rows, which shadow the durable base for the same session and reveal it when the live owner disappears. Unmounting persistence hides durable rows without discarding the cache; remounting reconciles it. Closing or reopening the database drops every live overlay while retaining persisted rows. -The database is disposable but reset is guarded: a recognized incompatible search schema rebuilds in place, while an unrelated or canonical database is refused before mutating journal mode. Never point `path` at the session-persistence database. Exactly one service in one process owns a derived-index path; external writers or a second process are unsupported because generations and TEMP shadow state are connection-owned. +The database is disposable but reset is guarded: a recognized incompatible search schema rebuilds in place, while an unrelated or canonical database is refused before mutating journal mode. Never point `path` at the session-persistence database. On filesystems with POSIX modes, missing directories and databases are created owner-only (`0700` and `0600` before the process umask), and SQLite sidecars inherit the database mode; existing modes are preserved. Exactly one service in one process owns a derived-index path; external writers or a second process are unsupported because generations and TEMP shadow state are connection-owned. ## Configuration | Key | Default | Contract | |---|---:|---| -| `path` | required | Dedicated derived-index SQLite path; `:memory:` is supported. | +| `path` | required | Dedicated derived-index SQLite path; `:memory:` is supported. Missing filesystem paths are created owner-only on POSIX filesystems. | | `journalMode` | `wal` | `wal`, `delete`, `truncate`, or `persist`. | | `defaultLimit` | `20` | Page size when a request omits `limit`; at most `Number.MAX_SAFE_INTEGER - 1`. | | `maxLimit` | `100` | Largest accepted request page size; at most `Number.MAX_SAFE_INTEGER - 1`. | diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index cb99cfd8db..b577872b2c 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -71,7 +71,11 @@ const STABLE_OBSERVATION_ATTEMPTS = 2 /** SQLite session-search configuration. */ export interface Config { - /** Dedicated derived-index path; `:memory:` is supported for tests. */ + /** + * Dedicated derived-index path; `:memory:` is supported for tests. Missing + * directories and database files are created owner-only on POSIX filesystems; + * existing modes are preserved. + */ path: string /** SQLite journal mode. Defaults to `wal`. */ journalMode?: JournalMode diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts index 2fecd388f7..56f873d8bc 100644 --- a/packages/session-query/session-query-sqlite/src/schema.ts +++ b/packages/session-query/session-query-sqlite/src/schema.ts @@ -1,7 +1,7 @@ /** SQLite schema for the disposable session full-text read model. */ import { DatabaseSync } from 'node:sqlite' -import { mkdir } from 'node:fs/promises' +import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' /** Current derived-index schema version. Incompatible versions reset in place. */ @@ -13,15 +13,31 @@ export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851 /** Supported SQLite journal modes. */ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' +/** + * Exclusively create a missing database file with owner-only permissions. + * Existing files retain their modes, and errors other than `EEXIST` propagate. + */ +async function createDatabaseFile(path: string): Promise { + try { + const handle = await open(path, 'wx', 0o600) + await handle.close() + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + } +} + /** * Open, validate, and initialize persistent and connection-local schemas. - * @param path - dedicated derived-index path or `:memory:`. + * @param path - dedicated derived-index path or `:memory:`; missing filesystem paths are created owner-only. * @param journalMode - validated SQLite journal mode. * @returns initialized database handle owned by the search service. */ export async function openSearchDatabase(path: string, journalMode: JournalMode): Promise { const actual = path === ':memory:' ? path : resolve(path) - if (actual !== ':memory:') await mkdir(dirname(actual), { recursive: true, mode: 0o700 }) + if (actual !== ':memory:') { + await mkdir(dirname(actual), { recursive: true, mode: 0o700 }) + await createDatabaseFile(actual) + } const db = new DatabaseSync(actual) try { const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number } 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 7c27b2091a..0aea3f6202 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from 'cordis' import { DatabaseSync } from 'node:sqlite' -import { mkdtemp, rm } from 'node:fs/promises' +import { chmod, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' @@ -924,6 +924,57 @@ describe('SQLite reconciliation and source lifecycle', () => { }) describe('SQLite schema, cancellation, and real persistence integration', () => { + it('creates a new database and WAL sidecars owner-only without changing its parent mode', async () => { + if (process.platform === 'win32') return + const path = await temporaryPath() + const directory = dirname(path) + await chmod(directory, 0o755) + + const ctx = await liveContext({ path }) + await ctx.sessionSearch.searchSessions({ query: 'needle' }) + + expect((await stat(directory)).mode & 0o777).toBe(0o755) + expect((await stat(path)).mode & 0o777).toBe(0o600) + expect((await stat(`${path}-wal`)).mode & 0o777).toBe(0o600) + expect((await stat(`${path}-shm`)).mode & 0o777).toBe(0o600) + await (ctx.sessionSearch as SessionSearchSqlite).close() + }) + + it('creates a persistent rollback journal owner-only', async () => { + if (process.platform === 'win32') return + const path = await temporaryPath() + const ctx = await liveContext({ path, journalMode: 'persist' }) + await ctx.sessionSearch.searchSessions({ query: 'needle' }) + + expect((await stat(path)).mode & 0o777).toBe(0o600) + expect((await stat(`${path}-journal`)).mode & 0o777).toBe(0o600) + await (ctx.sessionSearch as SessionSearchSqlite).close() + }) + + it('preserves the mode of an existing database file', async () => { + if (process.platform === 'win32') return + const path = await temporaryPath() + await writeFile(path, '', { mode: 0o644 }) + await chmod(path, 0o644) + + const ctx = await liveContext({ path, journalMode: 'delete' }) + await ctx.sessionSearch.searchSessions({ query: 'needle' }) + + expect((await stat(path)).mode & 0o777).toBe(0o644) + await (ctx.sessionSearch as SessionSearchSqlite).close() + }) + + it('surfaces filesystem failures while pre-creating the database', async () => { + const path = `${await temporaryPath()}\0` + const ctx = await liveContext({ path }) + + await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })).rejects.toMatchObject({ + code: 'SESSION_QUERY_INDEX_FAILED', + cause: { code: 'ERR_INVALID_ARG_VALUE' }, + }) + await (ctx.sessionSearch as SessionSearchSqlite).close() + }) + it('resets a recognized incompatible derived schema but refuses a foreign database', async () => { const stalePath = await temporaryPath('stale.db') const stale = new DatabaseSync(stalePath) From 1e457b22e0401691d1eff370064dcbcff6eb2713 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 23 Jul 2026 20:16:14 +0800 Subject: [PATCH 15/19] refactor(session-query): unify query service --- ...23-unified-session-query-service.i18n.yaml | 6 + ...026-07-23-unified-session-query-service.md | 35 +++ ...-07-23-unified-session-query-service.zh.md | 35 +++ .../2026-07-10-session-query-service.md | 6 +- ...026-07-10-sqlite-session-query-provider.md | 4 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 3 +- docs/architecture.zh.md | 3 +- docs/capability-seams.md | 9 +- docs/config-catalog.md | 31 +-- docs/cordis-catalog/services.md | 54 ++-- docs/core-data-structures/session-query.md | 2 +- docs/module-graph.md | 6 +- examples/package.json | 2 + .../tests/session-reference.spec.ts | 22 +- .../cordis/tool-cordis/src/api-catalog.ts | 24 +- packages/examples/acp-demo/README.md | 4 +- packages/examples/acp-demo/package.json | 2 + packages/examples/acp-demo/src/index.ts | 21 +- .../examples/acp-demo/tests/built-bin.e2e.ts | 3 +- packages/examples/acp-demo/tsconfig.json | 3 + packages/examples/tui-demo/README.md | 4 +- packages/examples/tui-demo/package.json | 2 + packages/examples/tui-demo/src/index.ts | 10 +- .../examples/tui-demo/tests/tui-agent.spec.ts | 3 +- packages/examples/tui-demo/tsconfig.json | 3 + packages/session-query/README.md | 6 +- .../session-query-sqlite/README.md | 3 +- .../session-query-sqlite/package.json | 2 +- .../session-query-sqlite/src/index.ts | 30 ++- .../tests/load-path.e2e.ts | 16 +- .../session-query-sqlite/tests/sqlite.spec.ts | 252 +++++++++--------- .../session-query/session-query/README.md | 8 +- .../session-query/session-query/package.json | 5 +- .../session-query/session-query/src/config.ts | 4 +- .../session-query/session-query/src/index.ts | 53 ++-- .../tests/search-helpers.spec.ts | 35 +-- .../session-query/tests/session-query.spec.ts | 15 +- .../session-query/tests/test-service.ts | 26 ++ .../session-query/tests/tracing.spec.ts | 5 +- .../session-query/session-query/tsconfig.json | 3 - packages/ui/acp/tests/harness.ts | 16 +- packages/ui/tui/tests/session-query.ts | 16 ++ .../tui/tests/session-reference.snapshot.ts | 4 +- packages/ui/tui/tests/tui.spec.ts | 14 +- pnpm-lock.yaml | 19 +- python/sdk-runtime/package.json | 1 + scripts/gen-doc-graphs.ts | 13 +- 48 files changed, 482 insertions(+), 365 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.md create mode 100644 .agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.zh.md create mode 100644 packages/session-query/session-query/tests/test-service.ts create mode 100644 packages/ui/tui/tests/session-query.ts diff --git a/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.i18n.yaml new file mode 100644 index 0000000000..2a27e6432f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-23-unified-session-query-service.md: 0a466e1c36ff1796c858666b0eb36bbd0f480bb0 +2026-07-23-unified-session-query-service.zh.md: 448122b8e6951058b9f633cd56112b0391e1912e diff --git a/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.md b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.md new file mode 100644 index 0000000000..0a466e1c36 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.md @@ -0,0 +1,35 @@ +# Agent Note: Unified session query service + +Status: implemented + +English | [中文](2026-07-23-unified-session-query-service.zh.md) + +## Problem + +Exact reads, semantic filters, relationship traces, and full-text search operate on the same live-preferred session corpus. Exposing full-text search under a second context key makes consumers and app compositions treat one capability as two services, even though the SQLite implementation is the only backend-specific part. + +The interface package already owns the shared record, filter, trace, search-request, cursor, and error contracts. A provider registry or coordinator would add runtime selection semantics unsupported by any current consumer. + +## Decision + +`SessionQueryService` is the single abstract service registered as `ctx.sessionQuery`. It concretely implements listing, title and event reads, surface reads, filtering, and relationship tracing through its backend-independent `SessionCorpus`. Its only abstract methods are `searchSessions()` and `searchEvents()`. + +`SessionQuerySqlite` extends that service and is the sole concrete backend. One mounted instance therefore exposes every operation through `ctx.sessionQuery`; its inherited exact operations use the shared corpus implementation, while its SQLite-owned lifecycle observes sources, reconciles the derived FTS index, ranks matches, and owns cursor generations. The interface package has no standalone concrete plugin, search-provider registry, or second context key. + +Backend configuration includes the inherited `readWindowMax` setting alongside its own index path, journal mode, page limits, and snippet limit. First-party apps that need session queries mount the SQLite backend and place its disposable index beside their configured persistence root. + +This service topology supersedes the separate-key portion of the [exact query decision](../feature/2026-07-10-session-query-service.md) and [SQLite search decision](../feature/2026-07-10-sqlite-session-query-provider.md); their corpus, query, tokenizer, reconciliation, and safety decisions remain in force. + +## Alternatives considered + +- **Keep `ctx.sessionQuery` and `ctx.sessionSearch` separate** — rejected because both expose operations over one logical corpus, force consumers to discover two keys, and let apps accidentally mount only a partial query surface. +- **Keep a concrete base service and let the SQLite plugin register or mutate two search methods** — rejected because method availability would depend on plugin order and teardown, and the service would need a provider registration protocol for one implementation. +- **Move every query implementation into the SQLite package** — rejected because exact reads, filters, and traces require no index and are shared behavior that belongs with their provider-independent contracts. + +## Consequences + +Consumers inject one service and can combine exact and full-text operations without a second capability lookup. A production composition must choose a concrete backend even when one consumer currently calls only inherited exact methods; tests may use a minimal subclass when backend behavior is outside their scope. + +The unified object deliberately retains two internal observation strategies: exact operations read authoritative live/persisted sources per call, while full-text operations reconcile a disposable index. Sharing the context key does not make the derived index authoritative or couple exact-read availability to an FTS query. + +Unit coverage pins inherited and abstract behavior on one key, SQLite coverage exercises both operation families on the concrete backend, and the real Loader path verifies that one exported plugin registers the combined service. diff --git a/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.zh.md b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.zh.md new file mode 100644 index 0000000000..448122b8e6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 统一会话查询服务 + +Status: implemented + +[English](2026-07-23-unified-session-query-service.md) | 中文 + +## 问题 + +精确读取、语义过滤、关系追踪与全文搜索都作用于同一个实时源优先的会话语料库。将全文搜索暴露在第二个上下文键下,会让消费方与应用组合把同一项查询功能视为两个服务,尽管只有 SQLite 实现是后端特有的部分。 + +接口包已经拥有共享的记录、过滤、追踪、搜索请求、游标与错误契约。提供方注册表或协调器会引入运行时选择语义,而目前没有任何消费方支持这种语义。 + +## 决策 + +`SessionQueryService` 是注册为 `ctx.sessionQuery` 的唯一抽象服务。它通过后端无关的 `SessionCorpus` 具体实现列表查询、标题与事件读取、表层读取、过滤和关系追踪。仅有 `searchSessions()` 与 `searchEvents()` 两个方法为抽象方法。 + +`SessionQuerySqlite` 扩展该服务,并且是唯一的具体后端。因此,一个挂载实例便可通过 `ctx.sessionQuery` 暴露全部操作;其继承的精确操作使用共享的语料库实现,而由 SQLite 管理的生命周期负责观察数据源、对齐派生 FTS 索引、对匹配项排序并管理游标代际。接口包不提供独立的具体插件、搜索提供方注册表或第二个上下文键。 + +后端配置除了自身的索引路径、日志模式、分页限制与文本片段长度上限外,还包含继承的 `readWindowMax` 设置。需要会话查询的第一方应用挂载 SQLite 后端,并将其可丢弃索引放在已配置的持久化根目录旁。 + +这一服务拓扑取代了[精确查询决策](../feature/2026-07-10-session-query-service.md)和 [SQLite 搜索决策](../feature/2026-07-10-sqlite-session-query-provider.md)中关于分离上下文键的部分;其中关于语料库、查询、分词器、对齐与安全性的决策仍然有效。 + +## 已考虑的替代方案 + +- **保留相互独立的 `ctx.sessionQuery` 与 `ctx.sessionSearch`**:不予采纳,因为二者都针对同一逻辑语料库提供操作,迫使消费方识别两个键,还可能让应用误挂载一组不完整的查询接口。 +- **保留具体的基础服务,再由 SQLite 插件注册或修改两个搜索方法**:不予采纳,因为方法是否可用将取决于插件顺序与资源释放时机,而且该服务需要为唯一的实现定义一套提供方注册协议。 +- **将所有查询实现移入 SQLite 包**:不予采纳,因为精确读取、过滤与追踪不需要索引,并且都属于应与提供方无关契约放在一起的共享行为。 + +## 后果 + +消费方只需注入一个服务,无需再次查找其他功能,便可组合精确操作与全文操作。生产环境的组合必须选择一个具体后端,即使当前某个消费方只调用继承的精确方法;如果后端行为不在测试范围内,测试可以使用最小子类。 + +统一后的对象有意保留两种内部观察策略:精确操作在每次调用时读取权威的实时源或持久化源,全文操作则使可丢弃索引与数据源对齐。共用上下文键不会让派生索引成为权威来源,也不会使精确读取的可用性依赖 FTS 查询。 + +单元测试在同一个键上同时固定继承实现与抽象方法的契约,SQLite 测试在具体后端上覆盖两类操作,真实 Loader 路径则验证单个导出的插件能够注册组合后的服务。 diff --git a/.agents/notes/implemented/feature/2026-07-10-session-query-service.md b/.agents/notes/implemented/feature/2026-07-10-session-query-service.md index 9f3e624403..8cc529377b 100644 --- a/.agents/notes/implemented/feature/2026-07-10-session-query-service.md +++ b/.agents/notes/implemented/feature/2026-07-10-session-query-service.md @@ -10,7 +10,7 @@ Full-text search is related but materially larger. Putting provider coordination ## Decision -`@deepseek-ai/dsh-session-query` owns `ctx.sessionQuery`, a small trusted exact-inspection service over one logical corpus. It exposes `listSessions()`, provider-independent `filterSessions(filters)`, `listEvents(sessionId)`, `filterEvents(sessionId, filters)`, bounded `readEvent(request)`, `traceSession(sessionId)`, and `traceEvent(request)`. The package also declares the separate abstract `ctx.sessionSearch` contract and shared semantic extraction used by the [SQLite search decision](2026-07-10-sqlite-session-query-provider.md), but `ctx.sessionQuery` does not coordinate providers or synchronize a derived index. The separate [tracing decision](2026-07-13-session-query-tracing.md) owns lineage and event-relationship semantics. +`@deepseek-ai/dsh-session-query` owns the single abstract `ctx.sessionQuery` service over one logical corpus. It concretely implements `listSessions()`, provider-independent `filterSessions(filters)`, `listEvents(sessionId)`, `filterEvents(sessionId, filters)`, bounded `readEvent(request)`, `traceSession(sessionId)`, and `traceEvent(request)`, while concrete backends implement its two full-text methods. The [unified service decision](../architecture/2026-07-23-unified-session-query-service.md) owns that topology, the [SQLite search decision](2026-07-10-sqlite-session-query-provider.md) owns search behavior, and the [tracing decision](2026-07-13-session-query-tracing.md) owns lineage and event-relationship semantics. The service observes the optional `ctx.sessionPersistence` binding dynamically but retains no persisted cache or invalidation listener. Each cross-corpus list asks the active backend for authoritative metadata, then overlays a fresh live-store list. Matching ids become one `SessionRecord`: the live header wins and `live`/`persisted` independently report source availability. Immutable header disagreement is `SESSION_QUERY_SOURCE_CONFLICT`. @@ -35,6 +35,6 @@ The service is context-wide trusted infrastructure, not an authorization layer. ## Consequences -Exact reads have one source-resolution state variable: the currently mounted persistence service. There are no provider queues, fingerprints, extractor registries, observation generations, or derived index updates in `ctx.sessionQuery`. Exact reads, semantic scans, and event traces remain usable in live-only deployments and deterministic when persistence is present. +The inherited exact-read implementation has one source-resolution state variable: the currently mounted persistence service. It has no provider queues, fingerprints, extractor registries, observation generations, or derived index updates; a concrete backend owns its full-text state separately. Exact reads, semantic scans, and event traces remain usable in live-only deployments and deterministic when persistence is present. -Cross-corpus listing, lineage tracing, and persisted event operations perform backend I/O on each call. That is deliberate: correctness comes from current authoritative state, while scale-oriented full-text search uses the separately owned SQLite derived index. +Cross-corpus listing, lineage tracing, and persisted event operations perform backend I/O on each call. That is deliberate: correctness comes from current authoritative state, while scale-oriented full-text methods use the concrete backend's SQLite derived index. diff --git a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md index ecaca27894..7cfef47d1d 100644 --- a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md +++ b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md @@ -10,9 +10,9 @@ Splitting those concerns across a provider coordinator and a database implementa ## Decision -`@deepseek-ai/dsh-session-query` declares an independent abstract `ctx.sessionSearch` service without changing the exact-read `ctx.sessionQuery` key. `searchSessions(request, exec?)` returns cursor-paginated `SessionSearchHit`s grouped by each session's strongest matching event; `searchEvents(request, exec?)` returns `SessionEventSearchHit`s within one logical session. Both requests require `query`, accept `limit` and an owned branded `SessionSearchCursor`, and support an optional abort signal. Session search accepts `sessionFilters` plus event metadata filters; event search accepts event metadata filters. Results expose bounded plain-text snippets but no provider identifier or numeric relevance score. +`@deepseek-ai/dsh-session-query` declares one abstract `ctx.sessionQuery` service whose exact reads, filters, and traces are concrete and whose two full-text methods are abstract. `searchSessions(request, exec?)` returns cursor-paginated `SessionSearchHit`s grouped by each session's strongest matching event; `searchEvents(request, exec?)` returns `SessionEventSearchHit`s within one logical session. Both requests require `query`, accept `limit` and an owned branded `SessionSearchCursor`, and support an optional abort signal. Session search accepts `sessionFilters` plus event metadata filters; event search accepts event metadata filters. Results expose bounded plain-text snippets but no provider identifier or numeric relevance score. The [unified service decision](../architecture/2026-07-23-unified-session-query-service.md) owns the single-key topology. -`@deepseek-ai/dsh-session-query-sqlite` is the sole concrete owner of `ctx.sessionSearch`. It depends on live `ctx.sessions`, observes optional `ctx.sessionPersistence` dynamically, and owns a dedicated derived SQLite database. There is no search-provider registry, coordinator, persistence event, or agent-loop integration. +`@deepseek-ai/dsh-session-query-sqlite` extends the interface service and is the sole concrete owner of `ctx.sessionQuery`. It depends on live `ctx.sessions`, observes optional `ctx.sessionPersistence` dynamically, and owns a dedicated derived SQLite database. There is no search-provider registry, coordinator, persistence event, or agent-loop integration. The interface package also owns shared first-party semantic extraction and provider-independent filtering. `SessionResultFilter` covers id, nullable cwd, created-at range, nullable parent, and availability; `ctx.sessionQuery.filterSessions()` applies it without an FTS provider. `SessionEventResultFilter` covers seq/time ranges, event type, surface, and literal semantic text. Arrays are ANDed and list values are ORed. The text clause escapes caller input into a Unicode case-insensitive regular expression whose whitespace runs match one or more whitespace characters; it is available through `ctx.sessionQuery.filterEvents()` and is not delegated to an FTS provider. diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index ca414b3e7a..8cb0d06636 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: 26dd6db29b4738d9a44ab12217ac34cab7b3d48c -architecture.zh.md: 283910ff03adefa024183fda02c6f0c33def9630 +architecture.md: be465d0e937da321737fd8c483b6bc49d077a68c +architecture.zh.md: 399072fd1f5174b7ec6f3c94c89449f6f03b6e72 diff --git a/docs/architecture.md b/docs/architecture.md index 26dd6db29b..be465d0e93 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,8 +43,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts whose packages contribute serv | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable storage for session logs | -| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred corpus querying/tracing | -| `ctx.sessionSearch` | [`session-query/`](../packages/session-query/README.md) | SQLite FTS | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | `session-query` interface: concrete live-preferred exact/filter/trace; only two FTS methods abstract; backend: `session-query-sqlite` | | `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallback titles and one optional asynchronous provider | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | registry and package-name selection for package-owned runtime checks | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 283910ff03..399072fd1f 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -43,8 +43,7 @@ | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | 持久化的同会话目标 | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | 会话日志的持久存储 | -| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 实时优先的语料查询与追踪 | -| `ctx.sessionSearch` | [`session-query/`](../packages/session-query/README.md) | SQLite 全文搜索 | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | `session-query` 接口:精确检索、过滤与追踪为实时优先的具体实现;仅两个全文搜索方法为抽象方法;后端:`session-query-sqlite` | | `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | 基于日志的回退标题和单个可选异步提供方 | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名选择包自有运行时检查的注册表 | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 3505319bb9..f0cb56ca54 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -36,9 +36,8 @@ flowchart LR pkg_hooks_claude["hooks-claude"] pkg_hooks_codex["hooks-codex"] pkg_acp["acp"] - svc_sessionQuery["ctx.sessionQuery
Exact session-history reads and traces"] + svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] - svc_sessionSearch["ctx.sessionSearch
Full-text session search"] svc_sessionReferences["ctx.sessionReferences
Cross-session snapshot preparation"] pkg_tui["tui"] pkg_session_title["session-title"] @@ -157,8 +156,7 @@ flowchart LR pkg_session_persistence_jsonl --> svc_sessionPersistence pkg_session_persistence_sqlite --> svc_sessionPersistence pkg_session_query --> svc_sessionQuery - pkg_session_query --> svc_sessionSearch - pkg_session_query_sqlite --> svc_sessionSearch + pkg_session_query_sqlite --> svc_sessionQuery pkg_session_reference --> svc_sessionReferences pkg_session_title --> svc_sessionTitle pkg_session_title_all_messages_llm --> svc_sessionTitle @@ -276,8 +274,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | -| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | - | [`session-reference`](../packages/context/session-reference) | - | Resolves live and optional persisted logs into one logical corpus for exact reads, semantic scans, and relationship traces. | -| `ctx.sessionSearch` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | - | The concrete backend owns source reconciliation, ranking, snippets, and cursor generations as one lifecycle. | +| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations on the same service. | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index eb7f166e07..b849b8979b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -58,7 +58,7 @@ export interface Config { dshHome?: string /** Fallback session-title limits forwarded through agent-spine-demo. */ sessionTitle?: NonNullable - /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ + /** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */ persistenceRoot?: string /** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `false`. */ packChunks?: boolean @@ -83,7 +83,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/acp-demo/src/index.ts:43`](../packages/examples/acp-demo/src/index.ts) +Source: [`packages/examples/acp-demo/src/index.ts:44`](../packages/examples/acp-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` @@ -997,27 +997,13 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:58`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) -## `@deepseek-ai/dsh-session-query` - -Requires: `sessions` - -```ts config-catalog -/** Configuration for exact session-query reads and traces. */ -export interface Config { - /** Maximum accepted raw read context on either side. Defaults to 50. */ - readWindowMax?: number -} -``` - -Source: [`packages/session-query/session-query/src/config.ts:9`](../packages/session-query/session-query/src/config.ts) - ## `@deepseek-ai/dsh-session-query-sqlite` Requires: `sessions` ```ts config-catalog -/** SQLite session-search configuration. */ -export interface Config { +/** Combined session-query configuration backed by SQLite full-text search. */ +export interface Config extends SessionQueryConfig { /** * Dedicated derived-index path; `:memory:` is supported for tests. Missing * directories and database files are created owner-only on POSIX filesystems; @@ -1038,7 +1024,9 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-query/session-query-sqlite/src/index.ts:73`](../packages/session-query/session-query-sqlite/src/index.ts) +Depends on: [`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts) + +Source: [`packages/session-query/session-query-sqlite/src/index.ts:74`](../packages/session-query/session-query-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-reference` @@ -1642,7 +1630,7 @@ export interface Config { dshHome?: string /** Fallback session-title limits forwarded through agent-spine-demo. */ sessionTitle?: NonNullable - /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ + /** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */ persistenceRoot?: string /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ persistenceCompression?: JsonlCompression @@ -1676,7 +1664,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) -Source: [`packages/examples/tui-demo/src/index.ts:38`](../packages/examples/tui-demo/src/index.ts) +Source: [`packages/examples/tui-demo/src/index.ts:39`](../packages/examples/tui-demo/src/index.ts) ## `@deepseek-ai/dsh-user-approval` @@ -1916,6 +1904,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-fs` — abstract `FileSystem` ([`packages/fs/fs/src/index.ts`](../packages/fs/fs/src/index.ts)) - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) +- `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) - `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts)) - `@deepseek-ai/dsh-workflow` — abstract `WorkflowService` ([`packages/workflow/workflow/src/index.ts`](../packages/workflow/workflow/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 1940809d8a..ebe32d839a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -946,11 +946,29 @@ Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../cor Source: [`packages/session-persistence/session-persistence/src/index.ts:52`](../../packages/session-persistence/session-persistence/src/index.ts) -## `ctx.sessionQuery` — `SessionQueryService` +## `ctx.sessionQuery` — `SessionQueryService` (abstract seam) -Live-preferred logical-corpus read, filtering, and relationship-tracing service. +Unified live-preferred session query service. + +Exact reads, filters, and traces are backend-independent concrete behavior. A backend implements full-text observation, reconciliation, ranking, cursor generations, and query execution on the same `ctx.sessionQuery` service. ```ts cordis-catalog +/** + * Search the live-preferred logical corpus and group by session. + * @param request - query text, metadata filters, page size, and cursor. + * @param exec - optional cancellation control. + * @returns session hits ranked by their strongest matching event. + */ +abstract searchSessions( request: SessionSearchRequest, exec?: SessionSearchExecContext, ): Promise> + +/** + * Search events within one live-preferred logical session. + * @param request - target session, query text, filters, page size, and cursor. + * @param exec - optional cancellation control. + * @returns matching event hits in deterministic relevance order. + */ +abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise> + /** * List the complete logical corpus using live-preferred records. * @returns deterministic newest-first cloned session records. @@ -1018,9 +1036,9 @@ async traceEvent(request: SessionEventTraceRequest): Promise async readEvent(request: SessionEventReadRequest): Promise ``` -Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventTrace](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) +Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchHit](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTrace](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-query/session-query/src/index.ts:103`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:73`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessionReferences` — `SessionReferenceService` @@ -1201,34 +1219,6 @@ Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [OutOfB Source: [`packages/core/session/src/index.ts:605`](../../packages/core/session/src/index.ts) -## `ctx.sessionSearch` — `SessionSearchService` (abstract seam) - -Abstract full-text search service implemented by one concrete backend. - -The implementation owns source observation, reconciliation, cursor generations, ranking, and query execution as one lifecycle. - -```ts cordis-catalog -/** - * Search the live-preferred logical corpus and group by session. - * @param request - query text, metadata filters, page size, and cursor. - * @param exec - optional cancellation control. - * @returns session hits ranked by their strongest matching event. - */ -abstract searchSessions( request: SessionSearchRequest, exec?: SessionSearchExecContext, ): Promise> - -/** - * Search events within one live-preferred logical session. - * @param request - target session, query text, filters, page size, and cursor. - * @param exec - optional cancellation control. - * @returns matching event hits in deterministic relevance order. - */ -abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise> -``` - -Types: [SessionEventSearchHit](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) - -Source: [`packages/session-query/session-query/src/index.ts:74`](../../packages/session-query/session-query/src/index.ts) - ## `ctx.sessionTitle` — `SessionTitleService` Log-backed title fold plus asynchronous fallback generation. diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index 03e406b84f..0fe1596aaf 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -97,7 +97,7 @@ interface SessionEventSearchDocument extends SessionEventRecord { ## Full-text search pages -The independent `ctx.sessionSearch` seam has two scopes. `searchSessions()` groups the corpus by strongest matching event; `searchEvents()` searches one session. Requests bind an opaque cursor to the normalized query, metadata filters, and limit. The event text scan is intentionally absent from provider metadata filters. +The combined `ctx.sessionQuery` seam has two full-text scopes. `searchSessions()` groups the corpus by strongest matching event; `searchEvents()` searches one session. Requests bind an opaque cursor to the normalized query, metadata filters, and limit. The event text scan is intentionally absent from provider metadata filters. ```ts type-equiv /** Provider-owned opaque continuation token returned by session search. */ diff --git a/docs/module-graph.md b/docs/module-graph.md index 626cce0a8f..96e7f57af3 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -719,6 +719,7 @@ flowchart TD pkg_acp_demo --> pkg_session_checkpoint_policy pkg_acp_demo --> pkg_session_persistence_jsonl pkg_acp_demo --> pkg_session_query + pkg_acp_demo --> pkg_session_query_sqlite pkg_acp_demo --> pkg_session_reference pkg_acp_demo --> pkg_tools pkg_acp_demo --> pkg_user_interaction @@ -745,6 +746,7 @@ flowchart TD pkg_tui_demo --> pkg_session_checkpoint_policy pkg_tui_demo --> pkg_session_persistence_jsonl pkg_tui_demo --> pkg_session_query + pkg_tui_demo --> pkg_session_query_sqlite pkg_tui_demo --> pkg_session_reference pkg_tui_demo --> pkg_tool_ask_user pkg_tui_demo --> pkg_tools @@ -879,6 +881,6 @@ flowchart TD | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | -| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | diff --git a/examples/package.json b/examples/package.json index db8cc142eb..1b4e28c4fd 100644 --- a/examples/package.json +++ b/examples/package.json @@ -38,6 +38,8 @@ "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", + "@deepseek-ai/dsh-session-query": "workspace:*", + "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-tui-demo": "workspace:*", diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index bb21cfab05..2470ae8d93 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -15,10 +15,24 @@ import SessionReferenceService, { } from '@deepseek-ai/dsh-session-reference' import { stringifyTagSafeJson } from '../src/serialization.ts' +class TestSessionQueryService extends SessionQueryService { + override searchSessions( + ..._args: Parameters + ): ReturnType { + return Promise.resolve({ items: [] }) + } + + override searchEvents( + ..._args: Parameters + ): ReturnType { + return Promise.resolve({ items: [] }) + } +} + async function harness(config: Config = {}): Promise { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(SessionQueryService) + await ctx.plugin(TestSessionQueryService) await ctx.plugin(SessionReferenceService, config) return ctx } @@ -524,19 +538,19 @@ describe('session reference discovery and preparation', () => { it('rejects direct invalid configuration before service publication', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(SessionQueryService) + await ctx.plugin(TestSessionQueryService) expect(() => new SessionReferenceService(ctx, { maxReferences: 0 })) .toThrow(expectCode('SESSION_REFERENCE_INVALID_CONFIG')) const oversizedCtx = new Context() await oversizedCtx.plugin(SessionStore) - await oversizedCtx.plugin(SessionQueryService) + await oversizedCtx.plugin(TestSessionQueryService) expect(() => new SessionReferenceService(oversizedCtx, { maxReferences: 4 })) .toThrow(expectCode('SESSION_REFERENCE_INVALID_CONFIG')) const defaultCtx = new Context() await defaultCtx.plugin(SessionStore) - await defaultCtx.plugin(SessionQueryService) + await defaultCtx.plugin(TestSessionQueryService) expect(() => new SessionReferenceService(defaultCtx)).not.toThrow() }) }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index bd988338c6..6852853fec 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -476,8 +476,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'sessionQuery', - summary: 'Live-preferred logical-corpus read, filtering, and relationship-tracing service.', + summary: 'Unified live-preferred session query service.', methods: [ + { + signature: 'abstract searchSessions( request: SessionSearchRequest, exec?: SessionSearchExecContext, ): Promise>', + jsDoc: '/**\n * Search the live-preferred logical corpus and group by session.\n * @param request - query text, metadata filters, page size, and cursor.\n * @param exec - optional cancellation control.\n * @returns session hits ranked by their strongest matching event.\n */', + }, + { + signature: 'abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise>', + jsDoc: '/**\n * Search events within one live-preferred logical session.\n * @param request - target session, query text, filters, page size, and cursor.\n * @param exec - optional cancellation control.\n * @returns matching event hits in deterministic relevance order.\n */', + }, { signature: 'listSessions(): Promise', jsDoc: '/**\n * List the complete logical corpus using live-preferred records.\n * @returns deterministic newest-first cloned session records.\n */', @@ -572,20 +580,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, - { - key: 'sessionSearch', - summary: 'Abstract full-text search service implemented by one concrete backend.', - methods: [ - { - signature: 'abstract searchSessions( request: SessionSearchRequest, exec?: SessionSearchExecContext, ): Promise>', - jsDoc: '/**\n * Search the live-preferred logical corpus and group by session.\n * @param request - query text, metadata filters, page size, and cursor.\n * @param exec - optional cancellation control.\n * @returns session hits ranked by their strongest matching event.\n */', - }, - { - signature: 'abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise>', - jsDoc: '/**\n * Search events within one live-preferred logical session.\n * @param request - target session, query text, filters, page size, and cursor.\n * @param exec - optional cancellation control.\n * @returns matching event hits in deterministic relevance order.\n */', - }, - ], - }, { key: 'sessionTitle', summary: 'Log-backed title fold plus asynchronous fallback generation.', diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index db13d069f5..fd90434d24 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -15,7 +15,7 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it | `@deepseek-ai/dsh-command-goal` | the discoverable direct `/goal` producer; the app enables the spine's persisted-goal stack with it | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) | -| `@deepseek-ai/dsh-session-query` + `@deepseek-ai/dsh-session-reference` | exact current-surface reads and bounded `dsh-session:` snapshots | +| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | combined exact/FTS session queries and bounded `dsh-session:` snapshots | | `@deepseek-ai/dsh-session-checkpoint-policy` | semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints | | `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool | | ~~`@deepseek-ai/dsh-tool-ask-user`~~ | **omitted by default** — ACP elicitation support is still client-dependent, so leaves must opt in deliberately | @@ -43,7 +43,7 @@ The app owns this cluster through one ordered Cordis effect. Teardown drains the | `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` | | `goals` | owner defaults | persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer | | `llmRetry` | owner defaults | bounded transient model-request retry policy routed through `dsh-agent-spine-demo` | -| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | +| `persistenceRoot` | `./.sessions` | the JSONL backend's root directory and the parent of the derived `session-query.db` index | | `packChunks` | `false` | write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`) | | `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | | `sessionReferences` | service defaults | cross-session candidate and snapshot limits routed to `dsh-session-reference` | diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index 6f3dd21ebd..36de692404 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", "@deepseek-ai/dsh-session-query": "^0.0.1", + "@deepseek-ai/dsh-session-query-sqlite": "^0.0.1", "@deepseek-ai/dsh-session-reference": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", @@ -66,6 +67,7 @@ "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index de36614365..1a05a14e3e 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -12,6 +12,7 @@ */ import type { Context } from 'cordis' +import { join } from 'node:path' import z from 'schemastery' import * as acp from '@deepseek-ai/dsh-acp' import CommandService from '@deepseek-ai/dsh-commands' @@ -25,7 +26,7 @@ import SessionPersistenceJsonl, { } from '@deepseek-ai/dsh-session-persistence-jsonl' import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import SessionQueryService from '@deepseek-ai/dsh-session-query' +import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite' import SessionReferenceService, { type Config as SessionReferenceConfig } from '@deepseek-ai/dsh-session-reference' export const name = 'acp-demo' @@ -57,7 +58,7 @@ export interface Config { dshHome?: string /** Fallback session-title limits forwarded through agent-spine-demo. */ sessionTitle?: NonNullable - /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ + /** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */ persistenceRoot?: string /** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `false`. */ packChunks?: boolean @@ -110,14 +111,16 @@ export const Config: z = z.object({ /** * Compose the spine with the ACP front door. The agent-spine-demo bundle pre-creates * NO agents (its `agents` list defaults to `[]`) and carries the deployment - * `persona`; the JSONL backend persists under `persistenceRoot`; the ACP - * bridge owns stdout for JSON-RPC and creates one agent per `session/new` - * from the provider/model pair. The composite effect unloads in reverse order, - * keeping checkpoint and persistence listeners attached until ACP agents have - * flushed their closing events. No logger, no `hmr` — stdout stays pure. + * `persona`; the JSONL backend and derived query index persist under + * `persistenceRoot`; the ACP bridge owns stdout for JSON-RPC and creates one + * agent per `session/new` from the provider/model pair. The composite effect + * unloads in reverse order, keeping checkpoint and persistence listeners + * attached until ACP agents have flushed their closing events. No logger, no + * `hmr` — stdout stays pure. */ export function apply(ctx: Context, config: Config): void { const goals = config.goals ?? {} + const persistenceRoot = config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT ctx.effect(function* () { yield ctx.plugin(CommandService).dispose if (goals !== false) yield ctx.plugin(commandGoal).dispose @@ -127,13 +130,13 @@ export function apply(ctx: Context, config: Config): void { // persistence passthroughs rather than sharing a facade with stdio-demo. /* jscpd:ignore-start */ yield ctx.plugin(SessionPersistenceJsonl, { - root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT, + root: persistenceRoot, ...config.packChunks !== undefined ? { packChunks: config.packChunks } : {}, ...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }), }).dispose /* jscpd:ignore-end */ yield ctx.plugin(sessionCheckpointPolicy).dispose - yield ctx.plugin(SessionQueryService).dispose + yield ctx.plugin(SessionQuerySqlite, { path: join(persistenceRoot, 'session-query.db') }).dispose yield ctx.plugin(SessionReferenceService, config.sessionReferences ?? {}).dispose yield ctx.plugin(acp, { provider: config.provider, model: config.model }).dispose }, 'acp-demo.composition') diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index 29791b497f..a578a7ae85 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -37,7 +37,8 @@ const dshPackages = [ 'bash/bash-local', 'bash/tool-bash', 'context/workspace-context', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', 'session-persistence/session-checkpoint-policy', 'session-persistence/session-persistence-jsonl', - 'session-query/session-query', 'context/session-reference', 'ui/acp', 'examples/acp-demo', 'util/paths', + 'session-query/session-query', 'session-query/session-query-sqlite', + 'context/session-reference', 'ui/acp', 'examples/acp-demo', 'util/paths', ] const vendorPackages = [ 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console', diff --git a/packages/examples/acp-demo/tsconfig.json b/packages/examples/acp-demo/tsconfig.json index cdc104e987..0115fc938b 100644 --- a/packages/examples/acp-demo/tsconfig.json +++ b/packages/examples/acp-demo/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../session-query/session-query" }, + { + "path": "../../session-query/session-query-sqlite" + }, { "path": "../../context/session-reference" }, diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md index 146e6503bf..4046b44e38 100644 --- a/packages/examples/tui-demo/README.md +++ b/packages/examples/tui-demo/README.md @@ -13,7 +13,7 @@ Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and | `@deepseek-ai/dsh-command-goal` | Direct `/goal` status and mutation over the spine's persisted-goal stack | | `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session log under `persistenceRoot` | | `@deepseek-ai/dsh-session-checkpoint-policy` | Semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints | -| `@deepseek-ai/dsh-session-query` + `@deepseek-ai/dsh-session-reference` | Exact current-surface reads and bounded `@session` snapshots consumed by the TUI | +| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | Combined exact/FTS session queries and bounded `@session` snapshots consumed by the TUI | | `@deepseek-ai/dsh-user-interaction` | Provider-neutral human question service | | `@deepseek-ai/dsh-tui` | Full-screen transcript, editor, tool cards, plan, and question overlays | | `@deepseek-ai/dsh-tool-ask-user` | Model-facing `ask_user_question` tool | @@ -37,7 +37,7 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le | `toolTasks` | owner defaults | Background-task control-tool config, or `false` | | `goals` | owner defaults | Persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer | | `workspaceContext` | required | Workspace-instruction config, or `false` | -| `persistenceRoot` | `./.sessions` | JSONL persistence root | +| `persistenceRoot` | `./.sessions` | JSONL persistence root and parent of the derived `session-query.db` index | | `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | | `sessionReferences` | service defaults | Cross-session candidate and snapshot limits routed to `dsh-session-reference` | | `welcome` | `ready.` | TUI subtitle | diff --git a/packages/examples/tui-demo/package.json b/packages/examples/tui-demo/package.json index ad2e92be5d..1ddf5060b1 100644 --- a/packages/examples/tui-demo/package.json +++ b/packages/examples/tui-demo/package.json @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1", "@deepseek-ai/dsh-session-query": "^0.0.1", + "@deepseek-ai/dsh-session-query-sqlite": "^0.0.1", "@deepseek-ai/dsh-session-reference": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", "@deepseek-ai/dsh-tui": "^0.0.1", @@ -72,6 +73,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts index 69b6a3a291..29f985c8e7 100644 --- a/packages/examples/tui-demo/src/index.ts +++ b/packages/examples/tui-demo/src/index.ts @@ -10,6 +10,7 @@ import type { Context } from 'cordis' import { randomUUID } from 'node:crypto' +import { join } from 'node:path' import z from 'schemastery' import { SessionId } from '@deepseek-ai/dsh-session' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' @@ -23,7 +24,7 @@ import SessionPersistenceJsonl, { } from '@deepseek-ai/dsh-session-persistence-jsonl' import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import SessionQueryService from '@deepseek-ai/dsh-session-query' +import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite' import SessionReferenceService, { type Config as SessionReferenceConfig } from '@deepseek-ai/dsh-session-reference' import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as uiTui from '@deepseek-ai/dsh-tui' @@ -52,7 +53,7 @@ export interface Config { dshHome?: string /** Fallback session-title limits forwarded through agent-spine-demo. */ sessionTitle?: NonNullable - /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ + /** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */ persistenceRoot?: string /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ persistenceCompression?: JsonlCompression @@ -119,14 +120,15 @@ export function composeTuiApp(ctx: Context, config: Config): void { const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`) const goals = config.goals ?? {} + const persistenceRoot = config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT ctx.plugin(CommandService) if (goals !== false) ctx.plugin(commandGoal) ctx.plugin(SessionPersistenceJsonl, { - root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT, + root: persistenceRoot, ...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }), }) ctx.plugin(sessionCheckpointPolicy) - ctx.plugin(SessionQueryService) + ctx.plugin(SessionQuerySqlite, { path: join(persistenceRoot, 'session-query.db') }) ctx.plugin(SessionReferenceService, config.sessionReferences ?? {}) ctx.plugin(UserInteractionService) ctx.plugin(uiTui, { diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts index 8c05abfbab..f515253dd2 100644 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts @@ -51,7 +51,7 @@ describe('dsh-tui-demo app', () => { 'command-goal', 'SessionPersistenceJsonl', 'session-checkpoint-policy', - 'SessionQueryService', + 'SessionQuerySqlite', 'SessionReferenceService', 'UserInteractionService', 'ui-tui', @@ -60,6 +60,7 @@ describe('dsh-tui-demo app', () => { ]) expect(calls[0]?.config).toBeUndefined() expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' }) + expect(calls[4]?.config).toEqual({ path: '/tmp/tui-sessions/session-query.db' }) expect(calls[5]?.config).toEqual({ maxReferences: 2, candidateLimit: 7, diff --git a/packages/examples/tui-demo/tsconfig.json b/packages/examples/tui-demo/tsconfig.json index cb219721a5..d87f0f1c9e 100644 --- a/packages/examples/tui-demo/tsconfig.json +++ b/packages/examples/tui-demo/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../session-query/session-query" }, + { + "path": "../../session-query/session-query-sqlite" + }, { "path": "../../context/session-reference" }, diff --git a/packages/session-query/README.md b/packages/session-query/README.md index f76815f166..f79d35936c 100644 --- a/packages/session-query/README.md +++ b/packages/session-query/README.md @@ -4,7 +4,7 @@ Trusted exact reads, relationship traces, provider-independent semantic filterin | Package | Role | ctx key | |---|---|---| -| [`session-query/`](session-query/README.md) | Logical-corpus title, event, lineage, relationship, and semantic-filter reads plus the abstract search seam | `ctx.sessionQuery`, `ctx.sessionSearch` | -| [`session-query-sqlite/`](session-query-sqlite/README.md) | SQLite FTS5 search with persistent bases and live overlays | `ctx.sessionSearch` | +| [`session-query/`](session-query/README.md) | Combined service contract with concrete logical-corpus reads, traces, and semantic filters plus abstract full-text methods | `ctx.sessionQuery` | +| [`session-query-sqlite/`](session-query-sqlite/README.md) | Concrete service backend with SQLite FTS5 persistent bases and live overlays | `ctx.sessionQuery` | -The family is independent of compaction: it reads canonical lineage, surface operations, logged provenance, and semantic event text but does not participate in compaction policy or execution. Search uses one abstract service and one concrete owner, not a provider registry or coordinator. +The family is independent of compaction: it reads canonical lineage, surface operations, logged provenance, and semantic event text but does not participate in compaction policy or execution. One abstract service combines every query operation, and one concrete backend owns the full-text lifecycle without a provider registry or coordinator. diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index 82a8899095..b3b25ae2c8 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-session-query-sqlite -SQLite FTS5 implementation of `ctx.sessionSearch`. The service searches the live-preferred logical session corpus and groups cross-session results by their strongest event. +Concrete `ctx.sessionQuery` backend. `SessionQuerySqlite` inherits exact reads, traces, and provider-independent filters from the interface package and implements its two full-text methods with SQLite FTS5. Search uses the live-preferred logical session corpus and groups cross-session results by their strongest event. ## Search contract @@ -27,6 +27,7 @@ The database is disposable but reset is guarded: a recognized incompatible searc | `defaultLimit` | `20` | Page size when a request omits `limit`; at most `Number.MAX_SAFE_INTEGER - 1`. | | `maxLimit` | `100` | Largest accepted request page size; at most `Number.MAX_SAFE_INTEGER - 1`. | | `snippetChars` | `240` | Maximum snippet length in Unicode code points. | +| `readWindowMax` | `50` | Maximum `before` or `after` raw-event count for inherited `readEvent()`. | ## Tokenizer and limits diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index fd17e78b1f..2d4758ba3e 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-session-query-sqlite", - "description": "SQLite FTS5 implementation of ctx.sessionSearch", + "description": "Concrete ctx.sessionQuery backend with SQLite FTS5 search", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index b577872b2c..43e0784dfa 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -1,5 +1,5 @@ /** - * SQLite FTS5 search over the live-preferred logical session corpus. + * Concrete session-query service with SQLite FTS5 over the live-preferred corpus. * * @module @deepseek-ai/dsh-session-query-sqlite */ @@ -14,14 +14,15 @@ import type { SessionPersistenceRevision, SessionPersistenceSnapshot, } from '@deepseek-ai/dsh-session-persistence' -import { +import SessionQueryService, { + SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError, SessionSearchCursor, - SessionSearchService, assertSessionHeadersCompatible, buildSessionEventSearchDocuments, } from '@deepseek-ai/dsh-session-query' import type { + Config as SessionQueryConfig, SessionEventSearchDocument, SessionEventSearchHit, SessionEventSearchRequest, @@ -69,8 +70,8 @@ export const SESSION_QUERY_SQLITE_SNIPPET_CHARS = 240 // One transient source change gets a retry; repeated churn fails rather than monopolizing the queue. const STABLE_OBSERVATION_ATTEMPTS = 2 -/** SQLite session-search configuration. */ -export interface Config { +/** Combined session-query configuration backed by SQLite full-text search. */ +export interface Config extends SessionQueryConfig { /** * Dedicated derived-index path; `:memory:` is supported for tests. Missing * directories and database files are created owner-only on POSIX filesystems; @@ -93,6 +94,7 @@ interface ResolvedConfig { defaultLimit: number maxLimit: number snippetChars: number + readWindowMax: number } interface ObservedSession { @@ -158,9 +160,9 @@ interface CursorPayload { offset: number } -/** Concrete SQLite owner of `ctx.sessionSearch`. */ -export class SessionSearchSqlite extends SessionSearchService { - static inject = ['sessions'] +/** Concrete SQLite owner of the combined `ctx.sessionQuery` service. */ +export class SessionQuerySqlite extends SessionQueryService { + static override inject = ['sessions'] static Config: z = z.object({ path: z.string().required(), @@ -168,6 +170,7 @@ export class SessionSearchSqlite extends SessionSearchService { defaultLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(SESSION_QUERY_SQLITE_DEFAULT_LIMIT), maxLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(SESSION_QUERY_SQLITE_MAX_LIMIT), snippetChars: z.number().step(1).min(1).default(SESSION_QUERY_SQLITE_SNIPPET_CHARS), + readWindowMax: z.number().step(1).min(0).default(SESSION_QUERY_READ_WINDOW_MAX), }) /** Validated and defaulted backend configuration. */ @@ -187,7 +190,7 @@ export class SessionSearchSqlite extends SessionSearchService { private readonly _optionalPersistenceFiber: Fiber constructor(ctx: Context, config: Config) { - super(ctx) + super(ctx, config) this.config = resolveConfig(config) this._ready = this._open() // Attach a rejection observer immediately; callers still receive the same @@ -201,12 +204,12 @@ export class SessionSearchSqlite extends SessionSearchService { /* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */ if (this._persistenceBinding !== binding) return this._persistenceBinding = { identity: Symbol() } - }, 'sessionSearchSqlite.persistenceBinding') + }, 'sessionQuerySqlite.persistenceBinding') }) ctx.effect(() => { return () => this._optionalPersistenceFiber.dispose() - }, 'sessionSearchSqlite.optionalPersistence') - ctx.effect(() => async () => this.close(), 'sessionSearchSqlite.close') + }, 'sessionQuerySqlite.optionalPersistence') + ctx.effect(() => async () => this.close(), 'sessionQuerySqlite.close') } override async searchSessions( @@ -882,6 +885,7 @@ function resolveConfig(config: Config): ResolvedConfig { defaultLimit: config.defaultLimit ?? SESSION_QUERY_SQLITE_DEFAULT_LIMIT, maxLimit: config.maxLimit ?? SESSION_QUERY_SQLITE_MAX_LIMIT, snippetChars: config.snippetChars ?? SESSION_QUERY_SQLITE_SNIPPET_CHARS, + readWindowMax: config.readWindowMax ?? SESSION_QUERY_READ_WINDOW_MAX, } if (typeof resolved.path !== 'string' || resolved.path.trim().length === 0) { throw invalidConfig('path must not be blank') @@ -963,4 +967,4 @@ function isRuntimeArray(value: unknown): boolean { return Array.isArray(value) } -export default SessionSearchSqlite +export default SessionQuerySqlite diff --git a/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts index c20a501964..200d171b50 100644 --- a/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts +++ b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts @@ -1,5 +1,5 @@ /** - * Keyless real-Loader-path smoke for the SQLite session-search service. + * Keyless real-Loader-path smoke for the combined SQLite session-query service. * * @module @deepseek-ai/dsh-session-query-sqlite/tests/load-path */ @@ -10,7 +10,7 @@ import Loader from '@cordisjs/plugin-loader' import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import SessionStore from '@deepseek-ai/dsh-session' import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' -import SessionSearchSqlite, * as searchModule from '@deepseek-ai/dsh-session-query-sqlite' +import SessionQuerySqlite, * as queryModule from '@deepseek-ai/dsh-session-query-sqlite' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -38,9 +38,9 @@ describe('dsh-session-query-sqlite real Loader path', () => { const persistence = await ctx.plugin(SessionPersistenceSqlite, { path: persistencePath }) const loader = Object.create(Loader.prototype) as Loader - const unwrapped = loader.unwrapExports(searchModule) as Parameters[0] - expect(unwrapped).toBe(SessionSearchSqlite) - const search = await ctx.plugin(unwrapped, { path: searchPath }) + const unwrapped = loader.unwrapExports(queryModule) as Parameters[0] + expect(unwrapped).toBe(SessionQuerySqlite) + const query = await ctx.plugin(unwrapped, { path: searchPath }) const id = SessionId('loader-path') await ctx.sessionPersistence.create({ version: SESSION_FORMAT_VERSION, id, createdAt: 10 }) @@ -52,9 +52,11 @@ describe('dsh-session-query-sqlite real Loader path', () => { surfaceOp: 'append', }]) - await expect(ctx.sessionSearch.searchSessions({ query: 'Loader needle' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'Loader needle' })) .resolves.toMatchObject({ items: [{ header: { id }, persisted: true, live: false }] }) - await search.dispose() + await expect(ctx.sessionQuery.listSessions()) + .resolves.toMatchObject([{ header: { id }, persisted: true, live: false }]) + await query.dispose() await persistence.dispose() }) }) 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 0aea3f6202..8a99108460 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -9,7 +9,7 @@ import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@d import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' import type { SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' -import SessionSearchSqlite, { +import SessionQuerySqlite, { SESSION_QUERY_SQLITE_APPLICATION_ID, SESSION_QUERY_SQLITE_SCHEMA_VERSION, } from '@deepseek-ai/dsh-session-query-sqlite' @@ -146,10 +146,10 @@ class TestPersistence extends SessionPersistence { } } -async function liveContext(config: ConstructorParameters[1] = { path: ':memory:' }): Promise { +async function liveContext(config: ConstructorParameters[1] = { path: ':memory:' }): Promise { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(SessionSearchSqlite, config) + await ctx.plugin(SessionQuerySqlite, config) return ctx } @@ -165,9 +165,9 @@ describe('SQLite session search', () => { { surfaceOp: 'append' }, ) - await expect(ctx.sessionSearch.searchEvents({ sessionId: session.id, query: 'AI' })) + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'AI' })) .resolves.toMatchObject({ items: [{ sessionId: session.id, seq: 0, snippet: 'An AI helper' }] }) - await expect(ctx.sessionSearch.searchSessions({ query: 'AI' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'AI' })) .resolves.toMatchObject({ items: [{ header: { ...session.header, seedLength: 1 }, live: true, persisted: false }] }) }) @@ -183,9 +183,9 @@ describe('SQLite session search', () => { ctx.sessions.create(SessionId('a'), { seed: events, meta: { cwd: '/a', parentSession: parent, createdAt: 20 } }) ctx.sessions.create(SessionId('b'), { seed: messageEvents('needle peer', 12), meta: { createdAt: 20 } }) - const all = await ctx.sessionSearch.searchEvents({ sessionId: SessionId('a'), query: 'needle' }) + const all = await ctx.sessionQuery.searchEvents({ sessionId: SessionId('a'), query: 'needle' }) expect(new Set(all.items.map(item => item.surface))).toEqual(new Set(['current', 'shadowed', 'log-only'])) - await expect(ctx.sessionSearch.searchEvents({ + await expect(ctx.sessionQuery.searchEvents({ sessionId: SessionId('a'), query: 'needle', filters: [ @@ -196,7 +196,7 @@ describe('SQLite session search', () => { ], })).resolves.toMatchObject({ items: [{ seq: 2, surface: 'current' }] }) - const grouped = await ctx.sessionSearch.searchSessions({ + const grouped = await ctx.sessionQuery.searchSessions({ query: 'needle', sessionFilters: [ { kind: 'id', values: [SessionId('a')] }, @@ -231,9 +231,9 @@ describe('SQLite session search', () => { () => ({ kind: 'type' as const, values: ['user/message' as const] }), ) - await expect(ctx.sessionSearch.searchSessions({ query: 'needle', sessionFilters })) + await expect(ctx.sessionQuery.searchSessions({ query: 'needle', sessionFilters })) .resolves.toMatchObject({ items: [{ header: { id: session.id } }] }) - await expect(ctx.sessionSearch.searchEvents({ + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'needle', filters: eventFilters, @@ -252,19 +252,19 @@ describe('SQLite session search', () => { () => ({ kind: 'type' as const, values: ['user/message' as const] }), ) - await expect(ctx.sessionSearch.searchSessions({ query: 'needle', sessionFilters })) + await expect(ctx.sessionQuery.searchSessions({ query: 'needle', sessionFilters })) .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) - await expect(ctx.sessionSearch.searchEvents({ + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'needle', filters: eventFilters, })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) - await expect(ctx.sessionSearch.searchSessions({ + await expect(ctx.sessionQuery.searchSessions({ query: 'needle', sessionFilters: sessionFilters.slice(0, 7), eventFilters: eventFilters.slice(0, 8), })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) - await expect(ctx.sessionSearch.searchEvents({ + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'needle', filters: eventFilters.slice(0, 14), @@ -281,15 +281,15 @@ describe('SQLite session search', () => { ctx.sessions.create(SessionId('only'), { seed: messageEvents('needle only', 10), meta: { createdAt: 1 } }) ctx.sessions.create(SessionId('quote'), { seed: messageEvents('say "needle" exactly', 10), meta: { createdAt: 1 } }) - const phrase = await ctx.sessionSearch.searchSessions({ query: 'alpha beta' }) + const phrase = await ctx.sessionQuery.searchSessions({ query: 'alpha beta' }) expect(phrase.items.map(item => item.header.id)).toEqual([SessionId('b'), SessionId('d'), SessionId('a')]) expect(phrase.items.every(item => Array.from(item.bestMatch.snippet).length <= 5)).toBe(true) - await expect(ctx.sessionSearch.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] }) - await expect(ctx.sessionSearch.searchSessions({ query: 'needle OR absent' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] }) + await expect(ctx.sessionQuery.searchSessions({ query: 'needle OR absent' })) .resolves.toMatchObject({ items: [{ header: { id: SessionId('operator') } }] }) - await expect(ctx.sessionSearch.searchSessions({ query: 'say "needle"' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'say "needle"' })) .resolves.toMatchObject({ items: [{ header: { id: SessionId('quote') } }] }) - await expect(ctx.sessionSearch.searchSessions({ query: '*' })).resolves.toEqual({ items: [] }) + await expect(ctx.sessionQuery.searchSessions({ query: '*' })).resolves.toEqual({ items: [] }) }) it('ranks live and persisted matches on one source-comparable contract', async () => { @@ -308,7 +308,7 @@ describe('SQLite session search', () => { meta: { createdAt: persisted.createdAt }, }) - const result = await ctx.sessionSearch.searchSessions({ + const result = await ctx.sessionQuery.searchSessions({ query: 'needle', sessionFilters: [{ kind: 'id', values: [SessionId('a-live'), persisted.id] }], }) @@ -322,7 +322,7 @@ describe('SQLite session search', () => { seed: messageEvents('long long long—café,\nnext value', 10), }) - const page = await ctx.sessionSearch.searchEvents({ sessionId: session.id, query: 'CAFE' }) + const page = await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'CAFE' }) expect(page.items).toHaveLength(1) expect(page.items[0]!.snippet).toContain('café') expect(page.items[0]!.snippet).toContain('—') @@ -341,14 +341,14 @@ describe('SQLite session search', () => { }) ctx.sessions.create(SessionId('other'), { seed: messageEvents('needle other', 10) }) - const eventPage = await ctx.sessionSearch.searchEvents({ sessionId: target.id, query: 'needle', limit: 1 }) - const sessionPage = await ctx.sessionSearch.searchSessions({ query: 'needle', limit: 1 }) + const eventPage = await ctx.sessionQuery.searchEvents({ sessionId: target.id, query: 'needle', limit: 1 }) + const sessionPage = await ctx.sessionQuery.searchSessions({ query: 'needle', limit: 1 }) expect(eventPage.nextCursor).toEqual(expect.any(String)) expect(sessionPage.nextCursor).toEqual(expect.any(String)) if (eventPage.nextCursor === undefined || sessionPage.nextCursor === undefined) throw new Error('expected cursors') const unsafeOffsetCursor = replaceCursorOffset(eventPage.nextCursor, 1e100) - await expect(ctx.sessionSearch.searchEvents({ + await expect(ctx.sessionQuery.searchEvents({ sessionId: target.id, query: 'needle', limit: 1, @@ -358,7 +358,7 @@ describe('SQLite session search', () => { const eventKeys = eventPage.items.map(item => `${item.sessionId}:${item.seq}`) let eventCursor: ReturnType | undefined = eventPage.nextCursor while (eventCursor !== undefined) { - const next = await ctx.sessionSearch.searchEvents({ + const next = await ctx.sessionQuery.searchEvents({ sessionId: target.id, query: 'needle', limit: 1, @@ -373,7 +373,7 @@ describe('SQLite session search', () => { const sessionIds = sessionPage.items.map(item => item.header.id) let sessionCursor: ReturnType | undefined = sessionPage.nextCursor while (sessionCursor !== undefined) { - const next = await ctx.sessionSearch.searchSessions({ query: 'needle', limit: 1, cursor: sessionCursor }) + const next = await ctx.sessionQuery.searchSessions({ query: 'needle', limit: 1, cursor: sessionCursor }) sessionIds.push(...next.items.map(item => item.header.id)) sessionCursor = next.nextCursor } @@ -381,15 +381,15 @@ describe('SQLite session search', () => { expect(new Set(sessionIds).size).toBe(sessionIds.length) ctx.sessions.create(SessionId('unrelated'), { seed: messageEvents('needle unrelated', 20) }) - await expect(ctx.sessionSearch.searchEvents({ + await expect(ctx.sessionQuery.searchEvents({ sessionId: target.id, query: 'needle', limit: 1, cursor: eventPage.nextCursor, })).resolves.toMatchObject({ items: [{ sessionId: target.id }] }) - await expect(ctx.sessionSearch.searchSessions({ query: 'needle', limit: 1, cursor: sessionPage.nextCursor })) + await expect(ctx.sessionQuery.searchSessions({ query: 'needle', limit: 1, cursor: sessionPage.nextCursor })) .rejects.toThrow(expectCode('SESSION_QUERY_STALE_CURSOR')) - await expect(ctx.sessionSearch.searchEvents({ + await expect(ctx.sessionQuery.searchEvents({ sessionId: target.id, query: 'different', limit: 1, @@ -397,7 +397,7 @@ describe('SQLite session search', () => { })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CURSOR')) target.append('user/message', { content: [{ type: 'text', text: 'needle four' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - await expect(ctx.sessionSearch.searchEvents({ + await expect(ctx.sessionQuery.searchEvents({ sessionId: target.id, query: 'needle', limit: 1, @@ -410,13 +410,13 @@ describe('SQLite session search', () => { const ctx = await liveContext({ path: ':memory:', defaultLimit: 1, maxLimit: 5 }) ctx.sessions.create(SessionId('first'), { seed: messageEvents('needle first') }) ctx.sessions.create(SessionId('second'), { seed: messageEvents('needle second') }) - const page = await ctx.sessionSearch.searchSessions({ query: 'needle', limit: 1 }) + const page = await ctx.sessionQuery.searchSessions({ query: 'needle', limit: 1 }) if (page.nextCursor === undefined) throw new Error('expected cursor') const persistence = await ctx.plugin(TestPersistence) await persistence.dispose() - await expect(ctx.sessionSearch.searchSessions({ + await expect(ctx.sessionQuery.searchSessions({ query: 'needle', limit: 1, cursor: page.nextCursor, @@ -434,32 +434,32 @@ describe('SQLite session search', () => { { sessionId: session.id, query: 'needle', filters: [{ kind: 'surface', values: ['future'] }] }, { sessionId: session.id, query: 'bad\0query' }, ] as const) { - await expect(ctx.sessionSearch.searchEvents(request as never)).rejects.toBeInstanceOf(Error) + await expect(ctx.sessionQuery.searchEvents(request as never)).rejects.toBeInstanceOf(Error) } - await expect(ctx.sessionSearch.searchSessions({ + await expect(ctx.sessionQuery.searchSessions({ query: 'needle', sessionFilters: [{ kind: 'availability', values: ['remote' as never] }], })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) - await expect(ctx.sessionSearch.searchSessions({ + await expect(ctx.sessionQuery.searchSessions({ query: 'needle', sessionFilters: [{ kind: 'future' } as never], })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) - await expect(ctx.sessionSearch.searchSessions({ + await expect(ctx.sessionQuery.searchSessions({ query: 'needle', eventFilters: [{ kind: 'future' } as never], })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) - await expect(ctx.sessionSearch.searchEvents({ + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'needle', filters: [{ kind: 'future' } as never], })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) - await expect(ctx.sessionSearch.searchEvents({ + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'needle', cursor: SessionSearchCursor('not-json'), })) .rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CURSOR')) - await expect(ctx.sessionSearch.searchEvents({ sessionId: SessionId('absent'), query: 'needle' })) + await expect(ctx.sessionQuery.searchEvents({ sessionId: SessionId('absent'), query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) for (const config of [ @@ -474,7 +474,7 @@ describe('SQLite session search', () => { ]) { const direct = new Context() await direct.plugin(SessionStore) - expect(() => new SessionSearchSqlite(direct, config as never)) + expect(() => new SessionQuerySqlite(direct, config as never)) .toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG')) } }) @@ -492,12 +492,12 @@ describe('SQLite session search', () => { const types = Array.from({ length: halfPortableLimit }, () => 'user/message' as const) const surfaces = Array.from({ length: halfPortableLimit }, () => 'current' as const) - await expect(ctx.sessionSearch.searchSessions({ + await expect(ctx.sessionQuery.searchSessions({ query: 'needle', sessionFilters: [{ kind: 'id', values: ids }], eventFilters: [{ kind: 'type', values: types }], })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) - await expect(ctx.sessionSearch.searchEvents({ + await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'needle', filters: [ @@ -514,7 +514,7 @@ describe('SQLite session search', () => { (_, index) => SessionId(`oversized-binding-${index}`), ) - await expect(ctx.sessionSearch.searchSessions({ + await expect(ctx.sessionQuery.searchSessions({ query: 'needle', sessionFilters: [{ kind: 'id', values: ids }], })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) @@ -535,7 +535,7 @@ describe('SQLite reconciliation and source lifecycle', () => { TestPersistence.listStarted = undefined markStarted() } - const blocking = ctx.sessionSearch.searchSessions({ query: 'needle' }) + const blocking = ctx.sessionQuery.searchSessions({ query: 'needle' }) await started const availability: SessionAvailability[] = ['persisted'] @@ -543,7 +543,7 @@ describe('SQLite reconciliation and source lifecycle', () => { query: 'needle', sessionFilters: [{ kind: 'availability', values: availability }], } - const queued = ctx.sessionSearch.searchSessions(request) + const queued = ctx.sessionQuery.searchSessions(request) request.query = 'absent' availability[0] = 'live' release() @@ -561,26 +561,26 @@ describe('SQLite reconciliation and source lifecycle', () => { { meta: durable, events: messageEvents('durable needle') }, ]) const ctx = await liveContext() - await expect(ctx.sessionSearch.searchSessions({ query: 'durable' })).resolves.toEqual({ items: [] }) + await expect(ctx.sessionQuery.searchSessions({ query: 'durable' })).resolves.toEqual({ items: [] }) const persistenceFiber = await ctx.plugin(TestPersistence) - await expect(ctx.sessionSearch.searchSessions({ query: 'durable' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'durable' })) .resolves.toMatchObject({ items: [{ header: durable, live: false, persisted: true }] }) const live = ctx.sessions.prepare(shared.id, { meta: { createdAt: 10, cwd: '/work' } }) live.append('user/message', { content: [{ type: 'text', text: 'live needle' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) const detach = ctx.sessions.enter(live) ctx.sessions.announce(live) - await expect(ctx.sessionSearch.searchSessions({ query: 'persisted' })).resolves.toEqual({ items: [] }) - await expect(ctx.sessionSearch.searchSessions({ query: 'live' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'persisted' })).resolves.toEqual({ items: [] }) + await expect(ctx.sessionQuery.searchSessions({ query: 'live' })) .resolves.toMatchObject({ items: [{ header: shared, live: true, persisted: true }] }) detach() - await expect(ctx.sessionSearch.searchSessions({ query: 'persisted' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'persisted' })) .resolves.toMatchObject({ items: [{ header: shared, live: false, persisted: true }] }) await persistenceFiber.dispose() - await expect(ctx.sessionSearch.searchSessions({ query: 'durable' })).resolves.toEqual({ items: [] }) - await expect(ctx.sessionSearch.searchEvents({ sessionId: durable.id, query: 'needle' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'durable' })).resolves.toEqual({ items: [] }) + await expect(ctx.sessionQuery.searchEvents({ sessionId: durable.id, query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) }) @@ -592,7 +592,7 @@ describe('SQLite reconciliation and source lifecycle', () => { ] }]) const ctx = await liveContext({ path: ':memory:', defaultLimit: 1, maxLimit: 2 }) const persistence = await ctx.plugin(TestPersistence) - const internals = ctx.sessionSearch as unknown as { + const internals = ctx.sessionQuery as unknown as { _reconcile(signal: AbortSignal | undefined): Promise<{ identity: symbol service?: SessionPersistence @@ -605,7 +605,7 @@ describe('SQLite reconciliation and source lifecycle', () => { return binding }) - const page = await ctx.sessionSearch.searchEvents({ + const page = await ctx.sessionQuery.searchEvents({ sessionId: durable.id, query: 'needle', limit: 1, @@ -613,7 +613,7 @@ describe('SQLite reconciliation and source lifecycle', () => { expect(page.items).toMatchObject([{ sessionId: durable.id }]) expect(page.nextCursor).toEqual(expect.any(String)) boundary.mockRestore() - await expect(ctx.sessionSearch.searchEvents({ sessionId: durable.id, query: 'needle' })) + await expect(ctx.sessionQuery.searchEvents({ sessionId: durable.id, query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) }) @@ -631,7 +631,7 @@ describe('SQLite reconciliation and source lifecycle', () => { markStarted() } - const search = ctx.sessionSearch.searchSessions({ query: 'needle' }) + const search = ctx.sessionQuery.searchSessions({ query: 'needle' }) await started await persistenceFiber.dispose() TestPersistence.failure = new Error('stale backend rejection') @@ -653,7 +653,7 @@ describe('SQLite reconciliation and source lifecycle', () => { markStarted() } - const search = ctx.sessionSearch.searchSessions({ query: 'needle' }) + const search = ctx.sessionQuery.searchSessions({ query: 'needle' }) await started await prior.dispose() TestPersistence.listGate = undefined @@ -669,17 +669,17 @@ describe('SQLite reconciliation and source lifecycle', () => { const revision = TestPersistence.revisions.get(durable.id)! const ctx = await liveContext() const prior = await ctx.plugin(TestPersistence) - await expect(ctx.sessionSearch.searchSessions({ query: 'old' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'old' })) .resolves.toMatchObject({ items: [{ header: durable }] }) await prior.dispose() TestPersistence.set({ meta: durable, events: messageEvents('new needle') }) TestPersistence.revisions.set(durable.id, revision) const replacement = await ctx.plugin(TestPersistence) - const page = await ctx.sessionSearch.searchSessions({ query: 'new needle' }) + const page = await ctx.sessionQuery.searchSessions({ query: 'new needle' }) expect(TestPersistence.loads.get(durable.id)).toBe(2) expect(page).toMatchObject({ items: [{ header: durable }] }) - await expect(ctx.sessionSearch.searchSessions({ query: 'old' })).resolves.toEqual({ items: [] }) + await expect(ctx.sessionQuery.searchSessions({ query: 'old' })).resolves.toEqual({ items: [] }) expect(TestPersistence.loads.get(durable.id)).toBe(2) await replacement.dispose() }) @@ -695,7 +695,7 @@ describe('SQLite reconciliation and source lifecycle', () => { if (lists === 2) await persistence.dispose() } - await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })).resolves.toEqual({ items: [] }) + await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })).resolves.toEqual({ items: [] }) expect(lists).toBe(2) }) @@ -710,7 +710,7 @@ describe('SQLite reconciliation and source lifecycle', () => { TestPersistence.set({ meta: added, events: messageEvents('added needle') }) } - const page = await ctx.sessionSearch.searchSessions({ query: 'needle' }) + const page = await ctx.sessionQuery.searchSessions({ query: 'needle' }) expect(page.items.map(item => item.header.id).sort()).toEqual([added.id, first.id].sort()) expect(TestPersistence.loads.get(first.id)).toBe(2) expect(TestPersistence.loads.get(added.id)).toBe(1) @@ -727,7 +727,7 @@ describe('SQLite reconciliation and source lifecycle', () => { TestPersistence.set({ meta: durable, events: messageEvents(`durable needle ${lists}`) }) } - await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) expect(lists).toBe(4) }) @@ -737,7 +737,7 @@ describe('SQLite reconciliation and source lifecycle', () => { TestPersistence.reset([{ meta: durable, events: messageEvents('durable needle') }]) const ctx = await liveContext() await ctx.plugin(TestPersistence) - const internals = ctx.sessionSearch as unknown as { + const internals = ctx.sessionQuery as unknown as { _persistenceBinding: { identity: symbol; service?: SessionPersistence } } const originalList = ctx.sessions.list.bind(ctx.sessions) @@ -753,7 +753,7 @@ describe('SQLite reconciliation and source lifecycle', () => { return originalList() }) - await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })) .resolves.toMatchObject({ items: [{ header: durable }] }) expect(TestPersistence.loads.get(durable.id)).toBe(2) list.mockRestore() @@ -766,22 +766,22 @@ describe('SQLite reconciliation and source lifecycle', () => { await ctx.plugin(TestPersistence) TestPersistence.snapshotOverride = () => 'not-an-array' as never - await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) TestPersistence.snapshotOverride = () => [{ header: durable, revision: 1 as never }] - await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) TestPersistence.snapshotOverride = () => [ { header: durable, revision: SessionPersistenceRevision('duplicate:1') }, { header: durable, revision: SessionPersistenceRevision('duplicate:2') }, ] - await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) TestPersistence.snapshotOverride = undefined const typed = new SessionQueryError('typed persistence failure', 'SESSION_QUERY_PERSISTENCE_FAILED') TestPersistence.failure = typed - await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })).rejects.toBe(typed) + await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })).rejects.toBe(typed) }) it('rejects immutable header conflicts between live and persisted sources', async () => { @@ -794,7 +794,7 @@ describe('SQLite reconciliation and source lifecycle', () => { meta: { createdAt: 10, delegationDepth: 2 }, }) - await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT')) }) @@ -811,10 +811,10 @@ describe('SQLite reconciliation and source lifecycle', () => { const first = new Context() await first.plugin(SessionStore) const firstPersistence = await first.plugin(TestPersistence) - const firstSearch = await first.plugin(SessionSearchSqlite, { path }) - await first.sessionSearch.searchSessions({ query: 'needle' }) + const firstSearch = await first.plugin(SessionQuerySqlite, { path }) + await first.sessionQuery.searchSessions({ query: 'needle' }) expect(Object.fromEntries(TestPersistence.loads)).toEqual({ unchanged: 1, changed: 1, deleted: 1 }) - await first.sessionSearch.searchSessions({ query: 'needle' }) + await first.sessionQuery.searchSessions({ query: 'needle' }) expect(Object.fromEntries(TestPersistence.loads)).toEqual({ unchanged: 1, changed: 1, deleted: 1 }) await firstSearch.dispose() await firstPersistence.dispose() @@ -831,8 +831,8 @@ describe('SQLite reconciliation and source lifecycle', () => { const second = new Context() await second.plugin(SessionStore) const secondPersistence = await second.plugin(TestPersistence) - const secondSearch = await second.plugin(SessionSearchSqlite, { path }) - const result = await second.sessionSearch.searchSessions({ query: 'needle' }) + const secondSearch = await second.plugin(SessionQuerySqlite, { path }) + const result = await second.sessionQuery.searchSessions({ query: 'needle' }) expect(result.items.map(item => item.header.id).sort()).toEqual([added.id, changed.id, unchanged.id].sort()) expect(Object.fromEntries(TestPersistence.loads)).toEqual({ unchanged: 1, @@ -861,17 +861,17 @@ describe('SQLite reconciliation and source lifecycle', () => { await first.plugin(SessionStore) const persistence = await first.plugin(TestPersistence) const live = first.sessions.create(shared.id, { seed: messageEvents('live needle'), meta: { createdAt: 10 } }) - const search = await first.plugin(SessionSearchSqlite, { path }) - await expect(first.sessionSearch.searchEvents({ sessionId: live.id, query: 'live' })).resolves.toMatchObject({ items: [{}] }) + const search = await first.plugin(SessionQuerySqlite, { path }) + await expect(first.sessionQuery.searchEvents({ sessionId: live.id, query: 'live' })).resolves.toMatchObject({ items: [{}] }) await search.dispose() await persistence.dispose() const second = new Context() await second.plugin(SessionStore) const persistenceAgain = await second.plugin(TestPersistence) - const searchAgain = await second.plugin(SessionSearchSqlite, { path }) - await expect(second.sessionSearch.searchSessions({ query: 'live' })).resolves.toEqual({ items: [] }) - await expect(second.sessionSearch.searchSessions({ query: 'persisted' })) + const searchAgain = await second.plugin(SessionQuerySqlite, { path }) + await expect(second.sessionQuery.searchSessions({ query: 'live' })).resolves.toEqual({ items: [] }) + await expect(second.sessionQuery.searchSessions({ query: 'persisted' })) .resolves.toMatchObject({ items: [{ header: shared, live: false, persisted: true }] }) expect(TestPersistence.loads.get(shared.id)).toBe(1) await searchAgain.dispose() @@ -887,10 +887,10 @@ describe('SQLite reconciliation and source lifecycle', () => { const ctx = await liveContext() await ctx.plugin(TestPersistence) - await expect(ctx.sessionSearch.searchSessions({ query: 'repaired' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'repaired' })) .resolves.toMatchObject({ items: [{ header: durable }] }) expect(TestPersistence.loads.get(durable.id)).toBe(2) - await ctx.sessionSearch.searchSessions({ query: 'repaired' }) + await ctx.sessionQuery.searchSessions({ query: 'repaired' }) expect(TestPersistence.loads.get(durable.id)).toBe(2) }) @@ -899,26 +899,26 @@ describe('SQLite reconciliation and source lifecycle', () => { const ctx = await liveContext() await ctx.plugin(TestPersistence) TestPersistence.failure = 'offline' - await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) const signal = new AbortController().signal - await expect(ctx.sessionSearch.searchSessions({ query: 'needle' }, { signal })) + await expect(ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal })) .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) TestPersistence.failure = new Error('still offline') - await expect(ctx.sessionSearch.searchSessions({ query: 'needle' }, { signal })) + await expect(ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal })) .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) TestPersistence.failure = undefined - await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })).resolves.toMatchObject({ items: [{}] }) + await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })).resolves.toMatchObject({ items: [{}] }) const live = ctx.sessions.create(SessionId('live'), { seed: messageEvents('base') }) - await ctx.sessionSearch.searchEvents({ sessionId: live.id, query: 'base' }) - const db = (ctx.sessionSearch as unknown as { _db: DatabaseSync })._db + await ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'base' }) + const db = (ctx.sessionQuery as unknown as { _db: DatabaseSync })._db db.exec('PRAGMA query_only = ON') live.append('user/message', { content: [{ type: 'text', text: 'retry needle' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - await expect(ctx.sessionSearch.searchEvents({ sessionId: live.id, query: 'needle' })) + await expect(ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) db.exec('PRAGMA query_only = OFF') - await expect(ctx.sessionSearch.searchEvents({ sessionId: live.id, query: 'needle' })) + await expect(ctx.sessionQuery.searchEvents({ sessionId: live.id, query: 'needle' })) .resolves.toMatchObject({ items: [{ seq: 1 }] }) }) }) @@ -931,24 +931,24 @@ describe('SQLite schema, cancellation, and real persistence integration', () => await chmod(directory, 0o755) const ctx = await liveContext({ path }) - await ctx.sessionSearch.searchSessions({ query: 'needle' }) + await ctx.sessionQuery.searchSessions({ query: 'needle' }) expect((await stat(directory)).mode & 0o777).toBe(0o755) expect((await stat(path)).mode & 0o777).toBe(0o600) expect((await stat(`${path}-wal`)).mode & 0o777).toBe(0o600) expect((await stat(`${path}-shm`)).mode & 0o777).toBe(0o600) - await (ctx.sessionSearch as SessionSearchSqlite).close() + await (ctx.sessionQuery as SessionQuerySqlite).close() }) it('creates a persistent rollback journal owner-only', async () => { if (process.platform === 'win32') return const path = await temporaryPath() const ctx = await liveContext({ path, journalMode: 'persist' }) - await ctx.sessionSearch.searchSessions({ query: 'needle' }) + await ctx.sessionQuery.searchSessions({ query: 'needle' }) expect((await stat(path)).mode & 0o777).toBe(0o600) expect((await stat(`${path}-journal`)).mode & 0o777).toBe(0o600) - await (ctx.sessionSearch as SessionSearchSqlite).close() + await (ctx.sessionQuery as SessionQuerySqlite).close() }) it('preserves the mode of an existing database file', async () => { @@ -958,21 +958,21 @@ describe('SQLite schema, cancellation, and real persistence integration', () => await chmod(path, 0o644) const ctx = await liveContext({ path, journalMode: 'delete' }) - await ctx.sessionSearch.searchSessions({ query: 'needle' }) + await ctx.sessionQuery.searchSessions({ query: 'needle' }) expect((await stat(path)).mode & 0o777).toBe(0o644) - await (ctx.sessionSearch as SessionSearchSqlite).close() + await (ctx.sessionQuery as SessionQuerySqlite).close() }) it('surfaces filesystem failures while pre-creating the database', async () => { const path = `${await temporaryPath()}\0` const ctx = await liveContext({ path }) - await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })).rejects.toMatchObject({ + await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })).rejects.toMatchObject({ code: 'SESSION_QUERY_INDEX_FAILED', cause: { code: 'ERR_INVALID_ARG_VALUE' }, }) - await (ctx.sessionSearch as SessionSearchSqlite).close() + await (ctx.sessionQuery as SessionQuerySqlite).close() }) it('resets a recognized incompatible derived schema but refuses a foreign database', async () => { @@ -984,8 +984,8 @@ describe('SQLite schema, cancellation, and real persistence integration', () => stale.close() const staleCtx = await liveContext({ path: stalePath }) staleCtx.sessions.create(SessionId('live'), { seed: messageEvents('needle') }) - await staleCtx.sessionSearch.searchSessions({ query: 'needle' }) - await (staleCtx.sessionSearch as SessionSearchSqlite).close() + await staleCtx.sessionQuery.searchSessions({ query: 'needle' }) + await (staleCtx.sessionQuery as SessionQuerySqlite).close() const rebuilt = new DatabaseSync(stalePath) expect((rebuilt.prepare('PRAGMA user_version').get() as { user_version: number }).user_version) .toBe(SESSION_QUERY_SQLITE_SCHEMA_VERSION) @@ -999,22 +999,22 @@ describe('SQLite schema, cancellation, and real persistence integration', () => foreign.exec("INSERT INTO canonical VALUES ('safe')") foreign.close() const foreignCtx = await liveContext({ path: foreignPath, journalMode: 'delete' }) - await expect(foreignCtx.sessionSearch.searchSessions({ query: 'needle' })) + await expect(foreignCtx.sessionQuery.searchSessions({ query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) 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.sessionSearch as SessionSearchSqlite).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.sessionSearch.searchSessions({ query: 'needle' })) + await expect(otherAppCtx.sessionQuery.searchSessions({ query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) - await (otherAppCtx.sessionSearch as SessionSearchSqlite).close() + await (otherAppCtx.sessionQuery as SessionQuerySqlite).close() }) it('observes asynchronous open rejection even when no query is made', async () => { @@ -1029,7 +1029,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const ctx = await liveContext({ path }) await new Promise((resolve) => { setImmediate(resolve) }) expect(unhandled).toEqual([]) - await (ctx.sessionSearch as SessionSearchSqlite).close() + await (ctx.sessionQuery as SessionQuerySqlite).close() } finally { process.off('unhandledRejection', onUnhandled) } @@ -1041,13 +1041,13 @@ describe('SQLite schema, cancellation, and real persistence integration', () => await ctx.plugin(TestPersistence) const boundaryController = new AbortController() - const boundary = ctx.sessionSearch.searchSessions({ query: 'needle' }, { signal: boundaryController.signal }) + const boundary = ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal: boundaryController.signal }) queueMicrotask(() => { boundaryController.abort() }) await expect(boundary).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) const readyController = new AbortController() readyController.abort() - const internals = ctx.sessionSearch as unknown as { + const internals = ctx.sessionQuery as unknown as { _ensureReady(signal: AbortSignal): Promise } await expect(internals._ensureReady(readyController.signal)) @@ -1061,11 +1061,11 @@ describe('SQLite schema, cancellation, and real persistence integration', () => TestPersistence.listStarted = undefined markBlockingStarted() } - const blocking = ctx.sessionSearch.searchSessions({ query: 'needle' }) + const blocking = ctx.sessionQuery.searchSessions({ query: 'needle' }) await blockingStarted const queuedController = new AbortController() - const queued = ctx.sessionSearch.searchSessions({ query: 'needle' }, { signal: queuedController.signal }) + const queued = ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal: queuedController.signal }) queuedController.abort() await expect(queued).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) @@ -1085,15 +1085,15 @@ describe('SQLite schema, cancellation, and real persistence integration', () => markActiveStarted() } const activeController = new AbortController() - const active = ctx.sessionSearch.searchSessions({ query: 'needle' }, { signal: activeController.signal }) + const active = ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal: activeController.signal }) await activeStarted activeController.abort() await expect(active).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) releaseActive() - const db = (ctx.sessionSearch as unknown as { _db: DatabaseSync })._db + const db = (ctx.sessionQuery as unknown as { _db: DatabaseSync })._db expect(db.prepare('SELECT COUNT(*) AS count FROM persisted_sessions').get()).toEqual({ count: 0 }) - await expect(ctx.sessionSearch.searchSessions({ query: 'needle' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })) .resolves.toMatchObject({ items: [{ header: { id: SessionId('uncommitted') } }] }) }) @@ -1109,7 +1109,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => } const ctx = await liveContext() await ctx.plugin(TestPersistence) - const search = ctx.sessionSearch as SessionSearchSqlite + const search = ctx.sessionQuery as SessionQuerySqlite const accepted = search.searchSessions({ query: 'needle' }) await started const queued = search.searchSessions({ query: 'needle' }) @@ -1130,9 +1130,9 @@ describe('SQLite schema, cancellation, and real persistence integration', () => TestPersistence.reset() const ctx = new Context() await ctx.plugin(SessionStore) - const search = await ctx.plugin(SessionSearchSqlite, { path: ':memory:' }) + const search = await ctx.plugin(SessionQuerySqlite, { path: ':memory:' }) const persistence = await ctx.plugin(TestPersistence) - const optional = (ctx.sessionSearch as unknown as { + const optional = (ctx.sessionQuery as unknown as { _optionalPersistenceFiber: Fiber })._optionalPersistenceFiber let release!: () => void @@ -1154,16 +1154,16 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const ctx = new Context() await ctx.plugin(SessionStore) const persistence = await ctx.plugin(SessionPersistenceSqlite, { path: persistencePath }) - const search = await ctx.plugin(SessionSearchSqlite, { path: searchPath }) + const search = await ctx.plugin(SessionQuerySqlite, { path: searchPath }) const meta = header('real', 10, { cwd: '/work' }) await ctx.sessionPersistence.create(meta) await ctx.sessionPersistence.append(meta.id, messageEvents('real SQLite needle')) - await expect(ctx.sessionSearch.searchSessions({ query: 'SQLite needle' })) + await expect(ctx.sessionQuery.searchSessions({ query: 'SQLite needle' })) .resolves.toMatchObject({ items: [{ header: meta, persisted: true, live: false }] }) - await expect(ctx.sessionSearch.searchEvents({ sessionId: meta.id, query: 'SQLite needle' })) + await expect(ctx.sessionQuery.searchEvents({ sessionId: meta.id, query: 'SQLite needle' })) .resolves.toMatchObject({ items: [{ sessionId: meta.id, seq: 0 }] }) - await expect(ctx.sessionSearch.searchEvents({ sessionId: SessionId('absent'), query: 'needle' })) + await expect(ctx.sessionQuery.searchEvents({ sessionId: SessionId('absent'), query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) await search.dispose() await expect(ctx.sessionPersistence.load(meta.id)).resolves.toMatchObject({ meta, events: [{ seq: 0 }] }) @@ -1182,8 +1182,8 @@ describe('SQLite schema, cancellation, and real persistence integration', () => await first.sessionPersistence.create(shared) await first.sessionPersistence.append(shared.id, messageEvents('alpha source')) const loadA = vi.spyOn(first.sessionPersistence, 'load') - const searchA = await first.plugin(SessionSearchSqlite, { path: searchPath }) - await expect(first.sessionSearch.searchSessions({ query: 'alpha' })) + const searchA = await first.plugin(SessionQuerySqlite, { path: searchPath }) + await expect(first.sessionQuery.searchSessions({ query: 'alpha' })) .resolves.toMatchObject({ items: [{ header: shared }] }) expect(loadA).toHaveBeenCalledTimes(1) await searchA.dispose() @@ -1193,8 +1193,8 @@ describe('SQLite schema, cancellation, and real persistence integration', () => await reopened.plugin(SessionStore) const persistenceAAgain = await reopened.plugin(SessionPersistenceSqlite, { path: persistencePathA }) const reopenedLoad = vi.spyOn(reopened.sessionPersistence, 'load') - const searchAAgain = await reopened.plugin(SessionSearchSqlite, { path: searchPath }) - await expect(reopened.sessionSearch.searchSessions({ query: 'alpha' })) + const searchAAgain = await reopened.plugin(SessionQuerySqlite, { path: searchPath }) + await expect(reopened.sessionQuery.searchSessions({ query: 'alpha' })) .resolves.toMatchObject({ items: [{ header: shared }] }) expect(reopenedLoad).not.toHaveBeenCalled() await searchAAgain.dispose() @@ -1206,10 +1206,10 @@ describe('SQLite schema, cancellation, and real persistence integration', () => await second.sessionPersistence.create(shared) await second.sessionPersistence.append(shared.id, messageEvents('bravo source')) const loadB = vi.spyOn(second.sessionPersistence, 'load') - const searchB = await second.plugin(SessionSearchSqlite, { path: searchPath }) - await expect(second.sessionSearch.searchSessions({ query: 'bravo' })) + const searchB = await second.plugin(SessionQuerySqlite, { path: searchPath }) + await expect(second.sessionQuery.searchSessions({ query: 'bravo' })) .resolves.toMatchObject({ items: [{ header: shared }] }) - await expect(second.sessionSearch.searchSessions({ query: 'alpha' })).resolves.toEqual({ items: [] }) + await expect(second.sessionQuery.searchSessions({ query: 'alpha' })).resolves.toEqual({ items: [] }) expect(loadB).toHaveBeenCalledTimes(1) await searchB.dispose() await persistenceB.dispose() diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index ea807bc595..a83317ecf8 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-session-query -Exact session-history retrieval, relationship tracing, and provider-independent filtering through `ctx.sessionQuery`. The service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`. The abstract `ctx.sessionSearch` service defines full-text search without introducing a provider registry. +`SessionQueryService` is the combined abstract `ctx.sessionQuery` contract. It implements exact session-history retrieval, relationship tracing, and provider-independent filtering over live `ctx.sessions` plus optional dynamically mounted `ctx.sessionPersistence`; concrete backends implement its two full-text methods. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`. ## Reads @@ -22,11 +22,11 @@ Persistence is optional and may mount or unmount dynamically. Cross-corpus listi The text clause is deliberately independent of FTS providers: caller text is escaped into a Unicode, case-insensitive regular expression, and each whitespace run matches one or more whitespace characters. It is a literal semantic-text scan, not a full-text query. `extractSessionEventText()` and `buildSessionEventSearchDocuments()` define the shared first-party document projection; structural boundaries, stream chunks, request headers, and unknown declaration-merged variants produce no document. -## Full-text seam +## Full-text methods -`SessionSearchService` owns the independent `ctx.sessionSearch` key. `searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. Both return pages whose continuation is an owned branded `SessionSearchCursor`, accept optional cancellation, and expose snippets without provider-specific numeric scores. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above. +`SessionQueryService.searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. These are the service's only abstract methods. Both return pages whose continuation is an owned branded `SessionSearchCursor`, accept optional cancellation, and expose snippets without provider-specific numeric scores. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above. -The package has no provider coordinator or registration protocol. A concrete backend owns observation, reconciliation, ranking, cursor generations, and query execution as one lifecycle; the first implementation is [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md). +The package has no provider coordinator, fallback implementation, or standalone concrete plugin. A concrete service backend inherits the implemented reads, filters, and traces while owning full-text observation, reconciliation, ranking, cursor generations, and query execution; the first implementation is [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md). `SessionQueryError.code` is a closed union covering request validation, missing targets, malformed surfaces, source conflicts, persistence/index failures, cancellation, and invalid or stale cursors; the exact literals are defined in [`src/config.ts`](src/config.ts). diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index f51b2eb432..daf4d4e680 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-session-query", - "description": "Live-preferred exact session-history retrieval and tracing service (ctx.sessionQuery)", + "description": "Combined session query service contract with concrete reads, traces, and filters", "version": "0.0.1", "private": true, "type": "module", @@ -40,9 +40,6 @@ "optional": true } }, - "dependencies": { - "schemastery": "^3.18.0" - }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/session-query/session-query/src/config.ts b/packages/session-query/session-query/src/config.ts index 60d6a5a35f..5b7ddffd90 100644 --- a/packages/session-query/session-query/src/config.ts +++ b/packages/session-query/session-query/src/config.ts @@ -1,11 +1,11 @@ -/** Public configuration and typed failures for session-query and search. */ +/** Public configuration and typed failures for the combined session-query service. */ import { HarnessError } from '@deepseek-ai/dsh-llm' /** Default maximum `before`/`after` raw-event window. */ export const SESSION_QUERY_READ_WINDOW_MAX = 50 -/** Configuration for exact session-query reads and traces. */ +/** Backend-independent configuration inherited by every session-query implementation. */ export interface Config { /** Maximum accepted raw read context on either side. Defaults to 50. */ readWindowMax?: number diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 5e136856ac..2028f908c1 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -1,11 +1,10 @@ /** - * Exact session-history reads and traces over live and optionally persisted logs. + * Combined session-history reads, traces, filters, and full-text search seam. * * @module @deepseek-ai/dsh-session-query */ import { Context, Service } from 'cordis' -import z from 'schemastery' import type { SessionId } from '@deepseek-ai/dsh-session' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title' @@ -61,19 +60,32 @@ export { assertSessionHeadersCompatible } from './sources.ts' declare module 'cordis' { interface Context { sessionQuery: SessionQueryService - sessionSearch: SessionSearchService } } /** - * Abstract full-text search service implemented by one concrete backend. + * Unified live-preferred session query service. * - * The implementation owns source observation, reconciliation, cursor - * generations, ranking, and query execution as one lifecycle. + * Exact reads, filters, and traces are backend-independent concrete behavior. + * A backend implements full-text observation, reconciliation, ranking, cursor + * generations, and query execution on the same `ctx.sessionQuery` service. */ -export abstract class SessionSearchService extends Service { - constructor(ctx: Context) { - super(ctx, 'sessionSearch') +export abstract class SessionQueryService extends Service { + static inject = ['sessions'] + + private readonly _readWindowMax: number + private readonly _corpus: SessionCorpus + + constructor(ctx: Context, config: Config = {}) { + super(ctx, 'sessionQuery') + this._readWindowMax = config.readWindowMax ?? SESSION_QUERY_READ_WINDOW_MAX + if (!Number.isInteger(this._readWindowMax) || this._readWindowMax < 0) { + throw new SessionQueryError( + 'session-query: readWindowMax must be a non-negative integer', + 'SESSION_QUERY_INVALID_CONFIG', + ) + } + this._corpus = new SessionCorpus(ctx) } /** @@ -97,29 +109,6 @@ export abstract class SessionSearchService extends Service { request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise> -} - -/** Live-preferred logical-corpus read, filtering, and relationship-tracing service. */ -export class SessionQueryService extends Service { - static inject = ['sessions'] - static Config: z = z.object({ - readWindowMax: z.number().step(1).min(0).default(SESSION_QUERY_READ_WINDOW_MAX), - }) - - private readonly _readWindowMax: number - private readonly _corpus: SessionCorpus - - constructor(ctx: Context, config: Config = {}) { - super(ctx, 'sessionQuery') - this._readWindowMax = config.readWindowMax ?? SESSION_QUERY_READ_WINDOW_MAX - if (!Number.isInteger(this._readWindowMax) || this._readWindowMax < 0) { - throw new SessionQueryError( - 'session-query: readWindowMax must be a non-negative integer', - 'SESSION_QUERY_INVALID_CONFIG', - ) - } - this._corpus = new SessionCorpus(ctx) - } /** * List the complete logical corpus using live-preferred records. diff --git a/packages/session-query/session-query/tests/search-helpers.spec.ts b/packages/session-query/session-query/tests/search-helpers.spec.ts index 1617b6b88f..327048b8c1 100644 --- a/packages/session-query/session-query/tests/search-helpers.spec.ts +++ b/packages/session-query/session-query/tests/search-helpers.spec.ts @@ -3,7 +3,7 @@ import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' -import SessionQueryService, { +import { buildSessionEventRecords, buildSessionEventSearchDocuments, compileSessionTextFilter, @@ -12,15 +12,9 @@ import SessionQueryService, { filterSessionResults, materializeSessionEventResultFilters, materializeSessionResultFilters, - SessionSearchService, - type SessionEventSearchHit, - type SessionEventSearchRequest, type SessionQueryErrorCode, - type SessionSearchExecContext, - type SessionSearchHit, - type SessionSearchPage, - type SessionSearchRequest, } from '@deepseek-ai/dsh-session-query' +import { TestSessionQueryService } from './test-service.ts' const id = SessionId('session') @@ -203,10 +197,10 @@ describe('session-query document and filter helpers', () => { .toThrow(expectCode('SESSION_QUERY_INVALID_FILTER')) }) - it('exposes the scan path on the concrete exact-read service', async () => { + it('exposes the scan path on the combined query service', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(SessionQueryService) + await ctx.plugin(TestSessionQueryService) const session = ctx.sessions.create(id) session.append('user/message', { content: [{ type: 'text', text: 'Alpha\n beta' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('user/message', { content: [{ type: 'text', text: 'other' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) @@ -215,21 +209,12 @@ describe('session-query document and filter helpers', () => { }) }) -class TestSearchService extends SessionSearchService { - searchSessions(_request: SessionSearchRequest, _exec?: SessionSearchExecContext): Promise> { - return Promise.resolve({ items: [] }) - } - - searchEvents(_request: SessionEventSearchRequest, _exec?: SessionSearchExecContext): Promise> { - return Promise.resolve({ items: [] }) - } -} - -it('registers the abstract search seam under its independent ctx key', async () => { +it('registers exact and abstract search behavior under one ctx key', async () => { const ctx = new Context() - const fiber = await ctx.plugin(TestSearchService) - await expect(ctx.sessionSearch.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] }) - await expect(ctx.sessionSearch.searchEvents({ sessionId: id, query: 'AI' })).resolves.toEqual({ items: [] }) + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(TestSessionQueryService) + await expect(ctx.sessionQuery.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] }) + await expect(ctx.sessionQuery.searchEvents({ sessionId: id, query: 'AI' })).resolves.toEqual({ items: [] }) await fiber.dispose() - expect(ctx.sessionSearch).toBeUndefined() + expect(ctx.sessionQuery).toBeUndefined() }) diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 0aa853a49e..98ca9a7863 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -8,6 +8,7 @@ import SessionQueryService, { type SessionQueryErrorCode, } from '@deepseek-ai/dsh-session-query' import { SessionTitleProviderId } from '@deepseek-ai/dsh-session-title' +import { TestSessionQueryService } from './test-service.ts' function header(id: string, createdAt = 1, extra: Partial = {}): SessionHeader { return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra } @@ -75,10 +76,10 @@ class TestPersistence extends SessionPersistence { } } -async function liveContext(config: ConstructorParameters[1] = {}): Promise { +async function liveContext(config: ConstructorParameters[1] = {}): Promise { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(SessionQueryService, config) + await ctx.plugin(TestSessionQueryService, config) return ctx } @@ -413,18 +414,18 @@ describe('session-query exact reads', () => { const direct = new Context() await direct.plugin(SessionStore) - expect(new SessionQueryService(direct)).toBeInstanceOf(SessionQueryService) + expect(new TestSessionQueryService(direct)).toBeInstanceOf(SessionQueryService) const invalid = new Context() await invalid.plugin(SessionStore) - expect(() => new SessionQueryService(invalid, { readWindowMax: -1 })) + expect(() => new TestSessionQueryService(invalid, { readWindowMax: -1 })) .toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG')) }) it('leaves the optional persistence dependency optional', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(SessionQueryService) - expect(ctx.sessionQuery).toBeInstanceOf(SessionQueryService) + const fiber = await ctx.plugin(TestSessionQueryService) + expect(ctx.sessionQuery).toBeInstanceOf(TestSessionQueryService) await fiber.dispose() expect(ctx.sessionQuery).toBeUndefined() }) @@ -433,7 +434,7 @@ describe('session-query exact reads', () => { TestPersistence.reset() const ctx = new Context() await ctx.plugin(SessionStore) - const query = await ctx.plugin(SessionQueryService) + const query = await ctx.plugin(TestSessionQueryService) const persistence = await ctx.plugin(TestPersistence) const optional = (ctx.sessionQuery as unknown as { _corpus: { _optionalPersistenceFiber: Fiber } diff --git a/packages/session-query/session-query/tests/test-service.ts b/packages/session-query/session-query/tests/test-service.ts new file mode 100644 index 0000000000..e37b0f71ff --- /dev/null +++ b/packages/session-query/session-query/tests/test-service.ts @@ -0,0 +1,26 @@ +import SessionQueryService from '@deepseek-ai/dsh-session-query' +import type { + SessionEventSearchHit, + SessionEventSearchRequest, + SessionSearchExecContext, + SessionSearchHit, + SessionSearchPage, + SessionSearchRequest, +} from '@deepseek-ai/dsh-session-query' + +/** Test-only concrete query service for backend-independent behavior. */ +export class TestSessionQueryService extends SessionQueryService { + override searchSessions( + _request: SessionSearchRequest, + _exec?: SessionSearchExecContext, + ): Promise> { + return Promise.resolve({ items: [] }) + } + + override searchEvents( + _request: SessionEventSearchRequest, + _exec?: SessionSearchExecContext, + ): Promise> { + return Promise.resolve({ items: [] }) + } +} diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index 0b096ab31c..dc7aa41641 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -3,7 +3,8 @@ import { Context } from 'cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import SessionPersistence from '@deepseek-ai/dsh-session-persistence' -import SessionQueryService, { type SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query' +import { type SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query' +import { TestSessionQueryService } from './test-service.ts' type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] } @@ -84,7 +85,7 @@ class TracePersistence extends SessionPersistence { async function queryContext(): Promise { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(SessionQueryService) + await ctx.plugin(TestSessionQueryService) return ctx } diff --git a/packages/session-query/session-query/tsconfig.json b/packages/session-query/session-query/tsconfig.json index a8e3e1a1f8..0f17353fee 100644 --- a/packages/session-query/session-query/tsconfig.json +++ b/packages/session-query/session-query/tsconfig.json @@ -14,9 +14,6 @@ { "path": "../../../vendor/cordis" }, - { - "path": "../../../vendor/schemastery" - }, { "path": "../../util/brand" }, diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts index 40e1f45b34..fa7700c5f3 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -37,6 +37,20 @@ import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as AcpPlugin from '../src/index.ts' import { type AcpConfig } from '../src/index.ts' +class TestSessionQueryService extends SessionQueryService { + override searchSessions( + ..._args: Parameters + ): ReturnType { + return Promise.resolve({ items: [] }) + } + + override searchEvents( + ..._args: Parameters + ): ReturnType { + return Promise.resolve({ items: [] }) + } +} + /** A scripted mock adapter (mirrors the agent-loop test adapter). */ class MockAdapter extends LlmAdapter { requests: GenerateOptions[] = [] @@ -221,7 +235,7 @@ export async function makeBridgeHarness(options: { await ctx.plugin(CommandService) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir }) - await ctx.plugin(SessionQueryService) + await ctx.plugin(TestSessionQueryService) if (options.withSessionReferences) { await ctx.plugin(SessionReferenceService) } diff --git a/packages/ui/tui/tests/session-query.ts b/packages/ui/tui/tests/session-query.ts new file mode 100644 index 0000000000..d9083ad6d1 --- /dev/null +++ b/packages/ui/tui/tests/session-query.ts @@ -0,0 +1,16 @@ +import SessionQueryService from '@deepseek-ai/dsh-session-query' + +/** Test-only backend-independent query service. */ +export class TestSessionQueryService extends SessionQueryService { + override searchSessions( + ..._args: Parameters + ): ReturnType { + return Promise.resolve({ items: [] }) + } + + override searchEvents( + ..._args: Parameters + ): ReturnType { + return Promise.resolve({ items: [] }) + } +} diff --git a/packages/ui/tui/tests/session-reference.snapshot.ts b/packages/ui/tui/tests/session-reference.snapshot.ts index 4fecad3b86..bfbc98b590 100644 --- a/packages/ui/tui/tests/session-reference.snapshot.ts +++ b/packages/ui/tui/tests/session-reference.snapshot.ts @@ -11,10 +11,10 @@ import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import CommandService from '@deepseek-ai/dsh-commands' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import SessionQueryService from '@deepseek-ai/dsh-session-query' import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference' import { createTuiChat } from '../src/index.ts' import { HeadlessTerminal } from './headless-terminal.ts' +import { TestSessionQueryService } from './session-query.ts' const EXPECTED = join(dirname(fileURLToPath(import.meta.url)), 'snapshots/session-reference.expected.txt') const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh' @@ -57,7 +57,7 @@ describe('TUI session-reference snapshot', () => { await ctx.plugin(CommandService) await ctx.plugin(UserInteractionService) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SessionQueryService) + await ctx.plugin(TestSessionQueryService) await ctx.plugin(SessionReferenceService) const adapter = new SnapshotAdapter() diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 22a50c9ccf..0ca0290038 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -11,7 +11,6 @@ import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek import type {} from '@deepseek-ai/dsh-session-title' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import SessionQueryService from '@deepseek-ai/dsh-session-query' import SessionReferenceService, { formatSessionReferenceMention } from '@deepseek-ai/dsh-session-reference' import type {} from '@deepseek-ai/dsh-llm-retry' import { @@ -28,6 +27,7 @@ import { disposeTuiTestHarness, type TuiHarnessOptions, } from './harness.ts' +import { TestSessionQueryService } from './session-query.ts' class FakeTerminal implements Terminal { columns = 88 @@ -1012,7 +1012,7 @@ describe('pi-tui chat lifecycle and transcript', () => { const result = await setup({ async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) - await ctx.plugin(SessionQueryService) + await ctx.plugin(TestSessionQueryService) await ctx.plugin(SessionReferenceService) const source = ctx.sessions.create(SessionId('source-session'), { meta: { cwd: process.cwd(), createdAt: 1 } }) sourceId = source.id @@ -1062,7 +1062,7 @@ describe('pi-tui chat lifecycle and transcript', () => { const result = await setup({ async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) - await ctx.plugin(SessionQueryService) + await ctx.plugin(TestSessionQueryService) await ctx.plugin(SessionReferenceService) const source = ctx.sessions.create(unsafeId, { meta: { cwd: unsafeCwd, createdAt: 1 } }) appendUser(source, 'safe background') @@ -1094,7 +1094,7 @@ describe('pi-tui chat lifecycle and transcript', () => { const result = await setup({ async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) - await ctx.plugin(SessionQueryService) + await ctx.plugin(TestSessionQueryService) await ctx.plugin(SessionReferenceService) }, }) @@ -1159,7 +1159,7 @@ describe('pi-tui chat lifecycle and transcript', () => { const result = await setup({ async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) - await ctx.plugin(SessionQueryService) + await ctx.plugin(TestSessionQueryService) await ctx.plugin(SessionReferenceService) }, }) @@ -1279,7 +1279,7 @@ describe('pi-tui chat lifecycle and transcript', () => { const result = await setup({ async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) - await ctx.plugin(SessionQueryService) + await ctx.plugin(TestSessionQueryService) await ctx.plugin(SessionReferenceService) ctx.sessions.create(SessionId('source')) }, @@ -1329,7 +1329,7 @@ describe('pi-tui chat lifecycle and transcript', () => { const lateSuccess = await setup({ async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) - await ctx.plugin(SessionQueryService) + await ctx.plugin(TestSessionQueryService) await ctx.plugin(SessionReferenceService) ctx.sessions.create(SessionId('source')) }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 439b7b3f16..0e8b8ca8c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -276,6 +276,12 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:* version: link:../packages/session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-query': + specifier: workspace:* + version: link:../packages/session-query/session-query + '@deepseek-ai/dsh-session-query-sqlite': + specifier: workspace:* + version: link:../packages/session-query/session-query-sqlite '@deepseek-ai/dsh-session-title-first-message-llm': specifier: workspace:* version: link:../packages/session-title/session-title-first-message-llm @@ -1220,6 +1226,9 @@ importers: '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query + '@deepseek-ai/dsh-session-query-sqlite': + specifier: workspace:^ + version: link:../../session-query/session-query-sqlite '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../context/session-reference @@ -1447,6 +1456,9 @@ importers: '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query + '@deepseek-ai/dsh-session-query-sqlite': + specifier: workspace:^ + version: link:../../session-query/session-query-sqlite '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../context/session-reference @@ -2729,10 +2741,6 @@ importers: version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) packages/session-query/session-query: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 devDependencies: '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -4282,6 +4290,9 @@ importers: '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../packages/session-query/session-query + '@deepseek-ai/dsh-session-query-sqlite': + specifier: workspace:^ + version: link:../../packages/session-query/session-query-sqlite '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../packages/context/session-reference diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index dbafb21881..8a8d31c815 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -53,6 +53,7 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 0afc63c0a6..e26d951017 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -135,18 +135,11 @@ const SERVICE_ROLES: ServiceRole[] = [ { key: 'sessionQuery', pkg: 'session-query', - title: 'Exact session-history reads and traces', - mode: 'seam', - consumers: ['session-reference'], - note: 'Resolves live and optional persisted logs into one logical corpus for exact reads, semantic scans, and relationship traces.', - }, - { - key: 'sessionSearch', - pkg: 'session-query', - title: 'Full-text session search', + title: 'Session reads, traces, filters, and search', mode: 'seam', implementations: ['session-query-sqlite'], - note: 'The concrete backend owns source reconciliation, ranking, snippets, and cursor generations as one lifecycle.', + consumers: ['session-reference'], + note: 'The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations on the same service.', }, { key: 'sessionReferences', From 1c6d26c44dcb744147b847cb801cff86d78f972c Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 23 Jul 2026 20:32:51 +0800 Subject: [PATCH 16/19] 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) } From d24a875c5d17e63ef81bc2f2d9fe554fa73ce786 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 23 Jul 2026 20:50:29 +0800 Subject: [PATCH 17/19] fix(session-query): protect live reconciliation --- ...026-07-10-sqlite-session-query-provider.md | 2 +- .../session-query-sqlite/README.md | 2 +- .../session-query-sqlite/src/index.ts | 51 ++++++++---- .../session-query-sqlite/src/schema.ts | 24 +++++- .../session-query-sqlite/tests/sqlite.spec.ts | 83 +++++++++++++++++-- 5 files changed, 138 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md index 7cfef47d1d..ff57904358 100644 --- a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md +++ b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md @@ -32,7 +32,7 @@ Both persistent and live FTS5 tables use `unicode61`. The implementation experim The shared extractor includes message text, reasoning, nested tool-call/result content, tool names and arguments, blocked-prompt reasons, todo status/content, and error or terminal status detail. Structural boundaries, stream chunks, request headers, successful completion markers, and unknown declaration-merged event/content variants produce no document. Surface classification reuses `foldSurface()` so search agrees with model-history derivation. -One serialized operation reads the provider-neutral `SessionPersistence` snapshot listing, compares each source-qualified opaque revision with the revision stored beside the indexed session, loads only new or changed logs, reconciles rows in one transaction, and executes the query. A revision identifies its backing persistence store as well as the backend-local log revision, so reopening against the same store reuses indexed rows while switching to an independent store cannot collide on a session id and local counter. Observation repeats when listing changes during a load; this incorporates a mutating load repair's refreshed revision before commit. Repeated queries and unchanged reopen load no full persisted logs. New, changed, and deleted sessions update on the next stable search. A source or extraction failure cannot mark a row current, and a transaction failure rolls back so a later search retries. +One serialized operation reads the provider-neutral `SessionPersistence` snapshot listing, compares each source-qualified opaque revision with the revision stored beside the indexed session, loads only new or changed logs, reconciles rows in one transaction, and executes the query. It never calls the backend's mutating `load()` for an id currently owned by `ctx.sessions`; the TEMP overlay records persisted availability, and the durable base refreshes after the live owner detaches. A revision identifies its backing persistence store as well as the backend-local log revision, so reopening against the same store reuses indexed rows while switching to an independent store cannot collide on a session id and local counter. Observation repeats when listing changes during a load; this incorporates a mutating load repair's refreshed revision before commit. Repeated queries and unchanged reopen load no full persisted logs. New, changed, and deleted sessions update on the next stable search. A source or extraction failure cannot mark a row current, and a transaction failure rolls back so a later search retries. Persisted documents survive restarts. Live sessions use connection-local TEMP tables, shadow the persisted base for the same id, and reveal that base on detach. Closing the database drops live rows. Unmounting persistence hides durable rows without treating absence as authoritative deletion; remounting observes and reconciles the backend again. Conflicting immutable live and durable headers fail rather than combining sources. diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index b3b25ae2c8..af37ff74c8 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -12,7 +12,7 @@ All three surfaces (`current`, `shadowed`, and `log-only`) are searchable by def ## Source and index lifecycle -The service requires `ctx.sessions` and observes optional `ctx.sessionPersistence` dynamically. One serialized state machine compares source-qualified lightweight durable snapshot revisions, loads only new or changed logs, extracts shared semantic documents, reconciles changes transactionally, and runs the query. Repeated queries and unchanged same-store reopen load no full durable logs; switching stores, or observing new, changed, deleted, or load-repaired sources, reconciles on the next stable observation. Source or transaction failure commits nothing, and the next search retries. +The service requires `ctx.sessions` and observes optional `ctx.sessionPersistence` dynamically. One serialized state machine compares source-qualified lightweight durable snapshot revisions, loads only new or changed logs, extracts shared semantic documents, reconciles changes transactionally, and runs the query. It never invokes the persistence backend's mutating `load()` for an id currently owned by `ctx.sessions`; the TEMP live row still records persisted availability, and the durable base refreshes after that live owner detaches. Repeated queries and unchanged same-store reopen load no full durable logs; switching stores, or observing new, changed, deleted, or load-repaired sources, reconciles on the next stable observation. Source or transaction failure commits nothing, and the next search retries. Persisted FTS rows live in a dedicated derived database. Connection-local TEMP tables hold live rows, which shadow the durable base for the same session and reveal it when the live owner disappears. Unmounting persistence hides durable rows without discarding the cache; remounting reconciles it. Closing or reopening the database drops every live overlay while retaining persisted rows. diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 290279579b..049999d2e7 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -129,6 +129,7 @@ interface IndexedPersistedRow { interface IndexedLiveRow { id: string fingerprint: string + persisted: number generation: number } @@ -338,7 +339,7 @@ export class SessionQuerySqlite extends SessionQueryService { 'SELECT id, revision, generation FROM persisted_sessions', ).all() as unknown as IndexedPersistedRow[] const liveRows = db.prepare( - 'SELECT id, fingerprint, generation FROM temp.live_sessions', + 'SELECT id, fingerprint, persisted, generation FROM temp.live_sessions', ).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])) @@ -350,7 +351,11 @@ export class SessionQuerySqlite extends SessionQueryService { const persistentDeletes = observation.persistenceBinding.service === undefined ? [] : persistedRows.filter(row => !observation.persisted.has(row.id as SessionId)) - const liveChanges = [...observation.live.values()].filter(entry => liveById.get(entry.header.id)?.fingerprint !== entry.fingerprint) + const liveChanges = [...observation.live.values()].filter((entry) => { + const indexed = liveById.get(entry.header.id) + const persisted = observation.persisted.has(entry.header.id) ? 1 : 0 + return indexed?.fingerprint !== entry.fingerprint || indexed.persisted !== persisted + }) const liveDeletes = liveRows.filter(row => !observation.live.has(row.id as SessionId)) const pointerChanged = this._lastPersistenceIdentity !== undefined && this._lastPersistenceIdentity !== observation.persistenceBinding.identity @@ -364,7 +369,11 @@ export class SessionQuerySqlite extends SessionQueryService { if (persistentChanges.length > 0 || persistentDeletes.length > 0) nextMainGeneration += 1 const liveReplacements = liveChanges.map((entry) => { nextLocalGeneration = Math.max(nextLocalGeneration, nextMainGeneration) + 1 - return { entry, generation: nextLocalGeneration } + return { + entry, + generation: nextLocalGeneration, + persisted: observation.persisted.has(entry.header.id), + } }) if (hasWrites) { @@ -382,8 +391,8 @@ export class SessionQuerySqlite extends SessionQueryService { 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._replaceLiveSession(entry, generation) + for (const { entry, generation, persisted } of liveReplacements) { + this._replaceLiveSession(entry, generation, persisted) } db.exec('COMMIT') } catch (error: unknown) { @@ -419,6 +428,7 @@ export class SessionQuerySqlite extends SessionQueryService { assertNotAborted(signal) const persistenceBinding = this._persistenceBinding const persistence = persistenceBinding.service + const initiallyLive = new Set(this.ctx.sessions.list().map(session => session.id)) let persisted = new Map() if (persistence !== undefined) { try { @@ -428,6 +438,10 @@ export class SessionQuerySqlite extends SessionQueryService { persisted = materializePersistenceSnapshots(before) for (const entry of persisted.values()) { if (canReuseIndexed && indexed.get(entry.header.id)?.revision === entry.revision) continue + // `load()` may durably repair an interrupted tail. Never invoke it + // for a session currently owned by the live store: a checkpointed + // open turn is active, not crash-interrupted. + if (initiallyLive.has(entry.header.id) || this.ctx.sessions.get(entry.header.id) !== undefined) continue const loaded = await waitWithAbort(persistence.load(entry.header.id), signal) assertSessionHeadersCompatible(entry.header, loaded.meta) entry.loaded = observeSession(loaded.meta, loaded.events) @@ -459,9 +473,8 @@ export class SessionQuerySqlite extends SessionQueryService { if (durable !== undefined) assertSessionHeadersCompatible(observed.header, durable.header) live.set(session.id, observed) } - if (this._persistenceBinding === persistenceBinding) { - return { persistenceBinding, persisted, live } - } + if (!sameSessionIds(initiallyLive, live)) continue + return { persistenceBinding, persisted, live } } throw new SessionQueryError( 'session-search persistence observation did not stabilize after one retry', @@ -527,13 +540,13 @@ export class SessionQuerySqlite extends SessionQueryService { } } - private _replaceLiveSession(entry: ObservedSession, generation: number): void { + private _replaceLiveSession(entry: ObservedSession, generation: number, persisted: boolean): 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, delegation_depth, fingerprint, generation) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, fingerprint, persisted, generation) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( entry.header.id, entry.header.version, @@ -543,6 +556,7 @@ export class SessionQuerySqlite extends SessionQueryService { entry.header.seedLength ?? null, entry.header.delegationDepth ?? null, entry.fingerprint, + persisted ? 1 : 0, generation, ) const insert = db.prepare(` @@ -709,9 +723,7 @@ function selectedDocumentsSql(): { sql: string } { ls.seed_length AS seed_length, ls.delegation_depth AS delegation_depth, 1 AS live, - CASE WHEN ? = 1 AND EXISTS ( - SELECT 1 FROM persisted_sessions AS ps WHERE ps.id = ld.session_id - ) THEN 1 ELSE 0 END AS persisted, + CASE WHEN ? = 1 THEN ls.persisted ELSE 0 END AS persisted, CAST(ld.seq AS INTEGER) AS seq, ld.type AS type, CAST(ld.time AS INTEGER) AS time, @@ -799,6 +811,17 @@ function samePersistenceSnapshots( return true } +function sameSessionIds( + before: ReadonlySet, + after: ReadonlyMap, +): boolean { + if (before.size !== after.size) return false + for (const id of before) { + if (!after.has(id)) return false + } + return true +} + function sameHeader(a: SessionHeader, b: SessionHeader): boolean { return a.version === b.version && a.id === b.id diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts index 56f873d8bc..045c84d960 100644 --- a/packages/session-query/session-query-sqlite/src/schema.ts +++ b/packages/session-query/session-query-sqlite/src/schema.ts @@ -13,6 +13,17 @@ export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851 /** Supported SQLite journal modes. */ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' +const DERIVED_USER_TABLES = new Set([ + 'search_state', + 'persisted_sessions', + 'persisted_docs', + 'persisted_docs_data', + 'persisted_docs_idx', + 'persisted_docs_content', + 'persisted_docs_docsize', + 'persisted_docs_config', +]) + /** * Exclusively create a missing database file with owner-only permissions. * Existing files retain their modes, and errors other than `EEXIST` propagate. @@ -50,7 +61,7 @@ export async function openSearchDatabase(path: string, journalMode: JournalMode) throw new Error(`session-search database at "${actual}" is not an empty or recognized derived index`) } if (applicationId === SESSION_QUERY_SQLITE_APPLICATION_ID && version !== SESSION_QUERY_SQLITE_SCHEMA_VERSION) { - resetDerivedSchema(db) + resetDerivedSchema(db, actual, userTables) } // Apply mutating pragmas only after refusing foreign or canonical files. // journalMode is a validated closed union, not caller-controlled SQL. @@ -71,8 +82,14 @@ function listUserTables(db: DatabaseSync): string[] { return rows.map(row => row.name) } -function resetDerivedSchema(db: DatabaseSync): void { - for (const name of listUserTables(db)) { +function resetDerivedSchema(db: DatabaseSync, path: string, userTables: readonly string[]): void { + const unknownTables = userTables.filter(name => !DERIVED_USER_TABLES.has(name)) + if (unknownTables.length > 0) { + throw new Error( + `session-search database at "${path}" has unrecognized user tables: ${unknownTables.join(', ')}`, + ) + } + for (const name of userTables) { db.exec(`DROP TABLE IF EXISTS ${quoteIdentifier(name)}`) } db.exec('PRAGMA user_version = 0') @@ -126,6 +143,7 @@ function ensureTemporarySchema(db: DatabaseSync): void { seed_length INTEGER, delegation_depth INTEGER, fingerprint TEXT NOT NULL, + persisted INTEGER NOT NULL CHECK (persisted IN (0, 1)), generation INTEGER NOT NULL ) STRICT `) 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 de7deb5efc..2e5aebfeeb 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -10,7 +10,6 @@ import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh import type { SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' import SessionQuerySqlite, { - SESSION_QUERY_SQLITE_APPLICATION_ID, SESSION_QUERY_SQLITE_SCHEMA_VERSION, } from '@deepseek-ai/dsh-session-query-sqlite' import { @@ -584,6 +583,63 @@ describe('SQLite reconciliation and source lifecycle', () => { .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) }) + it('does not load a persisted log while the same session is live', async () => { + const shared = header('checkpointed-live', 10) + TestPersistence.reset([{ meta: shared, events: messageEvents('persisted needle') }]) + const ctx = await liveContext() + const live = ctx.sessions.prepare(shared.id, { + seed: messageEvents('live needle'), + meta: { createdAt: shared.createdAt }, + }) + const detach = ctx.sessions.enter(live) + ctx.sessions.announce(live) + const persistence = await ctx.plugin(TestPersistence) + + await expect(ctx.sessionQuery.searchSessions({ + query: 'live', + sessionFilters: [{ kind: 'availability', values: ['persisted'] }], + })).resolves.toMatchObject({ + items: [{ header: shared, live: true, persisted: true }], + }) + expect(TestPersistence.loads.get(shared.id)).toBeUndefined() + + detach() + await expect(ctx.sessionQuery.searchSessions({ query: 'persisted' })) + .resolves.toMatchObject({ items: [{ header: shared, live: false, persisted: true }] }) + expect(TestPersistence.loads.get(shared.id)).toBe(1) + await persistence.dispose() + }) + + it('retries when a live owner attaches during persistence observation', async () => { + TestPersistence.reset() + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + TestPersistence.snapshotEffect = () => { + TestPersistence.snapshotEffect = undefined + ctx.sessions.create(SessionId('attached'), { seed: messageEvents('attached needle') }) + } + + await expect(ctx.sessionQuery.searchSessions({ query: 'attached' })) + .resolves.toMatchObject({ items: [{ header: { id: SessionId('attached') } }] }) + }) + + it('retries when one live owner replaces another during persistence observation', async () => { + TestPersistence.reset() + const ctx = await liveContext() + const first = ctx.sessions.prepare(SessionId('first'), { seed: messageEvents('first needle') }) + const detachFirst = ctx.sessions.enter(first) + ctx.sessions.announce(first) + await ctx.plugin(TestPersistence) + TestPersistence.snapshotEffect = () => { + TestPersistence.snapshotEffect = undefined + detachFirst() + ctx.sessions.create(SessionId('second'), { seed: messageEvents('second needle') }) + } + + await expect(ctx.sessionQuery.searchSessions({ query: 'second' })) + .resolves.toMatchObject({ items: [{ header: { id: SessionId('second') } }] }) + }) + it('uses the reconciled persistence binding through the query boundary', async () => { const durable = header('post-reconcile-unmount') TestPersistence.reset([{ meta: durable, events: [ @@ -976,12 +1032,12 @@ describe('SQLite schema, cancellation, and real persistence integration', () => expect(ctx.sessionQuery).toBeUndefined() }) - it('resets a recognized incompatible derived schema but refuses a foreign database', async () => { + it('resets a recognized incompatible schema but refuses unknown or foreign tables', async () => { const stalePath = await temporaryPath('stale.db') + const staleOwner = await liveContext({ path: stalePath }) + await (staleOwner.sessionQuery as SessionQuerySqlite).close() const stale = new DatabaseSync(stalePath) - stale.exec(`PRAGMA application_id = ${SESSION_QUERY_SQLITE_APPLICATION_ID}`) stale.exec('PRAGMA user_version = 999') - stale.exec('CREATE TABLE stale(value TEXT)') stale.close() const staleCtx = await liveContext({ path: stalePath }) staleCtx.sessions.create(SessionId('live'), { seed: messageEvents('needle') }) @@ -990,9 +1046,26 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const rebuilt = new DatabaseSync(stalePath) expect((rebuilt.prepare('PRAGMA user_version').get() as { user_version: number }).user_version) .toBe(SESSION_QUERY_SQLITE_SCHEMA_VERSION) - expect(rebuilt.prepare("SELECT name FROM sqlite_master WHERE name = 'stale'").get()).toBeUndefined() rebuilt.close() + const augmentedPath = await temporaryPath('augmented.db') + const augmentedOwner = await liveContext({ path: augmentedPath }) + await (augmentedOwner.sessionQuery as SessionQuerySqlite).close() + const augmented = new DatabaseSync(augmentedPath) + augmented.exec('CREATE TABLE unrelated(value TEXT)') + augmented.exec("INSERT INTO unrelated VALUES ('safe')") + augmented.exec('PRAGMA user_version = 999') + augmented.close() + const augmentedCtx = new Context() + await augmentedCtx.plugin(SessionStore) + await expect(augmentedCtx.plugin(SessionQuerySqlite, { path: augmentedPath })) + .rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + expect(augmentedCtx.sessionQuery).toBeUndefined() + const stillAugmented = new DatabaseSync(augmentedPath) + expect(stillAugmented.prepare('SELECT value FROM unrelated').get()).toEqual({ value: 'safe' }) + expect(stillAugmented.prepare('PRAGMA user_version').get()).toEqual({ user_version: 999 }) + stillAugmented.close() + const foreignPath = await temporaryPath('foreign.db') const foreign = new DatabaseSync(foreignPath) foreign.exec('PRAGMA journal_mode = WAL') From 9482affc4821fbdce4916a87c16339330d358350 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 23 Jul 2026 21:33:28 +0800 Subject: [PATCH 18/19] fix(session-query): make persisted observation non-mutating --- ...18-shared-persistence-write-coordinator.md | 10 +- docs/cordis-catalog/services.md | 10 ++ docs/core-data-structures/persistence.md | 6 +- .../cordis/tool-cordis/src/api-catalog.ts | 4 + .../tests/session-checkpoint-policy.spec.ts | 3 + .../session-persistence-jsonl/README.md | 1 + .../session-persistence-jsonl/src/index.ts | 4 + .../session-persistence-sqlite/README.md | 1 + .../session-persistence-sqlite/src/index.ts | 4 + .../session-persistence/README.md | 5 +- .../session-persistence/src/coordinator.ts | 22 ++++ .../session-persistence/src/index.ts | 10 ++ .../session-persistence/tests/contract.ts | 9 ++ .../tests/coordinator-contract.ts | 3 +- .../tests/persistence.spec.ts | 4 + .../session-query-sqlite/README.md | 4 +- .../session-query-sqlite/src/index.ts | 9 +- .../session-query-sqlite/src/schema.ts | 10 +- .../session-query-sqlite/tests/sqlite.spec.ts | 106 ++++++++++++++---- .../session-query/session-query/src/corpus.ts | 8 +- .../session-query/tests/session-query.spec.ts | 39 +++++-- .../session-query/tests/tracing.spec.ts | 30 ++--- 22 files changed, 239 insertions(+), 63 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index c349e8098a..4b734a8e3e 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -8,9 +8,9 @@ Status: implemented ## Decision -Extract a backend-agnostic `PersistenceCoordinator` into `dsh-session-persistence`. The coordinator owns the orchestration once; each first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements a small `PersistenceBackend` hook interface, and delegates its four public service methods (`create`/`append`/`load`/`list`) to it. +Extract a backend-agnostic `PersistenceCoordinator` into `dsh-session-persistence`. The coordinator owns the orchestration once; each first-party backend composes one (`new PersistenceCoordinator(ctx, this)`), implements a small `PersistenceBackend` hook interface, and delegates its stateful public methods (`create`/`append`/`load`/`inspect`) to it. Backend-owned metadata and revision listing bypass the coordinator. -Composition, not inheritance. The coordinator is a concrete class the backend holds, not a base class the backend extends. The Agent Note's risk — "a coordinator must not make unusual backends fight an inheritance hierarchy" — is avoided: a backend exposes only the hooks; it cannot reach the coordinator's private orchestration state, and the public `SessionPersistence` service shape is unchanged, so a third-party backend MAY still implement the abstract service directly without the coordinator at all. +Composition, not inheritance. The coordinator is a concrete class the backend holds, not a base class the backend extends. The Agent Note's risk — "a coordinator must not make unusual backends fight an inheritance hierarchy" — is avoided: a backend exposes only the hooks and cannot reach the coordinator's private orchestration state. A third-party backend MAY still implement the abstract service directly without the coordinator, including the non-mutating `inspect` contract used by read models. The coordinator retires each live session from its `session/disposed` notification: it waits for that exact Session object's initialization, serializes a final drain, and then removes the owned state, buffer, and init entries. Failed drains retain their buffers for backend teardown to retry. Settled per-id chain tails remove themselves only when they are still the current tail, so a completion cannot erase a newer operation for the same id. Backend teardown unregisters the write-path listeners before awaiting all admitted retirements, remaining buffers, and chains, then closes the backend. @@ -19,7 +19,7 @@ The coordinator retires each live session from its `session/disposed` notificati Five required members plus an optional lifecycle hook form the only boundary between the coordinator and storage: - `name` — backend label for the dispose-failure `AggregateError`. -- `loadStored(id)` — read one stored prefix by id across every storage scope (every JSONL cwd bucket; SQLite's id is globally unique). Resume/load, live adoption, and the create-collision probe share this lookup. The coordinator asserts the returned id and rejects a stored/live cwd mismatch before repair or state publication. +- `loadStored(id)` — read one stored prefix by id across every storage scope (every JSONL cwd bucket; SQLite's id is globally unique). Resume/load, non-mutating inspection, live adoption, and the create-collision probe share this lookup. The coordinator asserts the returned id and rejects a stored/live cwd mismatch before repair or state publication. - `appendBatch(meta, events, isMaterialized)` — durably append a contiguous batch, lazily materializing the session ATOMICALLY when not yet materialized (the materialize-write and the first event batch must commit together — a crash between them must not leave a materialized-but-empty session; this is why there is no separate `materialize` hook). - `commitRepair(meta, tornMarker, closers)` — make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined`) and append `closers`. **NOT required to be atomic** — JSONL legitimately truncates-then-appends in two fsync'd steps, SQLite does DELETE+INSERT in one transaction. Used by `load` (truncate + synthetic closers) and live-adoption (truncate only, `closers = []`). - `list()` — list all stored metadata. @@ -31,7 +31,7 @@ The single design choice that keeps the seam clean: the crash-repair "where is t ## Testing -The shared `runPersistenceContract` (public-API contract) keeps running for every backend. `runCoordinatorContract` (`tests/coordinator-contract.ts`) holds the write-path orchestration — adoption, HMR, collision, session and backend disposal drains, and crash-tail repair — and runs once per backend through a `CoordinatorFixture` (an in-memory reference + jsonl + sqlite). Coordinator-specific tests pin retirement map cleanup, same-id chain-tail races, failed-drain retry, and close ordering. The per-backend specs retain storage mechanics only (JSONL: path safety, fsync rollback, bucket listing; SQLite: schema version, `scanRows`, transaction rollback). A through-coordinator torn-tail→load→`commitRepair` test per real backend (via a `corruptTail` fixture hook) keeps the coordinator's torn-marker repair branch covered under the 100% per-file gate — the contract crash test only produces synthetic closers, never a torn marker, so it could not reach that branch. +The shared `runPersistenceContract` (public-API contract) keeps running for every backend and proves that `inspect` leaves interrupted logs and revisions unchanged before `load` performs recovery. `runCoordinatorContract` (`tests/coordinator-contract.ts`) holds the write-path orchestration — adoption, HMR, collision, session and backend disposal drains, and crash-tail repair — and runs once per backend through a `CoordinatorFixture` (an in-memory reference + jsonl + sqlite). Coordinator-specific tests pin retirement map cleanup, same-id chain-tail races, failed-drain retry, and close ordering. The per-backend specs retain storage mechanics only (JSONL: path safety, fsync rollback, bucket listing; SQLite: schema version, `scanRows`, transaction rollback). A through-coordinator torn-tail→load→`commitRepair` test per real backend (via a `corruptTail` fixture hook) keeps the coordinator's torn-marker repair branch covered under the 100% per-file gate — the contract crash test only produces synthetic closers, never a torn marker, so it could not reach that branch. ## Alternatives considered @@ -40,4 +40,4 @@ The shared `runPersistenceContract` (public-API contract) keeps running for ever ## Consequences -The coordinator adds one indirection, an opaque torn marker, and detached session-retirement tasks, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the session owner does not await persistence retirement; the coordinator contains failures, preserves uncommitted buffers, and makes backend teardown the quiescence boundary. Its hook surface stays narrow: identity, adoption, and collision checks reuse `loadStored`; materialization stays atomic inside `appendBatch`; and listing bypasses the coordinator. New backends implement storage primitives rather than copy the event-buffer-flush lifecycle. +The coordinator adds one indirection, an opaque torn marker, and detached session-retirement tasks, but centralizes correctness-heavy orchestration previously duplicated by every backend. Session disposal remains an observe-only event, so the session owner does not await persistence retirement; the coordinator contains failures, preserves uncommitted buffers, and makes backend teardown the quiescence boundary. Its hook surface stays narrow: identity, adoption, collision checks, and non-mutating inspection reuse `loadStored`; materialization stays atomic inside `appendBatch`; and listing bypasses the coordinator. Read models use `inspect` rather than `load`, so observing a persisted open turn cannot race a new live owner by committing interruption closers. New backends implement storage primitives rather than copy the event-buffer-flush lifecycle. diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0150c984bd..35bb0c8201 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -924,6 +924,16 @@ abstract append(id: SessionId, events: readonly SessionEvent[]): Promise */ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +/** + * Inspect a header and its valid contiguous stored prefix without repairing + * a torn tail, closing an interrupted turn, or publishing coordinator state. + * This read is serialized with writes for the same id and returns detached + * values, so observers cannot mutate backend-owned state. + * @param id - the persisted session to inspect. + * @returns the header and valid stored event prefix exactly as observed. + */ +abstract inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> + /** * Lightweight listing from metadata, without a full-log parse. * @returns one header per materialized session. diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index e598e908d3..9bfdca56bd 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -2,7 +2,7 @@ The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. The event vocabulary the log carries is enumerated, member by member, in the generated [persistence log event catalog](../persistence-catalog.md). -The seam is a textbook [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining locate/create/append/load plus lightweight list/snapshot observation over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). +The seam is a textbook [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session-persistence/session-persistence), `ctx.sessionPersistence`) defining locate/create/append, crash-repairing load, non-mutating inspect, and lightweight list/snapshot observation over the existing `SessionEvent` — **no parallel persisted type** — and two interchangeable backends that pass the same `runPersistenceContract` suite. See the [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). ## The flush checkpoint @@ -12,6 +12,8 @@ The seam is a textbook [capability seam](../../.agents/notes/implemented/archite A backend that reloads a log crashed mid-turn finds an open `turn/start` with no `turn/end`. It does **not** truncate — a single turn can be huge in a long-horizon task (many steps, large tool output), and those events were durably appended before the crash. Instead it closes the orphaned turn with a synthetic `turn/end { reason: { kind: 'interrupted' } }`, keeping the log balanced and the turn-enclosure invariant intact. `interrupted` is the one `TurnEndReason` no loop emits (see [session.md](session.md#why-a-turn-ended-turnendreasonmap)). +`SessionPersistence.inspect(id)` is the observer counterpart to recovery: it returns a detached valid stored prefix without truncating a torn record, adding interruption closers, or publishing write state. Same-id serialization keeps it coherent with backend writes. Derived read models use `inspect`, never `load`, so observing a checkpointed open turn cannot mutate the log if live ownership begins concurrently. + ## `SessionLocation` — optional per-session artifact target `SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns its absolute target path; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee. @@ -122,7 +124,7 @@ interface SessionPersistenceSnapshot { ## The backends -Both implement the same abstract `SessionPersistence` (locate/create/append/load/list/listSnapshots over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: +Both implement the same abstract `SessionPersistence` (locate/create/append/load/inspect/list/listSnapshots over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: - **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)** — an append-only logical JSONL log per session, stored as checksummed concatenated Zstandard frames by default or raw lines by configuration, with crash-safe atomic writes, interrupted-turn recovery, and a read/replay path. - **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data, source_event_seqs, surface_op)` maps 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d80d2cbf2e..ae8c55924a 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -464,6 +464,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', jsDoc: '/**\n * Load a header and balanced contiguous log. A complete interrupted final\n * turn is preserved and durably closed with missing tool errors plus any open\n * step and turn boundaries; only a torn final record is discarded. Unknown\n * versions and corruption in the committed prefix reject.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */', }, + { + signature: 'abstract inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', + jsDoc: '/**\n * Inspect a header and its valid contiguous stored prefix without repairing\n * a torn tail, closing an interrupted turn, or publishing coordinator state.\n * This read is serialized with writes for the same id and returns detached\n * values, so observers cannot mutate backend-owned state.\n * @param id - the persisted session to inspect.\n * @returns the header and valid stored event prefix exactly as observed.\n */', + }, { signature: 'abstract list(): Promise', jsDoc: '/**\n * Lightweight listing from metadata, without a full-log parse.\n * @returns one header per materialized session.\n */', diff --git a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts index 3310b191b8..3cbb3b9832 100644 --- a/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts +++ b/packages/session-persistence/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -19,6 +19,9 @@ class TestPersistence extends SessionPersistence { load(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { return Promise.reject(new Error('not used')) } + inspect(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return Promise.reject(new Error('not used')) + } list(): Promise { return Promise.resolve([]) } listSnapshots(): Promise { return Promise.resolve([]) } } diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index d21c47a39b..dfd90b3ce2 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -37,6 +37,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the - **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s the encoded header and first batch in a temporary file. POSIX publishes it without overwrite via a hard link and `fsync`s the parent directory. Windows publishes it without overwrite via `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` and creates missing directories through the same write-through pattern. A created-but-never-appended session leaves nothing on disk and is absent from `list`. - **Append-only.** Flushed events are never rewritten. Subsequent raw batches append lines; compressed batches append one frame. Both paths `fsync`, and a caught write or sync failure rolls the file back to its prior byte length. - **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. A checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end`, is corruption and rejects. +- **Non-mutating inspection.** `inspect()` returns the detached valid prefix without truncating an incomplete tail or closing an interrupted turn, and leaves the lightweight revision unchanged. - **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. - **Lightweight revisions.** `listSnapshots()` identifies a log by its device, inode, size, and nanosecond timestamps, avoiding a full-log parse while changing after append, repair, replacement, or store changes. diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index b02b147841..629c0e3ff1 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -131,6 +131,10 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return this.coordinator.load(id) } + inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return this.coordinator.inspect(id) + } + // One method serves both public `list` and the backend hook; delegating it to // the coordinator would call this hook recursively. diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 3e7fc42fbe..6dcfa2d125 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -19,6 +19,7 @@ On filesystems with POSIX modes, the backend requests mode `0700` for missing di - **Append = a transaction.** `append` runs `BEGIN`/`COMMIT` around the batch: it materializes the `sessions` row (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event's `seq` must equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (`load()` already balanced the stored log, so `append` never has to repair a crash tail.) - **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row). - **Interrupted-turn close on load.** `load()` implements the shared [crash-recovery contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md): preserve the valid interrupted turn, append its synthetic closing events in one transaction, and remove only a torn tail row. Committed parse errors or sequence gaps make the session unloadable. Because recovery mutates stored rows, the next append starts from a balanced log and accurate cursor. +- **Non-mutating inspection.** `inspect()` returns the detached valid row prefix without deleting a torn tail row or appending recovery closers, and leaves the lightweight revision unchanged. - **Lightweight revisions.** `listSnapshots()` combines the immutable store and database-file identity, a per-materialization incarnation id, and a per-session counter incremented in each mutating transaction. This keeps unchanged observations stable without parsing event rows and distinguishes independent stores and recreated same-id logs. ## Configuration (schemastery) diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 693d3119e7..5804c18282 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -157,6 +157,10 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.coordinator.load(id) } + inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return this.coordinator.inspect(id) + } + // One method serves both public `list` and the backend hook; delegating it to // the coordinator would call this hook recursively. diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 8efe22c20c..ea9265cb7a 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -12,6 +12,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise` | Durably persist a batch (from the `session/flush` drain). Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `load(id): Promise<{ meta; events }>` | Reload meta + log. Preserves an interrupted (unclosed) final turn and closes it with synthetic closers — an error `tool/result` per unanswered `tool-call`, then `step/end?`+`turn/end {interrupted}` (a turn can be huge — never truncated); only a torn tail fragment is dropped. Events contiguous (`events[i].seq === i`); rejects a committed-region gap/parse error or unknown `version`. | +| `inspect(id): Promise<{ meta; events }>` | Return a detached valid stored prefix without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; intended for read models and other observers that must never recover a log. | | `list(): Promise` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. | @@ -37,13 +38,13 @@ The `PersistenceBackend` hooks (the only seam between the coordinato | Hook | Role | |---|---| | `name` | Backend label for the dispose-failure `AggregateError`. | -| `loadStored(id)` | Read a stored prefix by id across every storage scope. Used by resume/load, live adoption, and the create-collision probe. Returned metadata identifies `id`; an opaque `tornMarker` is present iff a torn tail must be truncated. | +| `loadStored(id)` | Read a stored prefix by id across every storage scope. Used by resume/load, non-mutating inspect, live adoption, and the create-collision probe. Returned metadata identifies `id`; an opaque `tornMarker` is present iff a torn tail must be truncated. | | `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | | `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | | `list()` | List all stored metadata. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | -The coordinator asserts the stored id and compares stored/live cwd before repair or live adoption. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must also provide trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). +The coordinator asserts the stored id and compares stored/live cwd before repair or live adoption. Its `inspect()` path validates and clones the prefix without calling `commitRepair` or publishing write state. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). ## Testing backends diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index f558caa789..ce536c571c 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -252,6 +252,28 @@ export class PersistenceCoordinator { return this.serialize(id, () => this.loadCore(id)) } + /** + * Read a detached valid stored prefix without recovery mutations or + * coordinator-state publication. + * @param id - persisted session to inspect. + * @returns stored header and events before any synthetic recovery closers. + */ + inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return this.serialize(id, () => this.inspectCore(id)) + } + + private async inspectCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + const stored = await this.backend.loadStored(id) + if (stored === undefined) throw new Error(`session "${id}" not found`) + this.assertStoredId(id, stored.meta) + this.assertVersion(stored.meta) + assertSupportedEvents(stored.events, id) + return { + meta: structuredClone(stored.meta), + events: structuredClone(stored.events), + } + } + private async loadCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { const stored = await this.backend.loadStored(id) if (stored === undefined) throw new Error(`session "${id}" not found`) diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 7c98e37734..276b4e5bcf 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -93,6 +93,16 @@ export abstract class SessionPersistence extends Service { */ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> + /** + * Inspect a header and its valid contiguous stored prefix without repairing + * a torn tail, closing an interrupted turn, or publishing coordinator state. + * This read is serialized with writes for the same id and returns detached + * values, so observers cannot mutate backend-owned state. + * @param id - the persisted session to inspect. + * @returns the header and valid stored event prefix exactly as observed. + */ + abstract inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> + /** * Lightweight listing from metadata, without a full-log parse. * @returns one header per materialized session. diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index 1e50347f55..ae07bf77aa 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -99,6 +99,15 @@ export function runPersistenceContract(name: string, make: () => Promise snapshot.header.id === m.id)?.revision + const inspected = await persistence.inspect(m.id) + const afterInspect = (await persistence.listSnapshots()) + .find(snapshot => snapshot.header.id === m.id)?.revision + expect(afterInspect).toBe(beforeRepair) + expect(inspected.events.map(e => e.type)).toEqual([ + 'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', + 'turn/start', 'step/start', + ]) + // load PRESERVES the interrupted turn's events (a turn can be huge — they // must not be truncated) and closes the orphaned turn with synthetic // boundary events: step/end (the step was open) then turn/end {interrupted}. diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 620d069d32..c42bf935e8 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -643,11 +643,12 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) - it('load rejects a missing session', async () => { + it('load and inspect reject a missing session', async () => { const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { await expect(ctx.sessionPersistence.load(SessionId('nope'))).rejects.toThrow(/not found/) + await expect(ctx.sessionPersistence.inspect(SessionId('nope'))).rejects.toThrow(/not found/) } finally { await fiber.dispose() await fix.cleanup() diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 3ea7bc6adb..dc0ee1b7df 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -96,6 +96,10 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend return this.coordinator.load(id) } + inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return this.coordinator.inspect(id) + } + // --- PersistenceBackend hooks (the Map storage primitives) --- // A Map-backed store has no torn tails, so `tornMarker` is never set. diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index af37ff74c8..a2c48a669f 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -12,11 +12,11 @@ All three surfaces (`current`, `shadowed`, and `log-only`) are searchable by def ## Source and index lifecycle -The service requires `ctx.sessions` and observes optional `ctx.sessionPersistence` dynamically. One serialized state machine compares source-qualified lightweight durable snapshot revisions, loads only new or changed logs, extracts shared semantic documents, reconciles changes transactionally, and runs the query. It never invokes the persistence backend's mutating `load()` for an id currently owned by `ctx.sessions`; the TEMP live row still records persisted availability, and the durable base refreshes after that live owner detaches. Repeated queries and unchanged same-store reopen load no full durable logs; switching stores, or observing new, changed, deleted, or load-repaired sources, reconciles on the next stable observation. Source or transaction failure commits nothing, and the next search retries. +The service requires `ctx.sessions` and observes optional `ctx.sessionPersistence` dynamically. One serialized state machine compares source-qualified lightweight durable snapshot revisions, non-mutatingly inspects only new or changed logs, extracts shared semantic documents, reconciles changes transactionally, and runs the query. Session queries never invoke the persistence backend's crash-repairing `load()`; an owner attaching during inspection cannot mutate its log, and the stable-observation retry makes the result live-preferred. The TEMP live row still records persisted availability, and the durable base refreshes after that live owner detaches. Repeated queries and an unchanged same-store reopen perform no full durable-log inspection; switching stores, or observing new, changed, deleted, or externally load-repaired sources, reconciles on the next stable observation. Source or transaction failure commits nothing, and the next search retries. Persisted FTS rows live in a dedicated derived database. Connection-local TEMP tables hold live rows, which shadow the durable base for the same session and reveal it when the live owner disappears. Unmounting persistence hides durable rows without discarding the cache; remounting reconciles it. Closing or reopening the database drops every live overlay while retaining persisted rows. -The database is disposable but reset is guarded: a recognized incompatible search schema rebuilds in place, while an unrelated or canonical database is refused before mutating journal mode. Never point `path` at the session-persistence database. On filesystems with POSIX modes, missing directories and databases are created owner-only (`0700` and `0600` before the process umask), and SQLite sidecars inherit the database mode; existing modes are preserved. Exactly one service in one process owns a derived-index path; external writers or a second process are unsupported because generations and TEMP shadow state are connection-owned. +The database is disposable but reset is guarded: every recognized schema version rejects unknown user tables before mutating journal mode, and only a recognized incompatible schema containing derived tables rebuilds in place. An unrelated or canonical database is refused. Never point `path` at the session-persistence database. On filesystems with POSIX modes, missing directories and databases are created owner-only (`0700` and `0600` before the process umask), and SQLite sidecars inherit the database mode; existing modes are preserved. Exactly one service in one process owns a derived-index path; external writers or a second process are unsupported because generations and TEMP shadow state are connection-owned. ## Configuration diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 049999d2e7..a5fa0761e4 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -438,11 +438,12 @@ export class SessionQuerySqlite extends SessionQueryService { persisted = materializePersistenceSnapshots(before) for (const entry of persisted.values()) { if (canReuseIndexed && indexed.get(entry.header.id)?.revision === entry.revision) continue - // `load()` may durably repair an interrupted tail. Never invoke it - // for a session currently owned by the live store: a checkpointed - // open turn is active, not crash-interrupted. + // Skip work already shadowed by a live owner. `inspect()` is + // non-mutating, so an owner attaching after this check cannot cause + // crash-repair side effects; the live-membership retry below makes + // the returned observation live-preferred. if (initiallyLive.has(entry.header.id) || this.ctx.sessions.get(entry.header.id) !== undefined) continue - const loaded = await waitWithAbort(persistence.load(entry.header.id), signal) + const loaded = await waitWithAbort(persistence.inspect(entry.header.id), signal) assertSessionHeadersCompatible(entry.header, loaded.meta) entry.loaded = observeSession(loaded.meta, loaded.events) } diff --git a/packages/session-query/session-query-sqlite/src/schema.ts b/packages/session-query/session-query-sqlite/src/schema.ts index 045c84d960..b88e04b536 100644 --- a/packages/session-query/session-query-sqlite/src/schema.ts +++ b/packages/session-query/session-query-sqlite/src/schema.ts @@ -60,8 +60,9 @@ export async function openSearchDatabase(path: string, journalMode: JournalMode) if (applicationId === 0 && userTables.length > 0) { throw new Error(`session-search database at "${actual}" is not an empty or recognized derived index`) } - if (applicationId === SESSION_QUERY_SQLITE_APPLICATION_ID && version !== SESSION_QUERY_SQLITE_SCHEMA_VERSION) { - resetDerivedSchema(db, actual, userTables) + if (applicationId === SESSION_QUERY_SQLITE_APPLICATION_ID) { + assertDerivedUserTables(actual, userTables) + if (version !== SESSION_QUERY_SQLITE_SCHEMA_VERSION) resetDerivedSchema(db, userTables) } // Apply mutating pragmas only after refusing foreign or canonical files. // journalMode is a validated closed union, not caller-controlled SQL. @@ -82,13 +83,16 @@ function listUserTables(db: DatabaseSync): string[] { return rows.map(row => row.name) } -function resetDerivedSchema(db: DatabaseSync, path: string, userTables: readonly string[]): void { +function assertDerivedUserTables(path: string, userTables: readonly string[]): void { const unknownTables = userTables.filter(name => !DERIVED_USER_TABLES.has(name)) if (unknownTables.length > 0) { throw new Error( `session-search database at "${path}" has unrecognized user tables: ${unknownTables.join(', ')}`, ) } +} + +function resetDerivedSchema(db: DatabaseSync, userTables: readonly string[]): void { for (const name of userTables) { db.exec(`DROP TABLE IF EXISTS ${quoteIdentifier(name)}`) } 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 2e5aebfeeb..8a1a3454f1 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -67,7 +67,9 @@ class TestPersistence extends SessionPersistence { static revisions = new Map() static nextRevision = 0 static loads = new Map() + static inspections = new Map() static loadEffect: ((entry: { meta: SessionHeader; events: SessionEvent[] }) => void) | undefined + static inspectEffect: ((entry: { meta: SessionHeader; events: SessionEvent[] }) => void | Promise) | undefined static listGate: Promise | undefined static listStarted: (() => void) | undefined static snapshotEffect: (() => void | Promise) | undefined @@ -82,7 +84,9 @@ class TestPersistence extends SessionPersistence { this.entries = new Map() this.revisions = new Map() this.loads = new Map() + this.inspections = new Map() this.loadEffect = undefined + this.inspectEffect = undefined for (const entry of entries) this.set(entry) this.listGate = undefined this.listStarted = undefined @@ -123,6 +127,16 @@ class TestPersistence extends SessionPersistence { return structuredClone(entry) } + async inspect(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + TestPersistence.inspections.set(id, (TestPersistence.inspections.get(id) ?? 0) + 1) + if (TestPersistence.failure !== undefined) throw TestPersistence.failure + const entry = TestPersistence.entries.get(id) + if (entry === undefined) throw new Error('missing test session') + await TestPersistence.inspectEffect?.(entry) + TestPersistence.inspectEffect = undefined + return structuredClone(entry) + } + async list(): Promise { TestPersistence.listStarted?.() await TestPersistence.listGate @@ -602,11 +616,13 @@ describe('SQLite reconciliation and source lifecycle', () => { items: [{ header: shared, live: true, persisted: true }], }) expect(TestPersistence.loads.get(shared.id)).toBeUndefined() + expect(TestPersistence.inspections.get(shared.id)).toBeUndefined() detach() await expect(ctx.sessionQuery.searchSessions({ query: 'persisted' })) .resolves.toMatchObject({ items: [{ header: shared, live: false, persisted: true }] }) - expect(TestPersistence.loads.get(shared.id)).toBe(1) + expect(TestPersistence.loads.get(shared.id)).toBeUndefined() + expect(TestPersistence.inspections.get(shared.id)).toBe(1) await persistence.dispose() }) @@ -623,6 +639,28 @@ describe('SQLite reconciliation and source lifecycle', () => { .resolves.toMatchObject({ items: [{ header: { id: SessionId('attached') } }] }) }) + it('cannot crash-repair a log when live ownership begins during persisted inspection', async () => { + const shared = header('attach-during-inspect', 10) + const persistedEvents = messageEvents('persisted needle') + TestPersistence.reset([{ meta: shared, events: persistedEvents }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + TestPersistence.loadEffect = (entry) => { + entry.events = messageEvents('incorrect repair') + } + TestPersistence.inspectEffect = () => { + ctx.sessions.create(shared.id, { + seed: messageEvents('live needle'), + meta: { createdAt: shared.createdAt }, + }) + } + + await expect(ctx.sessionQuery.searchSessions({ query: 'live' })) + .resolves.toMatchObject({ items: [{ header: shared, live: true, persisted: true }] }) + expect(TestPersistence.loads.get(shared.id)).toBeUndefined() + expect(TestPersistence.entries.get(shared.id)?.events).toEqual(persistedEvents) + }) + it('retries when one live owner replaces another during persistence observation', async () => { TestPersistence.reset() const ctx = await liveContext() @@ -733,10 +771,10 @@ describe('SQLite reconciliation and source lifecycle', () => { TestPersistence.revisions.set(durable.id, revision) const replacement = await ctx.plugin(TestPersistence) const page = await ctx.sessionQuery.searchSessions({ query: 'new needle' }) - expect(TestPersistence.loads.get(durable.id)).toBe(2) + expect(TestPersistence.inspections.get(durable.id)).toBe(2) expect(page).toMatchObject({ items: [{ header: durable }] }) await expect(ctx.sessionQuery.searchSessions({ query: 'old' })).resolves.toEqual({ items: [] }) - expect(TestPersistence.loads.get(durable.id)).toBe(2) + expect(TestPersistence.inspections.get(durable.id)).toBe(2) await replacement.dispose() }) @@ -768,8 +806,8 @@ describe('SQLite reconciliation and source lifecycle', () => { const page = await ctx.sessionQuery.searchSessions({ query: 'needle' }) expect(page.items.map(item => item.header.id).sort()).toEqual([added.id, first.id].sort()) - expect(TestPersistence.loads.get(first.id)).toBe(2) - expect(TestPersistence.loads.get(added.id)).toBe(1) + expect(TestPersistence.inspections.get(first.id)).toBe(2) + expect(TestPersistence.inspections.get(added.id)).toBe(1) }) it('fails after one retry when persistence snapshots keep changing', async () => { @@ -811,7 +849,7 @@ describe('SQLite reconciliation and source lifecycle', () => { await expect(ctx.sessionQuery.searchSessions({ query: 'needle' })) .resolves.toMatchObject({ items: [{ header: durable }] }) - expect(TestPersistence.loads.get(durable.id)).toBe(2) + expect(TestPersistence.inspections.get(durable.id)).toBe(2) list.mockRestore() }) @@ -869,9 +907,9 @@ describe('SQLite reconciliation and source lifecycle', () => { const firstPersistence = await first.plugin(TestPersistence) const firstSearch = await first.plugin(SessionQuerySqlite, { path }) await first.sessionQuery.searchSessions({ query: 'needle' }) - expect(Object.fromEntries(TestPersistence.loads)).toEqual({ unchanged: 1, changed: 1, deleted: 1 }) + expect(Object.fromEntries(TestPersistence.inspections)).toEqual({ unchanged: 1, changed: 1, deleted: 1 }) await first.sessionQuery.searchSessions({ query: 'needle' }) - expect(Object.fromEntries(TestPersistence.loads)).toEqual({ unchanged: 1, changed: 1, deleted: 1 }) + expect(Object.fromEntries(TestPersistence.inspections)).toEqual({ unchanged: 1, changed: 1, deleted: 1 }) await firstSearch.dispose() await firstPersistence.dispose() @@ -890,7 +928,7 @@ describe('SQLite reconciliation and source lifecycle', () => { const secondSearch = await second.plugin(SessionQuerySqlite, { path }) const result = await second.sessionQuery.searchSessions({ query: 'needle' }) expect(result.items.map(item => item.header.id).sort()).toEqual([added.id, changed.id, unchanged.id].sort()) - expect(Object.fromEntries(TestPersistence.loads)).toEqual({ + expect(Object.fromEntries(TestPersistence.inspections)).toEqual({ unchanged: 1, changed: 2, deleted: 1, @@ -929,25 +967,30 @@ describe('SQLite reconciliation and source lifecycle', () => { await expect(second.sessionQuery.searchSessions({ query: 'live' })).resolves.toEqual({ items: [] }) await expect(second.sessionQuery.searchSessions({ query: 'persisted' })) .resolves.toMatchObject({ items: [{ header: shared, live: false, persisted: true }] }) - expect(TestPersistence.loads.get(shared.id)).toBe(1) + expect(TestPersistence.inspections.get(shared.id)).toBe(1) await searchAgain.dispose() await persistenceAgain.dispose() }) - it('refreshes the stored revision after a mutating load repair', async () => { + it('refreshes after an external mutating load repair without loading from the query path', async () => { const durable = header('repair') TestPersistence.reset([{ meta: durable, events: messageEvents('before repair') }]) + const ctx = await liveContext() + const persistence = await ctx.plugin(TestPersistence) + await expect(ctx.sessionQuery.searchSessions({ query: 'before' })) + .resolves.toMatchObject({ items: [{ header: durable }] }) TestPersistence.loadEffect = (entry) => { entry.events = messageEvents('repaired needle') } - const ctx = await liveContext() - await ctx.plugin(TestPersistence) + await ctx.sessionPersistence.load(durable.id) await expect(ctx.sessionQuery.searchSessions({ query: 'repaired' })) .resolves.toMatchObject({ items: [{ header: durable }] }) - expect(TestPersistence.loads.get(durable.id)).toBe(2) + expect(TestPersistence.inspections.get(durable.id)).toBe(2) await ctx.sessionQuery.searchSessions({ query: 'repaired' }) - expect(TestPersistence.loads.get(durable.id)).toBe(2) + expect(TestPersistence.inspections.get(durable.id)).toBe(2) + expect(TestPersistence.loads.get(durable.id)).toBe(1) + await persistence.dispose() }) it('recovers on the next search after source and SQLite transaction failures', async () => { @@ -1066,6 +1109,27 @@ describe('SQLite schema, cancellation, and real persistence integration', () => expect(stillAugmented.prepare('PRAGMA user_version').get()).toEqual({ user_version: 999 }) stillAugmented.close() + const currentAugmentedPath = await temporaryPath('current-augmented.db') + const currentAugmentedOwner = await liveContext({ path: currentAugmentedPath }) + await (currentAugmentedOwner.sessionQuery as SessionQuerySqlite).close() + const currentAugmented = new DatabaseSync(currentAugmentedPath) + currentAugmented.exec('CREATE TABLE unrelated(value TEXT)') + currentAugmented.exec("INSERT INTO unrelated VALUES ('safe')") + currentAugmented.close() + const currentAugmentedCtx = new Context() + await currentAugmentedCtx.plugin(SessionStore) + await expect(currentAugmentedCtx.plugin(SessionQuerySqlite, { + path: currentAugmentedPath, + journalMode: 'delete', + })).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED')) + expect(currentAugmentedCtx.sessionQuery).toBeUndefined() + const stillCurrentAugmented = new DatabaseSync(currentAugmentedPath) + expect(stillCurrentAugmented.prepare('SELECT value FROM unrelated').get()).toEqual({ value: 'safe' }) + expect(stillCurrentAugmented.prepare('PRAGMA user_version').get()) + .toEqual({ user_version: SESSION_QUERY_SQLITE_SCHEMA_VERSION }) + expect(stillCurrentAugmented.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'wal' }) + stillCurrentAugmented.close() + const foreignPath = await temporaryPath('foreign.db') const foreign = new DatabaseSync(foreignPath) foreign.exec('PRAGMA journal_mode = WAL') @@ -1260,22 +1324,22 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const persistenceA = await first.plugin(SessionPersistenceSqlite, { path: persistencePathA }) await first.sessionPersistence.create(shared) await first.sessionPersistence.append(shared.id, messageEvents('alpha source')) - const loadA = vi.spyOn(first.sessionPersistence, 'load') + const inspectA = vi.spyOn(first.sessionPersistence, 'inspect') const searchA = await first.plugin(SessionQuerySqlite, { path: searchPath }) await expect(first.sessionQuery.searchSessions({ query: 'alpha' })) .resolves.toMatchObject({ items: [{ header: shared }] }) - expect(loadA).toHaveBeenCalledTimes(1) + expect(inspectA).toHaveBeenCalledTimes(1) await searchA.dispose() await persistenceA.dispose() const reopened = new Context() await reopened.plugin(SessionStore) const persistenceAAgain = await reopened.plugin(SessionPersistenceSqlite, { path: persistencePathA }) - const reopenedLoad = vi.spyOn(reopened.sessionPersistence, 'load') + const reopenedInspect = vi.spyOn(reopened.sessionPersistence, 'inspect') const searchAAgain = await reopened.plugin(SessionQuerySqlite, { path: searchPath }) await expect(reopened.sessionQuery.searchSessions({ query: 'alpha' })) .resolves.toMatchObject({ items: [{ header: shared }] }) - expect(reopenedLoad).not.toHaveBeenCalled() + expect(reopenedInspect).not.toHaveBeenCalled() await searchAAgain.dispose() await persistenceAAgain.dispose() @@ -1284,12 +1348,12 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const persistenceB = await second.plugin(SessionPersistenceSqlite, { path: persistencePathB }) await second.sessionPersistence.create(shared) await second.sessionPersistence.append(shared.id, messageEvents('bravo source')) - const loadB = vi.spyOn(second.sessionPersistence, 'load') + const inspectB = vi.spyOn(second.sessionPersistence, 'inspect') const searchB = await second.plugin(SessionQuerySqlite, { path: searchPath }) await expect(second.sessionQuery.searchSessions({ query: 'bravo' })) .resolves.toMatchObject({ items: [{ header: shared }] }) await expect(second.sessionQuery.searchSessions({ query: 'alpha' })).resolves.toEqual({ items: [] }) - expect(loadB).toHaveBeenCalledTimes(1) + expect(inspectB).toHaveBeenCalledTimes(1) await searchB.dispose() await persistenceB.dispose() }) diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index 4a27c34e58..0e1753d5ce 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -72,16 +72,18 @@ export class SessionCorpus { if (persistence === undefined) throw notFound(sessionId) const listed = (await listPersisted(persistence)).find(header => header.id === sessionId) if (listed === undefined) throw notFound(sessionId) - let loaded: Awaited> + let loaded: Awaited> try { - loaded = await persistence.load(sessionId) + loaded = await persistence.inspect(sessionId) } catch (error: unknown) { throw new SessionQueryError( - `failed to load session "${sessionId}": ${errorMessage(error)}`, + `failed to inspect session "${sessionId}": ${errorMessage(error)}`, 'SESSION_QUERY_PERSISTENCE_FAILED', { cause: error }, ) } + const attached = this._ctx.sessions.get(sessionId) + if (attached !== undefined) return snapshotLive(attached) assertSessionHeadersCompatible(loaded.meta, listed) return { header: structuredClone(loaded.meta), diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 98ca9a7863..a2ea051dd0 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -27,13 +27,15 @@ function eventLog(text = 'hello'): SessionEvent[] { class TestPersistence extends SessionPersistence { static entries = new Map() static listFailure: unknown - static loadFailure: unknown + static inspectFailure: unknown + static inspectEffect: (() => void) | undefined static afterList: (() => void) | undefined static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void { this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)])) this.listFailure = undefined - this.loadFailure = undefined + this.inspectFailure = undefined + this.inspectEffect = undefined this.afterList = undefined } @@ -54,10 +56,17 @@ class TestPersistence extends SessionPersistence { } load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - if (TestPersistence.loadFailure !== undefined) return rejectUnknown(TestPersistence.loadFailure) + return this.inspect(id) + } + + inspect(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + if (TestPersistence.inspectFailure !== undefined) return rejectUnknown(TestPersistence.inspectFailure) const entry = TestPersistence.entries.get(id) if (entry === undefined) return Promise.reject(new Error('missing test session')) - return Promise.resolve(structuredClone(entry)) + const result = structuredClone(entry) + TestPersistence.inspectEffect?.() + TestPersistence.inspectEffect = undefined + return Promise.resolve(result) } list(): Promise { @@ -96,6 +105,22 @@ function rejectUnknown(reason: unknown): Promise { } describe('session-query exact reads', () => { + it('prefers a live owner that attaches while its persisted prefix is inspected', async () => { + const shared = header('attach-during-inspect', 2) + TestPersistence.reset([{ meta: shared, events: eventLog('persisted') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + TestPersistence.inspectEffect = () => { + ctx.sessions.create(shared.id, { + seed: eventLog('live'), + meta: { createdAt: shared.createdAt }, + }) + } + + await expect(ctx.sessionQuery.filterEvents(shared.id, [])) + .resolves.toMatchObject([{ sessionId: shared.id, text: 'live' }]) + }) + it('reads the latest title from one live-preferred or persisted log without widening listSessions', async () => { const persistedHeader = header('persisted-title', 2) const sharedHeader = header('shared-title', 3) @@ -363,7 +388,7 @@ describe('session-query exact reads', () => { ) await ctx.plugin(TestPersistence) TestPersistence.listFailure = new Error('list unavailable') - TestPersistence.loadFailure = new Error('load unavailable') + TestPersistence.inspectFailure = new Error('inspect unavailable') await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(2) await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 1 })).resolves.toMatchObject({ target: { seq: 1 } }) @@ -381,10 +406,10 @@ describe('session-query exact reads', () => { await expect(ctx.sessionQuery.listEvents(SessionId('absent'))) .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) - TestPersistence.loadFailure = 'raw failure' + TestPersistence.inspectFailure = 'raw failure' await expect(ctx.sessionQuery.listEvents(durable.id)) .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) - TestPersistence.loadFailure = undefined + TestPersistence.inspectFailure = undefined const durableEntry = TestPersistence.entries.get(durable.id)! durableEntry.meta = { ...durableEntry.meta, cwd: '/changed-after-list' } TestPersistence.afterList = () => { diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index dc7aa41641..2f115d2dc9 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -31,17 +31,17 @@ function appendEvent(seq: number, sources?: number[]): SessionEvent { class TracePersistence extends SessionPersistence { static entries = new Map() static listCalls = 0 - static loadCalls = 0 + static inspectCalls = 0 static listFailure: Error | undefined - static loadFailure: Error | undefined + static inspectFailure: Error | undefined static afterList: (() => void) | undefined static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void { this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)])) this.listCalls = 0 - this.loadCalls = 0 + this.inspectCalls = 0 this.listFailure = undefined - this.loadFailure = undefined + this.inspectFailure = undefined this.afterList = undefined } @@ -62,8 +62,12 @@ class TracePersistence extends SessionPersistence { } load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - TracePersistence.loadCalls += 1 - if (TracePersistence.loadFailure !== undefined) return Promise.reject(TracePersistence.loadFailure) + return this.inspect(id) + } + + inspect(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + TracePersistence.inspectCalls += 1 + if (TracePersistence.inspectFailure !== undefined) return Promise.reject(TracePersistence.inspectFailure) const entry = TracePersistence.entries.get(id) if (entry === undefined) return Promise.reject(new Error('missing test session')) return Promise.resolve(structuredClone(entry)) @@ -209,7 +213,7 @@ describe('session lineage tracing', () => { complete: true, }) expect(TracePersistence.listCalls).toBe(1) - expect(TracePersistence.loadCalls).toBe(0) + expect(TracePersistence.inspectCalls).toBe(0) TracePersistence.listFailure = new Error('unavailable') await expect(ctx.sessionQuery.traceSession(durable.id)) @@ -301,7 +305,7 @@ describe('session event tracing', () => { expect(repeated.derivedEventSeqs).toEqual([8]) }) - it('loads persisted logs once, prefers live logs, and preserves failures and conflicts', async () => { + it('inspects persisted logs once, prefers live logs, and preserves failures and conflicts', async () => { const durable = header('shared', 1, { cwd: '/same' }) TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }]) const ctx = await queryContext() @@ -309,7 +313,7 @@ describe('session event tracing', () => { await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) .resolves.toMatchObject({ target: { type: 'user/message', surface: 'current' } }) - expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1]) + expect([TracePersistence.listCalls, TracePersistence.inspectCalls]).toEqual([1, 1]) const live = ctx.sessions.create(durable.id, { meta: { createdAt: 1, cwd: '/same' } }) live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -319,10 +323,10 @@ describe('session event tracing', () => { { surfaceOp: 'append' }, ) TracePersistence.listFailure = new Error('list unavailable') - TracePersistence.loadFailure = new Error('load unavailable') + TracePersistence.inspectFailure = new Error('inspect unavailable') await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 1 })) .resolves.toMatchObject({ target: { type: 'context/message' } }) - expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1]) + expect([TracePersistence.listCalls, TracePersistence.inspectCalls]).toEqual([1, 1]) TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }]) const failedCtx = await queryContext() @@ -331,10 +335,10 @@ describe('session event tracing', () => { await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) TracePersistence.listFailure = undefined - TracePersistence.loadFailure = new Error('load unavailable') + TracePersistence.inspectFailure = new Error('inspect unavailable') await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 })) .rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) - TracePersistence.loadFailure = undefined + TracePersistence.inspectFailure = undefined TracePersistence.afterList = () => { mutableHeader(TracePersistence.entries.get(durable.id)!.meta).cwd = '/changed' } From 712c84f06f838182b930b87daf8ba3089da2c436 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Thu, 23 Jul 2026 21:48:12 +0800 Subject: [PATCH 19/19] fix(session-query): validate before service registration --- packages/session-query/session-query-sqlite/src/index.ts | 9 +++++++-- .../session-query-sqlite/tests/sqlite.spec.ts | 2 ++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index a5fa0761e4..5e795d4d2c 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -191,8 +191,10 @@ export class SessionQuerySqlite extends SessionQueryService { private readonly _optionalPersistenceFiber: Fiber constructor(ctx: Context, config: Config) { - super(ctx, config) - this.config = resolveConfig(config) + // The assignment expression resolves before the base constructor can + // register `ctx.sessionQuery`; keep that same validated value afterward. + super(ctx, config = resolveConfig(config)) + this.config = config as ResolvedConfig this._ready = this._open() this._optionalPersistenceFiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => { const service = childCtx.sessionPersistence @@ -919,6 +921,9 @@ function resolveConfig(config: Config): ResolvedConfig { assertPageLimit('defaultLimit', resolved.defaultLimit) assertPageLimit('maxLimit', resolved.maxLimit) assertPositiveInteger('snippetChars', resolved.snippetChars) + if (!Number.isInteger(resolved.readWindowMax) || resolved.readWindowMax < 0) { + throw invalidConfig('readWindowMax must be a non-negative integer') + } if (resolved.defaultLimit > resolved.maxLimit) { throw invalidConfig('defaultLimit must be less than or equal to maxLimit') } 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 8a1a3454f1..1923c6f3eb 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -482,6 +482,7 @@ describe('SQLite session search', () => { { path: ':memory:', defaultLimit: 1e100 }, { path: ':memory:', maxLimit: 1e100 }, { path: ':memory:', snippetChars: 0 }, + { path: ':memory:', readWindowMax: -1 }, { path: ':memory:', defaultLimit: 3, maxLimit: 2 }, { path: ':memory:', journalMode: 'memory' }, ]) { @@ -489,6 +490,7 @@ describe('SQLite session search', () => { await direct.plugin(SessionStore) expect(() => new SessionQuerySqlite(direct, config as never)) .toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG')) + expect(direct.sessionQuery).toBeUndefined() } })