feat(apiproxy): projection column on session.list — cold titles with zero log loads
SessionSummary grows an optional projections column (whole value per key, same passthrough posture as the history-tail block): attached rows cut the live registry watermark cache; cold rows view the persisted projection cache's stored rows via the new registry viewCheckpoint face (version- matching keys only, zero I/O) — the RFC's motivating scenario, every session's title across a listing without loading one event log. The column is fail-soft and absence-coded: no registry, no cache row, or a throwing read serve the row without the column, never breaking the listing.
This commit is contained in:
@@ -19,7 +19,7 @@ import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
// Empty type import: applies the package's cordis Context merge
|
||||
// (`ctx.sessionPersistence`), which this service reads on the cold path.
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { ProjectionCheckpoint, ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ProjectionCheckpoint, ProjectionSnapshot, SessionProjectionMap } from '@deepseek-ai/dsh-session-projection'
|
||||
import type { KvTable } from '@deepseek-ai/dsh-storage-domain'
|
||||
import { projectionCacheDomainSpec } from './spec.ts'
|
||||
import type { CheckpointRecord } from './spec.ts'
|
||||
@@ -98,6 +98,20 @@ export class SessionProjectionCache extends Service {
|
||||
return this.requireTable().get(id)?.rows ?? {}
|
||||
}
|
||||
|
||||
/**
|
||||
* The zero-I/O listing read: whole values viewed straight from the stored
|
||||
* rows (version-matching keys only), as stale as the last durable
|
||||
* checkpoint but never wrong. Synchronous — a listing over every stored
|
||||
* session touches no log. Fresher paths (the history tail baseline,
|
||||
* {@link coldSnapshot}) supersede these values whenever a session is
|
||||
* actually opened.
|
||||
* @param id - the session whose cached values are viewed.
|
||||
* @returns whole values per key with a usable row; empty when none stored.
|
||||
*/
|
||||
cachedValues(id: SessionId): Partial<SessionProjectionMap> {
|
||||
return this.ctx.sessionProjections.viewCheckpoint(this.checkpointOf(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Durably checkpoint one live session NOW (both mandatory points call
|
||||
* this; tests and carriers may too). The registry cut is snapshotted at
|
||||
|
||||
@@ -280,6 +280,27 @@ export class SessionProjectionRegistry extends Service {
|
||||
return floor === undefined ? undefined : Math.max(floor - 1, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* View a checkpoint's rows without any log read: for every registered
|
||||
* unit whose row's `stateVersion` matches, serve the schema-validated
|
||||
* `view` of the stored state; mismatched or absent rows leave their key
|
||||
* absent (a cold or listing consumer treats it as not-yet-available and a
|
||||
* fuller read path refolds it). The zero-I/O rung of the read ladder —
|
||||
* values are as stale as their rows, never wrong.
|
||||
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
||||
* @returns whole values per key with a usable row; empty when none.
|
||||
*/
|
||||
viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap> {
|
||||
const values: Record<string, unknown> = {}
|
||||
for (const registration of this.registrations.values()) {
|
||||
const def = registration.def
|
||||
const row = checkpoint[def.key]
|
||||
if (row === undefined || row.stateVersion !== def.stateVersion) continue
|
||||
values[def.key] = def.schema.parse(def.view(row.state))
|
||||
}
|
||||
return values as Partial<SessionProjectionMap>
|
||||
}
|
||||
|
||||
/**
|
||||
* Cold read: fold every registered unit over a stored log suffix, seeding
|
||||
* each from its checkpoint row when usable — the one read recipe (cached
|
||||
|
||||
@@ -267,6 +267,19 @@ describe('SessionProjectionRegistry drive', () => {
|
||||
expect(current.values['test/count']).toBe(5)
|
||||
})
|
||||
|
||||
it('viewCheckpoint serves version-matching rows without any log and skips mismatched keys', async () => {
|
||||
const { ctx } = await harness()
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
ctx.sessionProjections.register(countUnit())
|
||||
const values = ctx.sessionProjections.viewCheckpoint({
|
||||
'test/marks': { stateVersion: 1, observedSeq: 4, state: { marks: ['stored'] } },
|
||||
'test/count': { stateVersion: 99, observedSeq: 4, state: 5 }, // mismatched: absent
|
||||
})
|
||||
expect(values['test/marks']).toEqual({ marks: ['stored'] })
|
||||
expect('test/count' in values).toBe(false)
|
||||
expect(ctx.sessionProjections.viewCheckpoint({})).toEqual({})
|
||||
})
|
||||
|
||||
it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => {
|
||||
const { ctx } = await harness()
|
||||
ctx.sessionProjections.register(countUnit())
|
||||
|
||||
Reference in New Issue
Block a user