@deepseek-ai/dsh-session-projection-cache 0.0.1-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, DeepSeek
4
+
5
+ Redistribution and use in source and binary forms, with or without
6
+ modification, are permitted provided that the following conditions are met:
7
+
8
+ 1. Redistributions of source code must retain the above copyright notice, this
9
+ list of conditions and the following disclaimer.
10
+
11
+ 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ this list of conditions and the following disclaimer in the documentation
13
+ and/or other materials provided with the distribution.
14
+
15
+ 3. Neither the name of the copyright holder nor the names of its
16
+ contributors may be used to endorse or promote products derived from
17
+ this software without specific prior written permission.
18
+
19
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
22
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
23
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
24
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
25
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
26
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
27
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,6 @@
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/session/session-projection-cache/README.md
5
+ README.md: 5d4ad07fab6648acdb40c6aa86d32cc78b4c016e
6
+ README.zh.md: 9dfcd645097cc8390812be3aef82b9535fa953eb
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # @deepseek-ai/dsh-session-projection-cache
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ The persisted projection cache (`ctx.sessionProjectionCache`): durable checkpoints of every registered projection unit's state, one record per session on the domain data form (`session_projcache` domain — the shipped json backend lands it beside `workspace.json` under the configured storage root). Design authority: the [session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md) (persisted projection cache section).
6
+
7
+ A stored row `(key → {ver, seq, val})` is a fold shortcut, never an authority: possibly stale (`seq` says exactly how stale) but never wrong. Consequences the implementation commits to:
8
+
9
+ - **Every background write is fail-soft.** A failed durable write logs a warning and keeps the cache stale; the next write or cold read self-heals. A crash between writes costs a longer tail replay, never a wrong value.
10
+ - **A `ver` mismatch against the live unit's `stateVersion` discards, never migrates.** A unit bump invalidates its rows at read time; the key refolds from the log.
11
+ - **Whole-record writes.** Each write replaces the session's full checkpoint (the registry cut is always complete), snapshotted through the lossless-JSON boundary — a unit state violating the plain-JSON contract fails loud.
12
+ - **Records are bound to a log lifecycle, not just an id.** Each record stores the header identity (`createdAt`, `cwd`) it was folded from; every read validates it (the live or stored header is the witness) before accepting a row, so a deleted-then-recreated id or a persistence store swapped under a surviving cache discards the unrelated record instead of seeding phantom values.
13
+ - **The log leads, the cache follows.** A live checkpoint flushes the session's buffered events durably BEFORE the cache row lands, so a crash can leave the cache behind the log (a longer tail replay) but never ahead of it.
14
+
15
+ ## Write policy
16
+
17
+ Two mandatory points, throttled in between:
18
+
19
+ | Trigger | Nature |
20
+ |---|---|
21
+ | `turn/end` | Mandatory — the turn-final value is what cold reads want. |
22
+ | Session disposal (detach) | Mandatory — the live-to-cold moment; after it the cold ladder serves this session. |
23
+ | `writeEveryEvents` committed events | Config throttle (count). |
24
+ | `writeIntervalMs` since the first dirty event | Config throttle (interval). |
25
+
26
+ Both `Config` fields are required (no defaults): flush cadence is a deployment choice with no universally correct value, stated in cordis.yml.
27
+
28
+ ## Listing read (`cachedSnapshot(meta)`)
29
+
30
+ The zero-I/O rung: whole values viewed straight from the identity-matching stored record (version-matching keys only), returned as a `{asOfSeq, values}` cut — `asOfSeq` is the lowest served-row watermark, so a client seeding its per-session value store under higher-seq-wins can never let a stale list block overwrite a newer push frame. `undefined` when no usable record exists (unknown id, unrelated lifecycle, or no version-matching rows); the api-proxy list carrier turns that into an absent column.
31
+
32
+ ## Cold read (`coldSnapshot(id, signal?)`)
33
+
34
+ The read ladder, zero full-log load on the happy path: cached rows → `sessionProjections.restoreFloor` (anchored one event below the lowest usable watermark) → persistence `readFrom(id, floor)` → `sessionProjections.restore` → fail-soft write-back of the refreshed rows. The anchor makes a shrunk log (crash-repair truncation) provable: an overreaching row triggers exactly one full re-read from seq 0 instead of serving a ghost value. No registered units serve `{asOfSeq: -1, values: {}}` without touching persistence; a session with no persisted log rejects with the seam's `not found`.
35
+
36
+ `write(session)` is the synchronous-cut checkpoint both mandatory points use; carriers may call it directly (not fail-soft — the fail-soft wrappers own containment).
37
+
38
+ ## Composition
39
+
40
+ ```yaml
41
+ - id: session-projection-cache
42
+ name: '@deepseek-ai/dsh-session-projection-cache'
43
+ config:
44
+ writeEveryEvents: 200
45
+ writeIntervalMs: 5000
46
+ ```
47
+
48
+ Injects `storageDomain`, `sessionProjections`, `sessionPersistence`, `sessions`. Without this row the projection system runs live-only (watermark cache; cold reads fall back to full log loads wherever a carrier implements them).
49
+
50
+ ## Model Experience
51
+
52
+ None, as the cache only persists and restores host-side read models of already-logged session state and touches no prompt, message, schema, stream, or tool result.
53
+
54
+ #### KV Cache effect
55
+
56
+ None; the cache never assembles or sends provider requests.
57
+
58
+ ## Known Limitations and Deferred Work
59
+
60
+ - **No eviction or retention surface** — records accumulate per session; pruning stored checkpoints is out-of-band maintenance, same stance as session persistence itself.
61
+ - **Interval throttle is per-session coarse** — the timer arms at the first dirty event after a clean write; a steady sub-threshold trickle writes once per interval, not a sliding window.
62
+ - **`coldSnapshot` reads are not deduplicated** — two concurrent cold reads of one session each run the ladder; last write-back wins (rows are equivalent), acceptable for listing-scale call rates.
package/README.zh.md ADDED
@@ -0,0 +1,62 @@
1
+ # @deepseek-ai/dsh-session-projection-cache
2
+
3
+ [English](README.md) | 中文
4
+
5
+ 持久投影缓存(`ctx.sessionProjectionCache`):把每个已注册投影单元的状态持久化为检查点,基于域数据形态(domain data form)每会话一条记录(`session_projcache` 域——出厂 JSON 后端将其落在配置的存储根目录下、`workspace.json` 旁边)。设计权威:[session-projection RFC](../../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md)(persisted projection cache 一节)。
6
+
7
+ 一条存储行 `(key → {ver, seq, val})` 是折叠捷径,绝不是权威:可能陈旧(`seq` 精确说明陈旧到哪),但绝不会错。实现据此承诺:
8
+
9
+ - **每次后台写入都 fail-soft。** 持久写失败只记一条警告并保持缓存陈旧;下一次写入或冷读自愈。两次写之间崩溃的代价是更长的尾部回放,绝不是错误的值。
10
+ - **`ver` 与活单元 `stateVersion` 不匹配即丢弃,绝不迁移。** 单元递增版本会在读取时使其行失效;该 key 从日志重新折叠。
11
+ - **整记录写入。** 每次写入替换该会话的完整检查点(注册表切面始终是完整的),并经无损 JSON 边界快照——违反纯 JSON 约定的单元状态会大声失败。
12
+ - **记录绑定到日志生命周期,而不只是 id。** 每条记录存储其折叠来源的 header 身份(`createdAt`、`cwd`);每次读取先以活 header 或存储 header 为证验证它,再接受任何行——被删后重建的 id、或缓存幸存而持久化存储被换掉时,无关记录被整体丢弃,绝不播种幻影值。
13
+ - **日志领先,缓存跟随。** 活会话检查点先把缓冲事件持久 flush,缓存行才落地,因此崩溃只会让缓存落后于日志(更长的尾部回放),绝不领先于它。
14
+
15
+ ## 写策略
16
+
17
+ 两个必写点,其间节流:
18
+
19
+ | 触发 | 性质 |
20
+ |---|---|
21
+ | `turn/end` | 必写——冷读要的正是轮次终值。 |
22
+ | 会话销毁(detach) | 必写——live 转 cold 的时刻;此后冷读阶梯接管该会话。 |
23
+ | 累计 `writeEveryEvents` 个已提交事件 | 配置节流(条数)。 |
24
+ | 距首个脏事件 `writeIntervalMs` 毫秒 | 配置节流(间隔)。 |
25
+
26
+ 两个 `Config` 字段均必填(无默认值):写入节奏是部署选择,没有普适正确值,由 cordis.yml 明示。
27
+
28
+ ## 列表读(`cachedSnapshot(meta)`)
29
+
30
+ 零 I/O 一档:从身份匹配的存储记录直接 view 全量值(仅版本匹配的 key),以 `{asOfSeq, values}` 切面返回——`asOfSeq` 取所服务行的最低水位,客户端在 higher-seq-wins 规则下播种值存储时,陈旧列表块永远压不过更新的推送帧。无可用记录(未知 id、无关生命周期、无版本匹配行)时返回 `undefined`;api-proxy 列表载体将其转为列缺席。
31
+
32
+ ## 冷读(`coldSnapshot(id, signal?)`)
33
+
34
+ 读取阶梯,正常路径无需加载全量日志:缓存行 → `sessionProjections.restoreFloor`(锚定在最低可用水位之前一个事件的位置)→ 持久化 `readFrom(id, floor)` → `sessionProjections.restore` → 刷新行的 fail-soft 写回。这个锚使缩短的日志(崩溃修复截断)可被证明:越界的行恰好触发一次从 seq 0 的全量重读,而不是把幽灵值当现值服务。无已注册单元时直接服务 `{asOfSeq: -1, values: {}}`,不触碰持久化;无持久日志的会话以 seam 的 `not found` 拒绝。
35
+
36
+ `write(session)` 是两个必写点共用的同步切面检查点;载体可以直接调用(非 fail-soft——由 fail-soft 包装层负责遏制)。
37
+
38
+ ## 组合
39
+
40
+ ```yaml
41
+ - id: session-projection-cache
42
+ name: '@deepseek-ai/dsh-session-projection-cache'
43
+ config:
44
+ writeEveryEvents: 200
45
+ writeIntervalMs: 5000
46
+ ```
47
+
48
+ 注入 `storageDomain`、`sessionProjections`、`sessionPersistence`、`sessions`。没有这一行时,投影系统只跑 live(水位缓存;冷读在实现了它的载体处退回全量日志加载)。
49
+
50
+ ## 模型体验
51
+
52
+ 无,因为缓存只持久化并恢复 host 侧的、由已写入日志的会话状态派生的读模型,不触碰任何提示词、消息、schema、流或工具结果。
53
+
54
+ #### KV Cache 影响
55
+
56
+ 无;缓存从不组装或发送提供方请求。
57
+
58
+ ## 已知局限与延后工作
59
+
60
+ - **没有淘汰或保留面**——记录按会话累积;清理存储的检查点是带外维护,与会话持久化本身同一立场。
61
+ - **间隔节流按会话粗粒度**——计时器在一次干净写入后的首个脏事件时武装;持续的低于阈值的涓流每个间隔写一次,不是滑动窗口。
62
+ - **`coldSnapshot` 读取不去重**——同一会话的两个并发冷读各跑一遍阶梯;写回最后者胜(行等价),对列表级调用频率可接受。
package/lib/index.js ADDED
@@ -0,0 +1,286 @@
1
+ import { Service } from "@deepseek-ai/cordis";
2
+ import z from "@deepseek-ai/schemastery";
3
+ import { snapshotJsonValue } from "@deepseek-ai/dsh-session";
4
+ import { z as z$1 } from "zod";
5
+ import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
6
+ //#region lib/types/spec.js
7
+ /**
8
+ * The session-projcache domain declaration: one `sessions` table keyed by
9
+ * {@link SessionId}, each record the full projection checkpoint for one
10
+ * session (`key → {ver, seq, val}` rows). The spec object
11
+ * is the single source of the domain's identity, version, and record schema;
12
+ * the storage-domain routing decides the medium (the shipped composition's
13
+ * json backend lands it at `<root>/session_projcache.json`, beside
14
+ * `workspace.json`).
15
+ * @module @deepseek-ai/dsh-session-projection-cache/src/spec
16
+ */
17
+ /**
18
+ * One persisted checkpoint row (the RFC's `(sessionId, key, ver, seq, val)`
19
+ * minus the two record keys). `val` is the unit's internal state — plain
20
+ * JSON by the unit contract; `z.json()` enforces that at the durable
21
+ * boundary. A row is never wrong, only possibly stale: `seq` says exactly
22
+ * how stale, and a `ver` mismatch against the live unit's `stateVersion`
23
+ * discards it at read time (never a migration).
24
+ */
25
+ const checkpointRow = z$1.object({
26
+ ver: z$1.number().int().nonnegative(),
27
+ seq: z$1.number().int().gte(-1),
28
+ val: z$1.json()
29
+ });
30
+ /**
31
+ * The stored-log identity a record is bound to: the immutable header fields
32
+ * that distinguish one session lifecycle from another under the same id. A
33
+ * session id names a slot, not a lifecycle — a deleted-then-recreated id, or
34
+ * a persistence root swapped under a surviving cache, would otherwise let an
35
+ * old row pass every watermark check and seed state folded from an unrelated
36
+ * log. Reads validate this against the live header (listing) or the stored
37
+ * header (cold read) before accepting any row.
38
+ */
39
+ const checkpointIdentity = z$1.object({
40
+ createdAt: z$1.number().int().nonnegative(),
41
+ cwd: z$1.string().optional()
42
+ });
43
+ /**
44
+ * One session's stored record: the log identity it was folded from plus its
45
+ * checkpoint rows keyed by projection key. The whole record is replaced on
46
+ * every write (whole-value discipline — the registry checkpoint is always
47
+ * the complete per-session cut).
48
+ */
49
+ const checkpointRecord = z$1.object({
50
+ identity: checkpointIdentity,
51
+ rows: z$1.record(z$1.string(), checkpointRow)
52
+ });
53
+ /**
54
+ * The session-projcache domain spec. Version bumps discard the whole medium
55
+ * (cache semantics: a stale or unreadable cache costs a longer tail replay,
56
+ * never a wrong value).
57
+ */
58
+ const projectionCacheDomainSpec = defineDomain({
59
+ name: "session_projcache",
60
+ version: 3,
61
+ tables: { sessions: domainTable(checkpointRecord) }
62
+ });
63
+ //#endregion
64
+ //#region lib/types/index.js
65
+ /**
66
+ * Persisted projection cache (`ctx.sessionProjectionCache`): durable
67
+ * checkpoints of every registered projection unit's state, one record per
68
+ * session on the domain data form (`session_projcache` domain — the shipped
69
+ * json backend lands it beside `workspace.json`). The cache is a fold
70
+ * shortcut, never an authority: a row is possibly stale (its `seq`
71
+ * says how stale) but never wrong, so every write path is fail-soft (a lost
72
+ * write costs a longer tail replay on the next cold read) and a
73
+ * `ver` mismatch discards the row instead of migrating it. Design
74
+ * authority: the session-projection RFC
75
+ * (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
76
+ * @module @deepseek-ai/dsh-session-projection-cache
77
+ */
78
+ const Config = z.object({
79
+ writeEveryEvents: z.natural().min(1).required(),
80
+ writeIntervalMs: z.natural().min(1).required()
81
+ });
82
+ /**
83
+ * The persisted projection cache service. Opens the `session_projcache`
84
+ * domain at init, checkpoints live sessions on a throttled write-behind
85
+ * (count/interval triggers from {@link Config}) plus two mandatory points —
86
+ * `turn/end` and session disposal (the live-to-cold moment) — and serves the
87
+ * cold-read ladder: cached row, persistence `readFrom` tail, registry
88
+ * `restore`, durable write-back. Every durable write is fail-soft: failures
89
+ * log a warning and the cache self-heals on the next write or cold read.
90
+ */
91
+ var SessionProjectionCache = class extends Service {
92
+ config;
93
+ static inject = [
94
+ "storageDomain",
95
+ "sessionProjections",
96
+ "sessionPersistence",
97
+ "sessions"
98
+ ];
99
+ static Config = Config;
100
+ table;
101
+ dirty = /* @__PURE__ */ new Map();
102
+ constructor(ctx, config) {
103
+ super(ctx, "sessionProjectionCache");
104
+ this.config = config;
105
+ }
106
+ /** Open the domain and install the write-behind listeners. */
107
+ async [Service.init]() {
108
+ const domain = await this.ctx.storageDomain.open(projectionCacheDomainSpec);
109
+ this.ctx.effect(() => () => domain.close(), "sessionProjectionCache.domainClose");
110
+ this.table = domain.table("sessions");
111
+ this.installWritePath();
112
+ }
113
+ /**
114
+ * The stored record for one session, accepted only when its bound log
115
+ * identity matches `expected`. A session id names a slot, not a lifecycle:
116
+ * a recreated id or a persistence store swapped under a surviving cache
117
+ * must not let an old record seed state folded from an unrelated log.
118
+ * Synchronous from the domain's in-memory state.
119
+ * @param id - the session whose record is read.
120
+ * @param expected - the log identity the caller holds (live or stored header).
121
+ * @returns the identity-matching record, or `undefined` (absent or unrelated).
122
+ */
123
+ recordFor(id, expected) {
124
+ const record = this.requireTable().get(id);
125
+ if (record === void 0) return void 0;
126
+ return identityMatches(record.identity, expected) ? record : void 0;
127
+ }
128
+ /**
129
+ * The zero-I/O listing read: whole values viewed straight from the stored
130
+ * rows (version-matching keys only), each cut carried with its watermark
131
+ * so a client value store can seed under its higher-seq-wins rule — as
132
+ * stale as the last durable checkpoint but never wrong, and never from an
133
+ * unrelated log (the caller's header is the identity witness). Fresher
134
+ * paths (the history tail baseline, {@link coldSnapshot}) supersede these
135
+ * values whenever a session is actually opened.
136
+ * @param meta - the listed session's header (identity witness; no log read).
137
+ * @returns the cut (`asOfSeq` = lowest served-row watermark), or
138
+ * `undefined` when no usable row exists for this lifecycle.
139
+ */
140
+ cachedSnapshot(meta) {
141
+ const record = this.recordFor(meta.id, identityOf(meta));
142
+ if (record === void 0) return void 0;
143
+ const values = this.ctx.sessionProjections.viewCheckpoint(record.rows);
144
+ const keys = Object.keys(values);
145
+ if (keys.length === 0) return void 0;
146
+ return {
147
+ asOfSeq: Math.min(...keys.map((key) => record.rows[key].seq)),
148
+ values
149
+ };
150
+ }
151
+ /**
152
+ * Durably checkpoint one live session NOW (both mandatory points call
153
+ * this; tests and carriers may too). The registry cut is snapshotted at
154
+ * this boundary (states are live references), then the whole record is
155
+ * replaced. NOT fail-soft — callers on the fail-soft paths contain it.
156
+ * @param session - the live session to checkpoint.
157
+ * @returns resolution after durability and event emission.
158
+ */
159
+ async write(session) {
160
+ const rows = this.ctx.sessionProjections.checkpoint(session);
161
+ this.markClean(session);
162
+ if (this.ctx.sessions.get(session.id) === session) await this.ctx.sessions.flush(session);
163
+ await this.put(session.id, identityOf(session.header), rows);
164
+ }
165
+ /**
166
+ * Cold-read one persisted session's projections with zero full-log load:
167
+ * cached rows + a persistence `readFrom` tail from the registry's restore
168
+ * floor, refolded by the registry and written back (fail-soft) so the next
169
+ * cold read starts closer. A cache row invalidated by a shrunk log
170
+ * (crash-repair truncation) triggers one full re-read from seq 0 — the
171
+ * ladder's slow rung, still no crash. Rejects when the session has no
172
+ * persisted log (`not found` from the persistence seam).
173
+ * @param id - the persisted session to read.
174
+ * @param signal - optional cancellation for the persistence reads.
175
+ * @returns the snapshot cut at the stored log end.
176
+ */
177
+ async coldSnapshot(id, signal) {
178
+ const record = this.requireTable().get(id);
179
+ const cached = record?.rows ?? {};
180
+ const floor = this.ctx.sessionProjections.restoreFloor(cached);
181
+ const persistence = this.ctx.sessionPersistence;
182
+ if (floor === void 0) return {
183
+ asOfSeq: (await persistence.readFrom(id, 0, signal)).events.at(-1)?.seq ?? -1,
184
+ values: {}
185
+ };
186
+ let restored;
187
+ const tail = await persistence.readFrom(id, floor, signal);
188
+ const related = record === void 0 || identityMatches(record.identity, identityOf(tail.meta));
189
+ try {
190
+ if (!related) throw new Error("unrelated log identity");
191
+ restored = this.ctx.sessionProjections.restore(cached, tail.events, floor);
192
+ } catch {
193
+ const whole = await persistence.readFrom(id, 0, signal);
194
+ restored = this.ctx.sessionProjections.restore({}, whole.events, 0);
195
+ }
196
+ await this.putSoft(id, identityOf(tail.meta), restored.checkpoint, "cold-read write-back");
197
+ return restored.snapshot;
198
+ }
199
+ installWritePath() {
200
+ this.ctx.on("session/event", (session, event) => {
201
+ if (event.type === "turn/end") {
202
+ this.flushSoft(session, "turn/end");
203
+ return;
204
+ }
205
+ const state = this.dirty.get(session) ?? {
206
+ pending: 0,
207
+ timer: void 0
208
+ };
209
+ this.dirty.set(session, state);
210
+ state.pending += 1;
211
+ if (state.pending >= this.config.writeEveryEvents) {
212
+ this.flushSoft(session, "count threshold");
213
+ return;
214
+ }
215
+ state.timer ??= setTimeout(() => {
216
+ this.flushSoft(session, "interval");
217
+ }, this.config.writeIntervalMs);
218
+ });
219
+ this.ctx.on("session/disposed", (session) => {
220
+ this.flushSoft(session, "detach");
221
+ this.markClean(session);
222
+ this.dirty.delete(session);
223
+ });
224
+ this.ctx.effect(() => () => {
225
+ for (const state of this.dirty.values()) if (state.timer !== void 0) clearTimeout(state.timer);
226
+ this.dirty.clear();
227
+ }, "sessionProjectionCache.timers");
228
+ }
229
+ /**
230
+ * One fail-soft durable checkpoint. Every caller has work by construction:
231
+ * the throttle triggers only fire dirty (markClean clears the timer with
232
+ * the counter) and the two mandatory points write unconditionally.
233
+ */
234
+ async flushSoft(session, trigger) {
235
+ try {
236
+ await this.write(session);
237
+ } catch (error) {
238
+ this.ctx.logger.warn(`session projection cache: ${trigger} write for "${session.id}" failed (cache stays stale): ${String(error)}`);
239
+ }
240
+ }
241
+ /** Reset one session's dirty bookkeeping (its checkpoint is being written). */
242
+ markClean(session) {
243
+ const state = this.dirty.get(session);
244
+ if (state === void 0) return;
245
+ state.pending = 0;
246
+ if (state.timer !== void 0) {
247
+ clearTimeout(state.timer);
248
+ state.timer = void 0;
249
+ }
250
+ }
251
+ /** Replace one session's stored record with its log identity and a detached snapshot of `rows`. */
252
+ async put(id, identity, rows) {
253
+ const detached = snapshotJsonValue(rows);
254
+ if (detached === void 0) throw new TypeError("projection checkpoint is not losslessly JSON-serializable (a unit state violates the plain-JSON contract)");
255
+ await this.requireTable().put(id, {
256
+ identity,
257
+ rows: detached
258
+ });
259
+ }
260
+ /** Fail-soft {@link put}: cache writes must never fail their caller's read or event path. */
261
+ async putSoft(id, identity, rows, what) {
262
+ try {
263
+ await this.put(id, identity, rows);
264
+ } catch (error) {
265
+ this.ctx.logger.warn(`session projection cache: ${what} for "${id}" failed (cache stays stale): ${String(error)}`);
266
+ }
267
+ }
268
+ requireTable() {
269
+ /* v8 ignore next -- Service.init assigns the table before the service becomes injectable */
270
+ if (this.table === void 0) throw new Error("session projection cache is not initialized");
271
+ return this.table;
272
+ }
273
+ };
274
+ /** Project a header onto the identity fields a record is bound to. */
275
+ function identityOf(header) {
276
+ return {
277
+ createdAt: header.createdAt,
278
+ ...header.cwd === void 0 ? {} : { cwd: header.cwd }
279
+ };
280
+ }
281
+ /** Whether a stored record's bound identity names the caller's lifecycle. */
282
+ function identityMatches(stored, expected) {
283
+ return stored.createdAt === expected.createdAt && stored.cwd === expected.cwd;
284
+ }
285
+ //#endregion
286
+ export { Config, SessionProjectionCache, SessionProjectionCache as default, checkpointIdentity, checkpointRecord, checkpointRow, projectionCacheDomainSpec };
@@ -0,0 +1,28 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Package-owned invariant companion for `@deepseek-ai/dsh-session-projection-cache`.
4
+ * @module @deepseek-ai/dsh-session-projection-cache/invariant
5
+ */
6
+ const PACKAGE_NAME = "@deepseek-ai/dsh-session-projection-cache";
7
+ /** Cordis companion plugin name. */
8
+ const name = "session-projection-cache-invariant";
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ["invariants"];
11
+ /**
12
+ * No runtime invariant: the cache's correctness relation (a stored row equals
13
+ * the registry fold at its `seq` watermark) is only checkable by re-running the
14
+ * fold over the persisted log — duplicating the implementation rather than
15
+ * detecting drift — and its staleness is by design (fail-soft writes). The
16
+ * durable boundary is already schema-validated by the storage-domain layer
17
+ * on every reopen, and the read ladder's version/watermark guards are proven
18
+ * by the package spec.
19
+ */
20
+ const install = () => {};
21
+ /**
22
+ * Register this package's invariant companion.
23
+ * @param ctx - Cordis context carrying the invariant service.
24
+ * @returns the installed registration's disposer after setup succeeds.
25
+ */
26
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
27
+ //#endregion
28
+ export { apply, inject, name };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Persisted projection cache (`ctx.sessionProjectionCache`): durable
3
+ * checkpoints of every registered projection unit's state, one record per
4
+ * session on the domain data form (`session_projcache` domain — the shipped
5
+ * json backend lands it beside `workspace.json`). The cache is a fold
6
+ * shortcut, never an authority: a row is possibly stale (its `seq`
7
+ * says how stale) but never wrong, so every write path is fail-soft (a lost
8
+ * write costs a longer tail replay on the next cold read) and a
9
+ * `ver` mismatch discards the row instead of migrating it. Design
10
+ * authority: the session-projection RFC
11
+ * (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
12
+ * @module @deepseek-ai/dsh-session-projection-cache
13
+ */
14
+ import { Context, Service } from '@deepseek-ai/cordis';
15
+ import z from '@deepseek-ai/schemastery';
16
+ import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session';
17
+ import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection';
18
+ export { checkpointIdentity, checkpointRecord, checkpointRow, projectionCacheDomainSpec } from './spec.ts';
19
+ export type { CheckpointIdentity, CheckpointRecord } from './spec.ts';
20
+ declare module '@deepseek-ai/cordis' {
21
+ interface Context {
22
+ sessionProjectionCache: SessionProjectionCache;
23
+ }
24
+ }
25
+ /**
26
+ * Plugin config. Both throttle triggers are deployment choices with no
27
+ * universally correct value, so the composition states them explicitly
28
+ * (cordis.yml); the two mandatory write points (`turn/end` and session
29
+ * disposal) are policy, not tunables, and always fire.
30
+ */
31
+ export interface Config {
32
+ /** Committed events per session that force a durable checkpoint write between mandatory points. */
33
+ writeEveryEvents: number;
34
+ /** Longest time (milliseconds) a dirty checkpoint may stay unwritten between mandatory points. */
35
+ writeIntervalMs: number;
36
+ }
37
+ export declare const Config: z<Config>;
38
+ /**
39
+ * The persisted projection cache service. Opens the `session_projcache`
40
+ * domain at init, checkpoints live sessions on a throttled write-behind
41
+ * (count/interval triggers from {@link Config}) plus two mandatory points —
42
+ * `turn/end` and session disposal (the live-to-cold moment) — and serves the
43
+ * cold-read ladder: cached row, persistence `readFrom` tail, registry
44
+ * `restore`, durable write-back. Every durable write is fail-soft: failures
45
+ * log a warning and the cache self-heals on the next write or cold read.
46
+ */
47
+ export declare class SessionProjectionCache extends Service {
48
+ config: Config;
49
+ static inject: string[];
50
+ static Config: z<Config>;
51
+ private table?;
52
+ private readonly dirty;
53
+ constructor(ctx: Context, config: Config);
54
+ /** Open the domain and install the write-behind listeners. */
55
+ protected [Service.init](): Promise<void>;
56
+ /**
57
+ * The stored record for one session, accepted only when its bound log
58
+ * identity matches `expected`. A session id names a slot, not a lifecycle:
59
+ * a recreated id or a persistence store swapped under a surviving cache
60
+ * must not let an old record seed state folded from an unrelated log.
61
+ * Synchronous from the domain's in-memory state.
62
+ * @param id - the session whose record is read.
63
+ * @param expected - the log identity the caller holds (live or stored header).
64
+ * @returns the identity-matching record, or `undefined` (absent or unrelated).
65
+ */
66
+ private recordFor;
67
+ /**
68
+ * The zero-I/O listing read: whole values viewed straight from the stored
69
+ * rows (version-matching keys only), each cut carried with its watermark
70
+ * so a client value store can seed under its higher-seq-wins rule — as
71
+ * stale as the last durable checkpoint but never wrong, and never from an
72
+ * unrelated log (the caller's header is the identity witness). Fresher
73
+ * paths (the history tail baseline, {@link coldSnapshot}) supersede these
74
+ * values whenever a session is actually opened.
75
+ * @param meta - the listed session's header (identity witness; no log read).
76
+ * @returns the cut (`asOfSeq` = lowest served-row watermark), or
77
+ * `undefined` when no usable row exists for this lifecycle.
78
+ */
79
+ cachedSnapshot(meta: SessionHeader): ProjectionSnapshot | undefined;
80
+ /**
81
+ * Durably checkpoint one live session NOW (both mandatory points call
82
+ * this; tests and carriers may too). The registry cut is snapshotted at
83
+ * this boundary (states are live references), then the whole record is
84
+ * replaced. NOT fail-soft — callers on the fail-soft paths contain it.
85
+ * @param session - the live session to checkpoint.
86
+ * @returns resolution after durability and event emission.
87
+ */
88
+ write(session: Session): Promise<void>;
89
+ /**
90
+ * Cold-read one persisted session's projections with zero full-log load:
91
+ * cached rows + a persistence `readFrom` tail from the registry's restore
92
+ * floor, refolded by the registry and written back (fail-soft) so the next
93
+ * cold read starts closer. A cache row invalidated by a shrunk log
94
+ * (crash-repair truncation) triggers one full re-read from seq 0 — the
95
+ * ladder's slow rung, still no crash. Rejects when the session has no
96
+ * persisted log (`not found` from the persistence seam).
97
+ * @param id - the persisted session to read.
98
+ * @param signal - optional cancellation for the persistence reads.
99
+ * @returns the snapshot cut at the stored log end.
100
+ */
101
+ coldSnapshot(id: SessionId, signal?: AbortSignal): Promise<ProjectionSnapshot>;
102
+ private installWritePath;
103
+ /**
104
+ * One fail-soft durable checkpoint. Every caller has work by construction:
105
+ * the throttle triggers only fire dirty (markClean clears the timer with
106
+ * the counter) and the two mandatory points write unconditionally.
107
+ */
108
+ private flushSoft;
109
+ /** Reset one session's dirty bookkeeping (its checkpoint is being written). */
110
+ private markClean;
111
+ /** Replace one session's stored record with its log identity and a detached snapshot of `rows`. */
112
+ private put;
113
+ /** Fail-soft {@link put}: cache writes must never fail their caller's read or event path. */
114
+ private putSoft;
115
+ private requireTable;
116
+ }
117
+ export default SessionProjectionCache;
118
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package-owned invariant companion for `@deepseek-ai/dsh-session-projection-cache`.
3
+ * @module @deepseek-ai/dsh-session-projection-cache/invariant
4
+ */
5
+ import type { Context } from '@deepseek-ai/cordis';
6
+ /** Cordis companion plugin name. */
7
+ export declare const name = "session-projection-cache-invariant";
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export declare const inject: string[];
10
+ /**
11
+ * Register this package's invariant companion.
12
+ * @param ctx - Cordis context carrying the invariant service.
13
+ * @returns the installed registration's disposer after setup succeeds.
14
+ */
15
+ export declare const apply: (ctx: Context) => Promise<() => void>;
16
+ //# sourceMappingURL=invariant.d.ts.map
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The session-projcache domain declaration: one `sessions` table keyed by
3
+ * {@link SessionId}, each record the full projection checkpoint for one
4
+ * session (`key → {ver, seq, val}` rows). The spec object
5
+ * is the single source of the domain's identity, version, and record schema;
6
+ * the storage-domain routing decides the medium (the shipped composition's
7
+ * json backend lands it at `<root>/session_projcache.json`, beside
8
+ * `workspace.json`).
9
+ * @module @deepseek-ai/dsh-session-projection-cache/src/spec
10
+ */
11
+ import { z } from 'zod';
12
+ import { SessionId } from '@deepseek-ai/dsh-session';
13
+ /**
14
+ * One persisted checkpoint row (the RFC's `(sessionId, key, ver, seq, val)`
15
+ * minus the two record keys). `val` is the unit's internal state — plain
16
+ * JSON by the unit contract; `z.json()` enforces that at the durable
17
+ * boundary. A row is never wrong, only possibly stale: `seq` says exactly
18
+ * how stale, and a `ver` mismatch against the live unit's `stateVersion`
19
+ * discards it at read time (never a migration).
20
+ */
21
+ export declare const checkpointRow: z.ZodObject<{
22
+ ver: z.ZodNumber;
23
+ seq: z.ZodNumber;
24
+ val: z.ZodJSONSchema;
25
+ }, z.core.$strip>;
26
+ /**
27
+ * The stored-log identity a record is bound to: the immutable header fields
28
+ * that distinguish one session lifecycle from another under the same id. A
29
+ * session id names a slot, not a lifecycle — a deleted-then-recreated id, or
30
+ * a persistence root swapped under a surviving cache, would otherwise let an
31
+ * old row pass every watermark check and seed state folded from an unrelated
32
+ * log. Reads validate this against the live header (listing) or the stored
33
+ * header (cold read) before accepting any row.
34
+ */
35
+ export declare const checkpointIdentity: z.ZodObject<{
36
+ createdAt: z.ZodNumber;
37
+ cwd: z.ZodOptional<z.ZodString>;
38
+ }, z.core.$strip>;
39
+ /** The identity fields a record is bound to, inferred from {@link checkpointIdentity}. */
40
+ export type CheckpointIdentity = z.infer<typeof checkpointIdentity>;
41
+ /**
42
+ * One session's stored record: the log identity it was folded from plus its
43
+ * checkpoint rows keyed by projection key. The whole record is replaced on
44
+ * every write (whole-value discipline — the registry checkpoint is always
45
+ * the complete per-session cut).
46
+ */
47
+ export declare const checkpointRecord: z.ZodObject<{
48
+ identity: z.ZodObject<{
49
+ createdAt: z.ZodNumber;
50
+ cwd: z.ZodOptional<z.ZodString>;
51
+ }, z.core.$strip>;
52
+ rows: z.ZodRecord<z.ZodString, z.ZodObject<{
53
+ ver: z.ZodNumber;
54
+ seq: z.ZodNumber;
55
+ val: z.ZodJSONSchema;
56
+ }, z.core.$strip>>;
57
+ }, z.core.$strip>;
58
+ /** One stored per-session checkpoint record, inferred from {@link checkpointRecord}. */
59
+ export type CheckpointRecord = z.infer<typeof checkpointRecord>;
60
+ /**
61
+ * The session-projcache domain spec. Version bumps discard the whole medium
62
+ * (cache semantics: a stale or unreadable cache costs a longer tail replay,
63
+ * never a wrong value).
64
+ */
65
+ export declare const projectionCacheDomainSpec: {
66
+ name: string;
67
+ version: number;
68
+ tables: {
69
+ sessions: import("@deepseek-ai/dsh-storage-domain").DomainTableSpec<SessionId, {
70
+ identity: {
71
+ createdAt: number;
72
+ cwd?: string | undefined;
73
+ };
74
+ rows: Record<string, {
75
+ ver: number;
76
+ seq: number;
77
+ val: z.core.util.JSONType;
78
+ }>;
79
+ }>;
80
+ };
81
+ };
82
+ //# sourceMappingURL=spec.d.ts.map
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@deepseek-ai/dsh-session-projection-cache",
3
+ "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session projection checkpoints over the domain data form, throttled write-behind, and the cold-read ladder (cache row + persistence tail replay)",
4
+ "version": "0.0.1-rc.1",
5
+ "publishConfig": {
6
+ "access": "restricted"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "packages/session/session-projection-cache"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./invariant": {
22
+ "types": "./lib/types/invariant.d.ts",
23
+ "default": "./lib/invariant.js"
24
+ },
25
+ "./src/*": "./src/*",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "lib/index.js",
30
+ "lib/invariant.js",
31
+ "lib/types/**/*.d.ts"
32
+ ],
33
+ "license": "BSD-3-Clause",
34
+ "dependencies": {
35
+ "zod": "^4.4.3",
36
+ "@deepseek-ai/schemastery": "^3.18.1-rc.1"
37
+ },
38
+ "peerDependencies": {
39
+ "@deepseek-ai/dsh-session": "^0.0.1-rc.1",
40
+ "@deepseek-ai/dsh-session-persistence": "^0.0.1-rc.1",
41
+ "@deepseek-ai/dsh-session-projection": "^0.0.1-rc.1",
42
+ "@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
43
+ "@deepseek-ai/dsh-storage-domain": "^0.0.1-rc.1",
44
+ "@deepseek-ai/cordis": "^4.0.1-rc.1"
45
+ },
46
+ "devDependencies": {
47
+ "@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
48
+ "@deepseek-ai/dsh-session-projection": "^0.0.1-rc.1",
49
+ "@deepseek-ai/dsh-storage": "^0.0.1-rc.1",
50
+ "@deepseek-ai/dsh-storage-domain": "^0.0.1-rc.1",
51
+ "@deepseek-ai/cordis": "^4.0.1-rc.1",
52
+ "@deepseek-ai/dsh-session": "^0.0.1-rc.1",
53
+ "@deepseek-ai/dsh-session-persistence": "^0.0.1-rc.1"
54
+ }
55
+ }