diff --git a/packages/session-persistence-jsonl/src/index.ts b/packages/session-persistence-jsonl/src/index.ts index f68dde5a0c..7a0c97637c 100644 --- a/packages/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence-jsonl/src/index.ts @@ -25,8 +25,10 @@ import z from 'schemastery' import { open, mkdir, readFile, readdir, rename, link, rm, truncate } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { randomBytes } from 'node:crypto' -import { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import { isJsonValue, interruptedTurnClosers } from '@deepseek-ai/dsh-session' +import { + SessionPersistence, assertSerializable, seedCoversPrefix, +} from '@deepseek-ai/dsh-session-persistence' +import { interruptedTurnClosers } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionMeta, SessionSummary } from '@deepseek-ai/dsh-session' import { encodeSegment, eventLine, logPath, parseHeaderMeta, scanLog, sessionDir, sidecarPath, toHeaderLine, @@ -59,44 +61,6 @@ interface SessionState { owner?: Session } -/** - * Whether a live session's `seed` reproduces a persisted `prefix` exactly — the - * prefix is no longer than the seed, and each prefix event DEEP-equals the seed - * event at the same index. Used to tell a session legitimately continuing a - * persisted log (HMR re-seeing its own session, or a resume) from a different - * session that merely reuses the id: the latter would have its already-counted - * seq 0..prefix-1 events filtered out on flush and its conversation silently - * grafted onto the old log. - * - * The comparison is a full structural equality (via canonical JSON) of each - * event INCLUDING its `data` payload, not just `seq`/`type`/`time` — a session - * built from loaded events but with mutated message/tool payloads (same seq/ - * type/time) must NOT be accepted, or the live history and durable log diverge. - * Both sides are JSON-serializable by contract (Session.append enforces it), so - * JSON.stringify is a sound canonical form here. - */ -function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly SessionEvent[]): boolean { - return prefix.length <= seed.length - && prefix.every((e, i) => { - const s = seed[i] - return s !== undefined && JSON.stringify(s) === JSON.stringify(e) - }) -} - -/** - * Reject non-JSON-serializable `event.data`, naming the offending type. Used on - * the backend's `append(events)` entry point (replay/fork paths that bypass a - * live `Session`); events that flow through `Session.append` are already - * validated at the source, so the live write path never needs this. - */ -function assertSerializable(events: readonly SessionEvent[]): void { - for (const event of events) { - if (!isJsonValue(event.data)) { - throw new Error(`event "${event.type}" carries non-JSON-serializable data (seq ${event.seq})`) - } - } -} - /** * Whether `error` is a "no such file/directory" (`ENOENT`) failure — the ONLY * filesystem error that legitimately means "this session/root is absent" for a diff --git a/packages/session-persistence-sqlite/src/index.ts b/packages/session-persistence-sqlite/src/index.ts index 7f59e3dd8a..d28fc8d6f7 100644 --- a/packages/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence-sqlite/src/index.ts @@ -24,8 +24,10 @@ import z from 'schemastery' import { DatabaseSync } from 'node:sqlite' import { mkdir } from 'node:fs/promises' import { dirname, resolve } from 'node:path' -import { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' -import { isJsonValue, interruptedTurnClosers } from '@deepseek-ai/dsh-session' +import { + SessionPersistence, assertSerializable, seedCoversPrefix, +} from '@deepseek-ai/dsh-session-persistence' +import { interruptedTurnClosers } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionMeta, SessionSummary } from '@deepseek-ai/dsh-session' import { openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow, @@ -54,32 +56,6 @@ interface SessionState { owner?: Session } -/** - * Whether a live session's `seed` reproduces a persisted `prefix` exactly (the - * prefix is no longer than the seed and each event DEEP-equals the seed event - * at the same index). Distinguishes a session legitimately continuing a - * persisted log (HMR re-seeing its own session, or a resume) from a different - * session that merely reuses the id. Mirrors the JSONL backend's check; both - * sides are JSON-serializable by contract, so `JSON.stringify` is a sound - * canonical form. - */ -function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly SessionEvent[]): boolean { - return prefix.length <= seed.length - && prefix.every((e, i) => { - const s = seed[i] - return s !== undefined && JSON.stringify(s) === JSON.stringify(e) - }) -} - -/** Reject non-JSON-serializable `event.data`, naming the offending type. */ -function assertSerializable(events: readonly SessionEvent[]): void { - for (const event of events) { - if (!isJsonValue(event.data)) { - throw new Error(`event "${event.type}" carries non-JSON-serializable data (seq ${event.seq})`) - } - } -} - async function settledErrors(promises: Iterable>): Promise { const settled = await Promise.allSettled([...promises]) const errors: unknown[] = [] diff --git a/packages/session-persistence/src/index.ts b/packages/session-persistence/src/index.ts index 02e62940d7..0ce1e25146 100644 --- a/packages/session-persistence/src/index.ts +++ b/packages/session-persistence/src/index.ts @@ -22,6 +22,7 @@ */ import { Context, Service } from 'cordis' +import { isJsonValue } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionId, SessionMeta, SessionSummary } from '@deepseek-ai/dsh-session' // Re-export the metadata vocabulary so consumers import it from the seam. @@ -33,6 +34,35 @@ declare module 'cordis' { } } +/** + * Whether a live session's seed reproduces a persisted prefix exactly. Backends + * use this collision check to distinguish a legitimate resume/HMR rebind from a + * different live session reusing an existing session id. + * + * The comparison includes the full event payload, not just seq/type/time, so a + * mutated seed cannot be grafted onto a durable log with the same envelope. + */ +export function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly SessionEvent[]): boolean { + return prefix.length <= seed.length + && prefix.every((event, index) => { + const seedEvent = seed[index] + return seedEvent !== undefined && JSON.stringify(seedEvent) === JSON.stringify(event) + }) +} + +/** + * Reject non-JSON-serializable event data before a backend serializes a batch. + * Live session appends already enforce this; persistence append paths also + * accept replay/fork batches that may bypass a live session instance. + */ +export function assertSerializable(events: readonly SessionEvent[]): void { + for (const event of events) { + if (!isJsonValue(event.data)) { + throw new Error(`event "${event.type}" carries non-JSON-serializable data (seq ${event.seq})`) + } + } +} + /** * Abstract durable session-persistence service. Subclass, implement the * abstract methods, and load the subclass as a plugin — it registers as diff --git a/packages/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/tests/persistence.spec.ts index 32f97a30b5..5c0a29131f 100644 --- a/packages/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/tests/persistence.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import { SessionId, isJsonValue, interruptedTurnClosers } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionMeta, SessionSummary } from '@deepseek-ai/dsh-session' -import { SessionPersistence } from '../src/index.ts' +import { SessionPersistence, assertSerializable, seedCoversPrefix } from '../src/index.ts' import { runPersistenceContract, meta, oneTurnLog } from './contract.ts' /** @@ -104,3 +104,38 @@ describe('SessionPersistence service registration', () => { await fiber.dispose() }) }) + +describe('shared persistence helpers', () => { + it('accepts a seed that reproduces the persisted prefix exactly', () => { + const log = oneTurnLog() + expect(seedCoversPrefix(log, log.slice(0, 3))).toBe(true) + expect(seedCoversPrefix(log, [])).toBe(true) + }) + + it('rejects a prefix longer than the seed', () => { + const log = oneTurnLog() + expect(seedCoversPrefix(log.slice(0, 2), log)).toBe(false) + }) + + it('rejects a same-envelope event with mutated data', () => { + const log = oneTurnLog() + const tampered = structuredClone(log) + const event = tampered[1]! + tampered[1] = { + ...event, + data: { ...event.data, content: [{ type: 'text', text: 'tampered' }] }, + } as SessionEvent + expect(seedCoversPrefix(tampered, log.slice(0, 2))).toBe(false) + }) + + it('accepts JSON-serializable event data', () => { + expect(() => { assertSerializable(oneTurnLog()) }).not.toThrow() + }) + + it('rejects non-JSON-serializable event data with type and seq context', () => { + const bad = [ + { type: 'user/message', seq: 0, time: 1, data: { content: 1n } }, + ] as unknown as SessionEvent[] + expect(() => { assertSerializable(bad) }).toThrow(/"user\/message".*seq 0/) + }) +})