Status: implemented (v1) · Target: @deepseek-ai/dsh developer preview (breaking changes expected upstream)
Verified against deepseek-harness commit b150a551b8d465e31e418e1b2eaf5e79bbb7d28e
(0.1.1-rc.2, 2026-08-21) and Proton 3.0.29: the upstream persistence
contract (12 tests) and coordinator-orchestration contract (40 tests) both pass,
plus this package's durability, primitive, codec, and Timeplus-vs-SQLite
differential suites, and an end-to-end install into the published
@deepseek-ai/dsh@0.1.1-rc.2 CLI via dsh plugin add (README → Install).
pnpm build targets the published @deepseek-ai/*@0.1.1-rc.2 packages; the
test suites use a sibling upstream checkout because the contract suites are
not published.
Provide a SessionPersistence provider (ctx.sessionPersistence) that makes a
Timeplus stream the durable source of truth for the dsh append-only
SessionEvent log — not a mirror. Every capability that derives from the log
(resume, fork, replay, transcripts, readFrom watermark reads) works against
Timeplus, and the same stream is directly queryable with streaming SQL
(dashboards, materialized views, AgentGuard policies) with no export pipeline.
Non-goals (v1): multi-writer sessions, session-query provider (separate seam,
separate package later), raw artifacts (supportsRawArtifacts = false, like
SQLite), OTel telemetry (already covered by dsh-session-telemetry-otel →
Timeplus OTel ingest).
dsh backends do NOT implement the full 8-method SessionPersistence service by
hand. First-party providers implement the small PersistenceBackend interface
and delegate everything else to the shared PersistenceCoordinator
(@deepseek-ai/dsh-session-persistence), which owns write-behind batching, seq
cursors, live adoption, crash-repair sequencing, prepared-session LRU caching,
and dispose quiescence. Mirror session-persistence-sqlite/src/index.ts
wiring exactly (see src/index.ts in this package).
Backend primitives to implement (see src/store.ts):
| Primitive | Timeplus implementation |
|---|---|
appendBatch(meta, events, isMaterialized) |
one ingest batch to dsh_session_events; when !isMaterialized, a kind='header' row rides in the SAME batch (atomic materialization, §4.1) |
loadStored(id) |
historical query table(dsh_session_events) filtered by session_id, ordered by seq |
loadStoredFrom(id, fromSeq) |
same + seq >= fromSeq → seek-capable readFrom (parity with SQLite, better than JSONL) |
readStoredRevision(id) |
one aggregate row (§4.4) |
commitRepair(meta, tornMarker, closers) |
append kind='repair' closer rows; torn tails cannot occur (§4.3), so tornMarker is always undefined |
list() |
query kind='header' rows (or the headers MV) |
locate |
undefined — no per-session local artifact |
close |
close HTTP client / driver |
The service additionally implements listSnapshots() directly against the
store (one GROUP BY query), as SQLite does.
One canonical append stream. data holds the verbatim lossless JSON payload —
the durable log must reproduce events byte-faithfully (including raw
assistant/chunk events); extracted columns exist purely for querying.
CREATE STREAM IF NOT EXISTS dsh_session_events (
session_id string,
seq int64, -- header rows use -1
kind enum8('header' = 0, 'event' = 1, 'repair' = 2),
type string, -- '' for header rows; else SessionEvent type
turn int32 DEFAULT -1, -- convenience extraction; -1 when absent
step int32 DEFAULT -1,
event_time int64, -- event append time (ms); header: createdAt
data string -- verbatim JSON: SessionEvent.data or SessionHeader
);Convenience projections (not load-bearing for the contract; safe to add later):
-- headers for cheap list()
CREATE MATERIALIZED VIEW IF NOT EXISTS dsh_session_headers AS
SELECT session_id, data AS header, event_time AS created_at
FROM dsh_session_events WHERE kind = 'header';A store-identity row (random UUID written at bootstrap into a tiny
dsh_store_identity stream, first row wins) namespaces revisions so tokens
from different Timeplus stores never compare equal (§4.4).
Contract: the materialize-write and the first event batch MUST commit
atomically — a crash must not leave a materialized-but-empty session.
Implementation: the header row and the first events are rows of ONE ingest
batch into ONE stream. A single-batch ingest commits atomically or not at all.
list() sees a session iff its header row exists, so an abandoned
created-but-never-appended session leaves nothing behind (lazy
materialization, as the contract permits).
Contract: appendBatch returns only after the batch is durable, and a
subsequent read must see it (the coordinator probes loadStored for create
collisions and adoption).
Resolved (OPEN QUESTION #1): on Proton the HTTP ingest ack means the batch
is committed to the stream's NativeLog (durable), but it is NOT immediately
visible to table() queries, which read the historical store. Measured with
tests/durability.spec.ts: ack ≈ 50–70 ms, visibility up to ~2.1 s with the
default stream setting flush_threshold_ms = 2000; ≈ 40 ms past the ack with
flush_threshold_ms = 100. One insert block flushes as a unit (the first
non-empty observation holds the whole batch), so visibility is atomic.
Implementation:
- The stream is created with
SETTINGS flush_threshold_ms = <flushThresholdMs>(default 200, config knob). Timeplus does not allow altering it afterwards, so a pre-existing stream keeps whatever it was created with. appendBatch/commitRepairfirst read the session's watermark (one aggregate overtable()), reject a non-contiguous batch (append starts at seq X, stored next seq is Y, mirroring SQLite), treat an exact re-send of an already-stored batch as an idempotent no-op (a write-behind retry after a lost ack must not duplicate rows), ingest the rows as one request, then poll the watermark everyvisibilityPollIntervalMs(20) until the batch's rows and the header are queryable, failing aftervisibilityTimeoutMs(30 s).- Because every append resolves only after read-your-writes, all later
table()reads (load, revision, list) are consistent with the cursor. TRUNCATE STREAMhas the same ack-then-visible behavior (~60 ms); only the test helpers use it.
File backends can crash mid-record and must truncate a torn physical tail.
A Timeplus ingest batch cannot half-commit, so loadStored NEVER returns a
tornMarker and commitRepair reduces to appending the coordinator-supplied
synthetic closers (turn/end { reason: { kind: 'interrupted' } } etc.) as
kind='repair' rows with their assigned seqs. Type the backend
PersistenceBackend<undefined>. No truncation path, no tombstones — this is
the biggest simplification vs. JSONL/SQLite.
Contract: an opaque token that identifies one storage source and changes
whenever the stored log changes; repeated observation of an unchanged log
returns the same token; distinct stores never collide.
Implementation: timeplus:{storeIdentity}:{sessionId}:{rowCount}:{maxIngestMs}
derived by one aggregate query (count(), max(to_unix_timestamp64_milli(_tp_time))
over the session's rows); loadStored derives the same string from the rows it
fetched (src/revision.ts is the single formatter). Append-only + repair-append
means any change moves rowCount. storeIdentity is
{uuid from dsh_store_identity}:{database}.{stream}, so two providers on the
same database but different streams are still distinct sources.
Brand with SessionPersistenceRevision(...).
The coordinator enforces contiguous seqs from its in-memory cursor and assumes
ONE live owner per session id (dsh session docs: concurrent writers need a
signal beyond the log). SQLite gets this from file locking; a shared Timeplus
store does not. v1: document single-writer per session id as a deployment
contract. v1.1 (recommended): lease via a mutable stream
dsh_session_leases(session_id, owner, expires_at) checked on
create/prepare — reject a second live owner. Do not attempt server-side seq
validation in v1.
Store the header verbatim; on load, a SessionHeader.version ahead of this
build's SESSION_FORMAT_VERSION throws SessionFormatUnsupportedError with
the upgrade direction, one behind states no upgrade path — mirror the SQLite
wording. Unknown event types are handled by the coordinator's generated
vocabulary check; the backend just returns rows faithfully.
Every flush checkpoint is now a network RTT. The coordinator's bounded write
batching (writeBatchMaxDelayMs) coalesces the hot path; this backend targets
server/team deployments, not laptop defaults. Expose the batching knob in
Config like SQLite does.
loadStored: fetch all rows for id ordered by seq; split header row from event rows; parsedata(src/codec.ts); validate contiguity from 0; return fresh, unaliased object graphs (contract requires the backend retain nothing — the coordinator freezes them in place).loadStoredFrom: identical withseq >= fromSeq; also fetch the header row. Non-mutating; contiguity check scoped to the returned suffix.- Cancellation: honor
AbortSignalon all reads (wire into fetch).
# $DSH_HOME/profiles/<name>/cordis.patch.yml — a patch cannot change a row's
# plugin name (cordis include skips it with "name mismatch"), so disable the
# JSONL row and insert ours. Install the plugin first:
# dsh plugin --profile <name> add <path-to-tarball | npm spec>
- id: session-persistence-jsonl # the row from @deepseek-ai/dsh-base
disabled: true
- insert:
- id: session-persistence-timeplus
name: '@timeplus/dsh-session-persistence' # final npm name TBD
config:
url: http://localhost:8123 # Timeplus query/ingest endpoint
database: default
stream: dsh_session_events
username: ...
password: ... # or apiKey for Timeplus Cloud/Enterprise
bootstrapSchema: true # CREATE STREAM IF NOT EXISTS at init
flushThresholdMs: 200 # stream flush_threshold_ms at CREATE (§4.2)
visibilityPollIntervalMs: 20 # read-your-writes poll cadence (§4.2)
visibilityTimeoutMs: 30000
writeBatchMaxDelayMs: 25
preparedSessionCacheSize: 16Discover the row id to patch with dsh --profile web --dump-config.
tests/smoke.spec.ts applies this exact shape to upstream's headless-agent
composition through the real Loader.
Operational notes learned from Proton 3.0.29:
- Every stream preallocates a 2 GB NativeLog segment by default
(
data.datastore.log.segment_size/preallocate), and dropped streams are purged only on the next retention check (5 min). The provider creates exactly two streams per store, which is fine; test suites that churn streams are not —docker-compose.ymlships a dev override (64 MB segments, no preallocation) and the contract suites reuse one truncated stream per file. - All statements go over HTTP POST (GET is read-only); query parameters bind
as
{name:type}withparam_name=in the URL, which is how session ids are passed (never interpolated). Identifiers (database, stream) must match[A-Za-z_][A-Za-z0-9_]*and are backtick-quoted.
- Contract suites —
runPersistenceContractandrunCoordinatorContractfromdeepseek-harness/packages/session/session-persistence/tests/against a live Proton/Timeplus (dockertimeplus/protonin CI; skip whenTIMEPLUS_URLunset). These are the same conformance suites JSONL and SQLite run: creation, append, resume, crash-closers, revision stability, live-session adoption/collision, HMR, dispose quiescence.tests/contract.spec.ts— 52 tests. NocorruptTailinjector: torn tails are structurally impossible here (§4.3). - Differential —
tests/differential.spec.ts, ported fromsession-persistence-sqlite/tests/differential.spec.ts(fast-check): timeplus vs sqlite on identical random logs; every observable (inspect/list/readFrom for every seq, revision stability, remount) must be equal. 25 random runs by default (DSH_DIFFERENTIAL_RUNSto raise). - Durability probe —
tests/durability.spec.ts, the §4.2 read-your-writes integration test plus atomic materialization and lost-ack retry idempotence. - Primitives —
tests/revision.spec.ts: revision stability/agreement across the three read paths, distinct stores, repair, suffix reads, listing order, abort signals, format refusal (§4.6), contiguity corruption. - Unit —
tests/codec.spec.ts: codec round-trip (event ↔ row, header ↔ row), malformed-row errors, revision formatting, identifier quoting. Runs without a server. - Resume —
tests/resume.spec.ts: a live Session driven throughctx.sessions, provider restart,prepare()resume, and the stream's row count growing. - CLI smoke —
tests/smoke.spec.ts: upstream's headless-agent composition booted through the real Loader (dsh-app-boot, include + patches, keyless mock LLM) in a childtsxprocess with this provider patched in; a real bash tool round trip is persisted, a second process resumes the session id viactx.agents.resumeand continues it, andcount()over the stream grows.
dsh-session-query-timeplus: the cross-session read/search seam (packages/session-query/) backed by streaming SQL — fleet-scale full-text and trace queries.- Materialized views shipped as optional SQL: token spend per session/day, tool-call latency, error rates, active sessions.
- AgentGuard integration: streaming SQL policies over
dsh_session_eventsand/or atools/pre-executewaterfall plugin for inline enforcement. - Session lease (§4.5) for safe shared stores.
dsh is a developer preview and promises compatibility-breaking changes; the
persistence seam changed as recently as 2026-08 (write batching, session
preparation). Pin exact @deepseek-ai/* versions and re-run the contract
suite on every bump. The upstream Agent Notes under
.agents/notes/implemented/architecture/ (session-persistence,
session-preparation, bounded write batching, log version mechanism) are the
authoritative rationale — read them before changing semantics.