diff --git a/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml
new file mode 100644
index 0000000000..e1aaf99127
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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 .agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md
+2026-08-18-sqlite-physical-chunk-row-compression.md: e46adf26ab4ce0a495f3509977ab0835631c16a9
+2026-08-18-sqlite-physical-chunk-row-compression.zh.md: 8e3978c1319545fd09dc668fbd46434a03eb8def
diff --git a/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md b/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md
new file mode 100644
index 0000000000..e46adf26ab
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.md
@@ -0,0 +1,81 @@
+# Agent Note: SQLite physical chunk-row compression
+
+Status: implemented
+
+English | [中文](2026-08-18-sqlite-physical-chunk-row-compression.zh.md)
+
+## Problem
+
+The scalar [`session-persistence-sqlite`](../../../../packages/session/session-persistence-sqlite/README.md) layout stores one physical row per logical `SessionEvent`. Provider streams produce token-sized `assistant/chunk` events with repeated turn, step, block, type, and envelope fields, so transaction batching reduces commits without reducing row count or repeated JSON payload. The logical stream cannot be coalesced because chunk boundaries, sequence numbers, timestamps, replay, partial output, UI fidelity, and `sourceEventSeqs` remain observable.
+
+A physical row that represents several events affects append contiguity, crash repair, suffix seeks, schema ownership, revisions, and stale writers. Durable decoding must also be fixed by the schema version; a configurable codec set could make one schema version unreadable under a different Cordis composition.
+
+## Decision
+
+`@deepseek-ai/dsh-session-persistence-sqlite` uses the packed schema-17 implementation. It is the only SQLite persistence package and provider; the predecessor scalar layout and the temporary versioned sibling are not retained. SQLite remains an opt-in switch, while shipped default compositions continue to use JSONL. Both backends implement the same `SessionPersistence` service through `PersistenceCoordinator`, so physical packing changes neither live event delivery nor the logical session API.
+
+Schema 17 keeps ordinary ROWID tables and the composite `events(session_id, seq)` primary-key index. Scalar rows represent one logical event. Packed rows use the storage tags `text-chunks`, `reasoning-chunks`, and `tool-call-chunks`; the SQL `seq` and `time` columns hold the first logical member, and `data` holds the packed payload. Packed rows set `ignorable=0` as a physical discriminator and leave `source_event_seqs` and `surface_op` as `NULL`; scalar rows use `ignorable=1` only for logical ignorable events and `NULL` otherwise. A future ignorable logical event may therefore reuse a storage-tag name without being decoded as a packed row. The tags are storage vocabulary, not `SessionEventMap` members.
+
+SQLite owns chunk encoding and validation inside the schema-17 package. Exact-field whitelisting means unknown fields, surface metadata, incompatible chunk identity, sequence gaps, and unsafe timestamps remain scalar rather than losing information. One packed row represents at most 1,024 events and 1 MiB of uncompressed UTF-8 `data`; the encoder partitions longer runs, and the decoder rejects rows outside those format limits.
+
+The `data` column accepts `TEXT` or `BLOB`. Serialized values below 4 KiB remain text. At or above the threshold, the writer uses Zstandard level 3 and retains the frame only when it is smaller than the text; the reader decompresses the blob before strict UTF-8 decoding and JSON parsing. The fixed moderate level and threshold limit frame overhead and synchronous CPU work while capturing the repeated payloads that dominate retained bytes.
+
+`source_event_seqs` remains the complete ordered list of earlier events cited by a surface node, including every streamed chunk behind an assembled assistant message. Schema 17 stores the first sequence as an unsigned varint and every subsequent signed difference as a ZigZag varint. This preserves arbitrary order and every sequence while exploiting the overwhelmingly consecutive lists produced by streaming. An empty list is an empty non-null blob, distinct from absent provenance.
+
+### Transactional append packing
+
+Each append acquires `BEGIN IMMEDIATE`, rechecks schema ownership, selects the bounded physical span that may cover the last stored sequence, and derives the next logical sequence from that decoded tail. A mismatch rejects a stale writer before mutation. The codec packs only the new durable batch. Its inserts, lazy session materialization, and one revision increment commit or roll back together.
+
+Normal append never deletes or replaces an earlier event row. Fixed write-behind windows normally collect high-frequency deltas into useful runs, while sparse or explicitly flushed batches may remain scalar. This makes physical event writes proportional to newly durable batches and prevents a stable retained-row count from hiding repeated replacement of a growing JSON value.
+
+### Reads and repair
+
+Full reads decode each physical row as one all-or-nothing logical span and validate contiguous logical sequences. A reverse pass identifies the last valid `turn/end` without retaining a second decoded copy of the full physical scan; the forward pass decodes one row at a time into the required logical result. A malformed row or gap before that committed boundary is corruption; a malformed final physical row becomes the opaque repair marker at that row's base sequence. Recovery re-reads and validates that marker while holding the write lock, then deletes the whole physical row and any later rows before binding synthetic closers as scalar events. A stale repair cannot delete a newer writer's valid suffix.
+
+`readFrom(id, fromSeq)` examines packed predecessors only within the maximum schema-17 row span, then reads from the earliest candidate that may contain `fromSeq`. The decoder filters reconstructed members below `fromSeq`, so a suffix may begin inside a packed row without parsing an unrelated earlier scalar row. Reading from that candidate also exposes an overlapping scalar row to contiguity validation instead of letting it hide the packed member. Packed data exceeding the uncompressed format byte limit rejects before JSON parsing.
+
+### Schema ownership
+
+A pristine database initializes at schema 17. Older physical schemas, foreign application identities, non-pristine unversioned databases, and incompatible schema objects reject; the pre-release package supplies no migration. Every connection disables trusted schemas and memory-mapped I/O before inspecting durable schema, then reads both settings back. After selecting and verifying the journal mode, the provider pins `synchronous=FULL` and verifies it so SQLite build defaults cannot weaken committed-append durability. Package code loads every statement and fixed pragma from closed-name `.sql` resources and binds runtime values as parameters.
+
+### Physical-write regression
+
+The repository regression guard writes 1,000 streamed deltas in 40-event durable batches. After every committed batch it compares every retained physical field, requires cumulative inserts to equal the final row count, and rejects changed or removed rows. It also checks the exact 31-row bound, the largest persisted record against the schema byte limit, and an idle interval with no WAL extent change. These checks prove bounded row structure and catch coarse write amplification; they do not establish device traffic because WAL frames can be overwritten in place and checkpoints also write the main database. Incident-class validation separately samples process physical bytes around active and idle periods and stresses synchronized multi-process access. Lock tests hold `BEGIN IMMEDIATE` in another process and verify bounded waiting and successful continuation.
+
+## Alternatives considered
+
+**Coalesce logical chunk events.** Rejected because it changes sequence references, replay, partial output, and live delivery. Physical records provide the storage reduction while restoring the authoritative log exactly.
+
+**Run a periodic or post-commit compactor.** Rejected because it adds another writer lifecycle, races append and repair, changes revisions without a logical append, and adds disposal work.
+
+**Merge each new batch into the prior packed tail.** Rejected because a stable database and row count can hide repeated delete-and-insert churn. Paced-stream measurement found higher process and WAL writes than the predecessor scalar layout even when the retained database was smaller. Batch-local packing gives up timing-independent row convergence to bound physical writes.
+
+**Use `synchronous=NORMAL` with WAL.** Rejected because it permits a recent committed transaction to roll back after an operating-system crash or power loss. `append()` resolves only after its batch is durable, so the provider explicitly retains SQLite's `FULL` durability level across builds.
+
+**Remove ROWID from `events`.** Rejected because the composite text/integer primary key then becomes the table B-tree key and is repeated through internal pages. On the 105-session comparison corpus, selective Zstandard with ordinary ROWID used 107.02 MB; the otherwise equivalent `WITHOUT ROWID` database used 126.75 MB.
+
+**Set a larger SQLite page size.** Rejected because the retained-size change was negligible: 4 KiB pages used 107.08 MB and 32 KiB pages used 106.89 MB in the layout reconstruction. The larger page also increases WAL-frame and cache granularity. The provider therefore issues no `page_size` pragma.
+
+**Compress every payload.** Rejected because small independent Zstandard frames add headers and synchronous CPU work while losing the cross-record dictionary opportunity of a whole-file stream. On the 105-session comparison corpus, a threshold sweep produced 75.01 MB at 4 KiB, versus 93.87 MB at 16 KiB and 60.92 MB at 1 KiB. The writer fixes level 3 rather than inheriting a library default, matching the moderate level used by [Codex cold-rollout compression](https://github.com/openai/codex/blob/main/codex-rs/rollout/src/compression.rs) while retaining independent row access.
+
+The final frozen comparison used 105 sessions, 2,507,860 logical events, 512-event durable batches, three independent builds per backend, and three read passes per build. SQLite used 75.01 MB, wrote in 8.58 s, read complete sessions at 3.95/21.58 ms p50/p95, read 50-event tails at 0.253/0.378 ms, and forked every session in 13.10 s. Zstandard JSONL used 30.65 MB and measured 28.21 s, 4.49/23.36 ms, 10.58/80.90 ms, and 14.48 s. The predecessor scalar SQLite layout used 709.57 MB and measured 10.64 s, 9.02/69.16 ms, 0.189/0.293 ms, and 19.30 s. The packed layout is 89.4% smaller than the predecessor, writes 19.4% faster, improves complete-read p50/p95 by 56.2%/68.8%, and reduces 2,507,860 physical event rows to 65,810. Scalar tail-50 and list micro-latency are lower, but the packed provider remains materially faster than JSONL on those paths and wins the dominant size, write, full-read, and fork costs. The 4 KiB threshold is the accepted balance rather than a strict dominance claim.
+
+**Store packed payloads under the logical `assistant/chunk` type.** Rejected because payload heuristics make malformed rows ambiguous and couple physical decoding to future logical payload fields. Explicit tags fail loudly.
+
+**Store `SessionHeader` fields in an extensible metadata blob.** Rejected for schema 17 because `agentPreset` is a typed core resume invariant shared by JSONL and SQLite, not provider extension metadata. Persisting validated core fields directly keeps both backends aligned; an untyped catch-all would add another compatibility mechanism without a current producer. Revisit this only with a core-owned, namespaced `SessionHeader` extension protocol implemented by every backend.
+
+**Expose compression rules through configuration or a live registry.** Rejected because same-version databases must be readable independently of runtime topology. The codec is modular source code, but the durable rule set is fixed by schema version.
+
+**Migrate older schemas in place.** Rejected under the pre-release policy. Changing strict column types requires rebuilding the event table, which turns the first append into an unbounded historical rewrite and temporarily duplicates storage. A new database keeps activation explicit and failure predictable.
+
+**Store forked history as a parent reference.** Deferred because it changes independent-session persistence rather than physical row encoding. Codex uses referenced history and excludes referenced or pointer-bearing rollouts from cold compression, but this provider would first need explicit parent retention, deletion, repair, export, and cross-backend semantics. Copying remains the bounded local choice until the session service owns those rules.
+
+**Keep the packed implementation as a versioned sibling.** Rejected because the pre-release repository has no compatibility promise for the scalar format, while two SQLite package names duplicate configuration, documentation, tests, and ownership. Historical benchmark artifacts retain the comparison without exposing a rollback provider.
+
+## Consequences
+
+The canonical SQLite provider preserves every logical persistence, replay, revision, crash-recovery, and model-facing behavior. High-frequency batches use fewer rows and fewer measured process disk-written bytes than the predecessor in paced-stream validation; idle samples add no measured writes. Packing ratio depends on durable batch boundaries, but previously committed rows are immutable outside explicit crash repair.
+
+The cost is no migration from older pre-release SQLite schemas and timing-dependent physical row count. SQLite and Zstandard remain synchronous: each connection uses the configured `busyTimeoutMs` for a competing lock and blocks its JavaScript thread during that wait, while large row encoding and decoding also run on that thread. A cold open yields after an immediate `SQLITE_BUSY` journal-mode transition and starts no further attempt after an open-relative retry cutoff; an in-progress synchronous call may finish later. External SQL tooling must use the provider decoder rather than assuming every physical `events.type` is a logical event type or every payload column is text.
+
+The [JSONL packed-row decision](2026-07-26-packed-chunk-rows-by-default.md), [bounded persistence batching](2026-08-08-bounded-session-persistence-write-batching.md), and original [session-persistence decision](2026-06-14-session-persistence.md) remain active: they respectively own the JSONL format, write scheduling, and backend-neutral service semantics.
diff --git a/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md b/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md
new file mode 100644
index 0000000000..8e3978c131
--- /dev/null
+++ b/.agents/notes/implemented/architecture/2026-08-18-sqlite-physical-chunk-row-compression.zh.md
@@ -0,0 +1,81 @@
+# Agent Note: SQLite 物理分片行压缩
+
+Status: implemented
+
+[English](2026-08-18-sqlite-physical-chunk-row-compression.md) | 中文
+
+## 问题
+
+标量 [`session-persistence-sqlite`](../../../../packages/session/session-persistence-sqlite/README.md) 后端为每个逻辑 `SessionEvent` 存储一个物理行。提供方流会生成 token 大小的 `assistant/chunk` 事件,并重复轮次、步骤、块、类型和 envelope 字段,因此事务批处理可以减少提交次数,却不能减少行数或重复 JSON payload。逻辑流不能合并,因为分片边界、序列号、时间戳、回放、部分输出、UI 保真度和 `sourceEventSeqs` 仍然可观察。
+
+一个表示多个事件的物理行会影响追加连续性、崩溃修复、后缀定位、schema 所有权、revision 和陈旧写入方。持久解码规则还必须由包版本固定;可配置 codec 集可能导致同一 schema 版本在不同 Cordis 组合下无法读取。
+
+## 决策
+
+`@deepseek-ai/dsh-session-persistence-sqlite` 使用打包后的 schema 17 实现。它是唯一的 SQLite 持久化包和提供方;仓库不保留此前的标量布局与临时版本化同级包。SQLite 仍是可选开关,随产品交付的默认组合继续使用 JSONL。两个后端都通过 `PersistenceCoordinator` 实现同一 `SessionPersistence` 服务,因此物理打包既不改变实时事件投递,也不改变逻辑会话 API。
+
+Schema 17 保留普通 ROWID 表以及复合主键索引 `events(session_id, seq)`。标量行表示一个逻辑事件。打包行使用存储标签 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks`;SQL 的 `seq` 和 `time` 列保存第一个逻辑成员,`data` 保存打包 payload。打包行把 `ignorable=0` 用作物理判别值,并让 `source_event_seqs` 与 `surface_op` 保持 `NULL`;标量行仅在逻辑事件可忽略时使用 `ignorable=1`,否则使用 `NULL`。因此,未来的可忽略逻辑事件即使复用了某个存储标签名称,也不会被解码为打包行。这些标签属于存储词汇,而不是 `SessionEventMap` 成员。
+
+SQLite 在 schema 17 包内拥有分片编码和验证。字段完全匹配的白名单意味着未知字段、surface 元数据、不兼容的分片身份、序列缺口和不安全时间戳仍保持标量表示,不会丢失信息。一个打包行最多表示 1,024 个事件和 1 MiB 未压缩 UTF-8 `data`;编码器会分割更长的连续段,解码器则拒绝超出这些格式上限的行。
+
+`data` 列接受 `TEXT` 或 `BLOB`。序列化值小于 4 KiB 时保持为文本。达到或超过该阈值时,写入方使用 Zstandard level 3,并且只在 frame 小于原文本时保留该 frame;读取方会先解压,再进行严格 UTF-8 解码和 JSON 解析。固定的适中级别与阈值限制 frame 开销与同步 CPU 工作,同时覆盖占据大部分保留字节的重复 payload。
+
+`source_event_seqs` 是 surface 节点引用的早期事件的完整有序列表,包括组装后的 assistant 消息背后的每个流式分片。Schema 17 把第一个序列存为无符号 varint,把后续每个有符号差值存为 ZigZag varint。这样既能保留任意顺序和每个序列,又能利用流式处理所产生的绝大多数连续列表。空列表表示为空的非 `NULL` blob,与不存在来源区分开来。
+
+### 事务化追加打包
+
+每次追加会获取 `BEGIN IMMEDIATE`、重新检查 schema 所有权、选择可能覆盖最后存储序列的有界物理范围,并根据解码后的尾部推导下一逻辑序列。若不匹配,系统会在变更前拒绝陈旧写入方。Codec 只打包新的持久批次;其插入、会话惰性物化和一次 revision 递增会一起提交或回滚。
+
+普通追加绝不删除或替换既有事件行。固定写后缓冲窗口通常会把高频 delta 收集成有效连续段,而稀疏或显式 flush 的批次可能保持标量形式。这样,物理事件写入量与新增持久批次成正比,稳定的保留行数无法再掩盖对不断增长 JSON 值的反复替换。
+
+### 读取与修复
+
+完整读取把每个物理行解码为全有或全无的逻辑范围,并验证逻辑序列连续。反向扫描会定位最后一个有效 `turn/end`,但不会保留完整物理扫描的第二份解码副本;正向扫描则逐行解码并写入必需的逻辑结果。在该已提交边界之前出现的畸形行或缺口属于损坏;畸形最终物理行则以该行的起始序列作为不透明修复标记。恢复会在持有写锁时重新读取并验证该 marker,再删除整个物理行及其后所有行,然后把合成 closers 绑定为标量事件。陈旧修复无法删除较新写入方的有效后缀。
+
+`readFrom(id, fromSeq)` 只检查 schema 17 最大行跨度内的打包前驱,再从可能包含 `fromSeq` 的最早候选项开始读取。解码器会过滤重建后序列小于 `fromSeq` 的成员,因此后缀可以从打包行内部开始,而无需解析无关的更早标量行。从该候选项开始读取,还会让连续性验证看到相互重叠的标量行,而不是让它隐藏打包成员。打包数据超出未压缩格式字节上限时,会在解析 JSON 前拒绝。
+
+### Schema 所有权
+
+全新数据库初始化为 schema 17。旧物理 schema、外部 application identity、非空未版本化数据库以及不兼容 schema 对象都会被拒绝;该预发布提供方不提供迁移。每个连接都会在检查持久 schema 前禁用可信 schema 和内存映射 I/O,然后读回这两项设置。选择并验证 journal mode 后,提供方会把 `synchronous` 固定为 `FULL` 并验证该设置,避免 SQLite 构建默认值削弱已提交追加的持久性。包代码通过封闭名称的 `.sql` 资源加载每条语句和固定 pragma,并把运行时值作为参数绑定。
+
+### 物理写入回归
+
+仓库回归守卫以 40 个事件为持久批次写入 1,000 个流式 delta。它会在每个批次提交后比较所有保留物理字段,要求累计插入数等于最终行数,并拒绝发生变化或被移除的行。它还会检查精确的 31 行上限、最大持久记录不超过 schema 字节上限,并观察空闲区间内 WAL 范围不再变化。这些检查证明行结构有界并捕获粗粒度写放大;它们不能证明设备写流量,因为 WAL 帧可在原位覆写,检查点还会写入主数据库。事故级验证另行采样活动期和空闲期前后的进程物理写入字节,并对同步多进程访问进行压力测试。锁测试在另一个进程中持有 `BEGIN IMMEDIATE`,验证有界等待及之后成功继续。
+
+## 考虑过的替代方案
+
+**合并逻辑分片事件。** 不予采用,因为它会改变序列引用、回放、部分输出和实时投递。物理记录可以在准确恢复权威日志的同时获得存储缩减。
+
+**运行周期性或提交后压缩器。** 不予采用,因为它会增加另一个写入方生命周期,与追加和修复竞争,在没有逻辑追加的情况下改变 revision,并增加资源释放工作。
+
+**把每个新批次合并进已有打包尾部。** 不予采用,因为稳定的数据库与行数可能掩盖反复删除和插入产生的写入流量。节奏化流测量表明,即使保留数据库更小,该方案写入的进程字节与 WAL 字节仍高于此前的标量布局。逐批打包放弃与时序无关的行收敛,以换取有界物理写入。
+
+**在 WAL 模式下使用 `synchronous=NORMAL`。** 不予采用,因为操作系统崩溃或断电后,最近提交的事务可能回滚。`append()` 只会在批次持久化后返回,因此提供方会在不同 SQLite 构建中显式保留 `FULL` 持久性级别。
+
+**从 `events` 移除 ROWID。** 不予采用,因为复合文本/整数主键随后会成为表 B-tree 的键,并在内部页中重复。在 105 个会话的对比语料上,使用普通 ROWID 的选择性 Zstandard 数据库为 107.02 MB;其余条件相同的 `WITHOUT ROWID` 数据库为 126.75 MB。
+
+**设置更大的 SQLite page size。** 不予采用,因为保留体积变化可以忽略:在独立的 page-size 布局重建中,4 KiB page 使用 107.08 MB,32 KiB page 使用 106.89 MB。更大的 page 还会增大 WAL frame 和 cache 粒度。因此提供方不设置 `page_size` pragma。
+
+**压缩每个 payload。** 不予采用,因为小型独立 Zstandard frame 会增加 header 和同步 CPU 工作,也无法利用整文件流的跨记录字典。在 105 个会话的对比语料上,阈值扫描结果为:4 KiB 生成 75.01 MB,16 KiB 为 93.87 MB,1 KiB 为 60.92 MB。写入方固定使用 level 3,而不是继承库默认值;这与 [Codex 冷 rollout 压缩](https://github.com/openai/codex/blob/main/codex-rs/rollout/src/compression.rs)所用的适中级别一致,同时保留独立行访问。
+
+最终冻结对比包含 105 个会话、2,507,860 个逻辑事件,以 512 个事件为持久批次;每个后端独立构建三次,每次构建执行三轮读取。SQLite 使用 75.01 MB,写入耗时 8.58 秒,完整读取 p50/p95 为 3.95/21.58 毫秒,读取最后 50 个事件为 0.253/0.378 毫秒,对所有会话执行 fork 为 13.10 秒。Zstandard JSONL 使用 30.65 MB,对应指标为 28.21 秒、4.49/23.36 毫秒、10.58/80.90 毫秒和 14.48 秒。此前的标量 SQLite 布局使用 709.57 MB,对应指标为 10.64 秒、9.02/69.16 毫秒、0.189/0.293 毫秒和 19.30 秒。打包布局比此前布局小 89.4%,写入快 19.4%,完整读取 p50/p95 改善 56.2%/68.8%,并把 2,507,860 个物理事件行减少到 65,810 行。标量布局的最后 50 个事件读取与 list 微延迟更低,但打包提供方在这些路径上仍明显快于 JSONL,并改善主要的空间、写入、完整读取和 fork 成本。4 KiB 阈值是接受的平衡点,而不是严格支配所有指标的结论。
+
+**把打包 payload 存在逻辑 `assistant/chunk` 类型下。** 不予采用,因为 payload 启发式判断会使畸形行产生歧义,并把物理解码耦合到未来逻辑 payload 字段。显式标签会明确失败。
+
+**把 `SessionHeader` 字段存入可扩展元数据 blob。** Schema 17 不采用该方案,因为 `agentPreset` 是 JSONL 与 SQLite 共同使用的强类型核心恢复不变量,而不是提供方扩展元数据。直接持久化已校验的核心字段可使两个后端保持一致;在没有当前生产方的情况下加入无类型兜底字段,只会增加另一套兼容机制。只有核心层定义由所有后端实现、带命名空间的 `SessionHeader` 扩展协议后,才应重新考虑该方案。
+
+**通过配置或实时注册表暴露压缩规则。** 不予采用,因为同一版本数据库必须能独立于运行时拓扑被读取。Codec 在源码层保持模块化,但持久规则集由 schema 版本固定。
+
+**原地迁移旧 schema。** 预发布策略不采用此方案。改变 strict 列类型需要重建事件表,这会把第一次追加变成无界的历史改写,并暂时复制存储。使用新数据库可让启用行为明确、失败方式可预测。
+
+**把 fork 历史存为父级引用。** 延期处理,因为它改变的是独立会话持久化语义,而不是物理行编码。Codex 使用引用历史,并避免对被引用或带指针的 rollout 做冷压缩;但该提供方首先需要明确父级保留、删除、修复、导出和跨后端语义。在会话服务拥有这些规则之前,复制仍是有界的本地选择。
+
+**把打包实现保留为版本化同级包。** 不予采用,因为预发布仓库不承诺兼容此前的标量格式,而两个 SQLite 包名会重复配置、文档、测试和所有权。历史 benchmark 产物保留对比,无需暴露回滚提供方。
+
+## 后果
+
+标准 SQLite 提供方保留每一项逻辑持久化、回放、revision、崩溃恢复和模型可见行为。在节奏流验证中,高频批次使用的行数和测得的进程磁盘写入字节少于此前布局;空闲样本没有新增测得写入。打包率取决于持久批次边界,但除显式崩溃修复外,已经提交的行保持不可变。
+
+代价是不迁移旧的预发布 SQLite schema,以及取决于时序的物理行数。SQLite 与 Zstandard 都是同步操作:每个连接以配置的 `busyTimeoutMs` 等待竞争锁,该等待期间会阻塞其 JavaScript 线程,大型行的编码与解码也在该线程上执行。冷打开会在 journal-mode 切换立即返回 `SQLITE_BUSY` 后让出执行,并在从打开时计算的重试截止点后不再发起新尝试;正在执行的同步调用可能更晚才完成。外部 SQL 工具必须使用提供方解码器,而不能假定每个物理 `events.type` 都是逻辑事件类型或每个 payload 列都是文本。
+
+[JSONL 打包行决策](2026-07-26-packed-chunk-rows-by-default.md)、[有界持久化批处理](2026-08-08-bounded-session-persistence-write-batching.md)和原始[会话持久化决策](2026-06-14-session-persistence.md)继续保持 active:它们分别负责 JSONL 格式、写入调度以及后端无关的服务语义。
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index 9243513ecb..be4f529210 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.i18n.yaml
@@ -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 docs/config-catalog.md
-config-catalog.md: d9c4eff5a206ac17b54957d5364fb7b3d175d97c
-config-catalog.zh.md: 010878c582eec6263ea604c880a15407ebd777b6
+config-catalog.md: 83bd4760c9acff81f36234f88f63c86d09884add
+config-catalog.zh.md: f83e4c5ce223cb8f0d047e13193507efc154a46a
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index d9c4eff5a2..83bd4760c9 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1678,41 +1678,23 @@ Requires: `sessions`
```ts config-catalog
/** Plugin configuration. */
export interface Config {
- /**
- * Filesystem path to the SQLite database file. The special value `:memory:`
- * opens an in-process database (tests). On filesystems with POSIX modes,
- * missing directories and databases are created owner-only; existing path
- * modes are preserved. Filesystem setup errors other than an existing database
- * fail initialization. The backend does not protect confidentiality or
- * integrity when another principal can replace the database entry in its
- * parent directory.
- */
+ /** SQLite database path, or `:memory:` for an in-process database. */
path: string
- /**
- * SQLite `journal_mode` pragma. `wal` (the default) is the recorded
- * durability model; pick a rollback-journal mode (`delete`/`truncate`/
- * `persist`) on filesystems where WAL's shared-memory files do not work
- * (network mounts). See {@link JournalMode}.
- */
+ /** Durable SQLite journal mode; defaults to `wal`. */
journalMode?: JournalMode
+ /** Maximum wait for another SQLite connection's lock; defaults to 5,000 ms. */
+ busyTimeoutMs?: number
/** Maximum cold Session preparations retained for history-to-resume reuse. */
preparedSessionCacheSize?: number
/** Fixed live-event coalescing window; not a backend completion deadline. */
writeBatchMaxDelayMs?: number
}
-/**
- * Journal modes the backend will run under. `wal` is the default and the
- * durability model the persistence ADR records; the rollback-journal modes
- * (`delete`/`truncate`/`persist`) exist for filesystems where WAL's
- * shared-memory files do not work (network mounts). `memory`/`off` are
- * excluded: dropping journal durability silently contradicts what this
- * backend promises.
- */
+/** Durable journal modes accepted by the backend. */
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
```
-Source: [`packages/session/session-persistence-sqlite/src/index.ts:70`](../packages/session/session-persistence-sqlite/src/index.ts)
+Source: [`packages/session/session-persistence-sqlite/src/index.ts:36`](../packages/session/session-persistence-sqlite/src/index.ts)
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 010878c582..f83e4c5ce2 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -1680,41 +1680,23 @@ export type JsonlCompression = 'zstd' | 'none'
```ts config-catalog
/** Plugin configuration. */
export interface Config {
- /**
- * Filesystem path to the SQLite database file. The special value `:memory:`
- * opens an in-process database (tests). On filesystems with POSIX modes,
- * missing directories and databases are created owner-only; existing path
- * modes are preserved. Filesystem setup errors other than an existing database
- * fail initialization. The backend does not protect confidentiality or
- * integrity when another principal can replace the database entry in its
- * parent directory.
- */
+ /** SQLite database path, or `:memory:` for an in-process database. */
path: string
- /**
- * SQLite `journal_mode` pragma. `wal` (the default) is the recorded
- * durability model; pick a rollback-journal mode (`delete`/`truncate`/
- * `persist`) on filesystems where WAL's shared-memory files do not work
- * (network mounts). See {@link JournalMode}.
- */
+ /** Durable SQLite journal mode; defaults to `wal`. */
journalMode?: JournalMode
+ /** Maximum wait for another SQLite connection's lock; defaults to 5,000 ms. */
+ busyTimeoutMs?: number
/** Maximum cold Session preparations retained for history-to-resume reuse. */
preparedSessionCacheSize?: number
/** Fixed live-event coalescing window; not a backend completion deadline. */
writeBatchMaxDelayMs?: number
}
-/**
- * Journal modes the backend will run under. `wal` is the default and the
- * durability model the persistence ADR records; the rollback-journal modes
- * (`delete`/`truncate`/`persist`) exist for filesystems where WAL's
- * shared-memory files do not work (network mounts). `memory`/`off` are
- * excluded: dropping journal durability silently contradicts what this
- * backend promises.
- */
+/** Durable journal modes accepted by the backend. */
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
```
-来源:[`packages/session/session-persistence-sqlite/src/index.ts:70`](../packages/session/session-persistence-sqlite/src/index.ts)
+来源:[`packages/session/session-persistence-sqlite/src/index.ts:36`](../packages/session/session-persistence-sqlite/src/index.ts)
diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml
index 83709a6f2b..9dc9775839 100644
--- a/docs/module-graph.i18n.yaml
+++ b/docs/module-graph.i18n.yaml
@@ -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 docs/module-graph.md
-module-graph.md: 69d751f69ee254f4c2842216179f0072b208a49d
-module-graph.zh.md: c93c95aae022d040498025530304344e001de2f0
+module-graph.md: 55fbda46e76db21b5132f56716d3c300a3e469a0
+module-graph.zh.md: cd205a3f6d8d8ec3ddc8b07cce0f8d3b3eac9779
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 69d751f69e..55fbda46e7 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -571,6 +571,7 @@ flowchart TD
pkg_session_persistence_jsonl --> pkg_session
pkg_session_persistence_jsonl --> pkg_session_persistence
pkg_session_persistence_sqlite --> pkg_invariants
+ pkg_session_persistence_sqlite --> pkg_llm
pkg_session_persistence_sqlite --> pkg_session
pkg_session_persistence_sqlite --> pkg_session_persistence
pkg_session_projection_cache --> pkg_invariants
@@ -1537,7 +1538,7 @@ flowchart TD
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
-| [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
+| [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
| [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
| [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md
index c93c95aae0..cd205a3f6d 100644
--- a/docs/module-graph.zh.md
+++ b/docs/module-graph.zh.md
@@ -573,6 +573,7 @@ flowchart TD
pkg_session_persistence_jsonl --> pkg_session
pkg_session_persistence_jsonl --> pkg_session_persistence
pkg_session_persistence_sqlite --> pkg_invariants
+ pkg_session_persistence_sqlite --> pkg_llm
pkg_session_persistence_sqlite --> pkg_session
pkg_session_persistence_sqlite --> pkg_session_persistence
pkg_session_projection_cache --> pkg_invariants
@@ -1539,7 +1540,7 @@ flowchart TD
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
-| [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
+| [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
| [`session-projection-cache`](../packages/session/session-projection-cache) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
| [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml
index 2950610348..baa245bcd8 100644
--- a/docs/subsystems/persistence.i18n.yaml
+++ b/docs/subsystems/persistence.i18n.yaml
@@ -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 docs/subsystems/persistence.md
-persistence.md: 5b1b224e419aca205baba69894ed64467b8fb4e1
-persistence.zh.md: a91e7d66b92270c82287d054e619b665e96ea206
+persistence.md: ef193806ce6234d1c25e2118ca2db7233c0b9b2e
+persistence.zh.md: 5fa47497249888f478e471e2798b6dfcc724db84
diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md
index 5b1b224e41..ef193806ce 100644
--- a/docs/subsystems/persistence.md
+++ b/docs/subsystems/persistence.md
@@ -4,7 +4,7 @@ English | [中文](persistence.zh.md)
The **durability seam** for the event log. [session.md](session.md) describes the in-memory `Session` — the append-only `SessionEvent` log that is the source of truth. This page describes how that log is made durable: the abstract `SessionPersistence` service, its backends, the flush checkpoint, crash recovery, and the metadata header that travels alongside the log. The event vocabulary the log carries is enumerated, member by member, in the generated [persistence log event catalog](../persistence-catalog.md).
-The seam is a [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session/session-persistence), `ctx.sessionPersistence`) defining locate/create/append, reusable Session preparation, logical load/inspect, physical suffix reads, and lightweight list/snapshot observation over the existing `SessionEvent` — **no parallel persisted event type** — and two interchangeable backends implementing the same contract. See the [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md).
+The seam is a [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md): one abstract service ([dsh-session-persistence](../../packages/session/session-persistence), `ctx.sessionPersistence`) defining locate/create/append, reusable Session preparation, logical load/inspect, physical suffix reads, and lightweight list/snapshot observation over the existing `SessionEvent` — **no parallel persisted event type** — and three interchangeable providers implementing the same contract. See the [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md).
## The flush checkpoint
@@ -230,10 +230,10 @@ interface SessionPersistenceSnapshot {
## The backends
-Both implement the same abstract `SessionPersistence` (locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and pass the shared `runPersistenceContract` suite:
+All implement the same abstract `SessionPersistence` (locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and pass the shared `runPersistenceContract` suite:
- **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)** — an append-only logical JSONL log per session, stored as checksummed concatenated Zstandard frames by default or raw lines by configuration, with crash-safe atomic writes, interrupted-turn recovery, and a read/replay path.
-- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row fields `(session_id, seq, type, time, data, source_event_seqs, surface_op)` map 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync.
+- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)** — an opt-in `node:sqlite` backend using schema 17 to store exact same-block delta runs in bounded physical `text-chunks`, `reasoning-chunks`, and `tool-call-chunks` rows. It reconstructs the complete logical event stream before returning it, packs only newly durable batches, and rejects older schemas rather than migrating them.
diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md
index a91e7d66b9..5fa4749724 100644
--- a/docs/subsystems/persistence.zh.md
+++ b/docs/subsystems/persistence.zh.md
@@ -4,7 +4,7 @@
事件日志的**持久性 seam**。[session.md](session.md) 描述了内存中的 `Session`:仅追加的 `SessionEvent` 日志即为真源。本页描述如何使该日志持久化:抽象的 `SessionPersistence` 服务、它的后端、flush 检查点、崩溃恢复,以及随日志一同存储的元数据头。日志承载的事件词汇在生成的[持久化日志事件目录](../persistence-catalog.md)中逐项列举。
-该 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):一个抽象服务([dsh-session-persistence](../../packages/session/session-persistence),`ctx.sessionPersistence`)在现有 `SessionEvent` 上定义 locate/create/append、可复用的 Session 准备流程、逻辑 load/inspect、物理后缀读取,以及轻量的 list/snapshot 观察——**没有平行的持久化事件类型**——以及两个实现同一约定的可互换后端。见 [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)。
+该 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md):一个抽象服务([dsh-session-persistence](../../packages/session/session-persistence),`ctx.sessionPersistence`)在现有 `SessionEvent` 上定义 locate/create/append、可复用的 Session 准备流程、逻辑 load/inspect、物理后缀读取,以及轻量的 list/snapshot 观察——**没有平行的持久化事件类型**——以及三个实现同一约定的可互换提供方。见 [session-persistence Agent Note](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)。
## flush 检查点
@@ -232,8 +232,8 @@ interface SessionPersistenceSnapshot {
两者都实现同一个抽象 `SessionPersistence`(在 `SessionEvent` 上执行 locate/create/append/prepare/load/inspect/readFrom/list/listSnapshots,观察方法可选支持取消),并通过共享的 `runPersistenceContract` 套件:
-- **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)**——每个会话一份仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断轮次的恢复以及读取/回放路径。
-- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)**:基于 `node:sqlite`,每个 `SessionEvent` 一行。行字段 `(session_id, seq, type, time, data, source_event_seqs, surface_op)` 与事件 1:1 映射(包含可选的 surface 元数据),因此没有需要保持同步的并行持久化 schema。
+- **[dsh-session-persistence-jsonl](../../packages/session/session-persistence-jsonl)**——逐会话仅追加的逻辑 JSONL 日志,默认存储为带 checksum 的连续 Zstandard frame,也可配置为原始行;支持崩溃安全的原子写入、被中断轮次的恢复以及读取/回放路径。
+- **[dsh-session-persistence-sqlite](../../packages/session/session-persistence-sqlite)**:一个可选启用的 `node:sqlite` 后端,使用 schema 17 把同一分片块中字段完全匹配的 delta 连续段存为有界物理 `text-chunks`、`reasoning-chunks` 与 `tool-call-chunks` 行。它在返回前重建完整逻辑事件流,只打包新增的持久批次,并拒绝旧 schema,而不是执行迁移。
diff --git a/packages/session/README.i18n.yaml b/packages/session/README.i18n.yaml
index 2939d73342..fc393e9bd6 100644
--- a/packages/session/README.i18n.yaml
+++ b/packages/session/README.i18n.yaml
@@ -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/README.md
-README.md: 2680bc3c6aea519427da2f31ab2526b5115cac96
-README.zh.md: b446a6a1d17da39c2a3d29c10fbb8dddf573bf80
+README.md: 78fbc0ab6f0a3b9a77c9e3b1a538dd227c532296
+README.zh.md: 0aedc9b75c5452cdf9c81777cbbdcd1f60386011
diff --git a/packages/session/README.md b/packages/session/README.md
index 2680bc3c6a..78fbc0ab6f 100644
--- a/packages/session/README.md
+++ b/packages/session/README.md
@@ -13,7 +13,7 @@ Durable session persistence, semantic checkpoint policy, and the shipped storage
| [`session-persistence/`](session-persistence/README.md) | Defines the persistence service and shared write coordination | `ctx.sessionPersistence` |
| [`session-checkpoint-policy/`](session-checkpoint-policy/README.md) | Applies semantic durability checkpoints | wraps `ctx.llm` and `ctx.tools` |
| [`session-persistence-jsonl/`](session-persistence-jsonl/README.md) | Persists sessions in JSONL files | registers on `ctx.sessionPersistence` |
-| [`session-persistence-sqlite/`](session-persistence-sqlite/README.md) | Persists sessions in SQLite | registers on `ctx.sessionPersistence` |
+| [`session-persistence-sqlite/`](session-persistence-sqlite/README.md) | Opt-in SQLite backend with packed physical chunk rows | registers on `ctx.sessionPersistence` |
The [session-persistence decision](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md) records the persistence design.
diff --git a/packages/session/README.zh.md b/packages/session/README.zh.md
index b446a6a1d1..0aedc9b75c 100644
--- a/packages/session/README.zh.md
+++ b/packages/session/README.zh.md
@@ -13,7 +13,7 @@
| [`session-persistence/`](session-persistence/README.md) | 定义持久化服务和共享写入协调机制 | `ctx.sessionPersistence` |
| [`session-checkpoint-policy/`](session-checkpoint-policy/README.md) | 应用语义持久性检查点 | 包装 `ctx.llm` 和 `ctx.tools` |
| [`session-persistence-jsonl/`](session-persistence-jsonl/README.md) | 将会话持久化到 JSONL 文件 | 注册到 `ctx.sessionPersistence` |
-| [`session-persistence-sqlite/`](session-persistence-sqlite/README.md) | 将会话持久化到 SQLite | 注册到 `ctx.sessionPersistence` |
+| [`session-persistence-sqlite/`](session-persistence-sqlite/README.md) | 使用物理分片打包行的可选 SQLite 后端 | 注册到 `ctx.sessionPersistence` |
[会话持久化决策](../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)记录了持久化设计。
diff --git a/packages/session/session-persistence-sqlite/README.i18n.yaml b/packages/session/session-persistence-sqlite/README.i18n.yaml
index 8fd46fa101..b331013806 100644
--- a/packages/session/session-persistence-sqlite/README.i18n.yaml
+++ b/packages/session/session-persistence-sqlite/README.i18n.yaml
@@ -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/session-persistence-sqlite/README.md
-README.md: ec42419a132a26c1f23ab99ab3da1db97a5483b0
-README.zh.md: 67c6bcfbec92f8f5b91150fb108c65a287906db9
+README.md: ba005d79771bcbc2c0c1632da77d694aa3a18c07
+README.zh.md: 96a396d237a8abf263c50c46c8c7b23054d6e7aa
diff --git a/packages/session/session-persistence-sqlite/README.md b/packages/session/session-persistence-sqlite/README.md
index ec42419a13..ba005d7977 100644
--- a/packages/session/session-persistence-sqlite/README.md
+++ b/packages/session/session-persistence-sqlite/README.md
@@ -2,40 +2,39 @@
English | [中文](README.zh.md)
-A SQLite durable session-persistence backend — a second `SessionPersistence` provider ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)) satisfying the same contract as `dsh-session-persistence-jsonl` (append-only, contiguous-seq, lazy materialization, interrupted-turn close on load), expressed over `node:sqlite` rows instead of file bytes.
+An opt-in SQLite `SessionPersistence` provider. It stores eligible `assistant/chunk` runs in packed physical rows, selectively Zstandard-compresses large payloads, and delta-encodes provenance sequences while restoring the exact logical `SessionEvent[]`. No shipped composition selects it; deployments mount this package explicitly and provide its database path.
-`locate(meta)` returns `undefined`: all sessions share one database, so there is no honest independent per-session transcript path.
+`locate(meta)` returns `undefined` because every session shares one database. The provider exposes no per-session raw artifact.
## Storage model
-Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; `createdAt` is a non-negative safe integer stored in a strict `INTEGER` column. A singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row).
+Schema 17 keeps ordinary ROWID tables and the composite `events(session_id, seq)` primary-key index. Scalar rows store one logical event. Packed rows use `text-chunks`, `reasoning-chunks`, or `tool-call-chunks` as the physical `type`; `seq` and `time` identify the first represented event, and `data` holds the shared packed-chunk payload. Packed rows set `ignorable=0` as a physical discriminator and leave `source_event_seqs` and `surface_op` as `NULL`; scalar rows use `ignorable=1` only for logical ignorable events and `NULL` otherwise. A future ignorable logical event may therefore reuse a storage-tag name without being decoded as a packed row. These tags are storage records, not `SessionEventMap` members.
-The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA application_id` identifies the canonical persistence database, and `PRAGMA user_version` stores its layout version. A fresh database must have no application identity or user-defined schema objects; initialization creates every table and stamps both pragmas in one transaction. Non-pristine unversioned databases, foreign application identities, and every non-current version reject before journal-mode mutation because this unreleased format has no migrations.
+Schema 17 owns its codec locally rather than importing another persistence format's mutable implementation. Only exact, consecutive same-block text, reasoning, or tool-call delta forms pack. Unknown fields, surface metadata, sequence gaps, incompatible block/call identity, and unsafe timestamps remain scalar. A packed row represents at most 1,024 events and at most 1 MiB of uncompressed UTF-8 `data`; longer runs are partitioned without changing logical events. Reads reconstruct every original sequence number, timestamp, token boundary, argument fragment, and payload before returning data to the persistence coordinator.
-On filesystems with POSIX modes, the backend requests mode `0700` for missing directories and exclusively creates a missing database with mode `0600` before SQLite opens it; the process umask may further restrict both. New WAL, shared-memory, and persistent rollback-journal sidecars receive the database's resulting owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through a permissive process umask, but do not protect database confidentiality or integrity when another principal can replace the database entry in its parent directory.
+Serialized `data` smaller than 4 KiB stays as SQLite `TEXT`. At or above that threshold, the writer uses Zstandard level 3 and stores a `BLOB` only when the frame is smaller than the original text; the reader decompresses it before UTF-8 validation and JSON parsing. `source_event_seqs` remains the complete ordered provenance array. Its first sequence is an unsigned varint and each subsequent sequence is a signed delta encoded with ZigZag varints, stored as a `BLOB`; no source is omitted or converted to a range.
-## Contract semantics over rows
+Each append holds `BEGIN IMMEDIATE`, validates the bounded physical tail, packs only the new durable batch, inserts those records, and increments the session revision once. Normal appends never delete or replace an earlier event row. The default 200 ms write-behind window therefore compresses high-frequency streams while the physical write volume stays proportional to newly durable batches rather than repeatedly rewriting a growing packed value. A storage-level logical-tail check rejects a stale writer before mutation.
-- **Append = a transaction.** `append` runs `BEGIN`/`COMMIT` around the batch: it materializes the `sessions` row (if still lazy) and INSERTs every event, asserting the contiguous-seq contract first (the first event's `seq` must equal the stored next-seq). A mid-batch failure (a UNIQUE violation on a duplicated seq) rolls back entirely, so the stored log and the in-memory cursor stay consistent. (`load()` already balanced the stored log, so `append` never has to repair a crash tail.)
-- **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row).
-- **Interrupted-turn close on load.** `load()` implements the shared [crash-recovery contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md): preserve the valid interrupted turn, append its synthetic closing events in one transaction, and remove only a torn tail row. Committed parse errors or sequence gaps make the session unloadable. Because recovery mutates stored rows, the next append starts from a balanced log and accurate cursor.
-- **Non-mutating inspection.** `inspect()` returns an immutable balanced logical view and may synthesize recovery closers in memory, without deleting a torn tail row, appending recovery rows, or changing the lightweight revision.
-- **Lightweight revisions.** `listSnapshots(signal?)` combines the immutable store and database-file identity, a per-materialization incarnation id, and a per-session counter incremented in each mutating transaction. A full-prefix read captures that revision and its event rows in one read transaction, while `readStoredRevision()` queries only the session row to validate retained preparations. This keeps unchanged observations stable without parsing event rows and distinguishes independent stores and recreated same-id logs. It checks cancellation before and after shared readiness and the synchronous metadata query; the query itself is non-preemptible.
+Full reads scan physical rows in first-logical-sequence order. A reverse pass finds the last valid `turn/end` without retaining decoded copies of every physical row; the forward pass decodes and validates one physical row at a time into the returned logical event array. `readFrom(id, fromSeq)` examines packed predecessors only within the maximum row span and anchors the suffix at the earliest one that may contain `fromSeq`; this includes an event range that starts inside a packed row, detects overlapping physical corruption, and does not parse unrelated earlier scalar rows. A malformed packed row is all-or-nothing: committed corruption rejects, while a torn final row is deleted from its physical base during mutating recovery. Repair re-reads the tail under the write lock and rejects a stale marker before deleting anything. Packed `data` that exceeds the schema byte limit rejects before JSON parsing.
+
+## Schema compatibility
+
+A pristine database initializes directly at schema 17. Older schemas, foreign application identities, non-pristine unversioned databases, and incompatible schema objects reject; this pre-release provider supplies no migration. Every statement and fixed pragma lives in a packaged `.sql` resource; values use SQLite parameters and runtime code never assembles query text.
## Configuration (schemastery)
```ts
interface Config {
- path: string // SQLite database file path, or ':memory:' for an in-process DB
- journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal'
- preparedSessionCacheSize?: number // positive integer; default 5
- writeBatchMaxDelayMs?: number // positive integer; default 200; maximum 2_147_483_647
+ path: string
+ journalMode?: 'wal' | 'delete' | 'truncate' | 'persist'
+ busyTimeoutMs?: number
+ preparedSessionCacheSize?: number
+ writeBatchMaxDelayMs?: number
}
```
-## Write path
-
-Like the JSONL backend, the plugin copies each frozen `session/event` into one controller per live session. The first pending event starts the configured fixed batching window, and later events join without resetting it. Expiry starts one transaction; events admitted during that write form a separately bounded follow-up batch. `session/flush` cancels the wait and drains current and pending batches. The controller persists a fork's seed once, keeps a write cursor so resume never re-appends stored events, and seeds live sessions on apply because HMR does not replay `session/created`. Dispose drains every retained controller before closing the database. Every event remains a separate SQLite row; batching only groups more INSERTs into one transaction and revision increment.
+`journalMode` defaults to `wal`, `busyTimeoutMs` defaults to `5,000`, `preparedSessionCacheSize` defaults to `5`, and `writeBatchMaxDelayMs` defaults to `200`. The timeout bounds each synchronous SQLite lock wait. Because SQLite may return `SQLITE_BUSY` immediately while changing journal mode, cold open yields between attempts and starts no further attempt after an open-relative retry cutoff. An in-progress synchronous SQLite call may finish after that cutoff. The provider disables trusted schemas and memory-mapped I/O on every connection, then reads both settings back. The selected journal mode is also read back and must match; in-memory databases explicitly accept SQLite's `memory` result. After selecting the journal, the provider pins `synchronous=FULL` and verifies it so SQLite build defaults cannot weaken committed-append durability. On POSIX, the database parent and file must be owned by the current user, the parent must not be group/world-writable, and the file must have no group/world permissions. Symbolic links and non-regular files reject. Windows also rejects symbolic links and non-regular files, but deployments remain responsible for restricting the directory and file ACLs to the harness user. Path and ownership failures reject plugin initialization. Node SQLite loads lazily on the first persistence operation; the import suppresses only Node 22's exact SQLite `ExperimentalWarning`. Store-identity and schema failures reject that operation before data is exposed or mutated.
## Model Experience
@@ -43,20 +42,22 @@ Like the JSONL backend, the plugin copies each frozen `session/event` into one c
#### What the model sees
-SQLite storage contributes no live prompt or schema. Loading restores the same surface history as JSONL and preserves prior headers for reconstruction; the new loop composes its current envelope. Recovery balances an assistant request without a durable call with `TOOL_NOT_STARTED`; a durable call without a result becomes `TOOL_OUTCOME_UNKNOWN`, which tells the model to retry only read-only or idempotent work and to verify possible side effects or ask the user. Row metadata and raw chunks are not messages.
+Nothing specific to SQLite. Resume restores the same logical events and derived messages as JSONL; physical packed tags never reach prompts, tools, replay, or live `session/event` delivery.
#### Token effect
-Zero live-request tokens. Resume restores retained history and pays the current envelope, plus the quoted repair result for each interrupted call.
+Zero live-request tokens. Resume pays only for the retained logical history and current request envelope.
#### KV Cache effect
-SQLite storage does not mutate live request prefixes. A resumed loop can reuse provider cache only when its reconstructed history, current envelope, and model route match; crash-repair results append.
+Physical packing does not mutate request prefixes. Provider cache reuse depends on the reconstructed history, current envelope, and model route exactly as with other persistence backends.
## Known Limitations and Deferred Work
-- **`DatabaseSync` is synchronous** — every append transaction blocks the event loop for its duration; acceptable for local stores, a throughput ceiling for busy multi-session servers.
-- **Write contention has no wait or retry policy** — the backend sets no busy timeout and retries no locked-database error, so another connection holding a write transaction makes the operation reject immediately.
-- **Only a pristine new database or the current owned `SCHEMA_VERSION` opens** — unversioned schema objects, foreign application identities, and every other schema version are rejected rather than migrated (unreleased software; no persisted user data to preserve).
-- **Nothing deletes stored sessions** — rows accumulate until removed externally (the seam has no deletion API; `ON DELETE CASCADE` is wired for such out-of-band cleanup).
-- **TODO:** this backend talks to `node:sqlite` directly. If a cordis database service (`cordis/db` / a `@cordisjs` SQL driver plugin) is adopted, route through that instead of holding a raw `DatabaseSync` here — the contract surface (`SessionPersistence`) would not change, only the storage driver.
+- **Interim SQLite-specific design** — This efficiency-focused implementation is informed by [morlay/session-persistence-rdb](https://github.com/morlay/session-persistence-rdb). A unified relational-database design with multiple backends and configurable schemas is deferred; neither schema stability nor migration support is guaranteed during pre-release development.
+- **Packing follows durable batch boundaries** — compatible runs split by the write-behind window or an explicit flush remain separate physical records; this avoids rewriting prior rows at the cost of a timing-dependent packing ratio.
+- **Synchronous compression** — Node's SQLite and Zstandard calls block the JavaScript thread; the 4 KiB threshold limits per-frame work for small records.
+- **`DatabaseSync` blocks the event loop** — physical row reduction does not make SQLite operations asynchronous.
+- **Busy waits block the event loop** — SQLite waits inside synchronous `DatabaseSync` calls; only a busy journal-mode transition yields between attempts, and the open-relative cutoff prevents another attempt rather than interrupting an active call.
+- **External SQL readers must understand physical tags** — supported consumers read through this provider rather than treating every `events.type` as a logical event type.
+- **No deletion or background historical compaction** — normal appends are insert-only.
diff --git a/packages/session/session-persistence-sqlite/README.zh.md b/packages/session/session-persistence-sqlite/README.zh.md
index 67c6bcfbec..96a396d237 100644
--- a/packages/session/session-persistence-sqlite/README.zh.md
+++ b/packages/session/session-persistence-sqlite/README.zh.md
@@ -2,61 +2,62 @@
[English](README.md) | 中文
-SQLite 持久会话存储后端:第二个 `SessionPersistence` 提供方(见[会话持久化](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)),满足与 `dsh-session-persistence-jsonl` 相同的约定(仅追加、连续 seq、延迟实体化、在 load 时关闭中断轮次),但用 `node:sqlite` 行而非文件字节表达。
+一个可选启用的 SQLite `SessionPersistence` 提供方。它将符合条件的 `assistant/chunk` 连续段存入打包后的物理行,对大型 payload 选择性应用 Zstandard 压缩,并对来源序列进行 delta 编码,同时恢复完全一致的逻辑 `SessionEvent[]`。随产品交付的组合均不选择它;部署方需显式挂载本包并提供数据库路径。
-`locate(meta)` 返回 `undefined`:所有会话共享一个数据库,因此不存在真实、独立的逐会话 transcript(文本记录)路径。
+`locate(meta)` 返回 `undefined`,因为所有会话共享同一个数据库。该提供方不暴露逐会话原始产物。
## 存储模型
-每个 `SessionEvent` 1:1 映射到 `events` 表中的一行 `(session_id, seq, type, time, data, source_event_seqs, surface_op)`;`data` 是作为 JSON 文本的事件 payload,因此行结构就是原始事件本身(包括 `assistant/chunk`,保持 `seq` 连续)。两个 `TEXT` 列 `source_event_seqs` 和 `surface_op` 可为空,存储事件可选接口元数据字段(见[会话接口](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md))。日志外元数据(`SessionHeader`)、每实体化 incarnation id 和每日志单调修订位于 `sessions` 行;`createdAt` 是存储在 strict `INTEGER` 列中的非负安全整数。单例状态行携带不可变存储 id。`sessions` 行只由第一次 `append` 写入,其存在性是延迟实体化信号(`list` 精确报告有行的会话)。
+Schema 17 保留普通 ROWID 表以及复合主键索引 `events(session_id, seq)`。标量行存储一个逻辑事件。打包行把 `text-chunks`、`reasoning-chunks` 或 `tool-call-chunks` 用作物理 `type`;`seq` 与 `time` 标识所表示的第一个事件,`data` 保存共享的分片打包 payload。打包行把 `ignorable=0` 用作物理判别值,并让 `source_event_seqs` 与 `surface_op` 保持 `NULL`;标量行仅在逻辑事件可忽略时使用 `ignorable=1`,否则使用 `NULL`。因此,未来的可忽略逻辑事件即使复用了某个存储标签名称,也不会被解码为打包行。这些标签属于存储记录,而不是 `SessionEventMap` 成员。
-仓库支持的 Node 范围可不加 flag 使用 `node:sqlite`。数据库启用外键,并使用已配置 journal mode(默认 `wal`;WAL 共享内存文件不适用时使用 rollback mode)。`PRAGMA application_id` 标识规范持久化数据库,`PRAGMA user_version` 存储布局版本。新数据库必须没有 application identity 或用户定义 schema 对象;初始化在一个事务中创建全部表并盖上两个 pragma。非 pristine 无版本数据库、外部 application identity 和所有非当前版本在 journal-mode 变更前均会被拒绝,因为该未发布格式无迁移。
+Schema 17 在本包内拥有 codec,不导入其他持久化格式中可变的实现。只有字段完全匹配、连续且属于同一分片块的文本、推理或工具调用 delta 才会打包。未知字段、surface 元数据、序列缺口、不兼容的块/调用身份以及不安全时间戳仍以标量行存储。一个打包行最多表示 1,024 个事件,未压缩 UTF-8 `data` 最多 1 MiB;更长的连续段会在不改变逻辑事件的前提下分割。读取会在向持久化协调器返回数据前,重建每个原始序列号、时间戳、token 边界、参数片段和 payload。
-在具有 POSIX mode 的文件系统上,后端为缺失目录请求 mode `0700`,并在 SQLite 打开前以 mode `0600` 排他创建缺失数据库;进程 umask 可进一步限制两者。新 WAL、共享内存和持久 rollback-journal sidecar 获得数据库最终的仅所有者 mode。现有目录、数据库文件和 sidecar 保留原 mode;除已存在数据库外的文件系统设置错误会使初始化失败。这些默认值防止宽松进程 umask 造成的意外暴露,但当其他 principal 能替换父目录中的数据库条目时,不保护数据库机密性或完整性。
+序列化后的 `data` 小于 4 KiB 时保持为 SQLite `TEXT`。达到或超过该阈值时,写入方会使用 Zstandard level 3,并且只在 frame 小于原文本的情况下存储 `BLOB`;读取方会先解压,再执行 UTF-8 校验和 JSON 解析。`source_event_seqs` 仍是完整且有序的来源数组。第一个序列使用无符号 varint,后续序列使用 ZigZag varint 编码的有符号差值,并存为 `BLOB`;不会省略任何来源,也不会把数组转换成范围。
-## 行上的约定语义
+每次追加持有 `BEGIN IMMEDIATE`,验证有界物理尾部,只打包新的持久批次,插入这些记录,并把会话 revision 递增一次。普通追加绝不删除或替换既有事件行。默认 200 毫秒写后缓冲窗口因此仍能压缩高频流,而物理写入量与新增持久批次成正比,不会反复改写不断增长的打包值。存储层逻辑尾部检查会在陈旧写入方执行变更前拒绝该写入。
-- **Append = 事务。**`append` 围绕批次运行 `BEGIN`/`COMMIT`:它实体化 `sessions` 行(如果仍未实体化),并 INSERT 每个事件,首先断言连续 seq 约定(第一个事件 `seq` 必须等于已存储 next-seq)。批次中失败(重复 seq 上的 UNIQUE 违规)会完全回滚,使已存储日志和内存游标保持一致。(`load()` 已平衡已存储日志,因此 `append` 不必修复崩溃尾部。)
-- **延迟实体化。**`create()` 只在内存记录意图,第一次 `append` 前不写行。已创建但从未 append 的会话没有 `sessions` 行,因此不在 `list()` 中(它精确报告有行的会话)。
-- **在 load 时关闭中断轮次。**`load()` 实现共享[崩溃恢复约定](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md):保留有效中断轮次,在一个事务中追加合成关闭事件,并只移除撕裂尾部行。已提交解析错误或序列缺口使会话无法加载。恢复会变更已存储行,因此下一次 append 从平衡日志和准确游标开始。
-- **非修改式检查。**`inspect()` 返回不可变、平衡的逻辑视图,并可在内存中合成恢复 closer,但不会删除撕裂尾部行、追加恢复行或更改轻量修订。
-- **轻量修订。**`listSnapshots(signal?)` 组合不可变存储与数据库文件身份、每实体化 incarnation id,以及在每个变更事务中递增的每会话计数器。完整前缀读取在同一个读事务中捕获该 revision 及其事件行,`readStoredRevision()` 则只查询 session 行来校验保留的 preparation。它在不解析事件行的情况下保持未变观察稳定,并区分独立存储和重建的同 id 日志。它在共享就绪和同步元数据查询前后检查取消;查询本身不可抢占。
+完整读取按首个逻辑序列号的顺序扫描物理行。反向扫描会定位最后一个有效 `turn/end`,但不会保留每个物理行的解码副本;正向扫描则逐行解码并校验,写入最终返回的逻辑事件数组。`readFrom(id, fromSeq)` 只检查最大行跨度内的打包前驱,并把后缀锚定在可能包含 `fromSeq` 的最早前驱;这样既可包含从打包行内部开始的事件范围,也能检测相互重叠的物理损坏,而不会解析无关的更早标量行。畸形打包行按全有或全无处理:已提交区域中的损坏会拒绝读取,最终撕裂行则在可变恢复期间从其物理起点删除。修复会在持有写锁时重新读取尾部,并在删除任何数据前拒绝陈旧 marker。打包 `data` 超出 schema 字节上限时,会在解析 JSON 前拒绝。
+
+## Schema 兼容性
+
+全新数据库直接初始化为 schema 17。旧 schema、外部 application identity、非空未版本化数据库以及不兼容 schema 对象都会被拒绝;这个预发布提供方不提供迁移。每条语句和固定 pragma 都位于随包发布的 `.sql` 资源中;值使用 SQLite 参数,运行时代码不会拼装查询文本。
## 配置(schemastery)
```ts
interface Config {
- path: string // SQLite database file path, or ':memory:' for an in-process DB
- journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal'
- preparedSessionCacheSize?: number // positive integer; default 5
- writeBatchMaxDelayMs?: number // positive integer; default 200; maximum 2_147_483_647
+ path: string
+ journalMode?: 'wal' | 'delete' | 'truncate' | 'persist'
+ busyTimeoutMs?: number
+ preparedSessionCacheSize?: number
+ writeBatchMaxDelayMs?: number
}
```
-## 写入路径
-
-与 JSONL 后端一样,插件将每个冻结的 `session/event` 复制到对应活动会话的 controller 中,每个活动会话各有一个 controller。第一个待处理事件会开启配置的固定批处理窗口,后续事件会加入但不会重置截止时间。窗口到期后会启动一个事务;该次写入期间接纳的事件会形成另一个独立有界的后续批次。`session/flush` 会取消等待并排空当前与待处理批次。Controller 会持久化一次 fork 种子,并保留写入游标,使恢复操作绝不重新 append 已存储事件;它还会在 apply 时为活动会话设置初始状态,因为 HMR(热模块替换)不回放 `session/created`。dispose(资源释放)会在关闭数据库前排空每个保留的 controller。每个事件仍各占一行 SQLite 记录;批处理只把更多 INSERT 归入同一个事务和同一次修订版本递增。
+`journalMode` 默认为 `wal`,`busyTimeoutMs` 默认为 `5,000`,`preparedSessionCacheSize` 默认为 `5`,`writeBatchMaxDelayMs` 默认为 `200`。该超时限制每次同步 SQLite 锁等待的时长。SQLite 在切换 journal mode 时可能立即返回 `SQLITE_BUSY`,因此冷打开会在尝试之间让出执行,并在从打开时开始计算的重试截止点后不再发起新尝试。正在执行的同步 SQLite 调用可能在该截止点之后才完成。提供方会在每个连接上禁用可信 schema 与内存映射 I/O,然后读回这两项设置。提供方还会读回所选 journal mode 并要求它匹配;内存数据库显式接受 SQLite 返回的 `memory`。选择 journal 后,提供方会把 `synchronous` 固定为 `FULL` 并验证该设置,避免 SQLite 构建默认值削弱已提交追加的持久性。在 POSIX 上,数据库父目录和文件必须归当前用户所有,父目录不得允许组或其他用户写入,文件不得授予组或其他用户任何权限。符号链接和非普通文件会被拒绝。Windows 同样拒绝符号链接与非普通文件,但部署方仍负责把目录和文件 ACL 限制给 harness 用户。路径与所有权错误会拒绝插件初始化。Node SQLite 在第一次持久化操作时才加载;导入时只抑制 Node 22 精确的 SQLite `ExperimentalWarning`。存储身份与 schema 错误会在暴露或变更数据前拒绝该操作。
## 模型体验
### 恢复的对话历史
-#### 模型看到的内容
+#### 模型看到什么
-SQLite 存储不会向当前请求提供提示词或 schema。加载会恢复与 JSONL 相同的呈现历史,并保留之前的 header 用于重建;新 loop 组合当前 envelope。恢复会用 `TOOL_NOT_STARTED` 平衡没有已持久化调用的 assistant 请求;已有持久化调用但无结果时则变为 `TOOL_OUTCOME_UNKNOWN`,它要求模型只重试只读或幂等工作,并验证可能的副作用或询问用户。行元数据和原始分片不会成为消息。
+没有 SQLite 特有内容。恢复得到与 JSONL 相同的逻辑事件和派生消息;物理打包标签绝不会进入 prompt、工具、回放或实时 `session/event` 投递。
#### Token 影响
-SQLite 存储不会增加当前请求的 token 用量。恢复会还原已保留的历史,并产生当前 envelope 以及每个中断调用所附、以引用形式呈现的修复结果文本所产生的 token 开销。
+实时请求增加零 token。恢复只为保留的逻辑历史和当前请求 envelope 付出 token。
#### KV Cache 影响
-SQLite 存储不修改当前请求前缀。只有重建历史、当前 envelope 和模型路由匹配时,恢复 loop 才能重用提供方缓存;崩溃修复结果会追加到末尾。
+物理打包不会改变请求前缀。与其他持久化后端相同,提供方 cache 复用取决于重建历史、当前 envelope 和模型路由。
-## 已知限制与暂缓事项
+## 已知限制与延期工作
-- **`DatabaseSync` 是同步的**:每个 append 事务在整个期间阻塞事件循环;对本地存储可接受,对繁忙多会话服务器是吞吐上限。
-- **写入争用无等待或重试策略**:后端不设置 busy timeout,也不重试 locked-database 错误,因此其他连接持有写事务时操作立即拒绝。
-- **只有 pristine 新数据库或当前自有 `SCHEMA_VERSION` 才能打开**:无版本 schema 对象、外部 application identity 和所有其他 schema 版本被拒绝,而不是迁移(未发布软件,无持久用户数据需要保留)。
-- **不删除已存储会话**:行会累积,直到外部移除(seam 无删除接口;`ON DELETE CASCADE` 已为这种带外清理配置)。
-- **TODO:** 该后端直接调用 `node:sqlite`。如果采用 Cordis 数据库服务(`cordis/db` / `@cordisjs` SQL driver 插件),应改为通过该服务路由,而不在此直接持有 `DatabaseSync`;约定接口(`SessionPersistence`)不会变,只更换存储驱动。
+- **过渡性的 SQLite 专用设计**——这一以效率为重点的实现参考了 [morlay/session-persistence-rdb](https://github.com/morlay/session-persistence-rdb)。支持多种后端与可配置 schema 的统一关系数据库设计尚待后续完善;预发布开发阶段不保证 schema 稳定性或迁移支持。
+- **打包服从持久批次边界**——被写后缓冲窗口或显式 flush 分开的兼容连续段会保留为不同物理记录;这以打包率受时序影响为代价,避免改写既有行。
+- **同步压缩**——Node 的 SQLite 与 Zstandard 调用都会阻塞 JavaScript 线程;4 KiB 阈值限制了小型记录的逐 frame 工作。
+- **`DatabaseSync` 会阻塞事件循环**——减少物理行不会使 SQLite 操作变为异步。
+- **繁忙等待会阻塞事件循环**——SQLite 会在同步 `DatabaseSync` 调用内等待;只有繁忙的 journal-mode 切换会在两次尝试之间让出执行,而且从打开时计算的截止点只阻止新尝试,不会中断正在执行的调用。
+- **外部 SQL 读取方必须理解物理标签**——受支持的消费方通过本提供方读取,而不是把每个 `events.type` 都当作逻辑事件类型。
+- **没有删除或后台历史压缩**——普通追加只做插入。
diff --git a/packages/session/session-persistence-sqlite/package.json b/packages/session/session-persistence-sqlite/package.json
index 0335901faf..3e40541146 100644
--- a/packages/session/session-persistence-sqlite/package.json
+++ b/packages/session/session-persistence-sqlite/package.json
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-session-persistence-sqlite",
- "description": "SQLite durable session persistence backend for the DeepSeek Harness",
+ "description": "SQLite durable session persistence with physical chunk-row packing",
"version": "0.1.0-rc.7",
"publishConfig": {
"access": "public"
@@ -28,11 +28,13 @@
"files": [
"lib/index.js",
"lib/invariant.js",
+ "resources/sql/**/*.sql",
"lib/types/**/*.d.ts"
],
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
@@ -41,9 +43,13 @@
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
+ "@deepseek-ai/cordis-plugin-include": "workspace:^",
+ "@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "typescript": "^6.0.3"
}
}
diff --git a/packages/session/session-persistence-sqlite/resources/sql/begin-immediate.sql b/packages/session/session-persistence-sqlite/resources/sql/begin-immediate.sql
new file mode 100644
index 0000000000..67edb1dd06
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/begin-immediate.sql
@@ -0,0 +1 @@
+BEGIN IMMEDIATE;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/begin.sql b/packages/session/session-persistence-sqlite/resources/sql/begin.sql
new file mode 100644
index 0000000000..1775571fa7
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/begin.sql
@@ -0,0 +1 @@
+BEGIN;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/commit.sql b/packages/session/session-persistence-sqlite/resources/sql/commit.sql
new file mode 100644
index 0000000000..87ef767444
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/commit.sql
@@ -0,0 +1 @@
+COMMIT;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/delete-events-from.sql b/packages/session/session-persistence-sqlite/resources/sql/delete-events-from.sql
new file mode 100644
index 0000000000..ab5f81d89a
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/delete-events-from.sql
@@ -0,0 +1,2 @@
+DELETE FROM events
+WHERE session_id = ? AND seq >= ?;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/foreign-keys-on.sql b/packages/session/session-persistence-sqlite/resources/sql/foreign-keys-on.sql
new file mode 100644
index 0000000000..c8ccb1392a
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/foreign-keys-on.sql
@@ -0,0 +1 @@
+PRAGMA foreign_keys = ON;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/insert-event.sql b/packages/session/session-persistence-sqlite/resources/sql/insert-event.sql
new file mode 100644
index 0000000000..92b4de310d
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/insert-event.sql
@@ -0,0 +1,3 @@
+INSERT INTO events
+ (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?);
diff --git a/packages/session/session-persistence-sqlite/resources/sql/insert-persistence-state.sql b/packages/session/session-persistence-sqlite/resources/sql/insert-persistence-state.sql
new file mode 100644
index 0000000000..f516014d93
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/insert-persistence-state.sql
@@ -0,0 +1,2 @@
+INSERT INTO persistence_state (singleton, store_id)
+VALUES (1, ?);
diff --git a/packages/session/session-persistence-sqlite/resources/sql/journal-mode-delete.sql b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-delete.sql
new file mode 100644
index 0000000000..0f4c37efca
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-delete.sql
@@ -0,0 +1 @@
+PRAGMA journal_mode = DELETE;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/journal-mode-persist.sql b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-persist.sql
new file mode 100644
index 0000000000..02445b260b
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-persist.sql
@@ -0,0 +1 @@
+PRAGMA journal_mode = PERSIST;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/journal-mode-truncate.sql b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-truncate.sql
new file mode 100644
index 0000000000..d119c32bec
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-truncate.sql
@@ -0,0 +1 @@
+PRAGMA journal_mode = TRUNCATE;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/journal-mode-wal.sql b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-wal.sql
new file mode 100644
index 0000000000..2d30d8af9f
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/journal-mode-wal.sql
@@ -0,0 +1 @@
+PRAGMA journal_mode = WAL;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/mmap-off.sql b/packages/session/session-persistence-sqlite/resources/sql/mmap-off.sql
new file mode 100644
index 0000000000..22bcd0ea34
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/mmap-off.sql
@@ -0,0 +1 @@
+PRAGMA mmap_size = 0;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/rollback.sql b/packages/session/session-persistence-sqlite/resources/sql/rollback.sql
new file mode 100644
index 0000000000..3b18e77376
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/rollback.sql
@@ -0,0 +1 @@
+ROLLBACK;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/schema.sql b/packages/session/session-persistence-sqlite/resources/sql/schema.sql
new file mode 100644
index 0000000000..41d247dba7
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/schema.sql
@@ -0,0 +1,30 @@
+CREATE TABLE persistence_state (
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
+ store_id TEXT NOT NULL
+) STRICT;
+
+CREATE TABLE sessions (
+ id TEXT PRIMARY KEY,
+ version INTEGER NOT NULL,
+ created_at INTEGER NOT NULL,
+ cwd TEXT,
+ parent_session TEXT,
+ seed_length INTEGER,
+ origin TEXT,
+ delegation_depth INTEGER,
+ agent_preset TEXT,
+ incarnation TEXT NOT NULL,
+ revision INTEGER NOT NULL
+) STRICT;
+
+CREATE TABLE events (
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
+ seq INTEGER NOT NULL,
+ type TEXT NOT NULL,
+ time INTEGER NOT NULL,
+ data ANY NOT NULL,
+ source_event_seqs ANY,
+ surface_op TEXT,
+ ignorable INTEGER CHECK (ignorable IS NULL OR ignorable IN (0, 1)),
+ PRIMARY KEY (session_id, seq)
+) STRICT;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-application-id.sql b/packages/session/session-persistence-sqlite/resources/sql/select-application-id.sql
new file mode 100644
index 0000000000..3de4abbb88
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-application-id.sql
@@ -0,0 +1 @@
+PRAGMA application_id;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-events-from.sql b/packages/session/session-persistence-sqlite/resources/sql/select-events-from.sql
new file mode 100644
index 0000000000..a5748dd974
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-events-from.sql
@@ -0,0 +1,4 @@
+SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable
+FROM events
+WHERE session_id = ? AND seq >= ?
+ORDER BY seq;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-events.sql b/packages/session/session-persistence-sqlite/resources/sql/select-events.sql
new file mode 100644
index 0000000000..76437f8eaf
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-events.sql
@@ -0,0 +1,4 @@
+SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable
+FROM events
+WHERE session_id = ?
+ORDER BY seq;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-mmap-size.sql b/packages/session/session-persistence-sqlite/resources/sql/select-mmap-size.sql
new file mode 100644
index 0000000000..58e55155f4
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-mmap-size.sql
@@ -0,0 +1 @@
+PRAGMA mmap_size;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-packed-predecessors.sql b/packages/session/session-persistence-sqlite/resources/sql/select-packed-predecessors.sql
new file mode 100644
index 0000000000..54a52180fc
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-packed-predecessors.sql
@@ -0,0 +1,6 @@
+SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable
+FROM events
+WHERE session_id = ? AND seq >= ? AND seq < ?
+ AND type IN ('text-chunks', 'reasoning-chunks', 'tool-call-chunks')
+ AND ignorable = 0
+ORDER BY seq;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-schema-objects.sql b/packages/session/session-persistence-sqlite/resources/sql/select-schema-objects.sql
new file mode 100644
index 0000000000..216925f35a
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-schema-objects.sql
@@ -0,0 +1,4 @@
+SELECT type, name, tbl_name, sql
+FROM sqlite_schema
+WHERE name NOT GLOB 'sqlite_*'
+ORDER BY type, name;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-session.sql b/packages/session/session-persistence-sqlite/resources/sql/select-session.sql
new file mode 100644
index 0000000000..456cba91c3
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-session.sql
@@ -0,0 +1,4 @@
+SELECT id, version, created_at, cwd, parent_session, seed_length, origin,
+ delegation_depth, agent_preset, incarnation, revision
+FROM sessions
+WHERE id = ?;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-sessions.sql b/packages/session/session-persistence-sqlite/resources/sql/select-sessions.sql
new file mode 100644
index 0000000000..37a1f22e36
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-sessions.sql
@@ -0,0 +1,3 @@
+SELECT id, version, created_at, cwd, parent_session, seed_length, origin,
+ delegation_depth, agent_preset, incarnation, revision
+FROM sessions;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-store-id.sql b/packages/session/session-persistence-sqlite/resources/sql/select-store-id.sql
new file mode 100644
index 0000000000..168e09cb80
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-store-id.sql
@@ -0,0 +1,3 @@
+SELECT store_id
+FROM persistence_state
+WHERE singleton = 1;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-synchronous.sql b/packages/session/session-persistence-sqlite/resources/sql/select-synchronous.sql
new file mode 100644
index 0000000000..b41be01714
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-synchronous.sql
@@ -0,0 +1 @@
+PRAGMA synchronous;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-tail-events.sql b/packages/session/session-persistence-sqlite/resources/sql/select-tail-events.sql
new file mode 100644
index 0000000000..2d958de7c3
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-tail-events.sql
@@ -0,0 +1,5 @@
+SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable
+FROM events
+WHERE session_id = ?
+ORDER BY seq DESC
+LIMIT ?;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-trusted-schema.sql b/packages/session/session-persistence-sqlite/resources/sql/select-trusted-schema.sql
new file mode 100644
index 0000000000..d304b1f850
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-trusted-schema.sql
@@ -0,0 +1 @@
+PRAGMA trusted_schema;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-user-object-count.sql b/packages/session/session-persistence-sqlite/resources/sql/select-user-object-count.sql
new file mode 100644
index 0000000000..60665bb66e
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-user-object-count.sql
@@ -0,0 +1,3 @@
+SELECT COUNT(*) AS count
+FROM sqlite_schema
+WHERE name NOT GLOB 'sqlite_*';
diff --git a/packages/session/session-persistence-sqlite/resources/sql/select-user-version.sql b/packages/session/session-persistence-sqlite/resources/sql/select-user-version.sql
new file mode 100644
index 0000000000..4edeca1a4d
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/select-user-version.sql
@@ -0,0 +1 @@
+PRAGMA user_version;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/set-application-id.sql b/packages/session/session-persistence-sqlite/resources/sql/set-application-id.sql
new file mode 100644
index 0000000000..617d10cab3
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/set-application-id.sql
@@ -0,0 +1 @@
+PRAGMA application_id = 1146308688;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/set-user-version-17.sql b/packages/session/session-persistence-sqlite/resources/sql/set-user-version-17.sql
new file mode 100644
index 0000000000..5aac576e8c
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/set-user-version-17.sql
@@ -0,0 +1 @@
+PRAGMA user_version = 17;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/synchronous-full.sql b/packages/session/session-persistence-sqlite/resources/sql/synchronous-full.sql
new file mode 100644
index 0000000000..b0380b1197
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/synchronous-full.sql
@@ -0,0 +1 @@
+PRAGMA synchronous = FULL;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/trusted-schema-off.sql b/packages/session/session-persistence-sqlite/resources/sql/trusted-schema-off.sql
new file mode 100644
index 0000000000..973c6d1def
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/trusted-schema-off.sql
@@ -0,0 +1 @@
+PRAGMA trusted_schema = OFF;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/update-session-revision.sql b/packages/session/session-persistence-sqlite/resources/sql/update-session-revision.sql
new file mode 100644
index 0000000000..2cfbcb2b82
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/update-session-revision.sql
@@ -0,0 +1,3 @@
+UPDATE sessions
+SET revision = revision + 1
+WHERE id = ?;
diff --git a/packages/session/session-persistence-sqlite/resources/sql/upsert-session.sql b/packages/session/session-persistence-sqlite/resources/sql/upsert-session.sql
new file mode 100644
index 0000000000..c1000e4daa
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/resources/sql/upsert-session.sql
@@ -0,0 +1,13 @@
+INSERT INTO sessions
+ (id, version, created_at, cwd, parent_session, seed_length, origin,
+ delegation_depth, agent_preset, incarnation, revision)
+VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
+ON CONFLICT(id) DO UPDATE SET
+ version = excluded.version,
+ created_at = excluded.created_at,
+ cwd = excluded.cwd,
+ parent_session = excluded.parent_session,
+ seed_length = excluded.seed_length,
+ origin = excluded.origin,
+ delegation_depth = excluded.delegation_depth,
+ agent_preset = excluded.agent_preset;
diff --git a/packages/session/session-persistence-sqlite/src/codec.ts b/packages/session/session-persistence-sqlite/src/codec.ts
new file mode 100644
index 0000000000..5ee2126c6e
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/src/codec.ts
@@ -0,0 +1,343 @@
+/**
+ * Schema-17 physical chunk-row codec. This package owns the durable tags,
+ * validation, and row-size limits independently from other persistence formats.
+ * @module @deepseek-ai/dsh-session-persistence-sqlite/codec
+ */
+
+import type { StreamChunk } from '@deepseek-ai/dsh-llm'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+
+/* jscpd:ignore-start -- schema 17 deliberately owns a frozen physical codec;
+ * importing or sharing the JSONL codec would let that format mutate this database interpreter. */
+type DeltaKind = 'text-delta' | 'reasoning-delta' | 'tool-call-delta'
+type DeltaEvent = SessionEvent<'assistant/chunk'>
+
+interface RunDataBase {
+ readonly turn: number
+ readonly step: number
+ readonly index: number
+ readonly dt: number[]
+}
+
+interface TextRunData extends RunDataBase {
+ readonly texts: string[]
+}
+
+interface ToolCallRunData extends RunDataBase {
+ readonly id: Extract['id']
+ readonly name?: string
+ readonly args: string[]
+}
+
+/** One schema-17 packed physical record. */
+export type ChunkRow =
+ | { readonly type: 'text-chunks'; readonly seq0: number; readonly time0: number; readonly data: TextRunData }
+ | { readonly type: 'reasoning-chunks'; readonly seq0: number; readonly time0: number; readonly data: TextRunData }
+ | { readonly type: 'tool-call-chunks'; readonly seq0: number; readonly time0: number; readonly data: ToolCallRunData }
+
+/** One scalar event or schema-17 packed physical record. */
+export type StorageRecord = SessionEvent | ChunkRow
+
+/** Minimum eligible members in a packed physical record. */
+export const MIN_PACKED_ROW_MEMBERS = 3
+/** Maximum logical members represented by one packed physical record. */
+export const MAX_PACKED_ROW_MEMBERS = 1_024
+/** Maximum UTF-8 bytes in one packed physical record's data column. */
+export const MAX_PACKED_DATA_BYTES = 1_048_576
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null
+}
+
+function hasExactKeys(value: object, keys: readonly string[]): boolean {
+ return Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key))
+}
+
+function classify(event: SessionEvent): DeltaKind | undefined {
+ if (event.type !== 'assistant/chunk') return undefined
+ if (!hasExactKeys(event, ['type', 'seq', 'time', 'data'])) return undefined
+ if (!Number.isSafeInteger(event.seq) || event.seq < 0 || !Number.isSafeInteger(event.time)) return undefined
+ const data: unknown = event.data
+ if (!isRecord(data) || !hasExactKeys(data, ['turn', 'step', 'chunk'])) return undefined
+ if (typeof data.turn !== 'number' || typeof data.step !== 'number') return undefined
+ const chunk = data.chunk
+ if (!isRecord(chunk) || typeof chunk.index !== 'number') return undefined
+ switch (chunk.type) {
+ case 'text-delta':
+ case 'reasoning-delta':
+ return hasExactKeys(chunk, ['type', 'index', 'text']) && typeof chunk.text === 'string'
+ ? chunk.type
+ : undefined
+ case 'tool-call-delta': {
+ const validKeys = hasExactKeys(chunk, ['type', 'index', 'id', 'argumentsDelta'])
+ || (hasExactKeys(chunk, ['type', 'index', 'id', 'name', 'argumentsDelta'])
+ && typeof chunk.name === 'string')
+ return validKeys && typeof chunk.id === 'string' && typeof chunk.argumentsDelta === 'string'
+ ? chunk.type
+ : undefined
+ }
+ default:
+ return undefined
+ }
+}
+
+function toolCallOf(event: DeltaEvent): { readonly id: string; readonly name?: string } {
+ return event.data.chunk as { readonly id: string; readonly name?: string }
+}
+
+function indexOf(event: DeltaEvent): number {
+ return (event.data.chunk as { readonly index: number }).index
+}
+
+function continues(previous: DeltaEvent, next: DeltaEvent, kind: DeltaKind): boolean {
+ if (next.seq !== previous.seq + 1 || !Number.isSafeInteger(next.time - previous.time)) return false
+ if (next.data.turn !== previous.data.turn || next.data.step !== previous.data.step) return false
+ if (indexOf(next) !== indexOf(previous)) return false
+ if (kind !== 'tool-call-delta') return true
+ const left = toolCallOf(previous)
+ const right = toolCallOf(next)
+ return left.id === right.id
+ && Object.hasOwn(left, 'name') === Object.hasOwn(right, 'name')
+ && left.name === right.name
+}
+
+function buildRow(kind: DeltaKind, run: readonly DeltaEvent[]): ChunkRow {
+ const first = run[0] as DeltaEvent
+ const base = {
+ turn: first.data.turn,
+ step: first.data.step,
+ index: indexOf(first),
+ dt: run.slice(1).map((event, index) => event.time - (run[index] as DeltaEvent).time),
+ }
+ const envelope = { seq0: first.seq, time0: first.time }
+ if (kind === 'tool-call-delta') {
+ const call = toolCallOf(first)
+ return {
+ type: 'tool-call-chunks',
+ ...envelope,
+ data: {
+ ...base,
+ id: call.id as Extract['id'],
+ ...Object.hasOwn(call, 'name') ? { name: call.name as string } : {},
+ args: run.map(event => (event.data.chunk as { readonly argumentsDelta: string }).argumentsDelta),
+ },
+ }
+ }
+ const data = {
+ ...base,
+ texts: run.map(event => (event.data.chunk as { readonly text: string }).text),
+ }
+ return kind === 'text-delta'
+ ? { type: 'text-chunks', ...envelope, data }
+ : { type: 'reasoning-chunks', ...envelope, data }
+}
+
+function packedDataBytes(row: ChunkRow): number {
+ return Buffer.byteLength(JSON.stringify(row.data))
+}
+
+function emitBoundedRun(out: StorageRecord[], kind: DeltaKind, completeRun: readonly DeltaEvent[]): void {
+ let offset = 0
+ while (completeRun.length - offset >= MIN_PACKED_ROW_MEMBERS) {
+ let low = MIN_PACKED_ROW_MEMBERS
+ let high = Math.min(completeRun.length - offset, MAX_PACKED_ROW_MEMBERS)
+ const largest = buildRow(kind, completeRun.slice(offset, offset + high))
+ if (packedDataBytes(largest) <= MAX_PACKED_DATA_BYTES) {
+ out.push(largest)
+ offset += high
+ continue
+ }
+ high -= 1
+ let accepted = 0
+ let acceptedRow: ChunkRow | undefined
+ while (low <= high) {
+ const middle = Math.floor((low + high) / 2)
+ const candidate = buildRow(kind, completeRun.slice(offset, offset + middle))
+ if (packedDataBytes(candidate) <= MAX_PACKED_DATA_BYTES) {
+ accepted = middle
+ acceptedRow = candidate
+ low = middle + 1
+ } else {
+ high = middle - 1
+ }
+ }
+ if (accepted === 0) {
+ out.push(completeRun[offset] as DeltaEvent)
+ offset += 1
+ continue
+ }
+ /* v8 ignore next -- accepted is set only with its same-branch candidate. */
+ out.push(acceptedRow ?? malformed(kind, 'bounded encoder lost its accepted row'))
+ offset += accepted
+ }
+ out.push(...completeRun.slice(offset))
+}
+
+/**
+ * Pack eligible logical chunk runs into bounded schema-17 records.
+ * @param events - logical events in sequence order.
+ * @returns scalar and packed physical records in equivalent order.
+ */
+export function packChunkRuns(events: readonly SessionEvent[]): StorageRecord[] {
+ const out: StorageRecord[] = []
+ let kind: DeltaKind | undefined
+ let run: DeltaEvent[] = []
+ const flush = (): void => {
+ if (kind === undefined) out.push(...run)
+ else emitBoundedRun(out, kind, run)
+ kind = undefined
+ run = []
+ }
+ for (const event of events) {
+ const nextKind = classify(event)
+ if (nextKind === undefined) {
+ flush()
+ out.push(event)
+ continue
+ }
+ const delta = event as DeltaEvent
+ const previous = run.at(-1)
+ if (nextKind === kind && previous !== undefined && continues(previous, delta, nextKind)) {
+ run.push(delta)
+ continue
+ }
+ flush()
+ kind = nextKind
+ run = [delta]
+ }
+ flush()
+ return out
+}
+
+function malformed(tag: string, reason: string): never {
+ throw new Error(`malformed ${tag} storage row: ${reason}`)
+}
+
+function validateRunData(
+ tag: string,
+ data: Record,
+ payloadKey: 'texts' | 'args',
+ serializedBytes?: number,
+): string[] {
+ if (typeof data.turn !== 'number' || typeof data.step !== 'number' || typeof data.index !== 'number') {
+ malformed(tag, 'turn/step/index must be numbers')
+ }
+ const payload = data[payloadKey]
+ if (!Array.isArray(payload)
+ || payload.length < MIN_PACKED_ROW_MEMBERS
+ || payload.length > MAX_PACKED_ROW_MEMBERS
+ || payload.some(member => typeof member !== 'string')) {
+ malformed(tag, `${payloadKey} must contain ${MIN_PACKED_ROW_MEMBERS}..${MAX_PACKED_ROW_MEMBERS} strings`)
+ }
+ const gaps = data.dt
+ if (!Array.isArray(gaps) || gaps.some(gap => !Number.isSafeInteger(gap))) {
+ malformed(tag, 'dt must be an array of safe integers')
+ }
+ if (gaps.length !== payload.length - 1) malformed(tag, 'dt length must match the member count')
+ if ((serializedBytes ?? Buffer.byteLength(JSON.stringify(data))) > MAX_PACKED_DATA_BYTES) {
+ malformed(tag, `data exceeds ${MAX_PACKED_DATA_BYTES} UTF-8 bytes`)
+ }
+ return payload as string[]
+}
+
+function validateRow(
+ value: Record,
+ tag: ChunkRow['type'],
+ serializedBytes?: number,
+): ChunkRow {
+ if (!hasExactKeys(value, ['type', 'seq0', 'time0', 'data'])) malformed(tag, 'invalid envelope fields')
+ if (!Number.isSafeInteger(value.seq0) || (value.seq0 as number) < 0) malformed(tag, 'seq0 must be non-negative')
+ if (!Number.isSafeInteger(value.time0)) malformed(tag, 'time0 must be a safe integer')
+ const data = value.data
+ if (!isRecord(data)) malformed(tag, 'data must be an object')
+ let payload: string[]
+ if (tag === 'tool-call-chunks') {
+ const withName = hasExactKeys(data, ['turn', 'step', 'index', 'id', 'name', 'dt', 'args'])
+ if (!withName && !hasExactKeys(data, ['turn', 'step', 'index', 'id', 'dt', 'args'])) {
+ malformed(tag, 'invalid tool-call data fields')
+ }
+ if (typeof data.id !== 'string' || (withName && typeof data.name !== 'string')) {
+ malformed(tag, 'id and optional name must be strings')
+ }
+ payload = validateRunData(tag, data, 'args', serializedBytes)
+ } else {
+ if (!hasExactKeys(data, ['turn', 'step', 'index', 'dt', 'texts'])) malformed(tag, 'invalid text data fields')
+ payload = validateRunData(tag, data, 'texts', serializedBytes)
+ }
+ if (!Number.isSafeInteger((value.seq0 as number) + payload.length - 1)) malformed(tag, 'member seqs exceed safe integers')
+ let time = value.time0 as number
+ for (const gap of data.dt as number[]) {
+ time += gap
+ if (!Number.isSafeInteger(time)) malformed(tag, 'member times exceed safe integers')
+ }
+ return value as unknown as ChunkRow
+}
+
+function expandRow(row: ChunkRow): SessionEvent[] {
+ const members = row.type === 'tool-call-chunks' ? row.data.args : row.data.texts
+ const events: SessionEvent[] = []
+ let time = row.time0
+ for (let index = 0; index < members.length; index += 1) {
+ if (index > 0) time += row.data.dt[index - 1] as number
+ let chunk: StreamChunk
+ switch (row.type) {
+ case 'text-chunks':
+ chunk = { type: 'text-delta', index: row.data.index, text: members[index] as string }
+ break
+ case 'reasoning-chunks':
+ chunk = { type: 'reasoning-delta', index: row.data.index, text: members[index] as string }
+ break
+ case 'tool-call-chunks':
+ chunk = {
+ type: 'tool-call-delta',
+ index: row.data.index,
+ id: row.data.id,
+ ...Object.hasOwn(row.data, 'name') ? { name: row.data.name as string } : {},
+ argumentsDelta: members[index] as string,
+ }
+ break
+ }
+ events.push({
+ type: 'assistant/chunk',
+ seq: row.seq0 + index,
+ time,
+ data: { turn: row.data.turn, step: row.data.step, chunk },
+ })
+ }
+ return events
+}
+
+/**
+ * Decode one scalar or packed schema-17 record.
+ * @param value - parsed physical-record value.
+ * @returns the represented logical events.
+ */
+export function decodeStorageRecord(value: unknown): SessionEvent[] {
+ if (!isRecord(value)) return [value as SessionEvent]
+ const tag = value.type
+ if (tag !== 'text-chunks' && tag !== 'reasoning-chunks' && tag !== 'tool-call-chunks') {
+ return [value as SessionEvent]
+ }
+ return expandRow(validateRow(value, tag))
+}
+
+/**
+ * Decode one packed row from its exact uncompressed data value. The byte bound
+ * rejects oversized input before JSON parsing and avoids serializing it again.
+ * @param tag - validated packed physical type.
+ * @param seq0 - first represented logical sequence number.
+ * @param time0 - first represented logical timestamp.
+ * @param serializedData - decoded SQLite data-column text.
+ * @returns the represented logical events.
+ */
+export function decodeSerializedChunkRow(
+ tag: ChunkRow['type'],
+ seq0: number,
+ time0: number,
+ serializedData: string,
+): SessionEvent[] {
+ const bytes = Buffer.byteLength(serializedData)
+ if (bytes > MAX_PACKED_DATA_BYTES) malformed(tag, `data exceeds ${MAX_PACKED_DATA_BYTES} UTF-8 bytes`)
+ return expandRow(validateRow({ type: tag, seq0, time0, data: JSON.parse(serializedData) as unknown }, tag, bytes))
+}
+/* jscpd:ignore-end */
diff --git a/packages/session/session-persistence-sqlite/src/compression.ts b/packages/session/session-persistence-sqlite/src/compression.ts
new file mode 100644
index 0000000000..272b099a37
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/src/compression.ts
@@ -0,0 +1,276 @@
+/**
+ * Fixed physical-record compression for SQLite. Schema-owned functions
+ * encode logical events and decode tagged rows before persistence consumers
+ * observe them.
+ * @module @deepseek-ai/dsh-session-persistence-sqlite/compression
+ */
+
+import { TextDecoder } from 'node:util'
+import { constants, zstdCompressSync, zstdDecompressSync } from 'node:zlib'
+import type { SessionEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
+import {
+ decodeSerializedChunkRow,
+ type ChunkRow,
+ MAX_PACKED_DATA_BYTES,
+ type StorageRecord,
+} from './codec.ts'
+import type { EventRow } from './schema.ts'
+
+/** One physical row ready for SQLite parameter binding. */
+export interface BoundRecord {
+ readonly seq: number
+ readonly type: string
+ readonly time: number
+ readonly data: string | Uint8Array
+ readonly sourceEventSeqs: Uint8Array | null
+ readonly surfaceOp: string | null
+ readonly ignorable: number | null
+}
+
+/** Small values stay as SQLite text to avoid per-frame CPU and byte overhead. */
+export const ZSTD_DATA_THRESHOLD_BYTES = 4_096
+
+const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER)
+const MAX_ZIGZAG_INTEGER = MAX_SAFE_INTEGER * 2n
+const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true })
+const ZSTD_COMPRESSION_LEVEL = 3
+const PACKED_ROW_SENTINEL = 0
+
+const CHUNK_TAGS = ['text-chunks', 'reasoning-chunks', 'tool-call-chunks'] as const
+type ChunkTag = typeof CHUNK_TAGS[number]
+
+function isChunkTag(value: string): value is ChunkTag {
+ return (CHUNK_TAGS as readonly string[]).includes(value)
+}
+
+/**
+ * Decode one physical SQLite row into its complete logical event span.
+ * @param row - detached SQLite event row.
+ * @returns every logical event represented by the row.
+ */
+export function decodeRow(row: EventRow): SessionEvent[] {
+ if (row.ignorable !== PACKED_ROW_SENTINEL) return [decodeScalarRow(row)]
+ if (!isChunkTag(row.type)) {
+ throw new Error(`malformed ${row.type} storage row: packed discriminator requires a chunk tag`)
+ }
+ if (row.source_event_seqs !== null || row.surface_op !== null) {
+ throw new Error(`malformed ${row.type} storage row: packed surface fields must be null`)
+ }
+ return decodeSerializedChunkRow(
+ row.type,
+ row.seq,
+ row.time,
+ decodeData(row.data, MAX_PACKED_DATA_BYTES),
+ )
+}
+
+/**
+ * Convert a storage record to SQLite column values.
+ * @param record - scalar event or packed chunk record.
+ * @returns column values for one physical insert.
+ */
+export function bindRecord(record: StorageRecord): BoundRecord {
+ if (isChunkRow(record)) {
+ return {
+ seq: record.seq0,
+ type: record.type,
+ time: record.time0,
+ data: encodeData(JSON.stringify(record.data)),
+ sourceEventSeqs: null,
+ surfaceOp: null,
+ ignorable: PACKED_ROW_SENTINEL,
+ }
+ }
+ const event = record
+ const surface = event as SessionEvent
+ return {
+ seq: event.seq,
+ type: event.type,
+ time: event.time,
+ data: encodeData(JSON.stringify(event.data)),
+ sourceEventSeqs: surface.sourceEventSeqs === undefined
+ ? null
+ : encodeSourceEventSeqs(surface.sourceEventSeqs),
+ surfaceOp: surface.surfaceOp === undefined ? null : JSON.stringify(surface.surfaceOp),
+ ignorable: event.ignorable === true ? 1 : null,
+ }
+}
+
+function encodeData(serialized: string): string | Uint8Array {
+ const bytes = Buffer.from(serialized)
+ if (bytes.length < ZSTD_DATA_THRESHOLD_BYTES) return serialized
+ const compressed = zstdCompressSync(bytes, {
+ params: { [constants.ZSTD_c_compressionLevel]: ZSTD_COMPRESSION_LEVEL },
+ })
+ return compressed.length < bytes.length ? compressed : serialized
+}
+
+function decodeData(value: string | Uint8Array, maxOutputLength?: number): string {
+ if (typeof value === 'string') return value
+ const decoded = maxOutputLength === undefined
+ ? zstdDecompressSync(value)
+ : zstdDecompressSync(value, { maxOutputLength })
+ return UTF8_DECODER.decode(decoded)
+}
+
+function encodeSourceEventSeqs(values: readonly number[]): Uint8Array {
+ const bytes: number[] = []
+ let previous = 0n
+ for (let index = 0; index < values.length; index += 1) {
+ const sourceSeq = values[index] as number
+ if (!Number.isSafeInteger(sourceSeq) || sourceSeq < 0) {
+ throw new TypeError('sourceEventSeqs must contain non-negative safe integers')
+ }
+ const value = BigInt(sourceSeq)
+ const encoded = index === 0
+ ? value
+ : value >= previous
+ ? (value - previous) * 2n
+ : ((previous - value) * 2n) - 1n
+ appendVarint(bytes, encoded)
+ previous = value
+ }
+ return Buffer.from(bytes)
+}
+
+function appendVarint(bytes: number[], value: bigint): void {
+ let remaining = value
+ while (remaining >= 0x80n) {
+ bytes.push(Number(remaining & 0x7fn) | 0x80)
+ remaining >>= 7n
+ }
+ bytes.push(Number(remaining))
+}
+
+function decodeSourceEventSeqs(bytes: Uint8Array): number[] {
+ const values: number[] = []
+ let previous = 0n
+ let offset = 0
+ while (offset < bytes.length) {
+ const first = values.length === 0
+ const decoded = readVarint(bytes, offset, first ? MAX_SAFE_INTEGER : MAX_ZIGZAG_INTEGER)
+ offset = decoded.offset
+ const delta = first
+ ? decoded.value
+ : (decoded.value & 1n) === 0n
+ ? decoded.value / 2n
+ : -((decoded.value + 1n) / 2n)
+ const value = first ? delta : previous + delta
+ if (value < 0n || value > MAX_SAFE_INTEGER) {
+ throw new Error('malformed source_event_seqs storage value: decoded seq is out of range')
+ }
+ values.push(Number(value))
+ previous = value
+ }
+ return values
+}
+
+function readVarint(
+ bytes: Uint8Array,
+ offset: number,
+ limit: bigint,
+): { readonly value: bigint; readonly offset: number } {
+ let value = 0n
+ let shift = 0n
+ while (offset < bytes.length) {
+ const byte = bytes[offset] as number
+ offset += 1
+ value |= BigInt(byte & 0x7f) << shift
+ if ((byte & 0x80) === 0) {
+ if (shift > 0n && (byte & 0x7f) === 0) {
+ throw new Error('malformed source_event_seqs storage value: non-canonical varint')
+ }
+ if (value > limit) {
+ throw new Error('malformed source_event_seqs storage value: varint is out of range')
+ }
+ return { value, offset }
+ }
+ shift += 7n
+ if (shift > 56n) {
+ throw new Error('malformed source_event_seqs storage value: varint is out of range')
+ }
+ }
+ throw new Error('malformed source_event_seqs storage value: truncated varint')
+}
+
+function isChunkRow(record: StorageRecord): record is ChunkRow {
+ return isChunkTag(record.type) && 'seq0' in record && !('seq' in record)
+}
+
+function decodeScalarRow(row: EventRow): SessionEvent {
+ const surfaceFields = {
+ ...row.source_event_seqs === null
+ ? {}
+ : { sourceEventSeqs: decodeSourceEventSeqs(row.source_event_seqs) },
+ ...row.surface_op === null
+ ? {}
+ : { surfaceOp: JSON.parse(row.surface_op) as SessionEvent['surfaceOp'] },
+ }
+ return {
+ type: row.type as SessionEvent['type'],
+ seq: row.seq,
+ time: row.time,
+ data: JSON.parse(decodeData(row.data)) as SessionEvent['data'],
+ ...surfaceFields,
+ ...row.ignorable === 1 ? { ignorable: true as const } : {},
+ } as SessionEvent
+}
+
+/**
+ * Validate and flatten physical rows into their logical prefix. A malformed
+ * row or logical gap is committed corruption when a later valid turn end
+ * exists; otherwise it starts a removable physical tail.
+ * @param rows - physical rows ordered by their first logical sequence.
+ * @param base - logical sequence expected from the first selected row.
+ * @returns the contiguous logical prefix and optional physical deletion base.
+ */
+export function scanRows(
+ rows: readonly EventRow[],
+ base = 0,
+): { preserved: SessionEvent[]; tornFrom?: number } {
+ let lastTurnEndRow = -1
+ for (let index = rows.length - 1; index >= 0; index -= 1) {
+ try {
+ if (decodeRow(rows[index] as EventRow).some(event => event.type === 'turn/end')) {
+ lastTurnEndRow = index
+ break
+ }
+ } catch {
+ // A malformed row cannot prove that an earlier physical prefix committed.
+ }
+ }
+
+ const preserved: SessionEvent[] = []
+ let expected = base
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
+ const physical = rows[rowIndex] as EventRow
+ let logicalEvents: SessionEvent[] | undefined
+ try {
+ logicalEvents = decodeRow(physical)
+ } catch {
+ // The committed-prefix rule below owns whether this invalid row is fatal or repairable.
+ }
+ if (logicalEvents === undefined) {
+ if (rowIndex <= lastTurnEndRow) {
+ throw new Error(`corrupt session log: invalid committed physical row at seq ${physical.seq}`)
+ }
+ return { preserved, tornFrom: physical.seq }
+ }
+ let contiguous = true
+ for (const event of logicalEvents) {
+ if (event.seq !== expected) {
+ contiguous = false
+ break
+ }
+ expected += 1
+ }
+ if (!contiguous) {
+ if (rowIndex <= lastTurnEndRow) {
+ throw new Error(`corrupt session log: invalid committed physical row at seq ${physical.seq}`)
+ }
+ return { preserved, tornFrom: physical.seq }
+ }
+ preserved.push(...logicalEvents)
+ }
+ return { preserved }
+}
diff --git a/packages/session/session-persistence-sqlite/src/index.ts b/packages/session/session-persistence-sqlite/src/index.ts
index 969f1f5fac..5d6c3bb674 100644
--- a/packages/session/session-persistence-sqlite/src/index.ts
+++ b/packages/session/session-persistence-sqlite/src/index.ts
@@ -1,90 +1,45 @@
/**
- * SQLite durable session-persistence backend. It maps each session header and
- * event to rows, and delegates write-path orchestration to
- * {@link PersistenceCoordinator}. It has no independent per-session artifact,
- * so its locator returns `undefined`.
+ * Opt-in SQLite persistence provider. Logical sessions remain unchanged;
+ * the physical backend packs eligible chunk runs into schema-17 rows.
* @module @deepseek-ai/dsh-session-persistence-sqlite
*/
-import { Context } from '@deepseek-ai/cordis'
+import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
-import { randomUUID } from 'node:crypto'
-import { statSync } from 'node:fs'
-import { DatabaseSync } from 'node:sqlite'
-import { mkdir, open } from 'node:fs/promises'
-import { dirname, resolve } from 'node:path'
+import type {
+ SessionEvent,
+ SessionHeader,
+ SessionId,
+ SessionPreparation,
+} from '@deepseek-ai/dsh-session'
import {
- DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS,
- SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator,
- type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot,
- type SessionInspection, type SessionPersistenceRevision as PersistenceRevision,
- type StoredPrefix, type StoredSuffix,
+ DEFAULT_PREPARED_SESSION_CACHE_SIZE,
+ DEFAULT_WRITE_BATCH_MAX_DELAY_MS,
+ MAX_WRITE_BATCH_DELAY_MS,
+ PersistenceCoordinator,
+ SessionPersistence,
+ type SessionInspection,
+ type SessionLocation,
+ type SessionPersistenceSnapshot,
} from '@deepseek-ai/dsh-session-persistence'
-import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session'
-import {
- type JournalMode, openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow,
-} from './schema.ts'
+import type { JournalMode } from './schema.ts'
+import { SqliteStore } from './store.ts'
export { SCHEMA_VERSION } from './schema.ts'
-/**
- * Serialize an event's optional envelope fields for SQL binding. The surface
- * fields are nullable TEXT columns — null when the event has no surface
- * metadata (non-surface events, events written before surface support); the
- * ignorable marker is a nullable INTEGER column — `1` iff the envelope carries
- * `ignorable: true`.
- */
-function envelopeBindings(event: SessionEvent): [string | null, string | null, number | null] {
- const se = event as SessionEvent
- return [
- se.sourceEventSeqs ? JSON.stringify(se.sourceEventSeqs) : null,
- se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null,
- event.ignorable === true ? 1 : null,
- ]
-}
-
-/** Build the source-qualified revision shared by full and lightweight reads. */
-function sqliteRevision(storeIdentity: string, row: SessionRow): PersistenceRevision {
- return SessionPersistenceRevision(
- `${storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`,
- )
-}
-
-/**
- * Exclusively create a missing database file with owner-only permissions.
- * Existing files retain their modes, and errors other than `EEXIST` propagate.
- * `DatabaseSync` reopens by path, so this does not protect confidentiality or
- * integrity when another principal can replace the database entry in its parent
- * directory.
- */
-async function createDatabaseFile(path: string): Promise {
- try {
- const handle = await open(path, 'wx', 0o600)
- await handle.close()
- } catch (error) {
- if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
- }
-}
+/** Default wait for another SQLite connection's write reservation. */
+export const DEFAULT_BUSY_TIMEOUT_MS = 5_000
+/** Largest busy timeout accepted by SQLite's signed millisecond interface. */
+export const MAX_BUSY_TIMEOUT_MS = 2_147_483_647
/** Plugin configuration. */
export interface Config {
- /**
- * Filesystem path to the SQLite database file. The special value `:memory:`
- * opens an in-process database (tests). On filesystems with POSIX modes,
- * missing directories and databases are created owner-only; existing path
- * modes are preserved. Filesystem setup errors other than an existing database
- * fail initialization. The backend does not protect confidentiality or
- * integrity when another principal can replace the database entry in its
- * parent directory.
- */
+ /** SQLite database path, or `:memory:` for an in-process database. */
path: string
- /**
- * SQLite `journal_mode` pragma. `wal` (the default) is the recorded
- * durability model; pick a rollback-journal mode (`delete`/`truncate`/
- * `persist`) on filesystems where WAL's shared-memory files do not work
- * (network mounts). See {@link JournalMode}.
- */
+ /** Durable SQLite journal mode; defaults to `wal`. */
journalMode?: JournalMode
+ /** Maximum wait for another SQLite connection's lock; defaults to 5,000 ms. */
+ busyTimeoutMs?: number
/** Maximum cold Session preparations retained for history-to-resume reuse. */
preparedSessionCacheSize?: number
/** Fixed live-event coalescing window; not a backend completion deadline. */
@@ -92,84 +47,49 @@ export interface Config {
}
/**
- * The SQLite persistence backend. Load as a plugin; it registers as
- * `ctx.sessionPersistence` and (via the coordinator) installs the write-path
- * listeners. Its torn-tail marker is the seq to delete from.
+ * SQLite `SessionPersistence` provider with a schema-owned physical codec.
*/
-export class SqliteSessionPersistence extends SessionPersistence implements PersistenceBackend {
+export class SqliteSessionPersistence extends SessionPersistence {
override readonly supportsRawArtifacts = false
+ override readonly name = 'session-persistence-sqlite'
static inject = ['sessions']
static Config: z = z.object({
path: z.string().required(),
journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'),
+ busyTimeoutMs: z.number().step(1).min(0).max(MAX_BUSY_TIMEOUT_MS).default(DEFAULT_BUSY_TIMEOUT_MS),
preparedSessionCacheSize: z.number().step(1).min(1).default(DEFAULT_PREPARED_SESSION_CACHE_SIZE),
writeBatchMaxDelayMs: z.number().step(1).min(1).max(MAX_WRITE_BATCH_DELAY_MS)
.default(DEFAULT_WRITE_BATCH_MAX_DELAY_MS),
})
- /**
- * Backend label for the coordinator's dispose diagnostics. Intentionally
- * shadows cordis `Service.name` (set to `'sessionPersistence'` by the base);
- * see the JSONL backend for why this does not affect service resolution.
- */
- override readonly name = 'session-persistence-sqlite'
-
- private db!: DatabaseSync
- private storeIdentity!: string
- private ready: Promise
- private coordinator: PersistenceCoordinator
+ private readonly store: SqliteStore
+ private readonly coordinator: PersistenceCoordinator
constructor(ctx: Context, public config: Config) {
super(ctx)
- // Programmatic wrappers may construct the backend without Schemastery normalization.
const preparedSessionCacheSize = config.preparedSessionCacheSize
?? DEFAULT_PREPARED_SESSION_CACHE_SIZE
const writeBatchMaxDelayMs = config.writeBatchMaxDelayMs
?? DEFAULT_WRITE_BATCH_MAX_DELAY_MS
- // Open asynchronously so directory creation does not block plugin apply;
- // every storage hook awaits the same readiness promise.
- this.ready = this.openDb(config.path, (config as Required).journalMode)
- this.coordinator = new PersistenceCoordinator(this.ctx, this, {
+ this.store = new SqliteStore({
+ path: config.path,
+ journalMode: config.journalMode ?? 'wal',
+ busyTimeoutMs: config.busyTimeoutMs ?? DEFAULT_BUSY_TIMEOUT_MS,
+ })
+ this.coordinator = new PersistenceCoordinator(this.ctx, this.store, {
preparedSessionCacheSize,
writeBatchMaxDelayMs,
})
}
- private async openDb(path: string, journalMode: JournalMode): Promise {
- const actual = path === ':memory:' ? path : resolve(path)
- if (actual !== ':memory:') {
- await mkdir(dirname(actual), { recursive: true, mode: 0o700 })
- await createDatabaseFile(actual)
- }
- this.db = openDatabase(actual, journalMode)
- try {
- const row = this.db.prepare(
- 'SELECT store_id FROM persistence_state WHERE singleton = 1',
- ).get() as { store_id: string } | undefined
- /* v8 ignore next -- openDatabase inserts the singleton before returning. */
- if (row === undefined) {
- throw new Error(`session database at "${actual}" has no store identity`)
- }
- if (row.store_id.length === 0) {
- throw new Error(`session database at "${actual}" has no valid store identity`)
- }
- if (actual !== ':memory:') {
- const identity = statSync(actual, { bigint: true })
- this.storeIdentity = `file:${identity.dev}:${identity.ino}:${identity.birthtimeNs}:store:${row.store_id}`
- } else {
- this.storeIdentity = `memory:store:${row.store_id}`
- }
- } catch (error: unknown) {
- this.db.close()
- throw error
- }
+ /** Reject self-contained path and ownership failures without loading Node SQLite. */
+ protected async [Service.init](): Promise {
+ await this.store.validatePath()
}
- // --- SessionPersistence service API (delegated to the coordinator) ---
-
- /** SQLite has one database, not an independent local artifact per session. */
+ /** SQLite has one database, not an independent per-session artifact. */
locate(_meta: SessionHeader): SessionLocation | undefined {
return undefined
}
@@ -194,220 +114,20 @@ export class SqliteSessionPersistence extends SessionPersistence implements Pers
return this.coordinator.inspect(id, signal)
}
- readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
+ readFrom(
+ id: SessionId,
+ fromSeq: number,
+ signal?: AbortSignal,
+ ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.coordinator.readFrom(id, fromSeq, signal)
}
- // One method serves both public `list` and the backend hook; delegating it to
- // the coordinator would call this hook recursively.
-
- // --- PersistenceBackend hooks (the SQLite storage primitives) ---
-
- /** Read a stored prefix by id (ids are globally unique — no scope to scan). */
- loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> {
- return this.readPrefix(id, signal)
+ list(signal?: AbortSignal): Promise {
+ return this.store.list(signal)
}
- /** Read one row's revision without loading its events. */
- async readStoredRevision(id: SessionId, signal?: AbortSignal): Promise {
- signal?.throwIfAborted()
- await this.ready
- signal?.throwIfAborted()
- const row = this.rowFor(id)
- return row === undefined ? undefined : sqliteRevision(this.storeIdentity, row)
- }
-
- /**
- * Seek-capable suffix read: SQL selects `seq >= fromSeq` directly, so the
- * read scales with the suffix, not the log. Torn rows past the preserved
- * region are dropped, never repaired (non-mutating read).
- */
- async loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise {
- signal?.throwIfAborted()
- await this.ready
- signal?.throwIfAborted()
- const row = this.rowFor(id)
- if (row === undefined) return undefined
- const meta = rowToMeta(row)
- const eventRows = this.db
- .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq')
- .all(id, fromSeq) as unknown as EventRow[]
- signal?.throwIfAborted()
- const { preserved } = scanRows(eventRows, fromSeq)
- return { meta, events: preserved }
- }
-
- /**
- * Read a session's row + ordered events into a {@link StoredPrefix}. The
- * torn-tail marker is the seq from which a never-committed tail must be deleted
- * (`scanRows` already returns it as `number | undefined`).
- */
- private async readPrefix(id: SessionId, signal?: AbortSignal): Promise | undefined> {
- signal?.throwIfAborted()
- await this.ready
- signal?.throwIfAborted()
- this.db.exec('BEGIN')
- let snapshot: { row: SessionRow; eventRows: EventRow[] } | undefined
- try {
- const row = this.rowFor(id)
- if (row !== undefined) {
- const eventRows = this.db
- .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? ORDER BY seq')
- .all(id) as unknown as EventRow[]
- snapshot = { row, eventRows }
- }
- this.db.exec('COMMIT')
- } catch (error: unknown) {
- /* v8 ignore start -- synchronous read failures only need transaction cleanup before propagation. */
- this.db.exec('ROLLBACK')
- throw error
- /* v8 ignore stop */
- }
- signal?.throwIfAborted()
- if (snapshot === undefined) return undefined
- const { row, eventRows } = snapshot
- const { preserved, tornFrom } = scanRows(eventRows)
- return {
- meta: rowToMeta(row),
- events: preserved,
- revision: sqliteRevision(this.storeIdentity, row),
- ...tornFrom !== undefined ? { tornMarker: tornFrom } : {},
- }
- }
-
- /**
- * Durably append a batch in ONE transaction: materialize the sessions row (if
- * lazy) and INSERT every event, or roll back entirely. The transaction is the
- * atomicity + durability boundary, so a mid-batch failure (a UNIQUE violation
- * on a duplicated seq) leaves the stored log untouched.
- */
- async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise {
- await this.ready
- const insertEvent = this.db.prepare(
- 'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
- )
- this.db.exec('BEGIN')
- try {
- if (!isMaterialized) this.writeRow(meta)
- for (const event of events) {
- const [surfaceSeqs, surfaceOp, ignorable] = envelopeBindings(event)
- insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp, ignorable)
- }
- this.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(meta.id)
- this.db.exec('COMMIT')
- } catch (error) {
- this.db.exec('ROLLBACK')
- throw error
- }
- }
-
- /**
- * Make a crash repair durable in ONE transaction: DELETE the torn tail (from
- * `tornMarker`) and INSERT the synthetic `closers`. After COMMIT the stored rows
- * == the balanced log.
- */
- async commitRepair(meta: SessionHeader, tornMarker: number | undefined, closers: readonly SessionEvent[]): Promise {
- await this.ready
- this.db.exec('BEGIN')
- try {
- if (tornMarker !== undefined) {
- this.db.prepare('DELETE FROM events WHERE session_id = ? AND seq >= ?').run(meta.id, tornMarker)
- }
- if (closers.length > 0) {
- const insertEvent = this.db.prepare(
- 'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
- )
- for (const event of closers) {
- const [surfaceSeqs, surfaceOp, ignorable] = envelopeBindings(event)
- insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp, ignorable)
- }
- }
- if (tornMarker !== undefined || closers.length > 0) {
- this.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(meta.id)
- }
- this.db.exec('COMMIT')
- } catch (error) {
- // The DELETE+INSERT cannot collide (a row at a closer's seq is preserved or
- // deleted as torn first); this rolls back a DB-level failure (disk full,
- // etc.), unreachable in test.
- /* v8 ignore start */
- this.db.exec('ROLLBACK')
- throw error
- /* v8 ignore stop */
- }
- }
-
- /** List all materialized sessions' metadata (every row is a materialized session). */
- async list(signal?: AbortSignal): Promise {
- signal?.throwIfAborted()
- await this.ready
- signal?.throwIfAborted()
- const rows = this.db
- .prepare('SELECT * FROM sessions')
- .all() as unknown as SessionRow[]
- signal?.throwIfAborted()
- return rows.map(rowToMeta)
- }
-
- /** List metadata with a source-qualified monotonic revision per session. */
- async listSnapshots(signal?: AbortSignal): Promise {
- signal?.throwIfAborted()
- await this.ready
- signal?.throwIfAborted()
- const rows = this.db.prepare('SELECT * FROM sessions').all() as unknown as SessionRow[]
- signal?.throwIfAborted()
- return rows.map(row => ({
- header: rowToMeta(row),
- revision: SessionPersistenceRevision(
- `${this.storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`,
- ),
- }))
- }
-
- /** Close the database handle (awaited by the coordinator's dispose, post-drain). */
- async close(): Promise {
- await this.ready
- this.db.close()
- }
-
- // --- row helpers ---
-
- /** Fetch a session's row, or undefined if absent. */
- private rowFor(id: SessionId): SessionRow | undefined {
- return this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as unknown as SessionRow | undefined
- }
-
- /**
- * Insert-or-replace a session's metadata row. The only caller is the first
- * materializing `appendBatch`, so writing the row IS the materialization (its
- * existence is the signal `list` reads).
- */
- private writeRow(meta: SessionHeader): void {
- this.db.prepare(`
- INSERT INTO sessions
- (id, version, created_at, cwd, parent_session, seed_length, origin, delegation_depth, agent_preset, incarnation, revision)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
- ON CONFLICT(id) DO UPDATE SET
- version = excluded.version,
- created_at = excluded.created_at,
- cwd = excluded.cwd,
- parent_session = excluded.parent_session,
- seed_length = excluded.seed_length,
- origin = excluded.origin,
- delegation_depth = excluded.delegation_depth,
- agent_preset = excluded.agent_preset
- `).run(
- meta.id,
- meta.version,
- meta.createdAt,
- meta.cwd ?? null,
- meta.parentSession ?? null,
- meta.seedLength ?? null,
- meta.origin ?? null,
- meta.delegationDepth ?? null,
- meta.agentPreset ?? null,
- randomUUID(),
- )
+ listSnapshots(signal?: AbortSignal): Promise {
+ return this.store.listSnapshots(signal)
}
}
diff --git a/packages/session/session-persistence-sqlite/src/invariant.ts b/packages/session/session-persistence-sqlite/src/invariant.ts
index 7a5e905e30..8436d5fbe5 100644
--- a/packages/session/session-persistence-sqlite/src/invariant.ts
+++ b/packages/session/session-persistence-sqlite/src/invariant.ts
@@ -15,8 +15,8 @@ export const name = 'session-persistence-sqlite-invariant'
export const inject = ['invariants']
/**
- * No runtime invariant: persistence correctness requires backend round-trip and crash-tail tests;
- * this package exposes no continuously observable in-process relation.
+ * No runtime invariant: physical packing is observable only by database
+ * round-trip and row-count checks, not a continuous in-process relation.
*/
const install: InvariantInstaller = () => {}
diff --git a/packages/session/session-persistence-sqlite/src/schema.ts b/packages/session/session-persistence-sqlite/src/schema.ts
index c7402d7d4b..39fc00504b 100644
--- a/packages/session/session-persistence-sqlite/src/schema.ts
+++ b/packages/session/session-persistence-sqlite/src/schema.ts
@@ -1,87 +1,86 @@
/**
- * Schema + load-time helpers for the SQLite session-persistence backend: the
- * DDL (a store-identity row, `sessions` metadata, and a 1:1 `events` row per
- * `SessionEvent`), the database open/configure step, and the last-`turn/end`
- * cut that gives the SQLite backend the SAME crash-tail-on-load semantics as
- * the JSONL backend.
- *
- * @module dsh-session-persistence-sqlite/schema
+ * SQLite schema ownership and durable-row validation.
+ * @module @deepseek-ai/dsh-session-persistence-sqlite/schema
*/
import { randomUUID } from 'node:crypto'
-import { DatabaseSync } from 'node:sqlite'
-import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepseek-ai/dsh-session'
+import { isAbsolute } from 'node:path'
+import { performance } from 'node:perf_hooks'
+import type { DatabaseSync } from 'node:sqlite'
+import { setTimeout as delay } from 'node:timers/promises'
+import {
+ SessionId,
+ type SessionHeader,
+} from '@deepseek-ai/dsh-session'
+import { sql } from './sql.ts'
-/**
- * The on-disk schema version. Bumped only on a breaking change to the table
- * layout; orthogonal to a session's own `version` (which versions the EVENT
- * vocabulary, stored per session in the `sessions` row).
- */
-export const SCHEMA_VERSION = 15
-
-/** SQLite application id protecting unrelated databases from persistence writes. */
+/** Current physical-record schema with packed and compressed event rows. */
+export const SCHEMA_VERSION = 17
+/** Application id reserved for DeepSeek Harness SQLite session databases. */
export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850
-/**
- * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}).
- * The row's EXISTENCE is the materialization signal: it is written only by the
- * first `append` (lazy materialization), so a created-but-never-appended
- * session has no row and is absent from `list`, mirroring the JSONL
- * backend's "no file until first append".
- */
+/** A materialized session's metadata and monotonic revision. */
export interface SessionRow {
- id: string
- version: number
- created_at: number
- cwd: string | null
- parent_session: string | null
- seed_length: number | null
- origin: 'subagent' | null
- /** Stable identity assigned when this log is materialized. */
- incarnation: string
- /** Monotonic log-change token incremented in each mutating transaction. */
- revision: number
- delegation_depth: number | null
- agent_preset: string | null
+ readonly id: string
+ readonly version: number
+ readonly created_at: number
+ readonly cwd: string | null
+ readonly parent_session: string | null
+ readonly seed_length: number | null
+ readonly origin: 'subagent' | null
+ readonly incarnation: string
+ readonly revision: number
+ readonly delegation_depth: number | null
+ readonly agent_preset: string | null
}
-/** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */
+/** One physical event row; packed rows may represent multiple logical events. */
export interface EventRow {
- seq: number
- type: string
- time: number
- data: string
- /** JSON-encoded `number[]` — the event's sourceEventSeqs, or null. */
- source_event_seqs: string | null
- /** JSON-encoded `SurfaceOp` — how the event entered the surface, or null. */
- surface_op: string | null
- /** `1` iff the event carries the envelope's `ignorable: true` marker, else null. */
- ignorable: number | null
+ readonly seq: number
+ readonly type: string
+ readonly time: number
+ readonly data: string | Uint8Array
+ readonly source_event_seqs: Uint8Array | null
+ readonly surface_op: string | null
+ readonly ignorable: number | null
}
-/**
- * Journal modes the backend will run under. `wal` is the default and the
- * durability model the persistence ADR records; the rollback-journal modes
- * (`delete`/`truncate`/`persist`) exist for filesystems where WAL's
- * shared-memory files do not work (network mounts). `memory`/`off` are
- * excluded: dropping journal durability silently contradicts what this
- * backend promises.
- */
+/** Durable journal modes accepted by the backend. */
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
+interface SchemaObjectRow {
+ readonly type: string
+ readonly name: string
+ readonly tbl_name: string
+ readonly sql: string
+}
+
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
+const JOURNAL_BUSY_RETRY_INTERVAL_MS = 10
+type DatabaseSyncConstructor = typeof import('node:sqlite')['DatabaseSync']
+
/**
- * Open the database and apply its schema and pragmas. An empty database with a
- * zero `user_version` is initialized at {@link SCHEMA_VERSION}; a nonempty
- * unversioned database and every other non-current version reject rather than
- * being migrated in place.
- * @param path - the SQLite database file to open (created when absent).
+ * Open and validate a SQLite session database.
+ * @param Database - lazily imported Node SQLite constructor.
+ * @param path - SQLite path, including `:memory:`.
* @param journalMode - validated journal pragma.
- * @returns the open handle with pragmas applied and all three tables ensured.
+ * @param busyTimeoutMs - validated maximum wait for a competing SQLite lock.
+ * @returns the configured database handle.
+ * @throws when connection settings, schema ownership, or SQLite setup cannot be validated.
*/
-export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync {
- const db = new DatabaseSync(path)
+export async function openDatabase(
+ Database: DatabaseSyncConstructor,
+ path: string,
+ journalMode: JournalMode,
+ busyTimeoutMs: number,
+): Promise {
+ const deadline = performance.now() + busyTimeoutMs
+ const db = new Database(path, { timeout: busyTimeoutMs })
try {
- configureDatabase(db, path, journalMode)
+ configureConnectionSecurity(db, path)
+ configureDatabase(Database, db, path)
+ await selectJournalMode(db, path, journalMode, deadline)
+ configureDurability(db, path)
return db
} catch (error: unknown) {
db.close()
@@ -89,182 +88,337 @@ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSy
}
}
-function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalMode): void {
- db.exec('PRAGMA foreign_keys = ON')
+function configureConnectionSecurity(db: DatabaseSync, path: string): void {
+ db.exec(sql('trusted-schema-off'))
+ const trustedSchema = integerField(db.prepare(sql('select-trusted-schema')).get(), 'trusted_schema')
+ /* v8 ignore next 3 -- supported SQLite versions return the fixed setting. */
+ if (trustedSchema !== 0) {
+ throw new Error(`session database at "${path}" retained trusted_schema=${trustedSchema}, expected 0`)
+ }
+ db.exec(sql('mmap-off'))
+ if (path === ':memory:') return
+ const mmapSize = integerField(db.prepare(sql('select-mmap-size')).get(), 'mmap_size')
+ /* v8 ignore next 3 -- supported file-backed SQLite connections return the fixed setting. */
+ if (mmapSize !== 0) {
+ throw new Error(`session database at "${path}" retained mmap_size=${mmapSize}, expected 0`)
+ }
+}
+
+function configureDatabase(
+ Database: DatabaseSyncConstructor,
+ db: DatabaseSync,
+ path: string,
+): void {
+ db.exec(sql('foreign-keys-on'))
let began = false
try {
- db.exec('BEGIN IMMEDIATE')
+ db.exec(sql('begin-immediate'))
began = true
- // Validate while holding the write lock so no other connection can change
- // schema ownership between inspection and initialization.
- const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number }
- const { application_id: applicationId } = db.prepare('PRAGMA application_id').get() as { application_id: number }
- const { count: userObjectCount } = db.prepare(
- "SELECT COUNT(*) AS count FROM sqlite_schema WHERE name NOT GLOB 'sqlite_*'",
- ).get() as { count: number }
+ const onDisk = integerField(db.prepare(sql('select-user-version')).get(), 'user_version')
+ const applicationId = integerField(db.prepare(sql('select-application-id')).get(), 'application_id')
+ const userObjectCount = integerField(db.prepare(sql('select-user-object-count')).get(), 'count')
if (onDisk === 0 && (applicationId !== 0 || userObjectCount > 0)) {
throw new Error(`session database at "${path}" has an unversioned schema or application identity`)
}
if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) {
- throw new Error(`session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`)
+ throw new Error(
+ `session database at "${path}" has schema version ${onDisk}, incompatible with this build (${SCHEMA_VERSION})`,
+ )
}
- if (onDisk === SCHEMA_VERSION && applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) {
+ if (onDisk !== 0 && applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) {
throw new Error(
`session database at "${path}" has application id ${applicationId}, expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`,
)
}
- db.exec(`
- CREATE TABLE IF NOT EXISTS persistence_state (
- singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
- store_id TEXT NOT NULL
- ) STRICT;
-
- CREATE TABLE IF NOT EXISTS sessions (
- id TEXT PRIMARY KEY,
- version INTEGER NOT NULL,
- created_at INTEGER NOT NULL,
- cwd TEXT,
- parent_session TEXT,
- seed_length INTEGER,
- origin TEXT,
- delegation_depth INTEGER,
- agent_preset TEXT,
- incarnation TEXT NOT NULL,
- revision INTEGER NOT NULL
- ) STRICT;
-
- CREATE TABLE IF NOT EXISTS events (
- session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
- seq INTEGER NOT NULL,
- type TEXT NOT NULL,
- time INTEGER NOT NULL,
- data TEXT NOT NULL,
- source_event_seqs TEXT,
- surface_op TEXT,
- ignorable INTEGER,
- PRIMARY KEY (session_id, seq)
- ) STRICT
- `)
- db.prepare(
- 'INSERT OR IGNORE INTO persistence_state (singleton, store_id) VALUES (1, ?)',
- ).run(randomUUID())
- if (onDisk === 0) {
- db.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`)
- db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
- }
- db.exec('COMMIT')
+ if (onDisk === 0) initializeDatabase(db)
+ validateRequiredSchema(Database, db, path)
+ db.exec(sql('commit'))
began = false
} catch (error: unknown) {
- /* v8 ignore next -- a BEGIN failure leaves no transaction to roll back. */
+ /* v8 ignore else -- a failed begin leaves no transaction to roll back. */
if (began) {
- /* v8 ignore next 5 -- preserve the original schema failure if SQLite also refuses rollback. */
+ /* v8 ignore next 5 -- retain the original ownership failure if rollback fails too. */
try {
- db.exec('ROLLBACK')
+ db.exec(sql('rollback'))
} catch {
- // The original SQLite failure remains the actionable cause.
+ // The original database-ownership failure remains actionable.
}
}
throw error
}
- // The validated union is safe to interpolate into a non-bindable PRAGMA.
- // Apply it only after ownership validation and initialization commit.
- db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`)
}
-/**
- * Reconstruct the {@link SessionHeader} from a `sessions` row.
- * @param row - the `sessions` table row.
- * @returns the header, `NULL` columns mapped to omitted optional fields.
- */
-export function rowToMeta(row: SessionRow): SessionHeader {
- if (!Number.isSafeInteger(row.created_at) || row.created_at < 0) {
- throw new Error('stored session createdAt must be a non-negative safe integer')
- }
- return {
- version: row.version,
- id: row.id as SessionId,
- createdAt: row.created_at,
- ...row.cwd !== null ? { cwd: row.cwd } : {},
- ...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {},
- ...row.seed_length !== null ? { seedLength: row.seed_length } : {},
- ...row.origin !== null ? { origin: row.origin } : {},
- ...row.delegation_depth !== null ? { delegationDepth: row.delegation_depth } : {},
- ...row.agent_preset !== null ? { agentPreset: row.agent_preset } : {},
- }
-}
-
-/**
- * Reconstruct a {@link SessionEvent} from an `events` row (parses `data`).
- * @param row - the `events` table row; `data` and the surface columns hold JSON text.
- * @returns the reconstructed event; throws when a JSON column fails to parse
- * ({@link scanRows} treats that as a hole, not corruption, in the tail).
- */
-export function rowToEvent(row: EventRow): SessionEvent {
- // Surface-metadata fields are conditional on the event type in the type
- // system; spread them so each variant gets only the fields it declares.
- const surfaceFields = {
- ...row.source_event_seqs !== null ? { sourceEventSeqs: JSON.parse(row.source_event_seqs) as number[] } : {},
- ...row.surface_op !== null ? { surfaceOp: JSON.parse(row.surface_op) as SurfaceOp } : {},
- }
- const ignorableField = row.ignorable === 1 ? { ignorable: true as const } : {}
- return {
- type: row.type as SessionEvent['type'],
- seq: row.seq,
- time: row.time,
- data: JSON.parse(row.data) as SessionEvent['data'],
- ...surfaceFields,
- ...ignorableField,
- } as SessionEvent
-}
-
-/**
- * Find the preserved prefix of ordered event rows. Fully written rows in an
- * interrupted final turn remain in the prefix. The first unparsable row or seq
- * gap after the last `turn/end` marks a tolerated torn tail; the same hole in
- * the committed region rejects.
- *
- * @param rows - one session's event rows, ordered by seq ascending.
- * @param base - the seq the first row is expected to carry; `0` for a whole
- * log, the requested `fromSeq` for a suffix read (`loadStoredFrom`).
- * @returns the preserved event prefix, plus `tornFrom` — the seq the physical
- * delete starts at — when a torn tail exists.
- */
-export function scanRows(rows: readonly EventRow[], base = 0): { preserved: SessionEvent[]; tornFrom?: number } {
- // Pass 1: parse each row's data; a row whose data is not valid JSON is a hole.
- // (The seq/type COLUMNS are always present even when `data` is corrupt.)
- interface Parsed { ok: boolean; event?: SessionEvent }
- const parsed: Parsed[] = rows.map((row) => {
+async function selectJournalMode(
+ db: DatabaseSync,
+ path: string,
+ journalMode: JournalMode,
+ deadline: number,
+): Promise {
+ let result: unknown
+ while (true) {
try {
- return { ok: true, event: rowToEvent(row) }
- } catch {
- return { ok: false }
+ result = db.prepare(sql(journalResource(journalMode))).get()
+ break
+ } catch (error: unknown) {
+ const remainingMs = Math.max(0, Math.ceil(deadline - performance.now()))
+ if (!isSqliteBusy(error) || remainingMs === 0) throw error
+ await delay(Math.min(JOURNAL_BUSY_RETRY_INTERVAL_MS, remainingMs))
+ if (performance.now() >= deadline) throw error
+ }
+ }
+ const selected = stringField(result, 'journal_mode').toLowerCase()
+ const expected = path === ':memory:' ? 'memory' : journalMode
+ /* v8 ignore next 3 -- SQLite returns the selected mode from these fixed, valid pragmas. */
+ if (selected !== expected) {
+ throw new Error(`session database at "${path}" selected journal mode ${selected}, expected ${expected}`)
+ }
+}
+
+function configureDurability(db: DatabaseSync, path: string): void {
+ db.exec(sql('synchronous-full'))
+ const synchronous = integerField(db.prepare(sql('select-synchronous')).get(), 'synchronous')
+ /* v8 ignore next 3 -- supported SQLite versions return the fixed setting. */
+ if (synchronous !== 2) {
+ throw new Error(`session database at "${path}" retained synchronous=${synchronous}, expected FULL (2)`)
+ }
+}
+
+function isSqliteBusy(error: unknown): boolean {
+ return typeof error === 'object'
+ && error !== null
+ && Reflect.get(error, 'errcode') === 5
+}
+
+function journalResource(mode: JournalMode):
+ | 'journal-mode-wal'
+ | 'journal-mode-delete'
+ | 'journal-mode-truncate'
+ | 'journal-mode-persist' {
+ switch (mode) {
+ case 'wal': return 'journal-mode-wal'
+ case 'delete': return 'journal-mode-delete'
+ case 'truncate': return 'journal-mode-truncate'
+ case 'persist': return 'journal-mode-persist'
+ }
+}
+
+function initializeDatabase(db: DatabaseSync): void {
+ db.exec(sql('schema'))
+ db.prepare(sql('insert-persistence-state')).run(randomUUID())
+ db.exec(sql('set-application-id'))
+ db.exec(sql('set-user-version-17'))
+}
+
+let canonicalSchema: readonly SchemaObjectRow[] | undefined
+
+function expectedSchema(Database: DatabaseSyncConstructor): readonly SchemaObjectRow[] {
+ if (canonicalSchema !== undefined) return canonicalSchema
+ const reference = new Database(':memory:')
+ try {
+ reference.exec(sql('foreign-keys-on'))
+ reference.exec(sql('schema'))
+ canonicalSchema = schemaObjects(reference)
+ return canonicalSchema
+ } finally {
+ reference.close()
+ }
+}
+
+function schemaObjects(db: DatabaseSync): SchemaObjectRow[] {
+ return db.prepare(sql('select-schema-objects')).all().map((value) => {
+ const row = record(value, 'schema object')
+ return {
+ type: stringField(row, 'type'),
+ name: stringField(row, 'name'),
+ tbl_name: stringField(row, 'tbl_name'),
+ sql: normalizeSql(stringField(row, 'sql')),
}
})
+}
- // The last index that is a valid `turn/end` — holes through a closed turn
- // are always committed corruption.
- let lastTurnEnd = -1
- for (let i = parsed.length - 1; i >= 0; i--) {
- if (parsed[i]?.ok && rows[i]?.type === 'turn/end') { lastTurnEnd = i; break }
+function normalizeSql(value: string): string {
+ return value.replaceAll(/\s+/gu, ' ').trim()
+}
+
+function validateRequiredSchema(
+ Database: DatabaseSyncConstructor,
+ db: DatabaseSync,
+ path: string,
+): void {
+ if (JSON.stringify(schemaObjects(db)) !== JSON.stringify(expectedSchema(Database))) {
+ throw new Error(`session database at "${path}" does not contain the required schema objects`)
+ }
+}
+
+/**
+ * Recheck schema ownership inside the caller's mutation transaction.
+ * @param Database - constructor used to validate the canonical schema.
+ * @param db - open owned database with an active immediate transaction.
+ * @param path - database location used in ownership diagnostics.
+ * @throws when another writer changed the application identity, schema, or version.
+ */
+export function validateSchemaForMutation(
+ Database: DatabaseSyncConstructor,
+ db: DatabaseSync,
+ path: string,
+): void {
+ const version = integerField(db.prepare(sql('select-user-version')).get(), 'user_version')
+ const applicationId = integerField(db.prepare(sql('select-application-id')).get(), 'application_id')
+ if (applicationId !== SESSION_PERSISTENCE_SQLITE_APPLICATION_ID) {
+ throw new Error(
+ `session database application id changed before mutation (expected ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}, got ${applicationId})`,
+ )
+ }
+ validateRequiredSchema(Database, db, path)
+ if (version !== SCHEMA_VERSION) {
+ throw new Error(`session database schema changed before mutation (expected ${SCHEMA_VERSION}, got ${version})`)
+ }
+}
+
+/**
+ * Decode and validate one durable session row.
+ * @param value - value returned by SQLite.
+ * @returns a validated session row.
+ */
+export function decodeSessionRow(value: unknown): SessionRow {
+ const row = record(value, 'stored session metadata')
+ const id = nonemptyStringField(row, 'id')
+ const version = safeIntegerField(row, 'version')
+ const cwd = nullableStringField(row, 'cwd')
+ if (cwd !== null && !isAbsolute(cwd)) throw new Error('stored session cwd must be absolute')
+ const parent = nullableStringField(row, 'parent_session')
+ const origin = nullableStringField(row, 'origin')
+ if (origin !== null && origin !== 'subagent') throw new Error('stored session origin must be subagent or null')
+ const incarnation = nonemptyStringField(row, 'incarnation')
+ if (!UUID.test(incarnation)) throw new Error('stored session incarnation must be a UUID')
+ return {
+ id,
+ version,
+ created_at: nonnegativeSafeIntegerField(row, 'created_at'),
+ cwd,
+ parent_session: parent,
+ seed_length: nullableNonnegativeSafeIntegerField(row, 'seed_length'),
+ origin,
+ delegation_depth: nullableNonnegativeSafeIntegerField(row, 'delegation_depth'),
+ agent_preset: nullableStringField(row, 'agent_preset'),
+ incarnation,
+ revision: nonnegativeSafeIntegerField(row, 'revision'),
+ }
+}
+
+/**
+ * Decode and validate one durable event row before JSON interpretation.
+ * @param value - value returned by SQLite.
+ * @returns a validated physical event row.
+ */
+export function decodeEventRow(value: unknown): EventRow {
+ const row = record(value, 'stored event')
+ const ignorable = nullableSafeIntegerField(row, 'ignorable')
+ if (ignorable !== null && ignorable !== 0 && ignorable !== 1) {
+ throw new Error('stored event ignorable must be 0, 1, or null')
}
+ return {
+ seq: nonnegativeSafeIntegerField(row, 'seq'),
+ type: nonemptyStringField(row, 'type'),
+ time: safeIntegerField(row, 'time'),
+ data: stringOrBlobField(row, 'data'),
+ source_event_seqs: nullableBlobField(row, 'source_event_seqs'),
+ surface_op: nullableStringField(row, 'surface_op'),
+ ignorable,
+ }
+}
+
+/**
+ * Validate the singleton identity read from durable storage.
+ * @param value - value returned by SQLite.
+ * @returns the UUID store identity.
+ */
+export function decodeStoreIdentity(value: unknown): string {
+ const identity = nonemptyStringField(value, 'store_id')
+ if (!UUID.test(identity)) throw new Error('stored store_id must be a UUID')
+ return identity
+}
- // Preserve the contiguous prefix, including a complete interrupted turn;
- // holes through the last committed boundary throw, while later holes stop.
- const preserved: SessionEvent[] = []
- for (let i = 0; i < rows.length; i++) {
- const p = parsed[i]
- if (!p?.ok || p.event === undefined) {
- if (i <= lastTurnEnd) throw new Error(`corrupt session log: unparsable committed event at seq ${rows[i]?.seq}`)
- break // torn tail fragment after the last turn/end — stop, tolerate
- }
- if (p.event.seq !== base + i) {
- if (i <= lastTurnEnd) throw new Error(`corrupt session log: seq gap in committed region (expected ${base + i}, got ${p.event.seq})`)
- break // gap after the last turn/end — torn tail, stop
- }
- preserved.push(p.event)
+/**
+ * Reconstruct an immutable session header from a validated metadata row.
+ * @param row - validated stored metadata row.
+ * @returns the session header.
+ */
+export function rowToMeta(row: SessionRow): SessionHeader {
+ return {
+ version: row.version,
+ id: SessionId(row.id),
+ createdAt: row.created_at,
+ ...row.cwd === null ? {} : { cwd: row.cwd },
+ ...row.parent_session === null ? {} : { parentSession: SessionId(row.parent_session) },
+ ...row.seed_length === null ? {} : { seedLength: row.seed_length },
+ ...row.origin === null ? {} : { origin: row.origin },
+ ...row.delegation_depth === null ? {} : { delegationDepth: row.delegation_depth },
+ ...row.agent_preset === null ? {} : { agentPreset: row.agent_preset },
}
+}
+
+function record(value: unknown, label: string): Record {
+ if (typeof value !== 'object' || value === null) throw new Error(`${label} must be an object`)
+ return value as Record
+}
+
+function stringField(value: unknown, key: string): string {
+ const field = record(value, 'SQLite row')[key]
+ if (typeof field !== 'string') throw new Error(`stored ${key} must be a string`)
+ return field
+}
+
+function nonemptyStringField(value: unknown, key: string): string {
+ const field = stringField(value, key)
+ if (field.length === 0) throw new Error(`stored ${key} must not be empty`)
+ return field
+}
+
+function nullableStringField(value: unknown, key: string): string | null {
+ const field = record(value, 'SQLite row')[key]
+ if (field === null) return null
+ if (typeof field !== 'string') throw new Error(`stored ${key} must be a string or null`)
+ return field
+}
+
+function stringOrBlobField(value: unknown, key: string): string | Uint8Array {
+ const field = record(value, 'SQLite row')[key]
+ if (typeof field === 'string' || field instanceof Uint8Array) return field
+ throw new Error(`stored ${key} must be a string or blob`)
+}
+
+function nullableBlobField(value: unknown, key: string): Uint8Array | null {
+ const field = record(value, 'SQLite row')[key]
+ if (field === null || field instanceof Uint8Array) return field
+ throw new Error(`stored ${key} must be a blob or null`)
+}
+
+function integerField(value: unknown, key: string): number {
+ const field = record(value, 'SQLite row')[key]
+ if (!Number.isSafeInteger(field)) throw new Error(`stored ${key} must be a safe integer`)
+ return field as number
+}
+
+function safeIntegerField(value: unknown, key: string): number {
+ return integerField(value, key)
+}
+
+function nonnegativeSafeIntegerField(value: unknown, key: string): number {
+ const field = integerField(value, key)
+ if (field < 0) throw new Error(`stored ${key} must be non-negative`)
+ return field
+}
+
+function nullableSafeIntegerField(value: unknown, key: string): number | null {
+ const field = record(value, 'SQLite row')[key]
+ if (field === null) return null
+ if (!Number.isSafeInteger(field)) throw new Error(`stored ${key} must be a safe integer or null`)
+ return field as number
+}
- // Any rows past the preserved prefix are a never-committed torn tail; their
- // first seq is the deletion point for load's physical repair.
- return preserved.length < rows.length ? { preserved, tornFrom: base + preserved.length } : { preserved }
+function nullableNonnegativeSafeIntegerField(value: unknown, key: string): number | null {
+ const field = nullableSafeIntegerField(value, key)
+ if (field !== null && field < 0) throw new Error(`stored ${key} must be non-negative or null`)
+ return field
}
diff --git a/packages/session/session-persistence-sqlite/src/sql.ts b/packages/session/session-persistence-sqlite/src/sql.ts
new file mode 100644
index 0000000000..5d14855723
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/src/sql.ts
@@ -0,0 +1,65 @@
+/**
+ * Closed, package-owned SQL resource loading for SQLite.
+ * @module @deepseek-ai/dsh-session-persistence-sqlite/sql
+ */
+
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+
+const SQL_RESOURCES = [
+ 'begin',
+ 'begin-immediate',
+ 'commit',
+ 'delete-events-from',
+ 'foreign-keys-on',
+ 'insert-event',
+ 'insert-persistence-state',
+ 'journal-mode-delete',
+ 'journal-mode-persist',
+ 'journal-mode-truncate',
+ 'journal-mode-wal',
+ 'mmap-off',
+ 'rollback',
+ 'schema',
+ 'select-application-id',
+ 'select-events',
+ 'select-events-from',
+ 'select-mmap-size',
+ 'select-packed-predecessors',
+ 'select-schema-objects',
+ 'select-session',
+ 'select-sessions',
+ 'select-store-id',
+ 'select-synchronous',
+ 'select-tail-events',
+ 'select-trusted-schema',
+ 'select-user-object-count',
+ 'select-user-version',
+ 'set-application-id',
+ 'set-user-version-17',
+ 'synchronous-full',
+ 'trusted-schema-off',
+ 'update-session-revision',
+ 'upsert-session',
+] as const
+
+/** A resource basename selected exclusively by package code. */
+export type SqlResourceName = typeof SQL_RESOURCES[number]
+
+const cache = new Map()
+
+/**
+ * Load an immutable SQL statement by closed resource name.
+ * @param name - package-owned resource basename.
+ * @returns the resource text.
+ */
+export function sql(name: SqlResourceName): string {
+ const cached = cache.get(name)
+ if (cached !== undefined) return cached
+ const statement = readFileSync(
+ fileURLToPath(new URL(`../resources/sql/${name}.sql`, import.meta.url)),
+ 'utf8',
+ )
+ cache.set(name, statement)
+ return statement
+}
diff --git a/packages/session/session-persistence-sqlite/src/store.ts b/packages/session/session-persistence-sqlite/src/store.ts
new file mode 100644
index 0000000000..8e3e578cce
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/src/store.ts
@@ -0,0 +1,470 @@
+/**
+ * SQLite storage primitives: transactional append-batch packing, physical
+ * reads, schema validation, revisions, repair, and lifecycle closure.
+ * @module @deepseek-ai/dsh-session-persistence-sqlite/store
+ */
+
+import { randomUUID } from 'node:crypto'
+import { statSync } from 'node:fs'
+import { lstat, mkdir, open } from 'node:fs/promises'
+import { dirname, resolve } from 'node:path'
+import type { DatabaseSync, StatementSync } from 'node:sqlite'
+import {
+ type SessionEvent,
+ type SessionHeader,
+ type SessionId,
+} from '@deepseek-ai/dsh-session'
+import {
+ SessionPersistenceRevision,
+ type PersistenceBackend,
+ type SessionPersistenceRevision as PersistenceRevision,
+ type SessionPersistenceSnapshot,
+ type StoredPrefix,
+ type StoredSuffix,
+} from '@deepseek-ai/dsh-session-persistence'
+import {
+ MAX_PACKED_ROW_MEMBERS,
+ packChunkRuns,
+} from './codec.ts'
+import {
+ bindRecord,
+ decodeRow,
+ scanRows,
+ type BoundRecord,
+} from './compression.ts'
+import {
+ type EventRow,
+ type JournalMode,
+ decodeEventRow,
+ decodeSessionRow,
+ decodeStoreIdentity,
+ openDatabase,
+ validateSchemaForMutation,
+ rowToMeta,
+ type SessionRow,
+} from './schema.ts'
+import { sql } from './sql.ts'
+
+/** Storage options resolved by the service provider. */
+export interface SqliteStoreOptions {
+ readonly path: string
+ readonly journalMode: JournalMode
+ readonly busyTimeoutMs: number
+}
+
+/** SQLite implementation of the coordinator's physical backend hooks. */
+export class SqliteStore implements PersistenceBackend {
+ readonly name = 'session-persistence-sqlite'
+ private db!: DatabaseSync
+ private databaseConstructor!: typeof import('node:sqlite')['DatabaseSync']
+ private storeIdentity!: string
+ private databasePath!: string
+ private opened = false
+ private pathReady: Promise | undefined
+ private ready: Promise | undefined
+
+ constructor(private readonly options: SqliteStoreOptions) {}
+
+ /**
+ * Validate filesystem ownership without importing or opening Node SQLite.
+ * @returns settlement of the store's one path-validation operation.
+ */
+ validatePath(): Promise {
+ this.pathReady ??= this.preparePath(this.options.path)
+ return this.pathReady
+ }
+
+ /**
+ * Lazily open and validate the database on first persistence use.
+ * @returns settlement of the store's one database-open operation.
+ */
+ open(): Promise {
+ this.ready ??= this.openDb()
+ return this.ready
+ }
+
+ private async preparePath(path: string): Promise {
+ const actual = path === ':memory:' ? path : resolve(path)
+ if (actual !== ':memory:') {
+ await mkdir(dirname(actual), { recursive: true, mode: 0o700 })
+ await validateParentDirectory(dirname(actual))
+ await validateDatabaseFileIfPresent(actual)
+ }
+ this.databasePath = actual
+ }
+
+ private async openDb(): Promise {
+ await this.validatePath()
+ if (this.databasePath !== ':memory:') {
+ await createDatabaseFile(this.databasePath)
+ await validateDatabaseFile(this.databasePath)
+ }
+ const { DatabaseSync } = await loadNodeSqlite()
+ this.databaseConstructor = DatabaseSync
+ this.db = await openDatabase(
+ DatabaseSync,
+ this.databasePath,
+ this.options.journalMode,
+ this.options.busyTimeoutMs,
+ )
+ try {
+ const row = this.db.prepare(sql('select-store-id')).get()
+ if (row === undefined) {
+ throw new Error(`session database at "${this.databasePath}" has no valid store identity`)
+ }
+ let storeId: string
+ try {
+ storeId = decodeStoreIdentity(row)
+ } catch (error: unknown) {
+ throw new Error(`session database at "${this.databasePath}" has no valid store identity`, { cause: error })
+ }
+ if (this.databasePath === ':memory:') {
+ this.storeIdentity = `memory:store:${storeId}`
+ } else {
+ const identity = statSync(this.databasePath, { bigint: true })
+ this.storeIdentity = `file:${identity.dev}:${identity.ino}:${identity.birthtimeNs}:store:${storeId}`
+ }
+ this.opened = true
+ } catch (error: unknown) {
+ this.db.close()
+ throw error
+ }
+ }
+
+ async loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> {
+ await this.observe(signal)
+ const snapshot = this.readTransaction(() => {
+ const row = this.rowFor(id)
+ if (row === undefined) return undefined
+ const eventRows = this.db.prepare(sql('select-events')).all(id).map(decodeEventRow)
+ return { row, eventRows }
+ })
+ signal?.throwIfAborted()
+ if (snapshot === undefined) return undefined
+ const scanned = scanRows(snapshot.eventRows)
+ return {
+ meta: rowToMeta(snapshot.row),
+ events: scanned.preserved,
+ revision: sqliteRevision(this.storeIdentity, snapshot.row),
+ ...scanned.tornFrom === undefined ? {} : { tornMarker: scanned.tornFrom },
+ }
+ }
+
+ async readStoredRevision(id: SessionId, signal?: AbortSignal): Promise {
+ await this.observe(signal)
+ const row = this.rowFor(id)
+ signal?.throwIfAborted()
+ return row === undefined ? undefined : sqliteRevision(this.storeIdentity, row)
+ }
+
+ async loadStoredFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise {
+ await this.observe(signal)
+ const snapshot = this.readTransaction(() => {
+ const row = this.rowFor(id)
+ if (row === undefined) return undefined
+ return { row, ...this.physicalSpanFrom(id, fromSeq) }
+ })
+ signal?.throwIfAborted()
+ if (snapshot === undefined) return undefined
+ const { preserved } = scanRows(snapshot.eventRows, snapshot.base)
+ return { meta: rowToMeta(snapshot.row), events: preserved.filter(event => event.seq >= fromSeq) }
+ }
+
+ async appendBatch(
+ meta: SessionHeader,
+ events: readonly SessionEvent[],
+ isMaterialized: boolean,
+ ): Promise {
+ await this.open()
+ if (events.length === 0) return
+ this.db.exec(sql('begin-immediate'))
+ try {
+ validateSchemaForMutation(this.databaseConstructor, this.db, this.databasePath)
+ const tailRows = this.tailRows(meta.id)
+ const currentLast = this.logicalLastEvent(meta.id, tailRows)
+ const expected = currentLast === undefined ? 0 : currentLast.seq + 1
+ const first = events[0] as SessionEvent
+ if (first.seq !== expected) {
+ throw new Error(`session ${meta.id} append starts at seq ${first.seq}, stored next seq is ${expected}`)
+ }
+ if (!isMaterialized) this.writeRow(meta)
+
+ const insert = this.insertStatement()
+ for (const record of packChunkRuns(events)) this.insertRecord(insert, meta.id, bindRecord(record))
+ this.incrementRevision(meta.id)
+ this.db.exec(sql('commit'))
+ } catch (error: unknown) {
+ this.rollback(error, 'append')
+ }
+ }
+
+ async commitRepair(
+ meta: SessionHeader,
+ tornMarker: number | undefined,
+ closers: readonly SessionEvent[],
+ ): Promise {
+ await this.open()
+ if (tornMarker === undefined && closers.length === 0) return
+ this.db.exec(sql('begin-immediate'))
+ try {
+ validateSchemaForMutation(this.databaseConstructor, this.db, this.databasePath)
+ const row = this.rowFor(meta.id)
+ if (row === undefined) throw new Error(`session ${meta.id} metadata row is missing`)
+ const currentRows = this.db.prepare(sql('select-events')).all(meta.id).map(decodeEventRow)
+ const current = scanRows(currentRows)
+ if (tornMarker !== undefined) {
+ if (current.tornFrom !== tornMarker) {
+ throw new Error(`session ${meta.id} repair is stale: physical tail no longer starts at seq ${tornMarker}`)
+ }
+ this.db.prepare(sql('delete-events-from'))
+ .run(meta.id, tornMarker)
+ } else if (current.tornFrom !== undefined) {
+ throw new Error(`session ${meta.id} repair omitted current torn tail at seq ${current.tornFrom}`)
+ }
+ if (closers.length > 0) {
+ const expected = current.preserved.at(-1)?.seq === undefined
+ ? 0
+ : (current.preserved.at(-1) as SessionEvent).seq + 1
+ if (closers[0]?.seq !== expected) {
+ throw new Error(`session ${meta.id} repair is stale: closer starts at seq ${closers[0]?.seq}, stored next seq is ${expected}`)
+ }
+ const insert = this.insertStatement()
+ for (const closer of closers) this.insertRecord(insert, meta.id, bindRecord(closer))
+ }
+ this.incrementRevision(meta.id)
+ this.db.exec(sql('commit'))
+ } catch (error: unknown) {
+ this.rollback(error, 'repair')
+ }
+ }
+
+ async list(signal?: AbortSignal): Promise {
+ await this.observe(signal)
+ const rows = this.sessionRows()
+ signal?.throwIfAborted()
+ return rows.map(rowToMeta)
+ }
+
+ /**
+ * Return every materialized header with its source-qualified revision.
+ * @param signal - optional cancellation before or after the metadata query.
+ * @returns stored headers and revisions without loading event rows.
+ */
+ async listSnapshots(signal?: AbortSignal): Promise {
+ await this.observe(signal)
+ const rows = this.sessionRows()
+ signal?.throwIfAborted()
+ return rows.map(row => ({
+ header: rowToMeta(row),
+ revision: sqliteRevision(this.storeIdentity, row),
+ }))
+ }
+
+ async close(): Promise {
+ if (this.ready === undefined) {
+ if (this.pathReady !== undefined) await Promise.allSettled([this.pathReady])
+ return
+ }
+ await Promise.allSettled([this.ready])
+ if (!this.opened) return
+ this.opened = false
+ this.db.close()
+ }
+
+ private rowFor(id: SessionId): SessionRow | undefined {
+ const value = this.db.prepare(sql('select-session')).get(id)
+ return value === undefined ? undefined : decodeSessionRow(value)
+ }
+
+ private async observe(signal: AbortSignal | undefined): Promise {
+ signal?.throwIfAborted()
+ await this.open()
+ signal?.throwIfAborted()
+ }
+
+ private readTransaction(read: () => T): T {
+ this.db.exec(sql('begin'))
+ try {
+ const value = read()
+ this.db.exec(sql('commit'))
+ return value
+ } catch (error: unknown) {
+ this.rollback(error, 'read')
+ }
+ }
+
+ private sessionRows(): SessionRow[] {
+ return this.db.prepare(sql('select-sessions')).all().map(decodeSessionRow)
+ }
+
+ private rollback(error: unknown, operation: string): never {
+ try {
+ this.db.exec(sql('rollback'))
+ } catch (rollbackError: unknown) {
+ /* v8 ignore next -- requires SQLite to fail both an operation and its immediate rollback. */
+ throw new AggregateError([error, rollbackError], `${this.name} ${operation} failed and rollback also failed`)
+ }
+ throw error
+ }
+
+ private incrementRevision(id: SessionId): void {
+ const updated = this.db.prepare(sql('update-session-revision'))
+ .run(id)
+ /* v8 ignore next -- materialized writes follow coordinator create(); other writes upsert in this transaction. */
+ if (Number(updated.changes) !== 1) throw new Error(`session ${id} metadata row is missing`)
+ }
+
+ private tailRows(id: SessionId): EventRow[] {
+ const tail = this.db.prepare(sql('select-tail-events')).all(id, 2).map(decodeEventRow).reverse()
+ if (tail.length === 0) return []
+ return this.physicalSpanFrom(id, (tail[0] as EventRow).seq).eventRows
+ }
+
+ /** Select the bounded physical span that may represent `fromSeq`. */
+ private physicalSpanFrom(
+ id: SessionId,
+ fromSeq: number,
+ ): { readonly base: number; readonly eventRows: EventRow[] } {
+ const packedFloor = Math.max(0, fromSeq - MAX_PACKED_ROW_MEMBERS + 1)
+ const packedPredecessors = this.db.prepare(sql('select-packed-predecessors'))
+ .all(id, packedFloor, fromSeq)
+ .map(decodeEventRow)
+ let base = fromSeq
+ for (const predecessor of packedPredecessors) {
+ try {
+ const last = decodeRow(predecessor).at(-1)
+ if (last !== undefined && last.seq >= fromSeq) base = Math.min(base, predecessor.seq)
+ } catch {
+ // A malformed bounded predecessor may cover fromSeq; include it so the scanner fails closed.
+ base = Math.min(base, predecessor.seq)
+ }
+ }
+ const eventRows = this.db.prepare(sql('select-events-from')).all(id, base).map(decodeEventRow)
+ return { base, eventRows }
+ }
+
+ private logicalLastEvent(id: SessionId, tailRows: readonly EventRow[]): SessionEvent | undefined {
+ if (tailRows.length === 0) return undefined
+ const { preserved, tornFrom } = scanRows(tailRows, (tailRows[0] as EventRow).seq)
+ if (tornFrom !== undefined) throw new Error(`session ${id} has an invalid physical tail at seq ${tornFrom}`)
+ return preserved.at(-1)
+ }
+
+ private insertStatement(): StatementSync {
+ return this.db.prepare(sql('insert-event'))
+ }
+
+ private insertRecord(insert: StatementSync, id: SessionId, record: BoundRecord): void {
+ insert.run(
+ id,
+ record.seq,
+ record.type,
+ record.time,
+ record.data,
+ record.sourceEventSeqs,
+ record.surfaceOp,
+ record.ignorable,
+ )
+ }
+
+ private writeRow(meta: SessionHeader): void {
+ this.db.prepare(sql('upsert-session')).run(
+ meta.id,
+ meta.version,
+ meta.createdAt,
+ meta.cwd ?? null,
+ meta.parentSession ?? null,
+ meta.seedLength ?? null,
+ meta.origin ?? null,
+ meta.delegationDepth ?? null,
+ meta.agentPreset ?? null,
+ randomUUID(),
+ )
+ }
+}
+
+function sqliteRevision(storeIdentity: string, row: SessionRow): PersistenceRevision {
+ return SessionPersistenceRevision(
+ `${storeIdentity}:incarnation:${row.incarnation}:revision:${row.revision}`,
+ )
+}
+
+async function createDatabaseFile(path: string): Promise {
+ try {
+ const handle = await open(path, 'wx', 0o600)
+ await handle.close()
+ } catch (error: unknown) {
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
+ }
+}
+
+async function validateParentDirectory(path: string): Promise {
+ const parent = await lstat(path)
+ if (parent.isSymbolicLink() || !parent.isDirectory()) {
+ throw new Error(`session database parent "${path}" must be a real directory`)
+ }
+ const uid = process.getuid?.()
+ /* v8 ignore start -- Windows exposes neither process.getuid nor meaningful
+ * uid/mode bits; POSIX tests cover owner and mode rejection. */
+ if (uid !== undefined && (parent.uid !== uid || (parent.mode & 0o022) !== 0)) {
+ throw new Error(`session database parent "${path}" must be owned by the current user and not group/world-writable`)
+ }
+ /* v8 ignore stop */
+}
+
+async function validateDatabaseFile(path: string): Promise {
+ const file = await lstat(path)
+ if (file.isSymbolicLink() || !file.isFile()) {
+ throw new Error(`session database "${path}" must be a regular file, not a symbolic link`)
+ }
+ const uid = process.getuid?.()
+ /* v8 ignore start -- Windows exposes neither process.getuid nor meaningful
+ * uid/mode bits; POSIX tests cover owner and mode rejection. */
+ if (uid !== undefined && (file.uid !== uid || (file.mode & 0o077) !== 0)) {
+ throw new Error(`session database "${path}" must be owned by the current user and accessible only by that user`)
+ }
+ /* v8 ignore stop */
+}
+
+async function validateDatabaseFileIfPresent(path: string): Promise {
+ try {
+ await validateDatabaseFile(path)
+ } catch (error: unknown) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+ }
+}
+
+let nodeSqlite: Promise | undefined
+
+/** Load Node SQLite once so concurrent stores share one warning-filter lifetime. */
+function loadNodeSqlite(): Promise {
+ nodeSqlite ??= importNodeSqlite()
+ return nodeSqlite
+}
+
+/** Import Node 22's SQLite dependency without its process-wide experimental warning. */
+async function importNodeSqlite(): Promise {
+ const emitWarning = Reflect.get(process, 'emitWarning')
+ /* v8 ignore start -- Node 22 alone emits this warning; primary coverage runs on Node 24. */
+ const filteredEmitWarning = (warning: string | Error, ...args: unknown[]): void => {
+ const message = warning instanceof Error ? warning.message : warning
+ const first = args[0]
+ const type = warning instanceof Error
+ ? warning.name
+ : typeof first === 'string'
+ ? first
+ : typeof first === 'object' && first !== null && 'type' in first
+ ? first.type
+ : undefined
+ if (message === 'SQLite is an experimental feature and might change at any time'
+ && type === 'ExperimentalWarning') return
+ Reflect.apply(emitWarning, process, [warning, ...args])
+ }
+ Reflect.set(process, 'emitWarning', filteredEmitWarning)
+ try {
+ return await import('node:sqlite')
+ } finally {
+ Reflect.set(process, 'emitWarning', emitWarning)
+ }
+ /* v8 ignore stop */
+}
diff --git a/packages/session/session-persistence-sqlite/tests/built-package.spec.ts b/packages/session/session-persistence-sqlite/tests/built-package.spec.ts
new file mode 100644
index 0000000000..ebbe2fc691
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/built-package.spec.ts
@@ -0,0 +1,36 @@
+import { execFile } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { promisify } from 'node:util'
+import { describe, expect, it } from 'vitest'
+
+const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
+const builtBundle = fileURLToPath(new URL('../lib/index.js', import.meta.url))
+const execFileAsync = promisify(execFile)
+
+const probe = String.raw`
+import { resolve } from 'node:path';
+import { pathToFileURL } from 'node:url';
+const load = path => import(pathToFileURL(resolve(path)).href);
+const [{ Context }, { default: SessionStore }, { default: Sqlite }] = await Promise.all([
+ load('vendor/cordis/lib/index.js'),
+ load('packages/core/session/lib/index.js'),
+ load('packages/session/session-persistence-sqlite/lib/index.js'),
+]);
+const ctx = new Context();
+await ctx.plugin(SessionStore);
+await ctx.plugin(Sqlite, { path: ':memory:' });
+console.log(JSON.stringify(await ctx.sessionPersistence.list()));
+await ctx.fiber.dispose();
+`
+
+describe.skipIf(!existsSync(builtBundle))('SQLite built package', () => {
+ it('loads packaged SQL resources from the published entry', async () => {
+ const { stdout, stderr } = await execFileAsync(process.execPath, ['--input-type=module', '-e', probe], {
+ cwd: repoRoot,
+ timeout: 15_000,
+ })
+ expect(stderr).toBe('')
+ expect(JSON.parse(stdout) as unknown).toEqual([])
+ })
+})
diff --git a/packages/session/session-persistence-sqlite/tests/compression-unprofitable.spec.ts b/packages/session/session-persistence-sqlite/tests/compression-unprofitable.spec.ts
new file mode 100644
index 0000000000..f0fc765393
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/compression-unprofitable.spec.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+
+vi.mock('node:zlib', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...actual,
+ zstdCompressSync: (input: ArrayBufferView) => Buffer.alloc(input.byteLength + 1),
+ }
+})
+
+import { bindRecord, ZSTD_DATA_THRESHOLD_BYTES } from '../src/compression.ts'
+
+describe('SQLite compression fallback', () => {
+ it('keeps large data as text when its Zstandard frame is not smaller', () => {
+ const event = {
+ type: 'assistant/message',
+ seq: 0,
+ time: 1,
+ data: { text: 'x'.repeat(ZSTD_DATA_THRESHOLD_BYTES) },
+ } as unknown as SessionEvent
+
+ expect(typeof bindRecord(event).data).toBe('string')
+ })
+})
diff --git a/packages/session/session-persistence-sqlite/tests/compression.spec.ts b/packages/session/session-persistence-sqlite/tests/compression.spec.ts
new file mode 100644
index 0000000000..93f4da380f
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/compression.spec.ts
@@ -0,0 +1,357 @@
+import { describe, expect, it } from 'vitest'
+import { zstdCompressSync } from 'node:zlib'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
+import {
+ decodeStorageRecord,
+ MAX_PACKED_DATA_BYTES,
+ MAX_PACKED_ROW_MEMBERS,
+ packChunkRuns,
+ type StorageRecord,
+} from '../src/codec.ts'
+import {
+ bindRecord,
+ decodeRow,
+ scanRows,
+ ZSTD_DATA_THRESHOLD_BYTES,
+} from '../src/compression.ts'
+import type { EventRow } from '../src/schema.ts'
+
+function chunk(seq: number, text = `token-${seq}`): SessionEvent {
+ return {
+ type: 'assistant/chunk',
+ seq,
+ time: 1_000 + seq,
+ data: {
+ turn: 1,
+ step: 1,
+ chunk: { type: 'text-delta', index: 0, text },
+ },
+ }
+}
+
+function event(seq: number, time: number, value: StreamChunk, turn = 1, step = 1): SessionEvent {
+ return { type: 'assistant/chunk', seq, time, data: { turn, step, chunk: value } }
+}
+
+function row(record: StorageRecord): EventRow {
+ const bound = bindRecord(record)
+ return {
+ seq: bound.seq,
+ type: bound.type,
+ time: bound.time,
+ data: bound.data,
+ source_event_seqs: bound.sourceEventSeqs,
+ surface_op: bound.surfaceOp,
+ ignorable: bound.ignorable,
+ }
+}
+
+describe('SQLite compression', () => {
+ it('stores a 100-member run in one row and restores every logical event', () => {
+ const events = Array.from({ length: 100 }, (_, index) => chunk(index))
+ const records = packChunkRuns(events)
+ expect(records).toHaveLength(1)
+ expect(records[0]?.type).toBe('text-chunks')
+ expect(scanRows(records.map(row)).preserved).toEqual(events)
+ })
+
+ it('partitions long and large runs within schema-owned row limits', () => {
+ const long = Array.from({ length: MAX_PACKED_ROW_MEMBERS + 3 }, (_, index) => chunk(index))
+ const longRecords = packChunkRuns(long)
+ expect(longRecords).toHaveLength(2)
+ expect(scanRows(longRecords.map(row)).preserved).toEqual(long)
+
+ const large = Array.from({ length: 4 }, (_, index) => chunk(index, 'x'.repeat(300_000)))
+ const largeRecords = packChunkRuns(large)
+ expect(largeRecords).toHaveLength(2)
+ for (const record of largeRecords) {
+ if (record.type.endsWith('-chunks')) {
+ expect(Buffer.byteLength(JSON.stringify(record.data))).toBeLessThanOrEqual(MAX_PACKED_DATA_BYTES)
+ }
+ }
+ expect(scanRows(largeRecords.map(row)).preserved).toEqual(large)
+
+ const individuallyLarge = Array.from({ length: 3 }, (_, index) => chunk(index, 'x'.repeat(400_000)))
+ expect(packChunkRuns(individuallyLarge)).toEqual(individuallyLarge)
+
+ const byteBound = Array.from({ length: 10 }, (_, index) => chunk(index, 'x'.repeat(150_000)))
+ const byteBoundRecords = packChunkRuns(byteBound)
+ expect(byteBoundRecords.length).toBeGreaterThan(1)
+ expect(scanRows(byteBoundRecords.map(row)).preserved).toEqual(byteBound)
+ })
+
+ it('packs every owned kind and preserves optional tool-call names', () => {
+ const events = [
+ ...[0, 1, 2].map(seq => event(seq, seq, { type: 'reasoning-delta', index: 1, text: `${seq}` })),
+ ...[3, 4, 5].map(seq => event(seq, seq, {
+ type: 'tool-call-delta', index: 2, id: CallId('named'), name: 'write', argumentsDelta: `${seq}`,
+ })),
+ ...[6, 7, 8].map(seq => event(seq, seq, {
+ type: 'tool-call-delta', index: 3, id: CallId('unnamed'), argumentsDelta: `${seq}`,
+ })),
+ ]
+ const records = packChunkRuns(events)
+ expect(records.map(record => record.type)).toEqual([
+ 'reasoning-chunks', 'tool-call-chunks', 'tool-call-chunks',
+ ])
+ expect(records.flatMap(decodeStorageRecord)).toEqual(events)
+ })
+
+ it('keeps every off-format delta scalar and splits incompatible runs', () => {
+ const malformed = (seq: number, data: unknown): SessionEvent => ({
+ type: 'assistant/chunk', seq, time: 10 + seq, data,
+ } as SessionEvent)
+ const values: SessionEvent[] = [
+ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+ { ...chunk(1), extra: true } as unknown as SessionEvent,
+ { ...chunk(-1), seq: -1 },
+ { ...chunk(3), time: 1.5 },
+ malformed(4, 'data'),
+ malformed(5, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' }, extra: 1 }),
+ malformed(6, { turn: '1', step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } }),
+ malformed(7, { turn: 1, step: 1, chunk: 'chunk' }),
+ malformed(8, { turn: 1, step: 1, chunk: { type: 'text-delta', index: '0', text: 'x' } }),
+ malformed(9, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 1 } }),
+ malformed(10, { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 1, argumentsDelta: 'x' } }),
+ malformed(11, { turn: 1, step: 1, chunk: { type: 'tool-call-delta', index: 0, id: 'id', name: 1, argumentsDelta: 'x' } }),
+ malformed(12, { turn: 1, step: 1, chunk: { type: 'usage', index: 0, totalTokens: 1 } }),
+ ]
+ expect(packChunkRuns(values)).toEqual(values)
+
+ const gap = [chunk(0), chunk(1), chunk(3)]
+ const step = [chunk(0), chunk(1), event(2, 2, { type: 'text-delta', index: 0, text: 'x' }, 1, 2)]
+ const block = [chunk(0), chunk(1), event(2, 2, { type: 'text-delta', index: 1, text: 'x' })]
+ const unsafeTime = [
+ event(0, Number.MIN_SAFE_INTEGER, { type: 'text-delta', index: 0, text: 'a' }),
+ event(1, Number.MAX_SAFE_INTEGER, { type: 'text-delta', index: 0, text: 'b' }),
+ event(2, Number.MAX_SAFE_INTEGER, { type: 'text-delta', index: 0, text: 'c' }),
+ ]
+ const toolName = [0, 1, 2].map(seq => event(seq, seq, {
+ type: 'tool-call-delta', index: 0, id: CallId('id'),
+ ...seq === 2 ? {} : { name: 'write' }, argumentsDelta: 'x',
+ }))
+ for (const events of [gap, step, block, unsafeTime, toolName]) {
+ expect(packChunkRuns(events)).toEqual(events)
+ }
+ })
+
+ it.each([
+ ['extra envelope field', { type: 'text-chunks', seq0: 0, time0: 1, data: {}, extra: true }],
+ ['negative sequence', { type: 'text-chunks', seq0: -1, time0: 1, data: {} }],
+ ['fractional time', { type: 'text-chunks', seq0: 0, time0: 1.5, data: {} }],
+ ['primitive data', { type: 'text-chunks', seq0: 0, time0: 1, data: 'bad' }],
+ ['text fields', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], args: [] } }],
+ ['non-numeric placement', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: '1', step: 1, index: 0, dt: [0, 0], texts: ['a', 'b', 'c'] } }],
+ ['non-array members', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: 'abc' } }],
+ ['too few members', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0], texts: ['a', 'b'] } }],
+ ['too many members', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: Array(1_024).fill(0), texts: Array(1_025).fill('a') } }],
+ ['non-string member', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['a', 1, 'c'] } }],
+ ['invalid gaps', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0.5], texts: ['a', 'b', 'c'] } }],
+ ['non-array gaps', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: '00', texts: ['a', 'b', 'c'] } }],
+ ['gap arity', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0], texts: ['a', 'b', 'c'] } }],
+ ['oversized data', { type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['x'.repeat(400_000), 'x'.repeat(400_000), 'x'.repeat(400_000)] } }],
+ ['sequence overflow', { type: 'text-chunks', seq0: Number.MAX_SAFE_INTEGER, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], texts: ['a', 'b', 'c'] } }],
+ ['time overflow', { type: 'text-chunks', seq0: 0, time0: Number.MAX_SAFE_INTEGER, data: { turn: 1, step: 1, index: 0, dt: [1, 0], texts: ['a', 'b', 'c'] } }],
+ ['tool fields', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [0, 0], args: ['a', 'b', 'c'] } }],
+ ['tool id', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, id: 1, dt: [0, 0], args: ['a', 'b', 'c'] } }],
+ ['tool name', { type: 'tool-call-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, id: 'id', name: 1, dt: [0, 0], args: ['a', 'b', 'c'] } }],
+ ])('rejects malformed packed data: %s', (_label, record) => {
+ expect(() => decodeStorageRecord(record)).toThrow(/malformed .* storage row/)
+ })
+
+ it('decodes the schema-17 row vocabulary without another package codec', () => {
+ const fixture: EventRow = {
+ seq: 7,
+ type: 'text-chunks',
+ time: 90,
+ data: JSON.stringify({ turn: 2, step: 3, index: 1, dt: [2, -1], texts: ['a', 'b', 'c'] }),
+ source_event_seqs: null,
+ surface_op: null,
+ ignorable: 0,
+ }
+ expect(decodeRow(fixture)).toEqual([
+ { ...chunk(7, 'a'), time: 90, data: { turn: 2, step: 3, chunk: { type: 'text-delta', index: 1, text: 'a' } } },
+ { ...chunk(8, 'b'), time: 92, data: { turn: 2, step: 3, chunk: { type: 'text-delta', index: 1, text: 'b' } } },
+ { ...chunk(9, 'c'), time: 91, data: { turn: 2, step: 3, chunk: { type: 'text-delta', index: 1, text: 'c' } } },
+ ])
+ expect(decodeStorageRecord('scalar')).toEqual(['scalar'])
+ expect(decodeStorageRecord(chunk(0))).toEqual([chunk(0)])
+ })
+
+ it('rejects surface columns on packed rows', () => {
+ const packed = row(packChunkRuns([chunk(0), chunk(1), chunk(2)])[0]!)
+ const invalid: EventRow[] = [
+ { ...packed, source_event_seqs: Buffer.alloc(0) },
+ { ...packed, surface_op: '"append"' },
+ ]
+ for (const candidate of invalid) {
+ expect(() => decodeRow(candidate)).toThrow(/surface fields must be null/)
+ }
+ })
+
+ it('rejects the packed discriminator on a scalar event type', () => {
+ const scalar = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } })
+ expect(() => decodeRow({ ...scalar, ignorable: 0 }))
+ .toThrow(/packed discriminator requires a chunk tag/)
+ })
+
+ it.each(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])(
+ 'preserves an ignorable logical event named %s as a scalar row',
+ (type) => {
+ const logical = {
+ type,
+ seq: 0,
+ time: 1,
+ data: { future: true },
+ ignorable: true,
+ } as unknown as SessionEvent
+ const physical = row(logical)
+ expect(physical.ignorable).toBe(1)
+ expect(decodeRow(physical)).toEqual([logical])
+ },
+ )
+
+ it('compresses large data and delta-encodes complete provenance arrays', () => {
+ const sources = Array.from({ length: 2_000 }, (_, index) => index + 10)
+ const event = {
+ type: 'assistant/message',
+ seq: sources.at(-1)! + 1,
+ time: 1,
+ data: { text: 'x'.repeat(ZSTD_DATA_THRESHOLD_BYTES * 2) },
+ sourceEventSeqs: sources,
+ surfaceOp: 'append',
+ } as unknown as SessionEvent
+ const bound = bindRecord(event)
+ expect(bound.data).toBeInstanceOf(Uint8Array)
+ expect(bound.sourceEventSeqs).toBeInstanceOf(Uint8Array)
+ expect(bound.sourceEventSeqs?.byteLength).toBeLessThan(Buffer.byteLength(JSON.stringify(sources)))
+ expect(decodeRow(row(event))).toEqual([event])
+
+ const small = bindRecord({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } })
+ expect(typeof small.data).toBe('string')
+ })
+
+ it('round-trips empty, descending, and maximum-safe provenance deltas', () => {
+ for (const sources of [
+ [],
+ [Number.MAX_SAFE_INTEGER - 1, 0, Number.MAX_SAFE_INTEGER - 2],
+ ]) {
+ const event = {
+ type: 'assistant/message',
+ seq: Number.MAX_SAFE_INTEGER,
+ time: 1,
+ data: {},
+ sourceEventSeqs: sources,
+ surfaceOp: 'append',
+ } as unknown as SessionEvent
+ expect(decodeRow(row(event))).toEqual([event])
+ }
+ })
+
+ it.each([-1, 0.5])('rejects invalid provenance sequence %s before encoding', (sourceSeq) => {
+ const event = {
+ type: 'assistant/message',
+ seq: 1,
+ time: 1,
+ data: {},
+ sourceEventSeqs: [sourceSeq],
+ surfaceOp: 'append',
+ } as unknown as SessionEvent
+ expect(() => bindRecord(event)).toThrow(/non-negative safe integers/)
+ })
+
+ it('rejects malformed compressed and delta-encoded values', () => {
+ const scalar = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } })
+ expect(() => decodeRow({ ...scalar, data: Buffer.from('not zstd') })).toThrow()
+ expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x80]) }))
+ .toThrow(/truncated varint/)
+ expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x80, 0x00]) }))
+ .toThrow(/non-canonical varint/)
+ expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([0x00, 0x01]) }))
+ .toThrow(/decoded seq is out of range/)
+ expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([
+ 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0f, 0x02,
+ ]) })).toThrow(/decoded seq is out of range/)
+ expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.from([
+ 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x10,
+ ]) })).toThrow(/varint is out of range/)
+ expect(() => decodeRow({ ...scalar, source_event_seqs: Buffer.alloc(9, 0x80) }))
+ .toThrow(/varint is out of range/)
+ })
+
+ it('rejects an oversized packed data column before JSON decoding', () => {
+ const oversized: EventRow = {
+ seq: 0,
+ type: 'text-chunks',
+ time: 1,
+ data: ' '.repeat(MAX_PACKED_DATA_BYTES + 1),
+ source_event_seqs: null,
+ surface_op: null,
+ ignorable: 0,
+ }
+ expect(() => decodeRow(oversized)).toThrow(/data exceeds/)
+ })
+
+ it('bounds packed data while decompressing', () => {
+ const serialized = JSON.stringify({
+ turn: 1,
+ step: 1,
+ index: 0,
+ dt: [0, 0],
+ texts: ['x'.repeat(MAX_PACKED_DATA_BYTES), 'b', 'c'],
+ })
+ const oversized: EventRow = {
+ seq: 0,
+ type: 'text-chunks',
+ time: 1,
+ data: zstdCompressSync(serialized),
+ source_event_seqs: null,
+ surface_op: null,
+ ignorable: 0,
+ }
+ expect(() => decodeRow(oversized)).toThrow(/Buffer larger than/)
+ })
+
+ it('distinguishes removable and committed physical corruption', () => {
+ const start = row({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } })
+ const skipped = row({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } })
+ expect(scanRows([start, skipped])).toEqual({ preserved: [
+ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+ ], tornFrom: 2 })
+
+ const end = row({
+ type: 'turn/end',
+ seq: 3,
+ time: 3,
+ data: { turn: 1, reason: { kind: 'completed' } },
+ })
+ expect(() => scanRows([start, skipped, end])).toThrow(/invalid committed physical row at seq 2/)
+
+ const malformed = {
+ ...row(packChunkRuns([chunk(0), chunk(1), chunk(2)])[0]!),
+ data: '{not json',
+ }
+ const committedEnd = row({
+ type: 'turn/end',
+ seq: 1,
+ time: 4,
+ data: { turn: 1, reason: { kind: 'completed' } },
+ })
+ expect(() => scanRows([malformed, committedEnd]))
+ .toThrow(/invalid committed physical row at seq 0/)
+ })
+
+ it('treats a malformed packed tail as one removable physical row', () => {
+ const malformed: EventRow = {
+ seq: 0,
+ type: 'text-chunks',
+ time: 1,
+ data: JSON.stringify({ turn: 1, step: 1, index: 0, dt: [], texts: ['a', 'b'] }),
+ source_event_seqs: null,
+ surface_op: null,
+ ignorable: 0,
+ }
+ expect(scanRows([malformed])).toEqual({ preserved: [], tornFrom: 0 })
+ })
+})
diff --git a/packages/session/session-persistence-sqlite/tests/differential.spec.ts b/packages/session/session-persistence-sqlite/tests/differential.spec.ts
new file mode 100644
index 0000000000..45ec734992
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/differential.spec.ts
@@ -0,0 +1,273 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import fc from 'fast-check'
+import { Context } from '@deepseek-ai/cordis'
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { DatabaseSync } from 'node:sqlite'
+import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
+import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
+import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
+import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
+import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite'
+import { meta } from '../../session-persistence/tests/contract.ts'
+import { testSql } from './test-sql.ts'
+
+type BackendName = 'jsonl-zstd' | 'sqlite'
+
+interface MountedBackend {
+ readonly persistence: SessionPersistence
+ dispose(): Promise
+}
+
+const directories: string[] = []
+afterEach(async () => {
+ for (const directory of directories.splice(0)) {
+ await rm(directory, { recursive: true, force: true })
+ }
+})
+
+async function freshDirectory(prefix: string): Promise {
+ const directory = await mkdtemp(join(tmpdir(), prefix))
+ directories.push(directory)
+ return directory
+}
+
+async function mount(name: BackendName, root: string): Promise {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ switch (name) {
+ case 'jsonl-zstd': {
+ const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: join(root, 'jsonl') })
+ return { persistence: ctx.sessionPersistence, dispose: async () => { await fiber.dispose() } }
+ }
+ case 'sqlite': {
+ const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: join(root, 'sessions.db') })
+ return { persistence: ctx.sessionPersistence, dispose: async () => { await fiber.dispose() } }
+ }
+ }
+}
+
+function closedChunkLog(
+ entries: readonly { readonly chunk: StreamChunk; readonly time: number; readonly ignorable?: true }[],
+): SessionEvent[] {
+ const chunks = entries.map(({ chunk, time, ignorable }, index): SessionEvent => ({
+ type: 'assistant/chunk',
+ seq: index + 2,
+ time,
+ data: { turn: 1, step: 1, chunk },
+ ...ignorable === true ? { ignorable } : {},
+ }))
+ return [
+ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+ { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
+ ...chunks,
+ { type: 'step/end', seq: chunks.length + 2, time: 3, data: { turn: 1, step: 1 } },
+ {
+ type: 'turn/end',
+ seq: chunks.length + 3,
+ time: 4,
+ data: { turn: 1, reason: { kind: 'completed' } },
+ },
+ ]
+}
+
+function packingMatrixLog(): SessionEvent[] {
+ const entries: { chunk: StreamChunk; time: number; ignorable?: true }[] = [
+ ...Array.from({ length: 5 }, (_, index) => ({
+ chunk: { type: 'text-delta' as const, index: 0, text: `text-${index}` },
+ time: 1_000 + index,
+ })),
+ ...Array.from({ length: 4 }, (_, index) => ({
+ chunk: { type: 'reasoning-delta' as const, index: 1, text: `reason-${index}` },
+ time: 990 - index,
+ })),
+ ...Array.from({ length: 4 }, (_, index) => ({
+ chunk: {
+ type: 'tool-call-delta' as const,
+ index: 2,
+ id: CallId('named-call'),
+ name: 'write',
+ argumentsDelta: `{${index}`,
+ },
+ time: 2_000 + index,
+ })),
+ ...Array.from({ length: 3 }, (_, index) => ({
+ chunk: {
+ type: 'tool-call-delta' as const,
+ index: 3,
+ id: CallId('unnamed-call'),
+ argumentsDelta: `${index}}`,
+ },
+ time: 3_000 + index,
+ })),
+ { chunk: { type: 'block-start', index: 4, blockType: 'text' }, time: 4_000 },
+ { chunk: { type: 'text-delta', index: 4, text: 'short-a' }, time: 4_001 },
+ { chunk: { type: 'text-delta', index: 4, text: 'short-b' }, time: 4_002 },
+ { chunk: { type: 'text-delta', index: 5, text: 'scalar-envelope' }, time: 4_003, ignorable: true },
+ { chunk: { type: 'finish', reason: { kind: 'stop' } }, time: 4_004 },
+ ]
+ return closedChunkLog(entries)
+}
+
+function storageTagCollisionLog(): SessionEvent[] {
+ return [
+ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+ ...['text-chunks', 'reasoning-chunks', 'tool-call-chunks'].map((type, index) => ({
+ type,
+ seq: index + 1,
+ time: index + 2,
+ data: { future: true },
+ ignorable: true as const,
+ }) as unknown as SessionEvent),
+ { type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
+ ]
+}
+
+function batches(events: readonly SessionEvent[], sizes: readonly number[]): SessionEvent[][] {
+ const result: SessionEvent[][] = []
+ let offset = 0
+ let index = 0
+ while (offset < events.length) {
+ const size = sizes[index % sizes.length] as number
+ result.push(events.slice(offset, offset + size))
+ offset += size
+ index += 1
+ }
+ return result
+}
+
+async function verifyBackend(
+ name: BackendName,
+ root: string,
+ events: readonly SessionEvent[],
+ sizes: readonly number[],
+): Promise {
+ const header = { ...meta('differential', '/work'), delegationDepth: 0 }
+ let mounted = await mount(name, root)
+ try {
+ await mounted.persistence.create(header)
+ for (const batch of batches(events, sizes)) {
+ await mounted.persistence.append(header.id, batch)
+ }
+ expect(await mounted.persistence.inspect(header.id), name).toEqual({ meta: header, events })
+ expect(await mounted.persistence.list(), name).toEqual([header])
+ const revision = (await mounted.persistence.listSnapshots())[0]?.revision
+ for (let fromSeq = 0; fromSeq <= events.length + 1; fromSeq += 1) {
+ expect((await mounted.persistence.readFrom(header.id, fromSeq)).events, `${name} seq ${fromSeq}`)
+ .toEqual(events.slice(fromSeq))
+ }
+ expect((await mounted.persistence.listSnapshots())[0]?.revision, name).toBe(revision)
+ } finally {
+ await mounted.dispose()
+ }
+
+ mounted = await mount(name, root)
+ try {
+ expect(await mounted.persistence.inspect(header.id), `${name} reopen`).toEqual({ meta: header, events })
+ } finally {
+ await mounted.dispose()
+ }
+}
+
+const streamChunkArbitrary: fc.Arbitrary = fc.oneof(
+ fc.record({ type: fc.constant<'text-delta'>('text-delta'), index: fc.nat(2), text: fc.string() }),
+ fc.record({ type: fc.constant<'reasoning-delta'>('reasoning-delta'), index: fc.nat(2), text: fc.string() }),
+ fc.record({
+ type: fc.constant<'tool-call-delta'>('tool-call-delta'),
+ index: fc.nat(2),
+ id: fc.constantFrom(CallId('call-1'), CallId('call-2')),
+ argumentsDelta: fc.string(),
+ }),
+ fc.record({
+ type: fc.constant<'tool-call-delta'>('tool-call-delta'),
+ index: fc.nat(2),
+ id: fc.constantFrom(CallId('call-1'), CallId('call-2')),
+ name: fc.constantFrom('read', 'write'),
+ argumentsDelta: fc.string(),
+ }),
+ fc.record({
+ type: fc.constant<'block-start'>('block-start'),
+ index: fc.nat(2),
+ blockType: fc.constant<'text'>('text'),
+ }),
+ fc.record({ type: fc.constant<'finish'>('finish'), reason: fc.constant({ kind: 'stop' as const }) }),
+)
+
+const randomWorkload = fc.record({
+ entries: fc.array(fc.record({
+ chunk: streamChunkArbitrary,
+ time: fc.oneof(
+ { weight: 4, arbitrary: fc.integer({ min: 0, max: 10_000 }) },
+ { weight: 1, arbitrary: fc.integer({ min: Number.MIN_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER }) },
+ ),
+ ignorable: fc.option(fc.constant(true), { nil: undefined }),
+ }), { maxLength: 30 }),
+ batchSizes: fc.array(fc.integer({ min: 1, max: 8 }), { minLength: 1, maxLength: 8 }),
+}).map(({ entries, batchSizes }) => ({
+ events: JSON.parse(JSON.stringify(closedChunkLog(entries.map(({ chunk, time, ignorable }) => ({
+ chunk,
+ time,
+ ...ignorable === true ? { ignorable } : {},
+ }))))) as SessionEvent[],
+ batchSizes,
+}))
+
+describe('SQLite cross-backend differential behavior', () => {
+ it('preserves ignorable logical events whose names match physical storage tags', async () => {
+ const events = storageTagCollisionLog()
+ const directory = await freshDirectory('dsh-sqlite-storage-tag-collision-')
+ const root = join(directory, 'sqlite')
+ await verifyBackend('sqlite', root, events, [2, 1])
+ const db = new DatabaseSync(join(root, 'sessions.db'), { readOnly: true })
+ try {
+ expect(db.prepare(testSql('count-physical-types')).all()).toEqual([])
+ expect(db.prepare(testSql('count-ignorable-events')).get()).toEqual({ count: 3 })
+ } finally {
+ db.close()
+ }
+ })
+
+ it('matches JSONL/Zstandard for every packed kind, scalar fallback, suffix, partition, and reopen', async () => {
+ const events = packingMatrixLog()
+ for (const [partitionIndex, sizes] of [[events.length], [1], [2, 1, 5, 3]].entries()) {
+ const directory = await freshDirectory(`dsh-sqlite-matrix-${partitionIndex}-`)
+ for (const name of ['jsonl-zstd', 'sqlite'] as const) {
+ const root = join(directory, name)
+ await verifyBackend(name, root, events, sizes)
+ if (name === 'sqlite') {
+ const db = new DatabaseSync(join(root, 'sessions.db'), { readOnly: true })
+ try {
+ expect(db.prepare(testSql('count-physical-types')).all()).toEqual([
+ [
+ { type: 'reasoning-chunks', count: 1 },
+ { type: 'text-chunks', count: 1 },
+ { type: 'tool-call-chunks', count: 2 },
+ ],
+ [],
+ [
+ { type: 'reasoning-chunks', count: 1 },
+ { type: 'text-chunks', count: 1 },
+ { type: 'tool-call-chunks', count: 1 },
+ ],
+ ][partitionIndex])
+ expect(db.prepare(testSql('count-ignorable-events')).get())
+ .toEqual({ count: 1 })
+ } finally {
+ db.close()
+ }
+ }
+ }
+ }
+ }, 30_000)
+
+ it('matches JSONL/Zstandard across randomized logical logs and append partitions', async () => {
+ await fc.assert(fc.asyncProperty(randomWorkload, async ({ events, batchSizes }) => {
+ const directory = await freshDirectory('dsh-sqlite-property-')
+ for (const name of ['jsonl-zstd', 'sqlite'] as const) {
+ await verifyBackend(name, join(directory, name), events, batchSizes)
+ }
+ }), { numRuns: 100, seed: 0x5A17E })
+ }, 60_000)
+
+})
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/add-unexpected-column.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/add-unexpected-column.sql
new file mode 100644
index 0000000000..bc0d60887b
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/add-unexpected-column.sql
@@ -0,0 +1 @@
+ALTER TABLE events ADD COLUMN unexpected TEXT;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/count-events.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/count-events.sql
new file mode 100644
index 0000000000..b335590faa
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/count-events.sql
@@ -0,0 +1,2 @@
+SELECT COUNT(*) AS count
+FROM events;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/count-ignorable-events.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/count-ignorable-events.sql
new file mode 100644
index 0000000000..4de44570ec
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/count-ignorable-events.sql
@@ -0,0 +1,3 @@
+SELECT COUNT(*) AS count
+FROM events
+WHERE ignorable = 1;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/count-packed-events.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/count-packed-events.sql
new file mode 100644
index 0000000000..1c11b3dd0b
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/count-packed-events.sql
@@ -0,0 +1,3 @@
+SELECT COUNT(*) AS count
+FROM events
+WHERE type = 'text-chunks' AND ignorable = 0;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/count-physical-types.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/count-physical-types.sql
new file mode 100644
index 0000000000..ba5e7f9d72
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/count-physical-types.sql
@@ -0,0 +1,6 @@
+SELECT type, COUNT(*) AS count
+FROM events
+WHERE type IN ('text-chunks', 'reasoning-chunks', 'tool-call-chunks')
+ AND ignorable = 0
+GROUP BY type
+ORDER BY type;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/create-loose-schema.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/create-loose-schema.sql
new file mode 100644
index 0000000000..d63c09c540
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/create-loose-schema.sql
@@ -0,0 +1,14 @@
+CREATE TABLE persistence_state (singleton ANY, store_id ANY);
+CREATE TABLE sessions (
+ id ANY, version ANY, created_at ANY, cwd ANY, parent_session ANY,
+ seed_length ANY, origin ANY, delegation_depth ANY, agent_preset ANY,
+ incarnation ANY, revision ANY
+);
+CREATE TABLE events (
+ session_id ANY, seq ANY, type ANY, time ANY, data ANY,
+ source_event_seqs ANY, surface_op ANY, ignorable ANY
+);
+INSERT INTO persistence_state (singleton, store_id)
+VALUES (1, '00000000-0000-4000-8000-000000000000');
+PRAGMA application_id = 1146308688;
+PRAGMA user_version = 17;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/create-unrelated-table.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/create-unrelated-table.sql
new file mode 100644
index 0000000000..23c813df9a
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/create-unrelated-table.sql
@@ -0,0 +1 @@
+CREATE TABLE unrelated (value TEXT);
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/delete-persistence-state.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/delete-persistence-state.sql
new file mode 100644
index 0000000000..c337450451
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/delete-persistence-state.sql
@@ -0,0 +1 @@
+DELETE FROM persistence_state;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-events.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-events.sql
new file mode 100644
index 0000000000..c8f86c2f8f
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/delete-session-events.sql
@@ -0,0 +1,2 @@
+DELETE FROM events
+WHERE session_id = ?;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/empty-store-id.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/empty-store-id.sql
new file mode 100644
index 0000000000..5d3a64c1e5
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/empty-store-id.sql
@@ -0,0 +1,3 @@
+UPDATE persistence_state
+SET store_id = ''
+WHERE singleton = 1;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/insert-corrupt-event.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/insert-corrupt-event.sql
new file mode 100644
index 0000000000..82eb0af005
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/insert-corrupt-event.sql
@@ -0,0 +1,2 @@
+INSERT INTO events (session_id, seq, type, time, data, ignorable)
+VALUES (?, ?, ?, ?, ?, ?);
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/measure-write-traffic.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/measure-write-traffic.sql
new file mode 100644
index 0000000000..3ca0e6bd0c
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/measure-write-traffic.sql
@@ -0,0 +1,3 @@
+SELECT COUNT(*) AS rows,
+ COALESCE(MAX(length(CAST(data AS BLOB))), 0) AS largest
+FROM events;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/replace-events-with-nonstrict-table.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/replace-events-with-nonstrict-table.sql
new file mode 100644
index 0000000000..e39da0a3f4
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/replace-events-with-nonstrict-table.sql
@@ -0,0 +1,14 @@
+PRAGMA foreign_keys = OFF;
+ALTER TABLE events RENAME TO strict_events;
+CREATE TABLE events (
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
+ seq INTEGER NOT NULL,
+ type TEXT NOT NULL,
+ time INTEGER NOT NULL,
+ data TEXT NOT NULL,
+ source_event_seqs TEXT,
+ surface_op TEXT,
+ ignorable INTEGER,
+ PRIMARY KEY (session_id, seq)
+);
+DROP TABLE strict_events;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rowids.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rowids.sql
new file mode 100644
index 0000000000..91c3167273
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rowids.sql
@@ -0,0 +1,3 @@
+SELECT seq, rowid
+FROM events
+ORDER BY seq;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rows.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rows.sql
new file mode 100644
index 0000000000..037c34641a
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/select-event-rows.sql
@@ -0,0 +1,4 @@
+SELECT rowid, seq, type, time, data, source_event_seqs, surface_op, ignorable
+FROM events
+WHERE session_id = ?
+ORDER BY seq;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/select-last-event.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/select-last-event.sql
new file mode 100644
index 0000000000..14b6e17742
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/select-last-event.sql
@@ -0,0 +1,5 @@
+SELECT seq, type, data
+FROM events
+WHERE session_id = ?
+ORDER BY seq DESC
+LIMIT 1;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/select-user-version.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/select-user-version.sql
new file mode 100644
index 0000000000..4edeca1a4d
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/select-user-version.sql
@@ -0,0 +1 @@
+PRAGMA user_version;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-application-id-12345.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-application-id-12345.sql
new file mode 100644
index 0000000000..79d31a3a57
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/set-application-id-12345.sql
@@ -0,0 +1 @@
+PRAGMA application_id = 12345;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-15.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-15.sql
new file mode 100644
index 0000000000..fa5f49e3b2
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-15.sql
@@ -0,0 +1 @@
+PRAGMA user_version = 15;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-16.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-16.sql
new file mode 100644
index 0000000000..0750749350
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-16.sql
@@ -0,0 +1 @@
+PRAGMA user_version = 16;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-17.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-17.sql
new file mode 100644
index 0000000000..5aac576e8c
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/set-user-version-17.sql
@@ -0,0 +1 @@
+PRAGMA user_version = 17;
diff --git a/packages/session/session-persistence-sqlite/tests/resources/sql/update-invalid-session-metadata.sql b/packages/session/session-persistence-sqlite/tests/resources/sql/update-invalid-session-metadata.sql
new file mode 100644
index 0000000000..a4d6b530a2
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/resources/sql/update-invalid-session-metadata.sql
@@ -0,0 +1,3 @@
+UPDATE sessions
+SET origin = 'external', delegation_depth = -1, seed_length = -1
+WHERE id = ?;
diff --git a/packages/session/session-persistence-sqlite/tests/sql-resource-boundary.spec.ts b/packages/session/session-persistence-sqlite/tests/sql-resource-boundary.spec.ts
new file mode 100644
index 0000000000..8574d1c97f
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/sql-resource-boundary.spec.ts
@@ -0,0 +1,102 @@
+import { readdir, readFile } from 'node:fs/promises'
+import { fileURLToPath } from 'node:url'
+import ts from 'typescript'
+import { describe, expect, it } from 'vitest'
+
+const PACKAGE_ROOT = fileURLToPath(new URL('../', import.meta.url))
+const SQL_LITERAL = /^\s*(?:ALTER|ATTACH|BEGIN|COMMIT|CREATE|DELETE|DETACH|DROP|INSERT|PRAGMA|REINDEX|RELEASE|ROLLBACK|SAVEPOINT|SELECT|UPDATE|VACUUM|WITH)\s/iu // eslint-disable-line @stylistic/max-len
+
+async function filesUnder(path: string): Promise {
+ const entries = await readdir(path, { withFileTypes: true })
+ return (await Promise.all(entries.map(async entry => entry.isDirectory()
+ ? filesUnder(`${path}/${entry.name}`)
+ : [`${path}/${entry.name}`]))).flat()
+}
+
+function sqlLiteralText(node: ts.Node): string | undefined {
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text
+ if (node.kind === ts.SyntaxKind.TemplateHead) {
+ return (node as ts.Node & { readonly text: string }).text
+ }
+ return undefined
+}
+
+function isOwnedSqlSource(node: ts.Expression | undefined, source: ts.SourceFile): boolean {
+ if (node === undefined) return false
+ if (ts.isCallExpression(node)
+ && ts.isIdentifier(node.expression)
+ && (node.expression.text === 'sql' || node.expression.text === 'testSql')) return true
+ if (!ts.isIdentifier(node) || node.text !== 'source') return false
+ const call = node.parent
+ if (!ts.isCallExpression(call)
+ || call.arguments.length !== 1
+ || call.arguments[0] !== node
+ || !ts.isPropertyAccessExpression(call.expression)
+ || call.expression.expression.kind !== ts.SyntaxKind.SuperKeyword
+ || call.expression.name.text !== 'prepare') return false
+ let method: ts.Node | undefined = node.parent
+ while (method !== undefined && !ts.isMethodDeclaration(method)) method = method.parent
+ if (method === undefined
+ || method.name.getText(source) !== 'prepare'
+ || method.parameters.length !== 1
+ || method.parameters[0]?.name.getText(source) !== 'source') return false
+ let classNode: ts.Node | undefined = method.parent
+ while (classNode !== undefined && !ts.isClassExpression(classNode)) classNode = classNode.parent
+ if (classNode === undefined || classNode.name?.text !== 'JournalFailureDatabase') return false
+ const guard = method.body?.statements[0]
+ if (guard === undefined
+ || !ts.isIfStatement(guard)
+ || !ts.isBinaryExpression(guard.expression)
+ || guard.expression.operatorToken.kind !== ts.SyntaxKind.ExclamationEqualsEqualsToken
+ || guard.expression.left.getText(source) !== 'source'
+ || guard.expression.right.getText(source) !== "sql('journal-mode-wal')") return false
+ return ts.isReturnStatement(guard.thenStatement)
+ && guard.thenStatement.expression === call
+}
+
+describe('SQLite SQL resource boundary', () => {
+ it('keeps statements and query assembly out of TypeScript files', async () => {
+ const files = (await Promise.all([
+ filesUnder(`${PACKAGE_ROOT}/src`),
+ filesUnder(`${PACKAGE_ROOT}/tests`),
+ ])).flat().filter(path => path.endsWith('.ts'))
+ const violations: string[] = []
+ for (const path of files) {
+ const source = ts.createSourceFile(path, await readFile(path, 'utf8'), ts.ScriptTarget.Latest, true)
+ const usesNodeSqlite = source.statements.some(statement => ts.isImportDeclaration(statement)
+ && ts.isStringLiteral(statement.moduleSpecifier)
+ && statement.moduleSpecifier.text === 'node:sqlite')
+ const visit = (node: ts.Node): void => {
+ const literal = sqlLiteralText(node)
+ if (literal !== undefined && SQL_LITERAL.test(literal)) {
+ violations.push(`${path}:${source.getLineAndCharacterOfPosition(node.getStart()).line + 1}: SQL literal`)
+ }
+ // Awaited prepare() is SessionPersistence; DatabaseSync.prepare() is synchronous.
+ if (usesNodeSqlite
+ && ts.isCallExpression(node)
+ && ts.isPropertyAccessExpression(node.expression)
+ && (node.expression.name.text === 'exec'
+ || (node.expression.name.text === 'prepare' && !ts.isAwaitExpression(node.parent)))) {
+ const argument = node.arguments[0]
+ if (!isOwnedSqlSource(argument, source)) {
+ violations.push(`${path}:${source.getLineAndCharacterOfPosition(node.getStart()).line + 1}: unowned query source`)
+ }
+ }
+ ts.forEachChild(node, visit)
+ }
+ visit(source)
+ }
+ expect(violations).toEqual([])
+ })
+
+ it('keeps resource text static instead of interpolated', async () => {
+ const files = (await Promise.all([
+ filesUnder(`${PACKAGE_ROOT}/resources/sql`),
+ filesUnder(`${PACKAGE_ROOT}/tests/resources/sql`),
+ ])).flat()
+ for (const path of files) {
+ expect(path.endsWith('.sql')).toBe(true)
+ expect(await readFile(path, 'utf8')).not.toContain('${')
+ }
+ })
+})
diff --git a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts
index 8f0c49e71f..f49d5d8e69 100644
--- a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts
+++ b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts
@@ -1,949 +1,833 @@
-import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { spawn } from 'node:child_process'
import { Context } from '@deepseek-ai/cordis'
-import { existsSync } from 'node:fs'
-import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises'
+import { once } from 'node:events'
+import { chmod, mkdir, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
-import { dirname, join } from 'node:path'
+import { join } from 'node:path'
+import { performance } from 'node:perf_hooks'
+import { pathToFileURL } from 'node:url'
import { DatabaseSync } from 'node:sqlite'
-import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
-import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
-import SqliteSessionPersistence, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite'
+import Loader from '@deepseek-ai/cordis-plugin-loader'
+import Include from '@deepseek-ai/cordis-plugin-include'
+import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
+import SessionPersistenceSqlite, {
+ DEFAULT_BUSY_TIMEOUT_MS,
+ SCHEMA_VERSION,
+} from '@deepseek-ai/dsh-session-persistence-sqlite'
import {
+ runCoordinatorContract,
+ type CoordinatorFixture,
+} from '../../session-persistence/tests/coordinator-contract.ts'
+import {
+ meta,
+ runPersistenceContract,
+} from '../../session-persistence/tests/contract.ts'
+import { MAX_PACKED_DATA_BYTES } from '../src/codec.ts'
+import {
+ decodeEventRow,
+ decodeSessionRow,
+ decodeStoreIdentity,
openDatabase,
- rowToEvent,
+ validateSchemaForMutation,
rowToMeta,
- scanRows,
SESSION_PERSISTENCE_SQLITE_APPLICATION_ID,
- type EventRow,
+ type SessionRow,
} from '../src/schema.ts'
-import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
-import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
+import { SqliteStore } from '../src/store.ts'
+import { sql } from '../src/sql.ts'
+import { testSql } from './test-sql.ts'
const dirs: string[] = []
-afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
+afterEach(async () => {
+ for (const directory of dirs.splice(0)) await rm(directory, { recursive: true, force: true })
+})
-async function expectFlushError(promise: Promise, message: RegExp): Promise {
- try {
- await promise
- } catch (error) {
- expect(error).toBeInstanceOf(Error)
- expect((error as Error).message).toMatch(message)
- return
- }
- throw new Error('expected flush to reject')
+async function freshDbPath(prefix = 'dsh-sqlite-'): Promise {
+ const directory = await mkdtemp(join(tmpdir(), prefix))
+ dirs.push(directory)
+ return join(directory, 'sessions.db')
}
-async function freshDbPath(): Promise {
- const dir = await mkdtemp(join(tmpdir(), 'dsh-sqlite-'))
- dirs.push(dir)
- return join(dir, 'sessions.db')
-}
-
-/** A context with the session store + SQLite backend, plus a teardown. */
-async function backend(path = ':memory:'): Promise<{ ctx: Context; dispose: () => Promise }> {
+async function backendFailure(path: string): Promise {
const ctx = new Context()
await ctx.plugin(SessionStore)
- const fiber = await ctx.plugin(SqliteSessionPersistence, { path })
- return { ctx, dispose: () => fiber.dispose() }
+ try {
+ await ctx.plugin(SessionPersistenceSqlite, { path })
+ await ctx.sessionPersistence.list()
+ return undefined
+ } catch (error: unknown) {
+ return error
+ } finally {
+ await ctx.fiber.dispose()
+ }
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error)
+}
+
+function databaseWithJournalFailure(
+ nextFailure: () => Error | undefined,
+): typeof DatabaseSync {
+ return class JournalFailureDatabase extends DatabaseSync {
+ override prepare(source: string) {
+ if (source !== sql('journal-mode-wal')) return super.prepare(source)
+ const statement = super.prepare(sql('journal-mode-wal'))
+ const get = statement.get.bind(statement)
+ Object.defineProperty(statement, 'get', {
+ value: () => {
+ const failure = nextFailure()
+ if (failure !== undefined) throw failure
+ return get()
+ },
+ })
+ return statement
+ }
+ }
+}
+
+function chunk(seq: number, text = `token-${seq}`): SessionEvent {
+ return {
+ type: 'assistant/chunk',
+ seq,
+ time: 1_000 + seq,
+ data: {
+ turn: 1,
+ step: 1,
+ chunk: { type: 'text-delta', index: 0, text },
+ },
+ }
+}
+
+function chunkLog(count: number): SessionEvent[] {
+ return [
+ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
+ { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
+ ...Array.from({ length: count }, (_, index) => chunk(index + 2)),
+ { type: 'step/end', seq: count + 2, time: count + 3, data: { turn: 1, step: 1 } },
+ {
+ type: 'turn/end',
+ seq: count + 3,
+ time: count + 4,
+ data: { turn: 1, reason: { kind: 'completed' } },
+ },
+ ]
+}
+
+async function measureWriteTraffic(
+ path: string,
+ events: readonly SessionEvent[],
+): Promise<{
+ readonly walBytes: number
+ readonly idleWalBytes: number
+ readonly rows: number
+ readonly largest: number
+ readonly inserted: number
+ readonly changed: number
+ readonly removed: number
+}> {
+ interface PhysicalRow {
+ readonly rowid: number
+ readonly seq: number
+ readonly type: string
+ readonly time: number
+ readonly data: string | Uint8Array
+ readonly source_event_seqs: Uint8Array | null
+ readonly surface_op: string | null
+ readonly ignorable: number | null
+ }
+ const sameValue = (left: string | Uint8Array | null, right: string | Uint8Array | null): boolean => (
+ typeof left === 'string' || left === null
+ ? left === right
+ : right instanceof Uint8Array && Buffer.from(left).equals(Buffer.from(right))
+ )
+ const sameRow = (left: PhysicalRow, right: PhysicalRow): boolean => (
+ left.rowid === right.rowid
+ && left.seq === right.seq
+ && left.type === right.type
+ && left.time === right.time
+ && sameValue(left.data, right.data)
+ && sameValue(left.source_event_seqs, right.source_event_seqs)
+ && left.surface_op === right.surface_op
+ && left.ignorable === right.ignorable
+ )
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionPersistenceSqlite, { path, writeBatchMaxDelayMs: 200 })
+ try {
+ const header = meta('traffic')
+ await ctx.sessionPersistence.create(header)
+ let previous = new Map()
+ let inserted = 0
+ let changed = 0
+ let removed = 0
+ const probe = new DatabaseSync(path, { readOnly: true })
+ try {
+ const selectRows = probe.prepare(testSql('select-event-rows'))
+ for (let offset = 0; offset < events.length; offset += 40) {
+ await ctx.sessionPersistence.append(header.id, events.slice(offset, offset + 40))
+ const current = new Map((selectRows.all(header.id) as unknown as PhysicalRow[])
+ .map(row => [row.seq, row]))
+ for (const [seq, row] of current) {
+ const old = previous.get(seq)
+ if (old === undefined) inserted += 1
+ else if (!sameRow(old, row)) changed += 1
+ }
+ for (const seq of previous.keys()) if (!current.has(seq)) removed += 1
+ previous = current
+ }
+ } finally {
+ probe.close()
+ }
+ const db = new DatabaseSync(path, { readOnly: true })
+ const measured = db.prepare(testSql('measure-write-traffic')).get() as { rows: number; largest: number }
+ db.close()
+ const walBytes = (await stat(`${path}-wal`)).size
+ await new Promise(resolve => setTimeout(resolve, 250))
+ return {
+ walBytes,
+ idleWalBytes: (await stat(`${path}-wal`)).size,
+ rows: measured.rows,
+ largest: measured.largest,
+ inserted,
+ changed,
+ removed,
+ }
+ } finally {
+ await ctx.fiber.dispose()
+ }
}
-// Run the same backend-agnostic contract as JSONL to pin identical semantics.
runPersistenceContract('sqlite', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
- const fiber = await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' })
+ const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: ':memory:' })
return {
persistence: ctx.sessionPersistence,
dispose: async () => { await fiber.dispose() },
}
})
-// A file-backed database lets two mounts share rows across reload. `corruptTail` inserts invalid
-// JSON past the committed seq, exercising coordinator repair against real database rows.
runCoordinatorContract('sqlite', async (): Promise => {
- const dir = await mkdtemp(join(tmpdir(), 'dsh-sqlite-coord-'))
- const path = join(dir, 'sessions.db')
+ const directory = await mkdtemp(join(tmpdir(), 'dsh-sqlite-coord-'))
+ const path = join(directory, 'sessions.db')
return {
- mount: async ctx => ctx.plugin(SqliteSessionPersistence, { path }),
+ mount: async ctx => ctx.plugin(SessionPersistenceSqlite, { path }),
corruptTail: async (id) => {
- // A row past the committed region whose `data` does not parse: scanRows
- // bounds the preserved prefix at it and returns its seq as tornFrom, which
- // the backend surfaces to the coordinator as the tornMarker to delete from.
- const db = openDatabase(path, 'wal')
- const next = (db.prepare('SELECT COALESCE(MAX(seq), -1) + 1 AS n FROM events WHERE session_id = ?')
- .get(id) as { n: number }).n
- db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
- .run(id, next, 'assistant/chunk', 99, '{not valid json')
+ const db = new DatabaseSync(path)
+ const last = db.prepare(testSql('select-last-event'))
+ .get(id) as { seq: number; type: string; data: string }
+ const logicalLength = last.type === 'text-chunks'
+ ? (JSON.parse(last.data) as { texts: string[] }).texts.length
+ : 1
+ const next = last.seq + logicalLength
+ db.prepare(testSql('insert-corrupt-event'))
+ .run(id, next, 'assistant/chunk', 99, '{not valid json', null)
db.close()
},
- cleanup: async () => { await rm(dir, { recursive: true, force: true }) },
+ cleanup: async () => { await rm(directory, { recursive: true, force: true }) },
}
})
-describe('scanRows', () => {
- // scanRows works off EventRows (data is a JSON string column); build them from SessionEvents
- // so the unit tests read in terms of the event vocabulary. Surface metadata is serialized to
- // its nullable columns so the conversion remains faithful.
- const rows = (events: SessionEvent[]): EventRow[] =>
- events.map((e) => {
- const se = e as SessionEvent
- return {
- seq: e.seq, type: e.type, time: e.time, data: JSON.stringify(e.data),
- source_event_seqs: se.sourceEventSeqs !== undefined ? JSON.stringify(se.sourceEventSeqs) : null,
- surface_op: se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null,
- ignorable: e.ignorable === true ? 1 : null,
- }
+describe('SessionPersistenceSqlite physical packing', () => {
+ it('loads from cordis.yml and packs through the assembled service', async () => {
+ const path = await freshDbPath('dsh-sqlite-loader-')
+ const configPath = join(path, '..', 'cordis.yml')
+ await writeFile(configPath, [
+ "- name: '@deepseek-ai/dsh-session'",
+ "- name: '@deepseek-ai/dsh-session-persistence-sqlite'",
+ ' config:',
+ ` path: ${JSON.stringify(path)}`,
+ '',
+ ].join('\n'))
+
+ const ctx = new Context()
+ ctx.baseUrl = pathToFileURL(join(path, '..')).href + '/'
+ await ctx.plugin(Loader)
+ ctx.loader.builtins.include = Include
+ ctx.loader.internal = {
+ version: 'sqlite',
+ async import(specifier: string) {
+ if (specifier === '@deepseek-ai/dsh-session') return SessionStore
+ if (specifier === '@deepseek-ai/dsh-session-persistence-sqlite') {
+ return SessionPersistenceSqlite
+ }
+ throw new Error(`unexpected Loader import: ${specifier}`)
+ },
+ } as unknown as NonNullable
+ await ctx.loader.create({
+ name: 'cordis:include',
+ config: { path: pathToFileURL(configPath).href },
})
+ await ctx.loader.await()
- it('preserves the full log when it ends exactly on a turn/end (no torn tail)', () => {
- const { preserved, tornFrom } = scanRows(rows(oneTurnLog()))
- expect(preserved).toEqual(oneTurnLog())
- expect(tornFrom).toBeUndefined()
- })
-
- it('PRESERVES the real events of an interrupted turn after the last turn/end', () => {
- // turn 1 committed (0..5) + a crashed turn 2 (turn/start 6, step/start 7, no
- // close): all 8 rows are intact, so the whole prefix is preserved and there
- // is no torn fragment to delete. (load() then synthesizes the closers.)
- const withOpenTurn: SessionEvent[] = [
- ...oneTurnLog(),
- { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
- { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
- ]
- const { preserved, tornFrom } = scanRows(rows(withOpenTurn))
- expect(preserved.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
- expect(tornFrom).toBeUndefined()
- })
-
- it('preserves the contiguous prefix and flags a torn tail at a seq gap', () => {
- // A gap after seq 0 (no committed turn/end): seq 0 is the preserved
- // interrupted-turn event; the gap bounds it and marks the torn fragment.
- const gapped: SessionEvent[] = [
- { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
- { type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // seq 1 missing
- ]
- const { preserved, tornFrom } = scanRows(rows(gapped))
- expect(preserved.map(e => e.seq)).toEqual([0])
- expect(tornFrom).toBe(1)
- })
-
- it('an empty log preserves nothing and has no torn tail', () => {
- expect(scanRows([])).toEqual({ preserved: [] })
- })
-
- it('throws on a seq gap inside the committed region (before the last turn/end)', () => {
- const gapped: SessionEvent[] = [
- { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
- { type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // seq 1 missing
- { type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } },
- ]
- expect(() => scanRows(rows(gapped))).toThrow(/seq gap in committed region/)
- })
-
- it('throws on an unparsable row inside the committed region', () => {
- const withCorruptCommitted: EventRow[] = [
- { seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null, ignorable: null }, // corrupt, sits before a turn/end
- { seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null, ignorable: null },
- ]
- expect(() => scanRows(withCorruptCommitted)).toThrow(/unparsable committed event/)
- })
-
- it('tolerates an unparsable torn-tail row after the last turn/end', () => {
- const withCorruptTail: EventRow[] = [
- ...rows(oneTurnLog()),
- { seq: 6, type: 'turn/start', time: 7, data: '{not json', source_event_seqs: null, surface_op: null, ignorable: null }, // torn fragment, no committed turn/end after
- ]
- const { preserved, tornFrom } = scanRows(withCorruptTail)
- expect(preserved).toEqual(oneTurnLog())
- expect(tornFrom).toBe(6)
- })
-})
-
-describe('rowToMeta', () => {
- it('restores optional origin metadata', () => {
- expect(rowToMeta({
- id: 'with-origin',
- version: 0,
- created_at: 1,
- cwd: null,
- parent_session: null,
- seed_length: null,
- origin: 'subagent',
- incarnation: 'with-origin',
- revision: 1,
- delegation_depth: null,
- agent_preset: null,
- })).toMatchObject({ id: 'with-origin', origin: 'subagent' })
- })
-
- it('rejects fractional stored creation metadata', () => {
- expect(() => rowToMeta({
- id: 'fractional',
- version: 0,
- created_at: 1.5,
- cwd: null,
- parent_session: null,
- seed_length: null,
- origin: null,
- incarnation: 'fractional',
- revision: 1,
- delegation_depth: null,
- agent_preset: null,
- })).toThrow('stored session createdAt must be a non-negative safe integer')
- })
-
- it('restores the agent preset a session was composed from', () => {
- // The preset decides the resumed session's tools and prompt; a row that
- // dropped it would rebuild a composition the stored history contradicts.
- expect(rowToMeta({
- id: 'composed',
- version: 0,
- created_at: 1,
- cwd: null,
- parent_session: null,
- seed_length: null,
- origin: null,
- incarnation: 'composed',
- revision: 1,
- delegation_depth: null,
- agent_preset: 'minimal',
- })).toMatchObject({ agentPreset: 'minimal' })
- })
-})
-
-describe('SqliteSessionPersistence: durability and crash semantics', () => {
- it('rejects a stored v0 log containing a legacy request/header-delta event', async () => {
- const path = await freshDbPath()
- const m = meta('legacy-header-delta', '/legacy')
- const db = openDatabase(path, 'wal')
- db.prepare('INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision) VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, 1)')
- .run(m.id, m.version, m.createdAt, m.cwd ?? null, 'legacy-header-delta')
- const insert = db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
- insert.run(m.id, 0, 'turn/start', 1, JSON.stringify({ turn: 1 }))
- insert.run(m.id, 1, 'request/header-delta', 2, JSON.stringify({ config: { model: 'legacy' } }))
- insert.run(m.id, 2, 'turn/end', 3, JSON.stringify({ turn: 1, reason: { kind: 'completed' } }))
- db.close()
-
- const mounted = await backend(path)
- await expect(mounted.ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported legacy request\/header-delta event at seq 1/)
- await mounted.dispose()
- })
-
- it('rejects a stored v0 full header carrying the legacy fallback reason', async () => {
- const path = await freshDbPath()
- const m = meta('legacy-header-fallback', '/legacy')
- const db = openDatabase(path, 'wal')
- db.prepare('INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length, delegation_depth, incarnation, revision) VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, 1)')
- .run(m.id, m.version, m.createdAt, m.cwd ?? null, 'legacy-header-fallback')
- db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
- .run(m.id, 0, 'request/header', 1, JSON.stringify({
- header: { config: { model: 'legacy' } },
- reason: 'fallback',
- }))
- db.close()
-
- const mounted = await backend(path)
- await expect(mounted.ctx.sessionPersistence.load(m.id))
- .rejects.toThrow(/unsupported legacy request\/header reason "fallback" at seq 0/)
- await mounted.dispose()
- })
-
- it('has no independent per-session log location', async () => {
- const { ctx, dispose } = await backend()
- expect(ctx.sessionPersistence.locate(meta('sqlite-location'))).toBeUndefined()
- await dispose()
- })
-
- it('an interrupted turn (rows after the last turn/end) is PRESERVED and closed during load', async () => {
- const path = await freshDbPath()
- const m = meta('crash')
- // Run 1: persist a complete turn, then a half-written second turn (no turn/end).
- const ctx1 = new Context()
- await ctx1.plugin(SessionStore)
- const fiber1 = await ctx1.plugin(SqliteSessionPersistence, { path })
- await ctx1.sessionPersistence.create(m)
- await ctx1.sessionPersistence.append(m.id, oneTurnLog())
- await ctx1.sessionPersistence.append(m.id, [
- { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
- { type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
- ])
- await fiber1.dispose()
-
- // Run 2: load PRESERVES the interrupted turn's real events (a turn can be huge
- // — never truncated) and closes the orphaned turn with synthetic boundary
- // events: step/end (the step was open) then turn/end {interrupted}.
- const ctx2 = new Context()
- await ctx2.plugin(SessionStore)
- const fiber2 = await ctx2.plugin(SqliteSessionPersistence, { path })
- const loaded = await ctx2.sessionPersistence.load(m.id)
- expect(loaded.events.map(e => e.type)).toEqual([
- 'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1
- 'turn/start', 'step/start', 'step/end', 'turn/end', // turn 2: real events + synthetic closers
- ])
- expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
- const last = loaded.events.at(-1)!
- expect(last.type === 'turn/end' && last.data.reason).toEqual({ kind: 'interrupted' })
-
- // load durably closed the turn, so the next append continues at the balanced
- // length (seq 10) and a reload round-trips identically.
- await ctx2.sessionPersistence.append(m.id, [
- { type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } },
- { type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } },
- ])
- const reloaded = await ctx2.sessionPersistence.load(m.id)
- expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
- await fiber2.dispose()
- })
-
- it('load() durably closes the interrupted turn: the synthetic closers are on disk after load', async () => {
- const path = await freshDbPath()
- const m = meta('load-closes')
- const b1 = await backend(path)
- await b1.ctx.sessionPersistence.create(m)
- await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5
- await b1.dispose()
- // Hand-write an interrupted turn (turn/start seq 6, no turn/end).
- const db = openDatabase(path, 'wal')
- db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)')
- .run(m.id, 'turn/start', JSON.stringify({ turn: 2 }))
- db.close()
-
- const b2 = await backend(path)
- const loaded = await b2.ctx.sessionPersistence.load(m.id)
- // turn 2's real turn/start (seq 6) is preserved + a synthetic turn/end (seq 7).
- expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
- expect(loaded.events.at(-1)!.type).toBe('turn/end')
- // load() is mutating: the synthetic turn/end MUST be on disk so the stored log
- // is balanced and the cursor is truthful (contract: load closes, not defers).
- const probe = openDatabase(path, 'wal')
- const stored = probe.prepare('SELECT seq, type FROM events WHERE session_id = ? ORDER BY seq').all(m.id) as { seq: number; type: string }[]
- probe.close()
- expect(stored.map(r => r.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
- expect(stored.at(-1)!.type).toBe('turn/end')
- await b2.dispose()
- })
-
- it('all-tail load: a session whose only turn never closed is preserved and closed on load', async () => {
- const path = await freshDbPath()
- const m = meta('all-tail')
- const b1 = await backend(path)
- await b1.ctx.sessionPersistence.create(m)
- // A first turn that NEVER completed: turn/start + user/message, no turn/end.
- await b1.ctx.sessionPersistence.append(m.id, [
- { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
- { type: 'user/message', seq: 1, time: 2, data: createUserMessage({
- content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
- }), surfaceOp: 'append' },
- ])
- await b1.dispose()
-
- // A fresh backend loads it: the interrupted (only) turn's real events are
- // preserved and closed with a synthetic turn/end {interrupted} — NOT
- // truncated. The session was materialized, so list() reports it present.
- const b2 = await backend(path)
- const loaded = await b2.ctx.sessionPersistence.load(m.id)
- expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'user/message', 'turn/end'])
- expect(loaded.events.at(-1)!.type === 'turn/end' && loaded.events.at(-1)!.data).toMatchObject({ reason: { kind: 'interrupted' } })
- expect((await b2.ctx.sessionPersistence.list()).map(x => x.id)).toContain(m.id)
- await b2.dispose()
- })
-
- it('rejects opening a database whose schema version is not the current build (newer OR older)', async () => {
- const path = await freshDbPath()
- openDatabase(path, 'wal').close() // stamp user_version = SCHEMA_VERSION
- // Bump user_version past what this build supports.
- const dbNewer = openDatabase(path, 'wal')
- dbNewer.exec(`PRAGMA user_version = ${SCHEMA_VERSION + 1}`)
- dbNewer.close()
- expect(() => openDatabase(path, 'wal')).toThrow(/incompatible with this build/)
-
- // The immediately preceding layout lacks the required store identity and is
- // rejected rather than migrated (unreleased software, no backward-compat).
- const olderPath = await freshDbPath()
- openDatabase(olderPath, 'wal').close()
- const dbOlder = openDatabase(olderPath, 'wal')
- dbOlder.exec(`PRAGMA user_version = ${SCHEMA_VERSION - 1}`)
- dbOlder.close()
- expect(() => openDatabase(olderPath, 'wal')).toThrow(/incompatible with this build/)
- })
-
- it('rejects a table-backed unversioned database before stamping or changing journal mode', async () => {
- const path = await freshDbPath()
- const legacy = new DatabaseSync(path)
- legacy.exec('CREATE TABLE sessions (id TEXT PRIMARY KEY)')
- legacy.close()
-
- expect(() => openDatabase(path, 'wal')).toThrow(/unversioned schema or application identity/)
-
- const unchanged = new DatabaseSync(path)
- expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 })
- expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
- expect(unchanged.prepare(
- "SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'sessions'",
- ).get()).toEqual({ name: 'sessions' })
- unchanged.close()
- })
-
- it('counts a sqliteX table as user-owned instead of mistaking it for SQLite metadata', async () => {
- const path = await freshDbPath()
- const unrelated = new DatabaseSync(path)
- unrelated.exec('CREATE TABLE sqliteX (value TEXT)')
- unrelated.exec("INSERT INTO sqliteX VALUES ('safe')")
- unrelated.close()
-
- expect(() => openDatabase(path, 'wal')).toThrow(/unversioned schema or application identity/)
-
- const unchanged = new DatabaseSync(path)
- expect(unchanged.prepare('SELECT value FROM sqliteX').get()).toEqual({ value: 'safe' })
- expect(unchanged.prepare('PRAGMA application_id').get()).toEqual({ application_id: 0 })
- expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 })
- expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
- unchanged.close()
- })
-
- it('rejects view-only and foreign-application unversioned databases without mutation', async () => {
- const viewPath = await freshDbPath()
- const viewOnly = new DatabaseSync(viewPath)
- viewOnly.exec('CREATE VIEW foreign_view AS SELECT 1 AS value')
- viewOnly.close()
-
- expect(() => openDatabase(viewPath, 'wal')).toThrow(/unversioned schema or application identity/)
- const unchangedView = new DatabaseSync(viewPath)
- expect(unchangedView.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
- expect(unchangedView.prepare(
- "SELECT type FROM sqlite_schema WHERE name = 'foreign_view'",
- ).get()).toEqual({ type: 'view' })
- unchangedView.close()
-
- const applicationPath = await freshDbPath()
- const foreignApplication = new DatabaseSync(applicationPath)
- foreignApplication.exec('PRAGMA application_id = 12345')
- foreignApplication.close()
-
- expect(() => openDatabase(applicationPath, 'wal')).toThrow(/unversioned schema or application identity/)
- const unchangedApplication = new DatabaseSync(applicationPath)
- expect(unchangedApplication.prepare('PRAGMA application_id').get()).toEqual({ application_id: 12345 })
- expect(unchangedApplication.prepare('PRAGMA user_version').get()).toEqual({ user_version: 0 })
- expect(unchangedApplication.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
- unchangedApplication.close()
- })
-
- it('rejects a current-version database with a foreign application identity', async () => {
- const path = await freshDbPath()
- const foreign = new DatabaseSync(path)
- foreign.exec('PRAGMA application_id = 12345')
- foreign.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
- foreign.close()
-
- expect(() => openDatabase(path, 'wal')).toThrow(/has application id 12345/)
-
- const unchanged = new DatabaseSync(path)
- expect(unchanged.prepare('PRAGMA application_id').get()).toEqual({ application_id: 12345 })
- expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION })
- expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
- unchanged.close()
- })
-
- it('rolls back schema objects and identity stamps when initialization fails', async () => {
- const path = await freshDbPath()
- const conflicting = new DatabaseSync(path)
- conflicting.exec(`PRAGMA application_id = ${SESSION_PERSISTENCE_SQLITE_APPLICATION_ID}`)
- conflicting.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`)
- conflicting.exec("CREATE VIEW persistence_state AS SELECT 1 AS singleton, 'foreign' AS store_id")
- conflicting.close()
-
- expect(() => openDatabase(path, 'wal')).toThrow()
-
- const unchanged = new DatabaseSync(path)
- expect(unchanged.prepare(
- "SELECT type FROM sqlite_schema WHERE name = 'persistence_state'",
- ).get()).toEqual({ type: 'view' })
- expect(unchanged.prepare(
- "SELECT type FROM sqlite_schema WHERE name = 'sessions'",
- ).get()).toBeUndefined()
- expect(unchanged.prepare(
- "SELECT type FROM sqlite_schema WHERE name = 'events'",
- ).get()).toBeUndefined()
- expect(unchanged.prepare('PRAGMA application_id').get())
- .toEqual({ application_id: SESSION_PERSISTENCE_SQLITE_APPLICATION_ID })
- expect(unchanged.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION })
- expect(unchanged.prepare('PRAGMA journal_mode').get()).toEqual({ journal_mode: 'delete' })
- unchanged.close()
- })
-
- it('stamps the persistence application identity with the schema version', async () => {
- const path = await freshDbPath()
- openDatabase(path, 'wal').close()
+ const header = meta('loader')
+ const events = chunkLog(4)
+ await ctx.sessionPersistence.create(header)
+ await ctx.sessionPersistence.append(header.id, events)
+ expect((await ctx.sessionPersistence.inspect(header.id)).events).toEqual(events)
+ await ctx.fiber.dispose()
const db = new DatabaseSync(path)
- expect(db.prepare('PRAGMA application_id').get())
- .toEqual({ application_id: SESSION_PERSISTENCE_SQLITE_APPLICATION_ID })
- expect(db.prepare('PRAGMA user_version').get()).toEqual({ user_version: SCHEMA_VERSION })
+ expect(db.prepare(testSql('count-packed-events')).get())
+ .toEqual({ count: 1 })
db.close()
})
- it('rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns', async () => {
- // Version 3 identified two incompatible sibling layouts, so it is always rejected.
+ it('packs each append once without rewriting earlier rows and seeks inside packed rows', async () => {
const path = await freshDbPath()
- openDatabase(path, 'wal').close() // creates + stamps user_version = SCHEMA_VERSION
- const db = openDatabase(path, 'wal')
- db.exec('PRAGMA user_version = 3')
- db.close()
- expect(() => openDatabase(path, 'wal')).toThrow(/schema version 3, incompatible with this build/)
- })
-
- it('a corrupt-JSON row in the uncommitted tail is discarded on load, not unloadable', async () => {
- const path = await freshDbPath()
- const m = meta('corrupt-tail')
- const b1 = await backend(path)
- await b1.ctx.sessionPersistence.create(m)
- await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) // committed: seqs 0..5
- await b1.dispose()
-
- // A torn row after the last committed turn has invalid JSON. `scanRows` locates the boundary
- // from seq/type columns without parsing the tail, preserves the committed prefix, and load
- // deletes the row; invalid JSON inside the committed region would remain fatal.
- const db = openDatabase(path, 'wal')
- db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)')
- .run(m.id, 'turn/start', '{not valid json')
- db.close()
-
- const b2 = await backend(path)
- const loaded = await b2.ctx.sessionPersistence.load(m.id)
- expect(loaded.events).toEqual(oneTurnLog()) // torn tail discarded, committed intact (turn 1 already balanced → no closers)
- // load physically deleted the corrupt tail row, so a fresh append continues.
- await b2.ctx.sessionPersistence.append(m.id, [
- { type: 'turn/start', seq: 6, time: 8, data: { turn: 2 } },
- { type: 'turn/end', seq: 7, time: 9, data: { turn: 2, reason: { kind: 'completed' } } },
- ])
- const reloaded = await b2.ctx.sessionPersistence.load(m.id)
- expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
- await b2.dispose()
- })
-
- it('append rolls back the whole batch on a mid-batch seq collision (transaction)', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
- const fiber = await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' })
- const m = meta('rollback')
- await ctx.sessionPersistence.create(m)
- await ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5
+ const fiber = await ctx.plugin(SessionPersistenceSqlite, { path })
+ const header = meta('packed')
+ const events = chunkLog(100)
+ await ctx.sessionPersistence.create(header)
+ await ctx.sessionPersistence.append(header.id, events.slice(0, 3))
+ await ctx.sessionPersistence.append(header.id, events.slice(3, 4))
+ const before = new DatabaseSync(path, { readOnly: true })
+ const originalRows = before.prepare(testSql('select-event-rowids')).all()
+ before.close()
+ await ctx.sessionPersistence.append(header.id, events.slice(4))
- // A batch that re-states an already-stored seq must be rejected and leave
- // the stored log unchanged (the UNIQUE (session_id, seq) constraint fires
- // inside the transaction → ROLLBACK).
- await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toThrow()
- const loaded = await ctx.sessionPersistence.load(m.id)
- expect(loaded.events).toEqual(oneTurnLog()) // unchanged
+ const inspected = await ctx.sessionPersistence.inspect(header.id)
+ expect(inspected.events).toEqual(events)
+ for (const fromSeq of [0, 2, 25, 101, 104, 105]) {
+ expect((await ctx.sessionPersistence.readFrom(header.id, fromSeq)).events)
+ .toEqual(events.filter(event => event.seq >= fromSeq))
+ }
await fiber.dispose()
+
+ const db = new DatabaseSync(path)
+ expect(db.prepare(testSql('select-user-version')).get()).toEqual({ user_version: SCHEMA_VERSION })
+ expect(db.prepare(testSql('count-events')).get()).toEqual({ count: 7 })
+ expect(db.prepare(testSql('count-packed-events')).get())
+ .toEqual({ count: 1 })
+ expect(db.prepare(testSql('select-event-rowids')).all().slice(0, originalRows.length))
+ .toEqual(originalRows)
+ db.close()
})
- it('persists across separate backend instances over the same file', async () => {
- const path = await freshDbPath()
- const m = meta('persist', '/proj')
- const ctx1 = new Context()
- await ctx1.plugin(SessionStore)
- const fiber1 = await ctx1.plugin(SqliteSessionPersistence, { path })
- await ctx1.sessionPersistence.create(m)
- await ctx1.sessionPersistence.append(m.id, oneTurnLog())
- await fiber1.dispose()
+ it.runIf(process.platform !== 'win32')('bounds paced-stream WAL extent without rewriting committed rows', async () => {
+ const events = chunkLog(1_000)
+ const measured = await measureWriteTraffic(await freshDbPath('dsh-sqlite-traffic-'), events)
- const ctx2 = new Context()
- await ctx2.plugin(SessionStore)
- const fiber2 = await ctx2.plugin(SqliteSessionPersistence, { path })
- expect((await ctx2.sessionPersistence.list()).map(x => x.id)).toContain(m.id)
- const loaded = await ctx2.sessionPersistence.load(m.id)
- expect(loaded.meta).toMatchObject({ id: m.id, cwd: '/proj' })
- expect(loaded.events).toEqual(oneTurnLog())
- await fiber2.dispose()
+ expect(measured).toMatchObject({ rows: 31, inserted: 31, changed: 0, removed: 0 })
+ expect(measured.inserted).toBe(measured.rows)
+ expect(measured.largest).toBeLessThanOrEqual(MAX_PACKED_DATA_BYTES)
+ expect(measured.idleWalBytes).toBe(measured.walBytes)
})
- it('source-qualifies revisions across stores while preserving same-file reopen identity', async () => {
- const pathA = await freshDbPath()
- const pathB = await freshDbPath()
- const m = meta('revision-source')
- const a = await backend(pathA)
- await a.ctx.sessionPersistence.create(m)
- await a.ctx.sessionPersistence.append(m.id, oneTurnLog())
- const revisionA = (await a.ctx.sessionPersistence.listSnapshots())[0]?.revision
- await a.dispose()
+ it('includes a packed predecessor when an overlapping scalar tail hides it', async () => {
+ const path = await freshDbPath('dsh-sqlite-overlap-')
+ const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
+ const header = meta('overlap')
+ await store.appendBatch(header, [chunk(0), chunk(1), chunk(2)], false)
- const probeA = openDatabase(pathA, 'wal')
- const storeIdA = (probeA.prepare(
- 'SELECT store_id FROM persistence_state WHERE singleton = 1',
- ).get() as { store_id: string }).store_id
- probeA.close()
+ const db = new DatabaseSync(path)
+ db.prepare(testSql('insert-corrupt-event'))
+ .run(header.id, 1, 'assistant/chunk', 2, JSON.stringify(chunk(1).data), null)
+ db.close()
- const aliasA = `${pathA}.alias`
- await symlink(pathA, aliasA)
- const reopenedA = await backend(aliasA)
- expect((await reopenedA.ctx.sessionPersistence.listSnapshots())[0]?.revision).toBe(revisionA)
- await reopenedA.dispose()
+ expect((await store.loadStoredFrom(header.id, 2))?.events).toEqual([chunk(2)])
- const b = await backend(pathB)
- await b.ctx.sessionPersistence.create(m)
- await b.ctx.sessionPersistence.append(m.id, oneTurnLog())
- const revisionB = (await b.ctx.sessionPersistence.listSnapshots())[0]?.revision
- const probeB = openDatabase(pathB, 'wal')
- const storeIdB = (probeB.prepare(
- 'SELECT store_id FROM persistence_state WHERE singleton = 1',
- ).get() as { store_id: string }).store_id
- probeB.close()
- expect(storeIdB).not.toBe(storeIdA)
- expect(revisionB).not.toBe(revisionA)
- expect(String(revisionA)).toMatch(/:revision:1$/)
- expect(String(revisionB)).toMatch(/:revision:1$/)
- await b.dispose()
+ const malformed = new DatabaseSync(path)
+ malformed.prepare(testSql('delete-session-events')).run(header.id)
+ malformed.prepare(testSql('insert-corrupt-event'))
+ .run(header.id, 0, 'text-chunks', 1, '{not json', 0)
+ malformed.close()
+ expect((await store.loadStoredFrom(header.id, 2))?.events).toEqual([])
+ await store.close()
})
- it('binds a full stored prefix to the same revision as a lightweight read', async () => {
- const b = await backend()
- const m = meta('stored-prefix-revision')
- await b.ctx.sessionPersistence.create(m)
- await b.ctx.sessionPersistence.append(m.id, oneTurnLog())
- const persistence = b.ctx.sessionPersistence as SqliteSessionPersistence
+ it('waits for a competing process within the configured busy timeout', async () => {
+ const path = await freshDbPath('dsh-sqlite-busy-')
+ const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: 1_000 })
+ const header = meta('busy')
+ await store.appendBatch(header, [chunk(0)], false)
- const stored = await persistence.loadStored(m.id)
- expect(stored?.revision).toBe(await persistence.readStoredRevision(m.id))
- expect(await persistence.readStoredRevision(SessionId('missing-revision'))).toBeUndefined()
- await b.dispose()
- })
-
- it('changes revisions when a deleted session id is materialized again in the same database', async () => {
- const path = await freshDbPath()
- const m = meta('recreated-revision')
- const first = await backend(path)
- await first.ctx.sessionPersistence.create(m)
- await first.ctx.sessionPersistence.append(m.id, oneTurnLog())
- const before = (await first.ctx.sessionPersistence.listSnapshots())[0]?.revision
- await first.dispose()
-
- const cleanup = openDatabase(path, 'wal')
- cleanup.prepare('DELETE FROM sessions WHERE id = ?').run(m.id)
- cleanup.close()
-
- const second = await backend(path)
- await second.ctx.sessionPersistence.create(m)
- await second.ctx.sessionPersistence.append(m.id, oneTurnLog())
- const after = (await second.ctx.sessionPersistence.listSnapshots())[0]?.revision
- expect(after).not.toBe(before)
- expect(String(before)).toMatch(/:revision:1$/)
- expect(String(after)).toMatch(/:revision:1$/)
- await second.dispose()
- })
-
- it('awaits in-flight readiness before surfacing snapshot-list cancellation', async () => {
- const b = await backend()
- const internals = b.ctx.sessionPersistence as unknown as { ready: Promise }
- const originalReady = internals.ready
- const readiness = Promise.withResolvers()
- internals.ready = readiness.promise
- const reason = new Error('SQLite snapshot readiness cancelled')
- const controller = new AbortController()
- const pending = b.ctx.sessionPersistence.listSnapshots(controller.signal)
- let settled = false
- void pending.then(
- () => { settled = true },
- () => { settled = true },
- )
-
- controller.abort(reason)
- await Promise.resolve()
- expect(settled).toBe(false)
-
- readiness.resolve(undefined)
- await expect(pending).rejects.toBe(reason)
- internals.ready = originalReady
- await b.dispose()
- })
-
- it('exposes the schema version constant', () => {
- expect(SCHEMA_VERSION).toBe(15)
- })
-
- it('keeps the revision stable for an empty repair hook', async () => {
- const b = await backend()
- const m = meta('empty-repair')
- await b.ctx.sessionPersistence.create(m)
- await b.ctx.sessionPersistence.append(m.id, oneTurnLog())
- const before = await b.ctx.sessionPersistence.listSnapshots()
- await (b.ctx.sessionPersistence as SqliteSessionPersistence).commitRepair(m, undefined, [])
- expect(await b.ctx.sessionPersistence.listSnapshots()).toEqual(before)
- await b.dispose()
- })
-})
-
-describe('SqliteSessionPersistence: edge cases', () => {
- it('resolves the preparation-cache default without schema normalization', async () => {
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- let persistence!: SqliteSessionPersistence
- await ctx.plugin(Object.assign((inner: Context) => {
- persistence = new SqliteSessionPersistence(inner, {
- path: ':memory:',
- journalMode: 'wal',
- })
- }, { inject: ['sessions'] }))
-
- expect(await persistence.list()).toEqual([])
- await ctx.fiber.dispose()
- })
-
- it('uses the configured preparation cache through the public service', async () => {
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- const fiber = await ctx.plugin(SqliteSessionPersistence, {
- path: ':memory:',
- preparedSessionCacheSize: 1,
- writeBatchMaxDelayMs: 1,
+ const holder = spawn(process.execPath, ['--input-type=module', '-e', String.raw`
+ import { DatabaseSync } from 'node:sqlite';
+ const db = new DatabaseSync(process.argv[1]);
+ db.exec('BEGIN IMMEDIATE');
+ process.stdout.write('locked\n');
+ setTimeout(() => { db.exec('COMMIT'); db.close(); }, 100);
+ `, path], { stdio: ['ignore', 'pipe', 'pipe'] })
+ const exited = new Promise((resolve, reject) => {
+ holder.once('error', reject)
+ holder.once('exit', resolve)
})
- const m = meta('sqlite-preparation-cache')
- await ctx.sessionPersistence.create(m)
- await ctx.sessionPersistence.append(m.id, oneTurnLog())
-
- const preparation = await ctx.sessionPersistence.prepare(m.id)
- expect(preparation.session.header).toEqual(m)
- preparation[Symbol.dispose]()
- await fiber.dispose()
+ try {
+ await once(holder.stdout, 'data')
+ await expect(store.appendBatch(header, [chunk(1)], true)).resolves.toBeUndefined()
+ const code = await exited
+ expect(code).toBe(0)
+ expect((await store.loadStored(header.id))?.events).toEqual([chunk(0), chunk(1)])
+ } finally {
+ if (holder.exitCode === null) holder.kill()
+ await store.close()
+ }
})
- it('rejects and closes a current-schema database with an invalid store identity', async () => {
- const path = await freshDbPath()
- const db = openDatabase(path, 'wal')
- db.exec("UPDATE persistence_state SET store_id = '' WHERE singleton = 1")
+ it('rejects an older SQLite physical schema', async () => {
+ const path = await freshDbPath('dsh-sqlite-old-schema-')
+ const seed = await openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS)
+ seed.exec(testSql('set-user-version-16'))
+ seed.close()
+ await chmod(path, 0o600)
+ await expect(openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS))
+ .rejects.toThrow(/schema version 16.*incompatible/)
+ })
+
+ it('rejects a stale physical append without replacing the winning tail', async () => {
+ const path = await freshDbPath('dsh-sqlite-stale-')
+ const first = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
+ const second = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
+ const header = meta(SessionId('stale'))
+ await first.appendBatch(header, [chunk(0)], false)
+ await second.appendBatch(header, [chunk(1)], true)
+ await expect(first.appendBatch(header, [chunk(1)], true)).rejects.toThrow(/stored next seq is 2/)
+ expect((await first.loadStored(header.id))?.events).toEqual([chunk(0), chunk(1)])
+ await first.close()
+ await second.close()
+ })
+
+ it('rejects a stale repair without deleting a newer winning tail', async () => {
+ const path = await freshDbPath('dsh-sqlite-stale-repair-')
+ const stale = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
+ const winner = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
+ const header = meta(SessionId('stale-repair'))
+ await stale.appendBatch(header, [chunk(0)], false)
+ const db = new DatabaseSync(path)
+ db.prepare(testSql('insert-corrupt-event')).run(header.id, 1, 'assistant/chunk', 2, '{not json', null)
db.close()
+ expect((await stale.loadStored(header.id))?.tornMarker).toBe(1)
+ await winner.commitRepair(header, 1, [])
+ await winner.appendBatch(header, [chunk(1), chunk(2)], true)
+ await expect(stale.commitRepair(header, 1, [])).rejects.toThrow(/repair is stale/)
+ expect((await stale.loadStored(header.id))?.events).toEqual([chunk(0), chunk(1), chunk(2)])
+ await stale.close()
+ await winner.close()
+ })
+})
- const b = await backend(path)
- await expect(b.ctx.sessionPersistence.listSnapshots()).rejects.toThrow(/no valid store identity/)
- await expect(b.dispose()).resolves.toBeUndefined()
+describe('SessionPersistenceSqlite schema ownership', () => {
+ it('accepts every configured journal mode and SQLite memory mode result', async () => {
+ const resources = {
+ wal: 'journal-mode-wal',
+ delete: 'journal-mode-delete',
+ truncate: 'journal-mode-truncate',
+ persist: 'journal-mode-persist',
+ } as const
+ for (const mode of ['wal', 'delete', 'truncate', 'persist'] as const) {
+ ;(await openDatabase(DatabaseSync, ':memory:', mode, DEFAULT_BUSY_TIMEOUT_MS)).close()
+ const path = await freshDbPath(`dsh-sqlite-journal-${mode}-`)
+ const db = await openDatabase(DatabaseSync, path, mode, DEFAULT_BUSY_TIMEOUT_MS)
+ expect(db.prepare(sql(resources[mode])).get()).toEqual({ journal_mode: mode })
+ expect(db.prepare(sql('select-trusted-schema')).get()).toEqual({ trusted_schema: 0 })
+ expect(db.prepare(sql('select-mmap-size')).get()).toEqual({ mmap_size: 0 })
+ expect(db.prepare(sql('select-synchronous')).get()).toEqual({ synchronous: 2 })
+ db.close()
+ }
})
- it('creates a new database and WAL sidecars with owner-only modes without changing its parent mode', async () => {
- if (process.platform === 'win32') return
- const path = await freshDbPath()
- const dir = dirname(path)
- await chmod(dir, 0o755)
+ it('retries a busy journal-mode transition within its retry budget', async () => {
+ const path = await freshDbPath('dsh-sqlite-journal-busy-')
+ let attempts = 0
+ const BusyOnceDatabase = databaseWithJournalFailure(() => {
+ attempts += 1
+ return attempts === 1
+ ? Object.assign(new Error('database is locked'), {
+ code: 'ERR_SQLITE_ERROR',
+ errcode: 5,
+ errstr: 'database is locked',
+ })
+ : undefined
+ })
- const b = await backend(path)
- await b.ctx.sessionPersistence.list()
-
- expect((await stat(dir)).mode & 0o777).toBe(0o755)
- expect((await stat(path)).mode & 0o777).toBe(0o600)
- expect((await stat(`${path}-wal`)).mode & 0o777).toBe(0o600)
- expect((await stat(`${path}-shm`)).mode & 0o777).toBe(0o600)
- await b.dispose()
- })
-
- it('creates a persistent rollback journal with owner-only mode', async () => {
- if (process.platform === 'win32') return
- const path = await freshDbPath()
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- const fiber = await ctx.plugin(SqliteSessionPersistence, { path, journalMode: 'persist' })
- const m = meta('persist-permissions')
-
- await ctx.sessionPersistence.create(m)
- await ctx.sessionPersistence.append(m.id, oneTurnLog())
-
- expect((await stat(path)).mode & 0o777).toBe(0o600)
- expect((await stat(`${path}-journal`)).mode & 0o777).toBe(0o600)
- await fiber.dispose()
- })
-
- it('preserves the mode of an existing database file', async () => {
- if (process.platform === 'win32') return
- const path = await freshDbPath()
- await writeFile(path, '', { mode: 0o644 })
- await chmod(path, 0o644)
-
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- const fiber = await ctx.plugin(SqliteSessionPersistence, { path, journalMode: 'delete' })
- await ctx.sessionPersistence.list()
-
- expect((await stat(path)).mode & 0o777).toBe(0o644)
- await fiber.dispose()
- })
-
- it('surfaces an invalid database path during pre-creation', async () => {
- const path = await freshDbPath()
- const b = await backend(`${path}\0`)
-
- await expect(b.ctx.sessionPersistence.list()).rejects.toMatchObject({ code: 'ERR_INVALID_ARG_VALUE' })
- await b.dispose()
- })
-
- it('append rolls back and rethrows when an event INSERT fails inside the transaction', async () => {
- const path = await freshDbPath()
- const m = meta('rollback-insert')
- const b1 = await backend(path)
- await b1.ctx.sessionPersistence.create(m)
- await b1.ctx.sessionPersistence.append(m.id, oneTurnLog())
-
- // A SECOND backend over the same file loads the session first, so it adopts
- // cursor 6 (the committed length) into its OWN in-memory state.
- const b2 = await backend(path)
- await b2.ctx.sessionPersistence.load(m.id) // cursor 6 in b2
- const turn2: SessionEvent[] = [
- { type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
- { type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
- ]
- // b1 commits seq 6..7 first.
- await b1.ctx.sessionPersistence.append(m.id, turn2)
- // b2 still thinks its cursor is 6, so this batch passes the contiguity check
- // but its INSERT of seq 6 hits the UNIQUE (session_id, seq) constraint
- // mid-transaction → ROLLBACK + rethrow.
- await expect(b2.ctx.sessionPersistence.append(m.id, turn2)).rejects.toThrow(/UNIQUE/)
- // b1's turn is intact; b2's rolled-back attempt left nothing extra.
- const loaded = await b1.ctx.sessionPersistence.load(m.id)
- expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
- await b1.dispose()
- await b2.dispose()
- })
-
- it('journalMode config reaches the database (default wal, rollback modes selectable)', async () => {
- // :memory: databases always report journal_mode=memory, so probe file DBs.
- const walPath = await freshDbPath()
- const bWal = await backend(walPath)
- await bWal.ctx.sessionPersistence.create(meta('jm-wal'))
- const probe = openDatabase(walPath, 'wal')
- expect((probe.prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('wal')
- probe.close()
- await bWal.dispose()
-
- const deletePath = await freshDbPath()
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- const fiber = await ctx.plugin(SqliteSessionPersistence, { path: deletePath, journalMode: 'delete' })
- await ctx.sessionPersistence.create(meta('jm-delete'))
- // Probe through a second connection: journal_mode=delete is a per-database
- // property only insofar as no WAL files exist — assert the world, not the
- // backend's self-report (no -wal sidecar after writes in delete mode).
- const db = openDatabase(deletePath, 'delete')
- expect((db.prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('delete')
+ const db = await openDatabase(BusyOnceDatabase, path, 'wal', 100)
+ expect(attempts).toBe(2)
+ expect(db.prepare(sql('journal-mode-wal')).get()).toEqual({ journal_mode: 'wal' })
+ expect(db.prepare(sql('select-trusted-schema')).get()).toEqual({ trusted_schema: 0 })
+ expect(db.prepare(sql('select-mmap-size')).get()).toEqual({ mmap_size: 0 })
+ expect(db.prepare(sql('select-synchronous')).get()).toEqual({ synchronous: 2 })
db.close()
- expect(existsSync(`${deletePath}-wal`)).toBe(false)
- await fiber.dispose()
})
- it('HMR: a DIFFERENT session colliding with a materialized on-disk id is rejected', async () => {
- const path = await freshDbPath()
- // Instance 1 materializes a session and disposes.
- const b1 = await backend(path)
- const s1 = b1.ctx.sessions.create(SessionId('hmr-collide'))
- appendLog(s1, oneTurnLog())
- await b1.ctx.sessions.flush(s1)
- await b1.dispose()
+ it('does not retry journal failures outside the available busy budget', async () => {
+ for (const { errcode, timeout } of [
+ { errcode: 5, timeout: 0 },
+ { errcode: 6, timeout: 100 },
+ ]) {
+ let attempts = 0
+ const FailingDatabase = databaseWithJournalFailure(() => {
+ attempts += 1
+ return Object.assign(new Error(`SQLite error ${errcode}`), { errcode })
+ })
+ await expect(openDatabase(
+ FailingDatabase,
+ await freshDbPath(`dsh-sqlite-journal-failure-${errcode}-`),
+ 'wal',
+ timeout,
+ )).rejects.toThrow(`SQLite error ${errcode}`)
+ expect(attempts).toBe(1)
+ }
+ })
- // A fresh context with an UNRELATED live session reusing the id meets a
- // materialized row that is NOT a prefix of its events → reject.
+ it('starts no journal retry after its open-relative cutoff', async () => {
+ let attempts = 0
+ const BusyDatabase = databaseWithJournalFailure(() => {
+ attempts += 1
+ return Object.assign(new Error('database is locked'), { errcode: 5 })
+ })
+ const clock = vi.spyOn(performance, 'now')
+ .mockReturnValueOnce(0)
+ .mockReturnValueOnce(50)
+ .mockReturnValueOnce(100)
+ try {
+ await expect(openDatabase(
+ BusyDatabase,
+ await freshDbPath('dsh-sqlite-journal-cutoff-'),
+ 'wal',
+ 100,
+ )).rejects.toThrow('database is locked')
+ } finally {
+ clock.mockRestore()
+ }
+ expect(attempts).toBe(1)
+ })
+
+ it('paces repeated busy journal-mode attempts', async () => {
+ let attempts = 0
+ const BusyDatabase = databaseWithJournalFailure(() => {
+ attempts += 1
+ return Object.assign(new Error('database is locked'), { errcode: 5 })
+ })
+ await expect(openDatabase(
+ BusyDatabase,
+ await freshDbPath('dsh-sqlite-journal-paced-'),
+ 'wal',
+ 50,
+ )).rejects.toThrow('database is locked')
+ expect(attempts).toBeGreaterThan(1)
+ expect(attempts).toBeLessThanOrEqual(6)
+ })
+
+ it('rejects unversioned, incompatible, and foreign-application databases', async () => {
+ const unversionedPath = await freshDbPath('dsh-sqlite-unversioned-')
+ const unversioned = new DatabaseSync(unversionedPath)
+ unversioned.exec(testSql('create-unrelated-table'))
+ unversioned.close()
+ await expect(openDatabase(DatabaseSync, unversionedPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/unversioned schema/)
+
+ const incompatiblePath = await freshDbPath('dsh-sqlite-incompatible-')
+ const incompatible = new DatabaseSync(incompatiblePath)
+ incompatible.exec(testSql('set-user-version-16'))
+ incompatible.close()
+ await expect(openDatabase(DatabaseSync, incompatiblePath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/incompatible with this build/)
+
+ const foreignPath = await freshDbPath('dsh-sqlite-foreign-')
+ const foreign = new DatabaseSync(foreignPath)
+ foreign.exec(testSql('set-user-version-17'))
+ foreign.exec(testSql('set-application-id-12345'))
+ foreign.close()
+ await expect(openDatabase(DatabaseSync, foreignPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/has application id 12345/)
+ })
+
+ it('rejects changed columns and non-strict owned tables', async () => {
+ const changedPath = await freshDbPath('dsh-sqlite-columns-')
+ ;(await openDatabase(DatabaseSync, changedPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).close()
+ const changed = new DatabaseSync(changedPath)
+ changed.exec(testSql('add-unexpected-column'))
+ changed.close()
+ await expect(openDatabase(DatabaseSync, changedPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/required schema objects/)
+
+ const nonStrictPath = await freshDbPath('dsh-sqlite-nonstrict-')
+ ;(await openDatabase(DatabaseSync, nonStrictPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).close()
+ const nonStrict = new DatabaseSync(nonStrictPath)
+ nonStrict.exec(testSql('replace-events-with-nonstrict-table'))
+ nonStrict.close()
+ await expect(openDatabase(DatabaseSync, nonStrictPath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/required schema objects/)
+
+ const loosePath = await freshDbPath('dsh-sqlite-loose-')
+ const loose = new DatabaseSync(loosePath)
+ loose.exec(testSql('create-loose-schema'))
+ loose.close()
+ await expect(openDatabase(DatabaseSync, loosePath, 'wal', DEFAULT_BUSY_TIMEOUT_MS)).rejects.toThrow(/required schema objects/)
+ })
+
+ it('rejects schema ownership changes observed at mutation time', async () => {
+ const changedVersion = await openDatabase(DatabaseSync, ':memory:', 'wal', DEFAULT_BUSY_TIMEOUT_MS)
+ changedVersion.exec(testSql('set-user-version-16'))
+ expect(() => { validateSchemaForMutation(DatabaseSync, changedVersion, ':memory:') })
+ .toThrow(/schema changed before mutation/)
+ changedVersion.close()
+
+ const changedApplication = await openDatabase(DatabaseSync, ':memory:', 'wal', DEFAULT_BUSY_TIMEOUT_MS)
+ changedApplication.exec(testSql('set-application-id-12345'))
+ expect(() => { validateSchemaForMutation(DatabaseSync, changedApplication, ':memory:') })
+ .toThrow(/application id changed before mutation/)
+ changedApplication.close()
+ })
+
+ it('validates creation time and restores every optional header field', () => {
+ const base: SessionRow = {
+ id: 'stored-header',
+ version: 0,
+ created_at: 1,
+ cwd: '/project',
+ parent_session: 'parent',
+ seed_length: 4,
+ origin: 'subagent',
+ incarnation: '00000000-0000-4000-8000-000000000000',
+ revision: 1,
+ delegation_depth: 2,
+ agent_preset: 'minimal',
+ }
+ expect(rowToMeta(decodeSessionRow(base))).toMatchObject({
+ cwd: '/project',
+ parentSession: 'parent',
+ seedLength: 4,
+ origin: 'subagent',
+ delegationDepth: 2,
+ agentPreset: 'minimal',
+ })
+ expect(() => decodeSessionRow({ ...base, created_at: -1 })).toThrow(/created_at/)
+ expect(() => decodeSessionRow({ ...base, origin: 'external' })).toThrow(/origin/)
+ expect(() => decodeSessionRow({ ...base, delegation_depth: -1 })).toThrow(/delegation_depth/)
+ })
+
+ it('rejects malformed SQLite row primitives generically', () => {
+ const base: SessionRow = {
+ id: 'stored-header',
+ version: 0,
+ created_at: 1,
+ cwd: '/project',
+ parent_session: null,
+ seed_length: null,
+ origin: null,
+ incarnation: '00000000-0000-4000-8000-000000000000',
+ revision: 1,
+ delegation_depth: null,
+ agent_preset: null,
+ }
+ for (const [value, message] of [
+ [null, /object/],
+ [{ ...base, id: 1 }, /id.*string/],
+ [{ ...base, id: '' }, /id.*empty/],
+ [{ ...base, version: '0' }, /version.*safe integer/],
+ [{ ...base, cwd: 'relative' }, /cwd.*absolute/],
+ [{ ...base, cwd: 1 }, /cwd.*string or null/],
+ [{ ...base, incarnation: 'invalid' }, /incarnation.*UUID/],
+ [{ ...base, seed_length: '1' }, /seed_length.*safe integer or null/],
+ [{ ...base, agent_preset: 1 }, /agent_preset.*string or null/],
+ ] as const) {
+ expect(() => decodeSessionRow(value)).toThrow(message)
+ }
+
+ const eventRow = {
+ seq: 0, type: 'turn/start', time: 1, data: '{}',
+ source_event_seqs: null, surface_op: null, ignorable: null,
+ }
+ for (const [value, message] of [
+ [null, /object/],
+ [{ ...eventRow, seq: '0' }, /seq.*safe integer/],
+ [{ ...eventRow, type: '' }, /type.*empty/],
+ [{ ...eventRow, time: '1' }, /time.*safe integer/],
+ [{ ...eventRow, data: 1 }, /data.*string or blob/],
+ [{ ...eventRow, source_event_seqs: 1 }, /source_event_seqs.*blob or null/],
+ [{ ...eventRow, ignorable: 2 }, /ignorable.*0, 1, or null/],
+ ] as const) {
+ expect(() => decodeEventRow(value)).toThrow(message)
+ }
+ expect(() => decodeStoreIdentity({ store_id: 'invalid' })).toThrow(/store_id.*UUID/)
+ })
+
+ it('rejects invalid durable metadata before exposing a session header', async () => {
+ const path = await freshDbPath('dsh-sqlite-metadata-')
+ const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
+ const header = meta('invalid-metadata')
+ await store.appendBatch(header, [chunk(0)], false)
+ const db = new DatabaseSync(path)
+ db.prepare(testSql('update-invalid-session-metadata')).run(header.id)
+ db.close()
+ await expect(store.list()).rejects.toThrow(/seed_length|origin|delegation_depth/)
+ await expect(store.loadStored(header.id)).rejects.toThrow(/seed_length|origin|delegation_depth/)
+ await store.close()
+ })
+
+ it('uses the shared persistence application identity', () => {
+ expect(SESSION_PERSISTENCE_SQLITE_APPLICATION_ID).toBe(0x44534850)
+ })
+})
+
+describe('SessionPersistenceSqlite edge behavior', () => {
+ it('keeps a fresh database unopened until the first persistence operation', async () => {
+ const path = await freshDbPath('dsh-sqlite-lazy-')
const ctx = new Context()
await ctx.plugin(SessionStore)
- let session!: Session
- await ctx.plugin(Object.assign((inner: Context) => {
- session = inner.sessions.create(SessionId('hmr-collide'))
- }, { inject: ['sessions'] }))
- session.append('turn/start', { turn: 1 })
- await ctx.plugin(SqliteSessionPersistence, { path })
- await expectFlushError(ctx.sessions.flush(session), /id collision/)
+ await ctx.plugin(SessionPersistenceSqlite, { path })
+ await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
+ const emitWarning = Reflect.get(process, 'emitWarning')
+ expect(await ctx.sessionPersistence.list()).toEqual([])
+ expect(Reflect.get(process, 'emitWarning')).toBe(emitWarning)
+ expect(typeof (await stat(path)).size).toBe('number')
await ctx.fiber.dispose()
})
-})
-describe('surface field round-trip', () => {
- it('rowToEvent parses surface fields from EventRow columns', () => {
- const row: EventRow = {
- seq: 0, type: 'assistant/message', time: 1,
- data: JSON.stringify({ turn: 1, step: 1, content: [] }),
- source_event_seqs: JSON.stringify([3, 5]),
- surface_op: JSON.stringify('append'),
- ignorable: null,
- }
- const event = rowToEvent(row)
- expect((event as SurfaceEvent).sourceEventSeqs).toEqual([3, 5])
- expect((event as SurfaceEvent).surfaceOp).toBe('append')
- })
-
- it('rowToEvent handles replace surfaceOp object', () => {
- const row: EventRow = {
- seq: 0, type: 'assistant/message', time: 1,
- data: JSON.stringify({ turn: 1, step: 1, content: [] }),
- source_event_seqs: JSON.stringify([0, 1]),
- surface_op: JSON.stringify({ op: 'replace', start: 0, end: 1 }),
- ignorable: null,
- }
- const event = rowToEvent(row)
- expect((event as SurfaceEvent).sourceEventSeqs).toEqual([0, 1])
- expect((event as SurfaceEvent).surfaceOp).toEqual({ op: 'replace', start: 0, end: 1 })
- })
-
- it('scanRows with surface columns reconstructs events with surface fields', () => {
- const rows: EventRow[] = [
- { seq: 0, type: 'user/message', time: 1,
- data: JSON.stringify({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }),
- source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}', ignorable: null },
- { seq: 1, type: 'turn/end', time: 2,
- data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }),
- source_event_seqs: null, surface_op: null, ignorable: 1 },
- ]
- const { preserved } = scanRows(rows)
- expect(preserved).toHaveLength(2)
- expect((preserved[0]! as SurfaceEvent).surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
- expect((preserved[0]! as SurfaceEvent).sourceEventSeqs).toBeUndefined()
- expect((preserved[1] as SessionEvent).surfaceOp).toBeUndefined()
- })
-
- it('append and load round-trips surface fields through SQLite', async () => {
+ it('disposes after path validation without opening the database', async () => {
+ const path = await freshDbPath('dsh-sqlite-unused-')
const ctx = new Context()
await ctx.plugin(SessionStore)
- const fiber = await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' })
- const session = ctx.sessions.create(SessionId('roundtrip-surface'))
- session.append('turn/start', { turn: 1 })
- session.append('step/start', { turn: 1, step: 1 })
- session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- session.append('assistant/message', {
- turn: 1, step: 1,
- message: createMessage({
- role: 'assistant',
- content: [],
- source: {
- kind: 'model',
- ...{ provider: 'mock', model: 'mock' },
- },
- }),
- }, { surfaceOp: 'append', sourceEventSeqs: [2] })
- session.append('step/end', { turn: 1, step: 1 })
- session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- await ctx.sessions.flush(session)
- const loaded = await ctx.sessionPersistence.load(SessionId('roundtrip-surface'))
- expect(loaded.events).toHaveLength(6)
- const um = loaded.events[2]!
- expect((um as SurfaceEvent).surfaceOp).toBe('append')
- expect((um as SurfaceEvent).sourceEventSeqs).toBeUndefined()
- const am = loaded.events[3]!
- expect((am as SurfaceEvent).surfaceOp).toBe('append')
- expect((am as SurfaceEvent).sourceEventSeqs).toEqual([2])
- await fiber.dispose()
+ await ctx.plugin(SessionPersistenceSqlite, { path })
+ await ctx.fiber.dispose()
+ await expect(stat(path)).rejects.toMatchObject({ code: 'ENOENT' })
+
+ const untouchedPath = await freshDbPath('dsh-sqlite-never-validated-')
+ const untouched = new SqliteStore({
+ path: untouchedPath,
+ journalMode: 'wal',
+ busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS,
+ })
+ await untouched.close()
+ await expect(stat(untouchedPath)).rejects.toMatchObject({ code: 'ENOENT' })
})
- it('persists events with surfaceOp but no sourceEventSeqs (covers null branch in surfaceBindings)', async () => {
+ it('uses constructor defaults and exposes locate and prepare directly', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
- const fiber = await ctx.plugin(SqliteSessionPersistence, { path: ':memory:' })
- const session = ctx.sessions.create(SessionId('surface-noseq'))
- session.append('turn/start', { turn: 1 })
- session.append('user/message', createUserMessage({
- content: [],
- source: { kind: 'user' },
- }), { surfaceOp: 'append' })
- session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- await ctx.sessions.flush(session)
- const loaded = await ctx.sessionPersistence.load(SessionId('surface-noseq'))
- expect((loaded.events[1]! as SurfaceEvent).surfaceOp).toBe('append')
- expect((loaded.events[1]! as SurfaceEvent).sourceEventSeqs).toBeUndefined()
- await fiber.dispose()
+ let persistence!: SessionPersistenceSqlite
+ await ctx.plugin(Object.assign((inner: Context) => {
+ persistence = new SessionPersistenceSqlite(inner, { path: ':memory:' })
+ }, { inject: ['sessions'] }))
+
+ const header = meta('direct-provider')
+ const events = chunkLog(3)
+ expect(persistence.locate(header)).toBeUndefined()
+ await persistence.create(header)
+ await persistence.append(header.id, events)
+ const preparation = await persistence.prepare(header.id)
+ expect(preparation.session.header).toEqual(header)
+ preparation[Symbol.dispose]()
+ await ctx.fiber.dispose()
+ })
+
+ it('keeps empty mutations inert and rolls back a repair without metadata', async () => {
+ const store = new SqliteStore({
+ path: ':memory:',
+ journalMode: 'wal',
+ busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS,
+ })
+ const header = meta('empty-store')
+ await store.appendBatch(header, [], false)
+ await store.commitRepair(header, undefined, [])
+ expect(await store.readStoredRevision(header.id)).toBeUndefined()
+ await expect(store.commitRepair(header, 0, [])).rejects.toThrow(/metadata row is missing/)
+ await store.close()
+ })
+
+ it('rejects omitted torn markers and stale closer positions', async () => {
+ const path = await freshDbPath('dsh-sqlite-repair-validation-')
+ const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
+ const header = meta('repair-validation')
+ await store.appendBatch(header, [chunk(0)], false)
+ const db = new DatabaseSync(path)
+ db.prepare(testSql('insert-corrupt-event')).run(header.id, 1, 'assistant/chunk', 2, '{not json', null)
+ db.close()
+ await expect(store.commitRepair(header, undefined, [chunk(1)])).rejects.toThrow(/omitted current torn tail/)
+ await store.commitRepair(header, 1, [])
+ await expect(store.commitRepair(header, undefined, [chunk(2)])).rejects.toThrow(/closer starts at seq 2/)
+
+ const cleared = new DatabaseSync(path)
+ cleared.prepare(testSql('delete-session-events')).run(header.id)
+ cleared.close()
+ await store.commitRepair(header, undefined, [chunk(0)])
+ expect((await store.loadStored(header.id))?.events).toEqual([chunk(0)])
+ await store.close()
+ })
+
+ it('rejects malformed physical tail rows before appending', async () => {
+ const path = await freshDbPath('dsh-sqlite-tail-')
+ const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
+ const header = meta('invalid-tail')
+ await store.appendBatch(header, [chunk(0)], false)
+ const db = new DatabaseSync(path)
+ db.prepare(testSql('insert-corrupt-event'))
+ .run(header.id, 1, 'assistant/chunk', 2, '{not json', null)
+ db.close()
+
+ await expect(store.appendBatch(header, [chunk(2)], true)).rejects.toThrow(/invalid physical tail/)
+ await store.close()
+ })
+
+ it('rejects missing and empty store identities', async () => {
+ for (const mode of ['missing', 'empty'] as const) {
+ const path = await freshDbPath(`dsh-sqlite-identity-${mode}-`)
+ const db = await openDatabase(DatabaseSync, path, 'wal', DEFAULT_BUSY_TIMEOUT_MS)
+ if (mode === 'missing') db.exec(testSql('delete-persistence-state'))
+ else db.exec(testSql('empty-store-id'))
+ db.close()
+ await chmod(path, 0o600)
+
+ expect(errorMessage(await backendFailure(path))).toMatch(/no valid store identity/)
+ }
+ })
+
+ it('rejects invalid paths during service initialization', async () => {
+ const path = await freshDbPath('dsh-sqlite-invalid-path-')
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await expect(ctx.plugin(SessionPersistenceSqlite, { path: `${path}\0` })).rejects.toMatchObject({
+ code: 'ERR_INVALID_ARG_VALUE',
+ })
+ await ctx.fiber.dispose()
+ })
+
+ it('rejects non-files and symbolic links', async () => {
+ const directoryPath = await freshDbPath('dsh-sqlite-directory-')
+ await mkdir(directoryPath)
+ expect(errorMessage(await backendFailure(directoryPath)))
+ .toMatch(/must be a regular file/)
+
+ const linkPath = await freshDbPath('dsh-sqlite-link-')
+ const target = join(linkPath, '..', 'target.db')
+ await writeFile(target, '')
+ await symlink(target, linkPath)
+ expect(errorMessage(await backendFailure(linkPath)))
+ .toMatch(/not a symbolic link/)
+
+ const parentLinkPath = await freshDbPath('dsh-sqlite-parent-link-')
+ const realParent = join(parentLinkPath, '..', 'real-parent')
+ const linkedParent = join(parentLinkPath, '..', 'linked-parent')
+ await mkdir(realParent, { mode: 0o700 })
+ await symlink(realParent, linkedParent)
+ expect(errorMessage(await backendFailure(join(linkedParent, 'sessions.db'))))
+ .toMatch(/must be a real directory/)
+ })
+
+ it.runIf(
+ process.getuid !== undefined && process.getuid() !== 0,
+ )('rejects permissive files and writable parents', async () => {
+ const permissivePath = await freshDbPath('dsh-sqlite-permissive-')
+ await writeFile(permissivePath, '')
+ await chmod(permissivePath, 0o644)
+ expect(errorMessage(await backendFailure(permissivePath)))
+ .toMatch(/accessible only by that user/)
+
+ const writableParentPath = await freshDbPath('dsh-sqlite-parent-')
+ await chmod(join(writableParentPath, '..'), 0o770)
+ expect(errorMessage(await backendFailure(writableParentPath)))
+ .toMatch(/not group\/world-writable/)
+ })
+
+ it('surfaces database creation failures after path validation', async () => {
+ const path = await freshDbPath('dsh-sqlite-create-failure-')
+ const store = new SqliteStore({ path, journalMode: 'wal', busyTimeoutMs: DEFAULT_BUSY_TIMEOUT_MS })
+ await store.validatePath()
+ const parent = join(path, '..')
+ await rm(parent, { recursive: true })
+ await writeFile(parent, 'not a directory')
+ await expect(store.open()).rejects.toThrow(/ENOENT|ENOTDIR/)
+ await store.close()
})
})
diff --git a/packages/session/session-persistence-sqlite/tests/test-sql.ts b/packages/session/session-persistence-sqlite/tests/test-sql.ts
new file mode 100644
index 0000000000..77b53a404e
--- /dev/null
+++ b/packages/session/session-persistence-sqlite/tests/test-sql.ts
@@ -0,0 +1,32 @@
+/** Test-only loader for fixed SQLite fixtures. */
+
+import { readFileSync } from 'node:fs'
+
+export type TestSqlName =
+ | 'add-unexpected-column'
+ | 'count-events'
+ | 'count-ignorable-events'
+ | 'count-packed-events'
+ | 'count-physical-types'
+ | 'create-loose-schema'
+ | 'create-unrelated-table'
+ | 'delete-persistence-state'
+ | 'delete-session-events'
+ | 'empty-store-id'
+ | 'insert-corrupt-event'
+ | 'measure-write-traffic'
+ | 'replace-events-with-nonstrict-table'
+ | 'select-last-event'
+ | 'select-event-rowids'
+ | 'select-event-rows'
+ | 'select-user-version'
+ | 'set-application-id-12345'
+ | 'set-user-version-15'
+ | 'set-user-version-16'
+ | 'set-user-version-17'
+ | 'update-invalid-session-metadata'
+
+/** Load one fixed test SQL resource. */
+export function testSql(name: TestSqlName): string {
+ return readFileSync(new URL(`./resources/sql/${name}.sql`, import.meta.url), 'utf8')
+}
diff --git a/packages/session/session-persistence-sqlite/tsconfig.json b/packages/session/session-persistence-sqlite/tsconfig.json
index 2bb79c5919..8865e04321 100644
--- a/packages/session/session-persistence-sqlite/tsconfig.json
+++ b/packages/session/session-persistence-sqlite/tsconfig.json
@@ -20,6 +20,9 @@
{
"path": "../../core/session"
},
+ {
+ "path": "../../llm/llm"
+ },
{
"path": "../session-persistence"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ab96f791f1..1e03ab11da 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6344,15 +6344,27 @@ importers:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
+ '@deepseek-ai/cordis-plugin-include':
+ specifier: workspace:^
+ version: link:../../../vendor/include
+ '@deepseek-ai/cordis-plugin-loader':
+ specifier: workspace:^
+ version: link:../../../vendor/loader
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../runtime-diagnostics/invariants
+ '@deepseek-ai/dsh-llm':
+ specifier: workspace:^
+ version: link:../../llm/llm
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-session-persistence':
specifier: workspace:^
version: link:../session-persistence
+ typescript:
+ specifier: ^6.0.3
+ version: 6.0.3
packages/session/session-projection:
dependencies:
diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts
index 8b98333ce2..00d4f70077 100644
--- a/scripts/check-workspace-constraints.ts
+++ b/scripts/check-workspace-constraints.ts
@@ -156,6 +156,8 @@ const packageFileExtras: Readonly> = {
// sandbox-local resolves it through the package's ./runner export. tsdown
// also shares its generated FFI code through a hashed runtime chunk.
'@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js'],
+ // SQLite loads every statement from immutable package resources at runtime.
+ '@deepseek-ai/dsh-session-persistence-sqlite': ['resources/sql/**/*.sql'],
'@deepseek-ai/dsh-skill-badge': ['assets'],
'@deepseek-ai/dsh-subprocess-local': ['scripts/ensure-spawn-helper.mjs'],
}