fix review findings: skip collided SCHEMA_VERSION 3; reject marker-less surface events

P1: both merge parents shipped SCHEMA_VERSION=3 for different layouts (surface
columns vs seed_length), so an on-disk 3 was ambiguous and wrongly accepted.
Bump to 4 (merged layout) so the version check rejects both sibling v3s.

P2: a surface-eligible event with no surfaceOp lands in the log but vanishes
from deriveMessages() (surface is the sole derivation path). The typed append
overload enforces the marker only when the type arg is a literal; it collapses
to optional when widened to the union (a caller iterating raw events). Guard at
runtime in both append() and the seed constructor — no backward-compat for
surface-less logs. Shared seed fixtures carry surfaceOp explicitly and the
appendLog helper forwards it verbatim (no synthesized default). Exports
isSurfaceEligibleType. Regression tests for all three, each verified to fail
on the unfixed code.

Gates: typecheck, test (1115), snapshot (14), doc-sync, lint, build, hygiene green.
This commit is contained in:
Hypatia May
2026-06-24 17:45:48 +08:00
parent 9cc8dc371e
commit 828c3f85c9
14 changed files with 166 additions and 36 deletions

View File

@@ -7,7 +7,7 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
import { openDatabase, rowToEvent, scanRows, type EventRow } from '../src/schema.ts'
import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts'
import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
const dirs: string[] = []
@@ -66,9 +66,17 @@ runCoordinatorContract('sqlite', async (): Promise<CoordinatorFixture> => {
describe('scanRows', () => {
// scanRows works off EventRows (data is a JSON string column); build them from
// SessionEvents so the unit tests read in terms of the event vocabulary.
// SessionEvents so the unit tests read in terms of the event vocabulary. Surface
// fields are serialized to their nullable columns so a round trip is faithful.
const rows = (events: SessionEvent[]): EventRow[] =>
events.map(e => ({ seq: e.seq, type: e.type, time: e.time, data: JSON.stringify(e.data), source_event_seqs: null, surface_op: null }))
events.map((e) => {
const se = e as SessionEvent<SurfaceEventType>
return {
seq: e.seq, type: e.type, time: e.time, data: JSON.stringify(e.data),
source_event_seqs: se.sourceEventSeqs !== undefined ? JSON.stringify(se.sourceEventSeqs) : null,
surface_op: se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null,
}
})
it('preserves the full log when it ends exactly on a turn/end (no torn tail)', () => {
const { preserved, tornFrom } = scanRows(rows(oneTurnLog()))
@@ -246,6 +254,20 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
expect(() => openDatabase(olderPath)).toThrow(/incompatible with this build/)
})
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
// 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).close() // creates + stamps user_version = SCHEMA_VERSION (4)
const db = openDatabase(path)
db.exec('PRAGMA user_version = 3')
db.close()
expect(() => openDatabase(path)).toThrow(/schema version 3, incompatible with this build/)
})
it('a corrupt-JSON row in the uncommitted tail is discarded on load, not unloadable', async () => {
const path = await freshDbPath()
const m = meta('corrupt-tail')
@@ -315,7 +337,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
})
it('exposes the schema version constant', () => {
expect(SCHEMA_VERSION).toBe(3)
expect(SCHEMA_VERSION).toBe(4)
})
})
@@ -353,7 +375,7 @@ describe('SessionPersistenceSqlite: edge cases', () => {
// Instance 1 materializes a session and disposes.
const b1 = await backend(path)
const s1 = b1.ctx.sessions.create(SessionId('hmr-collide'))
for (const e of oneTurnLog()) s1.append(e.type, e.data)
appendLog(s1, oneTurnLog())
await b1.ctx.parallel('session/flush', s1)
await b1.dispose()