refactor(session-projection): compact checkpoint row fields to ver/seq/val

The persisted row (sessionId, key, stateVersion, observedSeq, state)
becomes (sessionId, key, ver, seq, val) — the cache medium repeats these
three names for every unit of every session, so the long forms dominated
the JSON payload. ProjectionCheckpointRow and the checkpointRow zod spec
rename together; the domain spec bumps to v3 (cache semantics: the old
medium is discarded, not migrated). The unit-facing declaration keeps
stateVersion — only the persisted/checkpoint row shape changes.
This commit is contained in:
imccyu
2026-07-28 22:45:35 +08:00
parent 931dd934e3
commit b4bc4f382e
17 changed files with 121 additions and 121 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-projection/session-projection/README.md
README.md: 2e026aab55933c96ba961481f9597bc18cbbe910
README.zh.md: a3e0b0f46466d19321b0950dc41d06473a54a1ce
README.md: f4898b8e567fa5998c18111c5f4e27a8a350a42e
README.zh.md: 385862868df495a5c857d91c32f6503c3ef72025

View File

@@ -23,7 +23,7 @@ Session-projection seam. It owns `ctx.sessionProjections`, the registry that DRI
- **Same-reference means no work.** `apply` MUST return the same state reference for events that do not concern the unit; the drive gates the change feed on `Object.is`, so non-matching events cost one call and nothing downstream.
- **Whole-value event rule (load-bearing).** A state-carrying log event MUST carry the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers).
- **Synchronous unit discipline.** `init`/`apply`/`view` MUST be synchronous; carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut. An accidentally-async `view` returns a Promise, which fails the boundary `schema.parse` loudly.
- **State is plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache (a later phase) stores `(sessionId, key, stateVersion, observedSeq, stateJson)` rows; bump `stateVersion` whenever the state shape or the fold semantics change so stale rows are discarded instead of forward-applied into garbage.
- **State is plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows; bump `stateVersion` whenever the state shape or the fold semantics change so stale rows are discarded instead of forward-applied into garbage.
- **No wire vocabulary here.** The registry exposes only the change feed and the snapshot read face; carriers (api-proxy) mint their own frames (`session/projection`) and blocks from them.
- **Optional seam.** Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected; carriers use `ctx.get('sessionProjections')` and omit their block/frames entirely when the registry is absent.
@@ -43,5 +43,5 @@ None; projections never assemble or send provider requests.
- **Every tail page carries every registered key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states (a todo list, a goal snapshot), revisit if a domain's value grows large.
- **Eager drive touches every unit per event** — cheap by construction (whole-value rule, same-reference gate), but a hot path would justify per-unit event-type prefilters, addable without contract change.
- **The persisted projection cache is a later phase** — cells live in memory only; a restart rebuilds by folding the in-memory log on first touch. The `stateVersion` field is the forward-declared invalidation anchor for that phase.
- **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead.
- **Synchronous unit discipline is only partially mechanical** — the boundary `schema.parse` rejects a Promise-returning `view`, but an `apply` that blocks or reads torn non-session state is a review concern; the invariant companion documents why no runtime check exists.

View File

@@ -23,7 +23,7 @@
- **同引用即无工作。** 对与单元无关的事件,`apply` 必须返回同一个状态引用;驱动以 `Object.is` 把守变更流,因此不匹配的事件只花一次调用,不产生任何下游工作。
- **全量值事件规则(承重)。** 携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。
- **单元的同步纪律。** `init`/`apply`/`view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 `view` 会返回 Promise,让边界的 `schema.parse` 当场大声失败。
- **状态是纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存(persisted projection cache,后续阶段)存储 `(sessionId, key, stateVersion, observedSeq, stateJson)` 行;状态形状或折叠语义一旦变化就递增 `stateVersion`,使陈旧行被丢弃,而不是被正向 apply 成垃圾。
- **状态是纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存(persisted projection cache)存储 `(sessionId, key, ver, seq, val)` 行;状态形状或折叠语义一旦变化就递增 `stateVersion`,使陈旧行被丢弃,而不是被正向 apply 成垃圾。
- **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。
- **可选 seam。** 领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响;载体使用 `ctx.get('sessionProjections')`,注册表缺席时完全省略自己的块与帧。
@@ -43,5 +43,5 @@
- **每个尾页携带每个已注册的 key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。
- **正向驱动(eager drive)逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,契约不变。
- **持久投影缓存属于后续阶段**——cell 目前只活在内存里;重启后首次触达时靠折叠内存日志重建。`stateVersion` 字段是为该阶段预先声明的失效锚点。
- **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。
- **单元同步纪律只有部分可机械把关**——边界 `schema.parse` 能拒绝返回 Promise 的 `view`,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关;invariant 配套记载了为何不存在运行时检查。

View File

@@ -66,9 +66,9 @@ export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
view(state: S): SessionProjectionMap[K]
/**
* Persisted-cache invalidation anchor: bump whenever the state shape or the
* fold semantics change, so persisted `(sessionId, key, stateVersion,
* observedSeq, state)` rows from an older unit are discarded instead of
* being forward-applied into garbage. Non-negative integer.
* fold semantics change, so persisted `(sessionId, key, ver, seq, val)`
* rows from an older unit are discarded instead of being forward-applied
* into garbage. Non-negative integer.
*/
stateVersion: number
}
@@ -99,20 +99,19 @@ export interface ProjectionSnapshot {
/**
* One unit's checkpoint: its internal state (plain JSON by the unit
* contract), the seq of the last event folded into it, and the
* contract), the seq of the last event folded into it, and the unit
* `stateVersion` that produced it — the persisted projection-cache row
* `(sessionId, key, stateVersion, observedSeq, state)` minus the two outer
* keys. A row is never authoritative, only a fold shortcut: `restore`
* discards it on a `stateVersion` mismatch or when it claims events past the
* stored log end.
* `(sessionId, key, ver, seq, val)` minus the two outer keys. A row is
* never authoritative, only a fold shortcut: `restore` discards it on a
* version mismatch or when it claims events past the stored log end.
*/
export interface ProjectionCheckpointRow {
/** The registering unit's `stateVersion` at fold time. */
stateVersion: number
/** Seq of the last event folded into `state`; -1 for the empty log. */
observedSeq: number
ver: number
/** Seq of the last event folded into `val`; -1 for the empty log. */
seq: number
/** The unit's internal state — plain JSON per the unit contract. */
state: unknown
val: unknown
}
/** Checkpoint rows keyed by projection key (one session's persisted cache value). */
@@ -231,9 +230,9 @@ export class SessionProjectionRegistry extends Service {
* State-level checkpoint of every registered unit for one session, read
* from the watermark cache (missing cells fold lazily over the in-memory
* log). This is the write side of the persisted projection cache: the
* returned rows are the `(key → {stateVersion, observedSeq, state})` part
* of the durable `(sessionId, key, stateVersion, observedSeq, state)`
* rows. Every `state` is a DETACHED structured clone — never the live
* returned rows are the `(key → {ver, seq, val})` part of the durable
* `(sessionId, key, ver, seq, val)`
* rows. Every `val` is a DETACHED structured clone — never the live
* cell reference: the watermark cache is this registry's authoritative
* mutable state, and a caller reaching the live reference could corrupt
* every subsequent snapshot and frame through it (plain JSON by the unit
@@ -246,9 +245,9 @@ export class SessionProjectionRegistry extends Service {
for (const registration of this.registrations.values()) {
const cell = this.cellFor(registration, session)
rows[registration.def.key] = {
stateVersion: registration.def.stateVersion,
observedSeq: cell.observedSeq,
state: structuredClone(cell.state),
ver: registration.def.stateVersion,
seq: cell.observedSeq,
val: structuredClone(cell.state),
}
}
return rows
@@ -257,7 +256,7 @@ export class SessionProjectionRegistry extends Service {
/**
* The stored seq a {@link restore} tail read over `checkpoint` must start
* at: one event BELOW the lowest usable watermark (a row is usable when
* its `stateVersion` matches the live unit; an absent or mismatched row
* its `ver` matches the live unit's `stateVersion`; an absent or mismatched row
* pulls the floor to `0` — that key must refold the full log). The
* one-below anchor is load-bearing: the tail then proves how far the
* stored log still extends, so {@link restore} can detect a log that
@@ -274,8 +273,8 @@ export class SessionProjectionRegistry extends Service {
let floor: number | undefined
for (const registration of this.registrations.values()) {
const row = checkpoint[registration.def.key]
const need = row !== undefined && row.stateVersion === registration.def.stateVersion
? Math.max(row.observedSeq + 1, 0)
const need = row !== undefined && row.ver === registration.def.stateVersion
? Math.max(row.seq + 1, 0)
: 0
floor = floor === undefined ? need : Math.min(floor, need)
}
@@ -284,7 +283,7 @@ export class SessionProjectionRegistry extends Service {
/**
* View a checkpoint's rows without any log read: for every registered
* unit whose row's `stateVersion` matches, serve the schema-validated
* unit whose row's `ver` 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 —
@@ -297,8 +296,8 @@ export class SessionProjectionRegistry extends Service {
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))
if (row === undefined || row.ver !== def.stateVersion) continue
values[def.key] = def.schema.parse(def.view(row.val))
}
return values
}
@@ -311,9 +310,9 @@ export class SessionProjectionRegistry extends Service {
* `readFrom(id, restoreFloor(checkpoint))` and that same floor as
* `baseSeq`; the floor's one-below anchor makes the supplied end honest,
* so a shrunk log is detected here. A row is usable iff its
* `stateVersion` matches the live unit, it does not predate `baseSeq`
* (`observedSeq >= baseSeq - 1`), and it does not claim events past the
* supplied end (`observedSeq <= endSeq`); an unusable row is discarded
* `ver` matches the live unit's `stateVersion`, it does not predate `baseSeq`
* (`seq >= baseSeq - 1`), and it does not claim events past the
* supplied end (`seq <= endSeq`); an unusable row is discarded
* and its key refolds from `init` — which is only sound over the full
* log, so a discarded row with `baseSeq > 0` throws (the caller re-reads
* from seq 0, e.g. after a crash-repair truncation shrank the log below
@@ -334,22 +333,22 @@ export class SessionProjectionRegistry extends Service {
const def = registration.def
const row = checkpoint[def.key]
const usable = row !== undefined
&& row.stateVersion === def.stateVersion
&& row.observedSeq >= baseSeq - 1
&& row.observedSeq <= endSeq
&& row.ver === def.stateVersion
&& row.seq >= baseSeq - 1
&& row.seq <= endSeq
if (!usable && baseSeq > 0) {
throw new Error(
`session projection ${JSON.stringify(def.key)} cannot restore from seq ${baseSeq}: `
+ 'its checkpoint row is missing, version-mismatched, or beyond the supplied log end; re-read from seq 0',
)
}
let state = usable ? row.state : def.init()
const from = usable ? row.observedSeq : baseSeq - 1
let state = usable ? row.val : def.init()
const from = usable ? row.seq : baseSeq - 1
for (const event of events) {
if (event.seq > from) state = def.apply(state, event)
}
values[def.key] = def.schema.parse(def.view(state))
refreshed[def.key] = { stateVersion: def.stateVersion, observedSeq: endSeq, state }
refreshed[def.key] = { ver: def.stateVersion, seq: endSeq, val: state }
}
return {
snapshot: { asOfSeq: endSeq, values: values },

View File

@@ -175,11 +175,11 @@ describe('SessionProjectionRegistry drive', () => {
ctx.sessionProjections.register({ ...countUnit(), stateVersion: 7 })
const markEvent = mark(session, ['a'])
const rows = ctx.sessionProjections.checkpoint(session)
expect(rows['test/marks']).toEqual({ stateVersion: 1, observedSeq: markEvent.seq, state: { marks: ['a'] } })
expect(rows['test/count']).toEqual({ stateVersion: 7, observedSeq: markEvent.seq, state: 1 })
expect(rows['test/marks']).toEqual({ ver: 1, seq: markEvent.seq, val: { marks: ['a'] } })
expect(rows['test/count']).toEqual({ ver: 7, seq: markEvent.seq, val: 1 })
// Empty log: init-derived state at watermark -1.
const fresh = ctx.sessions.create()
expect(ctx.sessionProjections.checkpoint(fresh)['test/marks']).toEqual({ stateVersion: 1, observedSeq: -1, state: null })
expect(ctx.sessionProjections.checkpoint(fresh)['test/marks']).toEqual({ ver: 1, seq: -1, val: null })
})
it('checkpoint states are detached clones — mutating them cannot corrupt the watermark cache', async () => {
@@ -188,11 +188,11 @@ describe('SessionProjectionRegistry drive', () => {
mark(session, ['a'])
const rows = ctx.sessionProjections.checkpoint(session)
// Hostile (or merely careless) consumer mutates the handed-out state.
;(rows['test/marks']?.state as { marks: string[] }).marks.push('INJECTED')
;(rows['test/marks']?.val as { marks: string[] }).marks.push('INJECTED')
// The registry's authoritative cell is untouched: snapshot and a fresh
// checkpoint both still serve the committed value.
expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['a'] })
expect(ctx.sessionProjections.checkpoint(session)['test/marks']?.state).toEqual({ marks: ['a'] })
expect(ctx.sessionProjections.checkpoint(session)['test/marks']?.val).toEqual({ marks: ['a'] })
})
it('restoreFloor anchors one below the lowest usable watermark and at 0 for missing or mismatched rows', async () => {
@@ -204,18 +204,18 @@ describe('SessionProjectionRegistry drive', () => {
// Lowest usable watermark is count's 5 → the anchored tail starts AT 5
// (one below the first needed seq 6), so the read proves seq 5 still exists.
expect(ctx.sessionProjections.restoreFloor({
'test/marks': { stateVersion: 1, observedSeq: 10, state: { marks: [] } },
'test/count': { stateVersion: 1, observedSeq: 5, state: 6 },
'test/marks': { ver: 1, seq: 10, val: { marks: [] } },
'test/count': { ver: 1, seq: 5, val: 6 },
})).toBe(5)
// A version-mismatched row forces that key back to a full refold.
expect(ctx.sessionProjections.restoreFloor({
'test/marks': { stateVersion: 2, observedSeq: 10, state: { marks: [] } },
'test/count': { stateVersion: 1, observedSeq: 5, state: 6 },
'test/marks': { ver: 2, seq: 10, val: { marks: [] } },
'test/count': { ver: 1, seq: 5, val: 6 },
})).toBe(0)
// A fresh (-1) row still needs the whole tail from 0.
expect(ctx.sessionProjections.restoreFloor({
'test/marks': { stateVersion: 1, observedSeq: -1, state: null },
'test/count': { stateVersion: 1, observedSeq: -1, state: 0 },
'test/marks': { ver: 1, seq: -1, val: null },
'test/count': { ver: 1, seq: -1, val: 0 },
})).toBe(0)
})
@@ -230,8 +230,8 @@ describe('SessionProjectionRegistry drive', () => {
// marks row usable (watermark 2, tail starts at 3); count row mismatched — but
// a mismatch with baseSeq > 0 cannot silently refold: it throws for a re-read.
expect(() => ctx.sessionProjections.restore({
'test/marks': { stateVersion: 1, observedSeq: 2, state: { marks: ['old'] } },
'test/count': { stateVersion: 99, observedSeq: 2, state: 3 },
'test/marks': { ver: 1, seq: 2, val: { marks: ['old'] } },
'test/count': { ver: 99, seq: 2, val: 3 },
}, tail, 3)).toThrow(/re-read from seq 0/)
// The full-log re-read (baseSeq 0) refolds the mismatched key from init.
const full: SessionEvent[] = [
@@ -241,15 +241,15 @@ describe('SessionProjectionRegistry drive', () => {
...tail,
]
const { snapshot, checkpoint } = ctx.sessionProjections.restore({
'test/marks': { stateVersion: 1, observedSeq: 2, state: { marks: ['old', '2'] } },
'test/count': { stateVersion: 99, observedSeq: 2, state: 3 },
'test/marks': { ver: 1, seq: 2, val: { marks: ['old', '2'] } },
'test/count': { ver: 99, seq: 2, val: 3 },
}, full, 0)
expect(snapshot.asOfSeq).toBe(4)
expect(snapshot.values['test/marks']).toEqual({ marks: ['new'] })
expect(snapshot.values['test/count']).toBe(5) // refolded from init over all 5 events
// The refreshed rows sit at the served cut, ready for a durable write-back.
expect(checkpoint['test/marks']).toEqual({ stateVersion: 1, observedSeq: 4, state: { marks: ['new'] } })
expect(checkpoint['test/count']).toEqual({ stateVersion: 1, observedSeq: 4, state: 5 })
expect(checkpoint['test/marks']).toEqual({ ver: 1, seq: 4, val: { marks: ['new'] } })
expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 4, val: 5 })
})
it('restore over a suffix folds only past each row watermark and serves an exact empty-tail cut', async () => {
@@ -257,8 +257,8 @@ describe('SessionProjectionRegistry drive', () => {
ctx.sessionProjections.register(marksUnit())
ctx.sessionProjections.register(countUnit())
const rows = {
'test/marks': { stateVersion: 1, observedSeq: 4, state: { marks: ['done'] } },
'test/count': { stateVersion: 1, observedSeq: 2, state: 3 },
'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } },
'test/count': { ver: 1, seq: 2, val: 3 },
}
const tail: SessionEvent[] = [
{ type: 'turn/start', seq: 3, time: 3, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
@@ -273,8 +273,8 @@ describe('SessionProjectionRegistry drive', () => {
// Empty tail (checkpoint is current): the cut sits at baseSeq - 1.
const { snapshot: current } = ctx.sessionProjections.restore({
'test/marks': { stateVersion: 1, observedSeq: 4, state: { marks: ['done'] } },
'test/count': { stateVersion: 1, observedSeq: 4, state: 5 },
'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } },
'test/count': { ver: 1, seq: 4, val: 5 },
}, [], 5)
expect(current.asOfSeq).toBe(4)
expect(current.values['test/count']).toBe(5)
@@ -285,8 +285,8 @@ describe('SessionProjectionRegistry drive', () => {
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
'test/marks': { ver: 1, seq: 4, val: { marks: ['stored'] } },
'test/count': { ver: 99, seq: 4, val: 5 }, // mismatched: absent
})
expect(values['test/marks']).toEqual({ marks: ['stored'] })
expect('test/count' in values).toBe(false)
@@ -296,7 +296,7 @@ describe('SessionProjectionRegistry drive', () => {
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())
const rows = { 'test/count': { stateVersion: 1, observedSeq: 9, state: 10 } }
const rows = { 'test/count': { ver: 1, seq: 9, val: 10 } }
// The anchored floor sits ON the watermark, so the tail read must return
// at least seq 9 from an intact log…
const floor = ctx.sessionProjections.restoreFloor(rows)