From 1ef7c9473ce34c03b600659ca7320a8f87606a29 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:44:19 +0800 Subject: [PATCH] fix(session-projection-cache): coldSnapshot honors not-found with zero registered units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../session-projection-cache/src/index.ts | 8 ++++++- .../tests/cache.spec.ts | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/session-projection/session-projection-cache/src/index.ts b/packages/session-projection/session-projection-cache/src/index.ts index abb39ad1de..92515015f8 100644 --- a/packages/session-projection/session-projection-cache/src/index.ts +++ b/packages/session-projection/session-projection-cache/src/index.ts @@ -141,8 +141,14 @@ export class SessionProjectionCache extends Service { async coldSnapshot(id: SessionId, signal?: AbortSignal): Promise { 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 { diff --git a/packages/session-projection/session-projection-cache/tests/cache.spec.ts b/packages/session-projection/session-projection-cache/tests/cache.spec.ts index defeddfd32..33d82cea24 100644 --- a/packages/session-projection/session-projection-cache/tests/cache.spec.ts +++ b/packages/session-projection/session-projection-cache/tests/cache.spec.ts @@ -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: {} }) + }) })