fix(session-projection-cache): coldSnapshot honors not-found with zero registered units

Review finding (PR #791): with no projection definitions registered,
restoreFloor() is undefined and the fast path returned a successful empty
snapshot without touching persistence — a nonexistent session 'succeeded',
violating the documented not-found contract in that supported topology.
The no-unit branch now probes readFrom(id, 0): an absent log rejects with
the seam's not-found, a present one dates the empty cut at its stored end.
This commit is contained in:
imccyu
2026-07-28 11:44:19 +08:00
parent 54c893d7af
commit 1ef7c9473c
2 changed files with 29 additions and 1 deletions

View File

@@ -141,8 +141,14 @@ export class SessionProjectionCache extends Service {
async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot> {
const cached = this.checkpointOf(id)
const floor = this.ctx.sessionProjections.restoreFloor(cached)
if (floor === undefined) return { asOfSeq: -1, values: {} }
const persistence = this.ctx.sessionPersistence
if (floor === undefined) {
// No unit registered: nothing to fold, but the not-found contract must
// hold in this topology too — the probe read rejects for an absent log
// and dates the empty cut for a present one.
const probe = await persistence.readFrom(id, 0, signal)
return { asOfSeq: probe.events.at(-1)?.seq ?? -1, values: {} }
}
let restored: { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
const tail = await persistence.readFrom(id, floor, signal)
try {

View File

@@ -252,4 +252,26 @@ describe('SessionProjectionCache cold read', () => {
const { cache } = await harness()
await expect(cache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found')
})
it('holds the not-found contract with zero registered units, and dates the empty cut for a present log', async () => {
// Same composition minus any registered unit: restoreFloor is undefined,
// yet coldSnapshot must still reject for an absent log (probe read) and
// serve an empty cut at the stored end for a present one.
const pool = new MemoryMediaPool()
const logs = new Map([['bare', storedLog([['a']])]]) // seqs 0..2
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(Storage)
ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
ctx.storage.mount('domain', facility)
ctx.provide('storageDomain', facility)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
ctx.provide('sessionPersistence', fakePersistence(logs) as never)
await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('absent'))).rejects.toThrow('not found')
await expect(ctx.sessionProjectionCache.coldSnapshot(SessionId('bare')))
.resolves.toEqual({ asOfSeq: 2, values: {} })
})
})