fix(session-projection-cache): bind records to the log lifecycle; flush before checkpoint

Review finding (PR #791): rows carried only version/watermark/state, so a
recreated session id, or a persistence store replaced under a surviving
cache, could pass every watermark check and seed state folded from an
unrelated log; a checkpoint racing ahead of an eager log flush could
likewise expose values no stored log contains. Records now store the
header identity (createdAt, cwd) they were folded from — reads validate it
against the live header (listing) or the tail's stored header (cold read)
and discard unrelated records whole (domain version 2 discards v1 media by
the pre-release stance). A live checkpoint additionally flushes the
session's buffered events durably before the cache row lands: the cache
can trail the log, never lead it. cachedValues is reshaped into
cachedSnapshot(meta): the identity witness plus the {asOfSeq, values} cut
the list carrier serves.
This commit is contained in:
imccyu
2026-07-28 12:09:45 +08:00
parent 1ef7c9473c
commit 27198d3091
6 changed files with 158 additions and 45 deletions

View File

@@ -28,11 +28,30 @@ export const checkpointRow = z.object({
})
/**
* One session's stored record: its checkpoint rows keyed by projection key.
* The whole record is replaced on every write (whole-value discipline — the
* registry checkpoint is always the complete per-session cut).
* The stored-log identity a record is bound to: the immutable header fields
* that distinguish one session lifecycle from another under the same id. A
* session id names a slot, not a lifecycle — a deleted-then-recreated id, or
* a persistence root swapped under a surviving cache, would otherwise let an
* old row pass every watermark check and seed state folded from an unrelated
* log. Reads validate this against the live header (listing) or the stored
* header (cold read) before accepting any row.
*/
export const checkpointIdentity = z.object({
createdAt: z.number().int().nonnegative(),
cwd: z.string().optional(),
})
/** The identity fields a record is bound to, inferred from {@link checkpointIdentity}. */
export type CheckpointIdentity = z.infer<typeof checkpointIdentity>
/**
* One session's stored record: the log identity it was folded from plus its
* checkpoint rows keyed by projection key. The whole record is replaced on
* every write (whole-value discipline — the registry checkpoint is always
* the complete per-session cut).
*/
export const checkpointRecord = z.object({
identity: checkpointIdentity,
rows: z.record(z.string(), checkpointRow),
})
@@ -42,10 +61,10 @@ export type CheckpointRecord = z.infer<typeof checkpointRecord>
/**
* The session-projcache domain spec. Version bumps discard the whole medium
* (cache semantics: a stale or unreadable cache costs a longer tail replay,
* never a wrong value).
* never a wrong value). v2 added the record's log-identity binding.
*/
export const projectionCacheDomainSpec = defineDomain({
name: 'session_projcache',
version: 1,
version: 2,
tables: { sessions: domainTable<SessionId, CheckpointRecord>(checkpointRecord) },
})