fix(session-export): distinguish unsupported raw artifacts

SessionPersistence.readRaw previously used undefined for two unrelated states: a supported backend could not find the requested session, or the backend had no per-session artifact concept at all. The export endpoint consequently reported an existing SQLite-backed session as HTTP 404, which falsely diagnosed storage capability as session absence.

Make raw-artifact support an explicit backend capability. Unsupported backends now fail their inherited readRaw path loudly and the host answers 501 before reading, while undefined retains the single meaning of an absent artifact on a supporting backend. First-party backends, test providers, generated API catalogs, bilingual persistence docs, and export error contracts now state that distinction; focused tests cover both the 501 and the inherited rejection.
This commit is contained in:
Tianyi Cui
2026-08-11 15:04:35 +08:00
parent 7f14c7e165
commit e58cc13de4
24 changed files with 78 additions and 30 deletions

View File

@@ -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/session-persistence/README.md
README.md: 324c00b3202bd136566137e1bd398b29d2ea4b82
README.zh.md: 2ef5e9a90f0323f8edf8fdc4f936c41ca7e08c70
README.md: 09aa7ad8263454d6c5edbb2358033370504c0cb9
README.zh.md: 901c41b6894d86bdc4ffb345314a3dd506e4a770

View File

@@ -11,6 +11,8 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
| Method | Contract |
|---|---|
| `locate(meta): SessionLocation \| undefined` | Resolve an absolute per-session artifact target without I/O or materialization. Backends without an independent local artifact return `undefined`. |
| `supportsRawArtifacts: boolean` | State explicitly whether this backend exposes one verbatim artifact per session. Consumers check this capability before calling `readRaw`; `false` is not session absence. |
| `readRaw(id, signal?): Promise<SessionRawArtifact \| undefined>` | Read a supported backend's own artifact text verbatim, decoded from its physical encoding but never reconstructed from events. `undefined` means only that the requested artifact is absent; an unsupported backend rejects. |
| `create(meta): Promise<void>` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). |
| `append(id, events): Promise<void>` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. |
| `prepare(id, signal?): Promise<SessionPreparation>` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. |

View File

@@ -11,6 +11,8 @@
| 方法 | 约定 |
|---|---|
| `locate(meta): SessionLocation \| undefined` | 在不执行 I/O 或实体化的情况下解析绝对的每会话产物目标。没有独立本地产物的后端返回 `undefined`。 |
| `supportsRawArtifacts: boolean` | 明确说明该后端是否为每个会话暴露一份逐字工件。Consumer 在调用 `readRaw` 前检查此能力;`false` 并不表示会话缺失。 |
| `readRaw(id, signal?): Promise<SessionRawArtifact \| undefined>` | 读取受支持后端自身的逐字工件文本;只解码物理编码,绝不从事件重建。`undefined` 仅表示所请求工件缺失;不支持的后端会拒绝。 |
| `create(meta): Promise<void>` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 |
| `append(id, events): Promise<void>` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq非 JSON 可序列化数据会被拒绝,并命名违规类型。 |
| `prepare(id, signal?): Promise<SessionPreparation>` | 预留恢复所使用的那个未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复并在 dispose 时将未发布 reservation 释放回有界缓存。 |

View File

@@ -95,24 +95,29 @@ export abstract class SessionPersistence extends Service {
*/
abstract locate(meta: SessionHeader): SessionLocation | undefined
/** Whether this backend exposes one verbatim raw artifact per session. */
abstract readonly supportsRawArtifacts: boolean
/**
* Read a session's backend-owned artifact text verbatim — the exact durable
* bytes the backend wrote (decoded from its physical encoding, e.g. a
* decompressed JSONL). The returned `content` is the raw text, not a
* reconstruction from parsed events, so it preserves backend-specific
* serialization (chunk packing, key order, line breaks). Backends without a
* per-session artifact (SQLite) inherit the `undefined` default.
* serialization (chunk packing, key order, line breaks). Callers first test
* {@link supportsRawArtifacts}; `undefined` then means only that the requested
* session has no materialized artifact.
* @param _id - the persisted session to read (unused by the default: no
* per-session artifact).
* @param signal - optional cancellation for backend read work.
* @returns the raw artifact plus its parsed header, or `undefined` when the
* session is absent or the backend owns no per-session artifact.
* session is absent.
* @throws when this backend does not expose per-session raw artifacts.
*/
readRaw(_id: SessionId, signal?: AbortSignal): Promise<SessionRawArtifact | undefined> {
if (signal?.aborted === true) {
return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'))
}
return Promise.resolve(undefined)
return Promise.reject(new Error('this session persistence backend does not expose raw artifacts'))
}
/**

View File

@@ -68,6 +68,8 @@ interface CoordinatorInternals {
* durable behavior is covered by the JSONL and SQLite backends.
*/
class MemoryPersistence extends SessionPersistence implements PersistenceBackend<never> {
override readonly supportsRawArtifacts = false
static inject = ['sessions']
override readonly name = 'session-persistence-memory'
@@ -247,11 +249,14 @@ runPersistenceContract('memory', async () => {
})
describe('the inherited readRaw default', () => {
it('answers undefined and honors an aborted signal', async () => {
it('rejects unsupported reads distinctly from absence and honors an aborted signal', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(MemoryPersistence)
expect(await ctx.sessionPersistence.readRaw(SessionId('any-session'))).toBeUndefined()
expect(ctx.sessionPersistence.supportsRawArtifacts).toBe(false)
await expect(
ctx.sessionPersistence.readRaw(SessionId('any-session')),
).rejects.toThrow('does not expose raw artifacts')
await expect(
ctx.sessionPersistence.readRaw(SessionId('any-session'), AbortSignal.abort()),
).rejects.toThrow()