fix(apiproxy,client): the list projection column becomes a seedable watermarked block
Review finding (PR #791): the column carried bare values (no seq), so the client could not seed its value store without risking a stale list block outranking newer push frames — and nothing consumed the column at all, leaving cold titles absent after a restart. SessionSummary.projections is now the same SessionProjectionsBlock as the history tail (values + asOfSeq; attached rows cut the live registry, cold rows serve the cache's identity-checked cachedSnapshot whose asOfSeq is the lowest served-row watermark). SessionManager.refreshList seeds each row's block into the per-session projection store via per-key apply — partial-baseline semantics: an absent key never clears, and higher-seq-wins keeps stale list blocks beneath push frames and tail baselines — so cold titles surface in the sidebar without opening a session.
This commit is contained in:
@@ -212,6 +212,19 @@ export class SessionManager {
|
||||
session.handleBlank(s.blank)
|
||||
session.handleRunning(s.running)
|
||||
}
|
||||
// Seed each row's projection baseline into the per-session value
|
||||
// store (cold titles surface without opening the session). Per-key
|
||||
// apply, not seed(): the list block is a partial baseline — the
|
||||
// cold cache serves only version-matching keys — so an absent key
|
||||
// must not clear; higher-seq-wins still keeps a stale list block
|
||||
// from overwriting a newer push frame or tail baseline.
|
||||
for (const s of result.value.items) {
|
||||
const block = s.projections
|
||||
if (block === undefined) continue
|
||||
const store = this.projectionStore(s.sessionId)
|
||||
const values = block.values as Record<string, unknown>
|
||||
for (const key of Object.keys(values)) store.apply(key, values[key], block.asOfSeq)
|
||||
}
|
||||
} else {
|
||||
this.listState = 'error'
|
||||
this.listError = result.error
|
||||
|
||||
@@ -160,6 +160,28 @@ describe('list lifecycle', () => {
|
||||
expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined()
|
||||
})
|
||||
|
||||
it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const manager = new SessionManager(api)
|
||||
// A push frame landed before the list (S2's title is newer than the block's cut).
|
||||
manager.handleMuxEnvelope({
|
||||
rpcId: 'push-newer' as never,
|
||||
payload: { type: 'session/projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9 } as never,
|
||||
})
|
||||
api.onList = () => Promise.resolve(ok({
|
||||
items: [
|
||||
{ ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } },
|
||||
{ ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } },
|
||||
] as never[],
|
||||
}))
|
||||
await manager.refreshList()
|
||||
const items = manager.getListSnapshot().items
|
||||
// Cold row: title surfaces straight from the list block — no open, no history.
|
||||
expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached')
|
||||
// The stale list block (seq 5) cannot overwrite the newer push frame (seq 9).
|
||||
expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed')
|
||||
})
|
||||
|
||||
it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => {
|
||||
const api = new FakeApiClient()
|
||||
api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] }))
|
||||
|
||||
Reference in New Issue
Block a user