From 6e577843c83d57cc69bc3b4cfa2e843d10f41e53 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 16:09:05 +0800 Subject: [PATCH] feat(session-query): expose projectSessions batch projection Public SessionQueryService.projectSessions wraps the existing corpus projectMany: one persistence listing, bounded persisted-inspect concurrency, per-id failure isolation, and a synchronous projector over a borrowed source with no replay validation or cloning. readTitleSnapshots now routes through it; LogicalSessionSource and LogicalProjectionResult are exported and documented. --- docs/cordis-catalog/services.md | 21 ++++++++++++-- .../session-query.i18n.yaml | 6 ++-- docs/core-data-structures/session-query.md | 19 +++++++++++++ docs/core-data-structures/session-query.zh.md | 19 +++++++++++++ .../cordis/tool-cordis/src/api-catalog.ts | 12 ++++++++ .../session-query/README.i18n.yaml | 4 +-- .../session-query/session-query/README.md | 3 +- .../session-query/session-query/README.zh.md | 3 +- .../session-query/session-query/src/index.ts | 28 +++++++++++++++++-- .../session-query/tests/session-query.spec.ts | 27 ++++++++++++++++++ scripts/gen-cordis-catalog.ts | 2 ++ scripts/type-equiv.manifest.json | 10 +++++++ 12 files changed, 143 insertions(+), 11 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d92f467469..000c12f27a 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1442,6 +1442,23 @@ async readTitleSnapshot( sessionId: SessionId, signal?: AbortSignal, ): Promise< */ async readTitleSnapshots( sessionIds: readonly SessionId[], signal?: AbortSignal, ): Promise +/** + * Project unique logical sessions synchronously from one cancellable corpus + * observation. + * + * Each source is a borrowed raw log without replay validation or cloning, so + * a batch summary costs one bounded read per persisted session instead of a + * full validated copy; the projector must clone anything it retains beyond + * its own call. Results preserve first-occurrence input order. Operational + * failures stay isolated per session, while cancellation rejects the + * complete operation. + * @param sessionIds - live or persisted session ids to observe. + * @param project - synchronous fold that owns/clones every retained value. + * @param signal - optional cancellation shared by all source reads. + * @returns one fulfilled or rejected result per unique requested id. + */ +async projectSessions( sessionIds: readonly SessionId[], project: (source: LogicalSessionSource) => Value, signal?: AbortSignal, ): Promise[]> + /** * List lightweight raw-log event records for one logical session. * @param sessionId - live-preferred session id to read. @@ -1492,9 +1509,9 @@ async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promi async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): 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) · [SessionEventSearchPage](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTraceObservation](../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) · [SessionLogSnapshot](../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) · [SessionTitleObservation](../core-data-structures/session-query.md) · [SessionTitleObservationResult](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) +Types: [LogicalProjectionResult](../core-data-structures/session-query.md) · [LogicalSessionSource](../core-data-structures/session-query.md) · [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) · [SessionEventSearchPage](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTraceObservation](../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) · [SessionLogSnapshot](../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) · [SessionTitleObservation](../core-data-structures/session-query.md) · [SessionTitleObservationResult](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-query/session-query/src/index.ts:81`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:82`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessionReferences` — `SessionReferenceService` diff --git a/docs/core-data-structures/session-query.i18n.yaml b/docs/core-data-structures/session-query.i18n.yaml index f9c7355148..fc00c40e0a 100644 --- a/docs/core-data-structures/session-query.i18n.yaml +++ b/docs/core-data-structures/session-query.i18n.yaml @@ -1,6 +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 -session-query.md: d92af4bac34f7d41457e9e193111c3a53fe8022e -session-query.zh.md: ecf330b0a361ffae352a91c0d35524444936606d +# pnpm run verify-translation-pairing --write docs/core-data-structures/session-query.md +session-query.md: cffbd792e8cab6e79365ceb4e0b1d996e7e93cf5 +session-query.zh.md: 2b67761ec3bcd96ee9de15511ec44516f1fe525d diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index d92af4bac3..cffbd792e8 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -84,6 +84,25 @@ type SessionTitleObservationResult = } ``` +`projectSessions` batches arbitrary synchronous folds over the same live-preferred corpus: each `LogicalSessionSource` is a borrowed raw log — never replay-validated or cloned — that is valid only for the projector call, so a batch summary costs one bounded read per persisted session. Each `LogicalProjectionResult` settles per unique requested id under the same isolation and cancellation rules as batch title reads. + +```ts type-equiv +/** Borrowed source visible only during one synchronous batch projection. */ +interface LogicalSessionSource { + /** Header selected with `events`; callers must clone retained output. */ + readonly header: SessionHeader + /** Raw events selected with `header`; valid only for the projection call. */ + readonly events: readonly SessionEvent[] +} +``` + +```ts type-equiv +/** One source-projection result in a batch logical-corpus observation. */ +type LogicalProjectionResult = + | { sessionId: SessionId; status: 'fulfilled'; value: Value } + | { sessionId: SessionId; status: 'rejected'; reason: unknown } +``` + ```ts type-equiv /** Lightweight metadata for one event within a logical session. */ interface SessionEventRecord { diff --git a/docs/core-data-structures/session-query.zh.md b/docs/core-data-structures/session-query.zh.md index ecf330b0a3..2b67761ec3 100644 --- a/docs/core-data-structures/session-query.zh.md +++ b/docs/core-data-structures/session-query.zh.md @@ -84,6 +84,25 @@ type SessionTitleObservationResult = } ``` +`projectSessions` 在同一实时优先语料库上批量执行任意同步折叠:每个 `LogicalSessionSource` 都是借用的原始日志——从不做回放验证,也从不克隆——仅在投影函数调用期间有效,因此一次批量摘要对每个持久化会话只需一次有界读取。每个 `LogicalProjectionResult` 按唯一请求 id 结算,其失败隔离与取消规则与批量标题读取一致。 + +```ts type-equiv +/** Borrowed source visible only during one synchronous batch projection. */ +interface LogicalSessionSource { + /** Header selected with `events`; callers must clone retained output. */ + readonly header: SessionHeader + /** Raw events selected with `header`; valid only for the projection call. */ + readonly events: readonly SessionEvent[] +} +``` + +```ts type-equiv +/** One source-projection result in a batch logical-corpus observation. */ +type LogicalProjectionResult = + | { sessionId: SessionId; status: 'fulfilled'; value: Value } + | { sessionId: SessionId; status: 'rejected'; reason: unknown } +``` + ```ts type-equiv /** Lightweight metadata for one event within a logical session. */ interface SessionEventRecord { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b888f33ed4..10c2a0bdcf 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -678,6 +678,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async readTitleSnapshots( sessionIds: readonly SessionId[], signal?: AbortSignal, ): Promise', jsDoc: '/**\n * Fold titles for unique sessions from one cancellable corpus observation.\n *\n * Results preserve first-occurrence input order. Operational failures stay\n * isolated per session, while cancellation rejects the complete operation.\n * @param sessionIds - live or persisted session ids to observe.\n * @param signal - optional cancellation shared by all source reads.\n * @returns one fulfilled or rejected result per unique requested id.\n */', }, + { + signature: 'async projectSessions( sessionIds: readonly SessionId[], project: (source: LogicalSessionSource) => Value, signal?: AbortSignal, ): Promise[]>', + jsDoc: '/**\n * Project unique logical sessions synchronously from one cancellable corpus\n * observation.\n *\n * Each source is a borrowed raw log without replay validation or cloning, so\n * a batch summary costs one bounded read per persisted session instead of a\n * full validated copy; the projector must clone anything it retains beyond\n * its own call. Results preserve first-occurrence input order. Operational\n * failures stay isolated per session, while cancellation rejects the\n * complete operation.\n * @param sessionIds - live or persisted session ids to observe.\n * @param project - synchronous fold that owns/clones every retained value.\n * @param signal - optional cancellation shared by all source reads.\n * @returns one fulfilled or rejected result per unique requested id.\n */', + }, { signature: 'async listEvents(sessionId: SessionId): Promise', jsDoc: '/**\n * List lightweight raw-log event records for one logical session.\n * @param sessionId - live-preferred session id to read.\n * @returns event records in ascending seq order.\n */', @@ -2055,6 +2059,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmResolvedModelInfo', declaration: 'export interface LlmResolvedModelInfo extends LlmModelInfo {\n context?: LlmModelContext;\n defaultMaxTokens?: number;\n reasoning?: LlmModelReasoningInfo;\n}', }, + { + name: 'LogicalProjectionResult', + declaration: 'export type LogicalProjectionResult = {\n sessionId: SessionId;\n status: \'fulfilled\';\n value: Value;\n} | {\n sessionId: SessionId;\n status: \'rejected\';\n reason: unknown;\n};', + }, + { + name: 'LogicalSessionSource', + declaration: 'export interface LogicalSessionSource {\n readonly header: SessionHeader;\n readonly events: readonly SessionEvent[];\n}', + }, { name: 'Message', declaration: 'export interface Message {\n readonly id: MessageId;\n readonly role: \'system\' | \'user\' | \'assistant\';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n}', diff --git a/packages/session-query/session-query/README.i18n.yaml b/packages/session-query/session-query/README.i18n.yaml index fb266a82de..0ac53ddb14 100644 --- a/packages/session-query/session-query/README.i18n.yaml +++ b/packages/session-query/session-query/README.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 packages/session-query/session-query/README.md -README.md: df97333be3b2c2cf71dd8c9287959bcbd83a5063 -README.zh.md: 1a3df1ce38360975d88a9f578b071b29cefbba0f +README.md: 15ab403100b45e35808e95f84dcd8ab521854c66 +README.zh.md: 5e3cbfa0d13ba4884d0eb2cc1b4506fbfdef2446 diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index df97333be3..15ab403100 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -11,13 +11,14 @@ English | [中文](README.zh.md) - `filterSessions(filters, signal?)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus. - `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order. - `readTitleSnapshots(sessionIds, signal?)` resolves unique ids from one live-preferred corpus observation, passes cancellation through persisted listing and inspection, and returns ordered per-session settlements so one missing or malformed title source does not discard its peers. Each live source is folded directly, and each persisted worker folds to a detached header/title result and releases the full log before dequeuing another id. Cancellation rejects the whole batch. `readTitleSnapshot(sessionId, signal?)` is the one-observation view; `readTitle(sessionId, signal?)` returns only its optional folded `session/title`. +- `projectSessions(sessionIds, project, signal?)` runs one synchronous caller fold per unique id under the same batched corpus observation, isolation, and cancellation rules as `readTitleSnapshots`. Each source is a borrowed raw log — never replay-validated or cloned — valid only for the projector call, so a batch summary (for example the resume selector) scales with what the projector retains instead of total log size; the projector must clone anything it keeps. - `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. - `readSurface(sessionId)` returns one cloned header, raw-log capture boundary, and the complete folded current surface in model-history order. A live session wins over persistence; compaction is observed before or after its replacement append, never as a synthetic mixture. - `readEvent(request, signal?)` 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`. - `traceSession(sessionId, signal?)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`. - `traceEvent(request, signal?)` loads the logical log once and returns its cloned source header with direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive. -Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title read, event trace, or event read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. Lineage-trace cancellation is passed to persisted listing; event-trace and event-read cancellation is passed to persisted listing and inspection. Each waits for the started backend call to settle, then rejects with the signal's exact reason even when the backend ignored that signal. A pre-aborted known-live title read, event trace, or event read rejects before folding or snapshotting without consulting persistence. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles. +Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title read, event trace, or event read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. Lineage-trace cancellation is passed to persisted listing; event-trace and event-read cancellation is passed to persisted listing and inspection. Each waits for the started backend call to settle, then rejects with the signal's exact reason even when the backend ignored that signal. A pre-aborted known-live title read, event trace, or event read rejects before folding or snapshotting without consulting persistence. A batch observation — titles or caller projections — performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each result's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles. ## Filtering and extraction diff --git a/packages/session-query/session-query/README.zh.md b/packages/session-query/session-query/README.zh.md index 1a3df1ce38..5e3cbfa0d1 100644 --- a/packages/session-query/session-query/README.zh.md +++ b/packages/session-query/session-query/README.zh.md @@ -11,13 +11,14 @@ - `filterSessions(filters, signal?)` 对同一份克隆逻辑语料库应用与提供方无关的会话元数据和可用性谓词。 - `filterEvents(sessionId, filters)` 提取第一方语义文档,并按 seq 升序应用与提供方无关的元数据和字面文本谓词。 - `readTitleSnapshots(sessionIds, signal?)` 从一次实时优先的语料库观察中解析唯一 id,将取消信号传递给持久化列表查询和检查,并按顺序返回每个会话的结算结果,使某个缺失或格式错误的标题来源不会丢弃其他来源。每个实时来源直接 fold,每个持久化 worker fold 为脱离存储的 header/标题结果,并在出队下一个 id 前释放完整日志。取消会拒绝整个批次。`readTitleSnapshot(sessionId, signal?)` 是单次观察视图;`readTitle(sessionId, signal?)` 只返回其可选的 folded `session/title`。 +- `projectSessions(sessionIds, project, signal?)` 按唯一 id 各执行一次调用方的同步 fold,其批量语料库观察、失败隔离和取消规则与 `readTitleSnapshots` 相同。每个来源都是借用的原始日志——从不做回放验证,也从不克隆——仅在投影函数调用期间有效,因此一次批量摘要(例如恢复选择器)的开销取决于投影函数保留的内容,而不是日志总大小;投影函数必须克隆它要保留的任何值。 - `listEvents(sessionId)` 加载实时优先的原始日志,将每个事件分类为 `current`、`shadowed` 或 `log-only`;该分类使用共享 `dsh-session` 表层 fold。 - `readSurface(sessionId)` 返回一个克隆 header、原始日志捕获边界,以及按模型历史顺序排列的完整折叠后当前表层。实时会话优先于持久化;压缩(compaction)只会在其替换追加之前或之后被观察,绝不会出现合成混合。 - `readEvent(request, signal?)` 返回一个克隆 header、完整目标事件和有界的原始 seq 窗口。`before` 和 `after` 默认为 0,且不得超过 `readWindowMax`。 - `traceSession(sessionId, signal?)` 只读取一次语料库,返回从直接父级向外的祖先,以及确定性的递归后代树。`complete: false` 标识第一个缺失父级;与目标相连的循环会以 `SESSION_QUERY_INVALID_LINEAGE` 失败。 - `traceEvent(request, signal?)` 只加载一次逻辑日志,返回其克隆源 header、直接位置替换和直接已记录来源信息。`replacementChain` 沿位置替换者跟踪到最终替换;来源链接仍不传递。 -持久化是可选的,可动态挂载或卸载。已挂载持久化无法读取时,跨语料库列表和血缘跟踪以 `SESSION_QUERY_PERSISTENCE_FAILED` 失败。针对已知实时会话的标题读取、事件跟踪或事件读取不会查询持久化,因此持久化后端的健康状态无法使当前内存状态变得不可读。持久化标题和事件操作在加载前先执行列表查询,并在元数据不匹配时拒绝,而不会组合不一致的观察。血缘跟踪的取消信号会传递给持久化列表查询;事件跟踪和事件读取的取消信号会传递给持久化列表查询和检查。每项操作都会等待已启动的后端调用结算,然后使用信号的精确原因拒绝,即使后端忽略了该信号。针对已知实时会话且预先中止的标题读取、事件跟踪或事件读取会在 fold 或快照之前拒绝,且不查询持久化。批量标题观察执行一次元数据列表查询,使用最多 `persistedInspectConcurrency` 个 worker 检查唯一持久化 id,并保留每个标题自己观察到的 header,供下游授权使用。取消不会启动已排队检查,且只在已启动 worker 结算后拒绝。`listSessions()` 仍保持轻量,不加载日志或索引标题。 +持久化是可选的,可动态挂载或卸载。已挂载持久化无法读取时,跨语料库列表和血缘跟踪以 `SESSION_QUERY_PERSISTENCE_FAILED` 失败。针对已知实时会话的标题读取、事件跟踪或事件读取不会查询持久化,因此持久化后端的健康状态无法使当前内存状态变得不可读。持久化标题和事件操作在加载前先执行列表查询,并在元数据不匹配时拒绝,而不会组合不一致的观察。血缘跟踪的取消信号会传递给持久化列表查询;事件跟踪和事件读取的取消信号会传递给持久化列表查询和检查。每项操作都会等待已启动的后端调用结算,然后使用信号的精确原因拒绝,即使后端忽略了该信号。针对已知实时会话且预先中止的标题读取、事件跟踪或事件读取会在 fold 或快照之前拒绝,且不查询持久化。批量观察——标题或调用方投影——执行一次元数据列表查询,使用最多 `persistedInspectConcurrency` 个 worker 检查唯一持久化 id,并保留每个结果自己观察到的 header,供下游授权使用。取消不会启动已排队检查,且只在已启动 worker 结算后拒绝。`listSessions()` 仍保持轻量,不加载日志或索引标题。 ## 过滤与提取 diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 809971b798..cf3dd95e39 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -36,7 +36,7 @@ import { SessionQueryError, type Config, } from './config.ts' -import { SessionCorpus } from './corpus.ts' +import { SessionCorpus, type LogicalProjectionResult, type LogicalSessionSource } from './corpus.ts' import { buildSessionEventSearchDocuments } from './documents.ts' import { filterSessionEventDocuments, @@ -64,6 +64,7 @@ export { materializeSessionResultFilters, } from './filters.ts' export { assertSessionHeadersCompatible } from './sources.ts' +export type { LogicalProjectionResult, LogicalSessionSource } from './corpus.ts' declare module 'cordis' { interface Context { @@ -205,7 +206,7 @@ export abstract class SessionQueryService extends Service { sessionIds: readonly SessionId[], signal?: AbortSignal, ): Promise { - return this._corpus.projectMany(sessionIds, (source): SessionTitleObservation => { + return this.projectSessions(sessionIds, (source): SessionTitleObservation => { const title = foldSessionTitle(source.events) return { session: structuredClone(source.header), @@ -214,6 +215,29 @@ export abstract class SessionQueryService extends Service { }, signal) } + /** + * Project unique logical sessions synchronously from one cancellable corpus + * observation. + * + * Each source is a borrowed raw log without replay validation or cloning, so + * a batch summary costs one bounded read per persisted session instead of a + * full validated copy; the projector must clone anything it retains beyond + * its own call. Results preserve first-occurrence input order. Operational + * failures stay isolated per session, while cancellation rejects the + * complete operation. + * @param sessionIds - live or persisted session ids to observe. + * @param project - synchronous fold that owns/clones every retained value. + * @param signal - optional cancellation shared by all source reads. + * @returns one fulfilled or rejected result per unique requested id. + */ + async projectSessions( + sessionIds: readonly SessionId[], + project: (source: LogicalSessionSource) => Value, + signal?: AbortSignal, + ): Promise[]> { + return this._corpus.projectMany(sessionIds, project, signal) + } + /** * List lightweight raw-log event records for one logical session. * @param sessionId - live-preferred session id to read. 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 2713de9a72..0613673f88 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -548,6 +548,33 @@ describe('session-query exact reads', () => { expect(TestPersistence.inspectSignals).toEqual([signal, signal]) }) + it('projects borrowed raw logs in one corpus scan with per-session failure isolation', async () => { + const persisted = header('project-persisted', 1) + TestPersistence.reset([{ meta: persisted, events: eventLog('persisted-projection') }]) + const ctx = await liveContext() + const live = ctx.sessions.create(SessionId('project-live'), { meta: { createdAt: 2 } }) + live.append('session/title', { + title: 'Live projection', + messageSeqs: [], + source: { kind: 'fallback' }, + }) + await ctx.plugin(TestPersistence) + const missing = SessionId('project-missing') + + const results = await ctx.sessionQuery.projectSessions( + [live.id, persisted.id, missing], + source => ({ id: source.header.id, eventCount: source.events.length }), + ) + + expect(results).toMatchObject([ + { sessionId: live.id, status: 'fulfilled', value: { id: live.id, eventCount: 1 } }, + { sessionId: persisted.id, status: 'fulfilled', value: { id: persisted.id, eventCount: 1 } }, + { sessionId: missing, status: 'rejected' }, + ]) + expect(TestPersistence.listCalls).toBe(1) + expect(TestPersistence.inspectCalls).toEqual([persisted.id]) + }) + it('bounds persisted title inspection concurrency while preserving ordered results', async () => { const entries = Array.from({ length: 12 }, (_, index) => { const meta = header(`bounded-title-${index}`, index) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 86368b03ed..a5a17bbb5a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -55,6 +55,8 @@ export const LINK_MAP: Readonly> = { SessionEvent: 'core.md', SessionId: 'core.md', SessionStartSource: 'core.md', + LogicalProjectionResult: 'session-query.md', + LogicalSessionSource: 'session-query.md', SessionLogSnapshot: 'session-query.md', SessionSurfaceSnapshot: 'session-query.md', ApprovalOutcome: 'approval.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index d18bd54268..d511bfecaa 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -449,6 +449,16 @@ "symbol": "SessionTitleObservationResult", "source": "packages/session-query/session-query/src/types.ts" }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "LogicalSessionSource", + "source": "packages/session-query/session-query/src/corpus.ts" + }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "LogicalProjectionResult", + "source": "packages/session-query/session-query/src/corpus.ts" + }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord",