DSH plugin storage: StorageBackend, DomainSpec and changes

Plugin DevelopmentPublished 2026-10-17Author: DeepSeek Plugin Market
DeepSeek HarnessDSH pluginStorageBackendDomainSpecdomain/changedstorage
How a DSH plugin uses storage: the hub does no IO, backends own the medium, DomainSpec declares layout, and durable writes emit domain/changed.

DSH plugin storage has a clean division: the hub only meets and matches, backends own the medium, and data forms own semantics. So ctx.storage performs no IO — it is a name-to-backend table; writes actually happen in dsh-storage-json (atomic whole-file republish) or dsh-storage-sqlite (one document per row), and product code always goes through ctx.storageDomain. Two consequences follow: which backend serves a consumer is that consumer's own routing configuration, never a hub-wide choice, and a write persists first, updates memory second, and emits domain/changed last.

DSH plugin storage hub: backends own media, data forms own meaning

Bottom line: ctx.storage is a meeting point, not a store; it registers backends and mounts data forms.

  1. Backends sit side by side: ctx.storage.backend is a name-to-backend table.
  2. Routing is not the hub's job: consumer configuration decides which backend serves which domain.
  3. Register returns a disposer: duplicate names or lookups of unknown names throw StorageError.
  4. Dispose unregisters only: the owning plugin closes its backend afterwards.
  5. Lifecycle key: each backend plugin publishes storageBackendServiceKey(name) so data forms avoid registration races.
  6. Mountable forms: mount(form, facility) is an effect; a duplicate mount throws duplicate-mount, and resolving before mounting throws form-not-mounted — compose plugin order accordingly.

DSH plugin backend contract: StorageBackend, KvFacet and KvUnit

Bottom line: one backend owns one medium with optional facets; each unit call is atomic and durable, but concurrent ordering is the caller's problem.

ts
interface StorageBackend {
  readonly kv?: KvFacet
  close(): Promise<void>   // idempotent; drains in-flight writes
}
  1. Optional facets: a backend that cannot serve a data kind omits the facet and resolution fails loudly.
  2. Unit descriptor: name, current format version, optional compatible versions, table names and whether a global slot exists.
  3. Name constraint: unit and table names must match UNIT_NAME_RE; record keys stay arbitrary and never enter paths.
  4. Atomicity boundary: units do not serialize concurrent writes, but each call is atomic and durable on resolve.
  5. Error semantics: single mismatches reject version-mismatch; per-record documents outside the accepted set read as absent; unparseable media reject malformed-medium.
  6. Two shipped backends: json republishes one human-readable file per unit atomically; sqlite keeps one document per row for frequently updated data.

DSH plugin domain declaration: DomainSpec, layout and invalidRecords

Bottom line: a domain is declared once by its owner with a zod schema; declaration errors throw at module load, before touching media.

  1. Identity: name must match UNIT_NAME_RE and doubles as the backend unit name; version is a non-negative integer.
  2. Layout choice: single (one document per unit) or per-record (one document per record).
  3. Compatible versions: older versions whose records the current schema accepts; per-record backends read stamped documents and accept a legacy whole-unit file for bootstrapping.
  4. Invalid records: the default rejects the whole open with invalid-record (right for authoritative data); disposable derived data can use 'backup-and-skip'.
  5. Global slot: optional singleton; the schema must not accept null, since null is the medium's never-written sentinel.
  6. Declaration helpers: defineDomain(spec) validates at load, domainTable(schema) declares tables, descriptorOf(spec) projects the backend descriptor.

DSH plugin open and read/write: the write chain and domain/changed

Bottom line: reads come from authoritative memory; writes run persist → update memory → emit, and failures leave memory untouched.

  1. Opening: ctx.storageDomain.open(spec) runs in strict order and fails whole on any step (already-open, backend-not-found, facet-unsupported, version-mismatch, malformed-medium, invalid-record).
  2. Stable handles: domain.table(name) returns the same instance on repeat; global returns the spec's initial value before the first set.
  3. Reads: get/entries/keys/size from authoritative memory, with snapshot iterators stable across queued writes.
  4. Writes: put, delete, update and global.set queue on the per-domain write chain; a rejected backend write leaves memory unchanged.
  5. Update semantics: update(key, fn) is an atomic read-modify-write on the chain slot and rejects missing-key; deleting an absent key resolves false without a write or event.
  6. Events: domain/changed follows durability; put carries the new snapshot, deleted is a tombstone, and the event is in-process only.

DSH plugin routing and lifecycle: backend, routes and close

Bottom line: routing is domain-plugin configuration, callers close their own handles, and the facility only closes leftovers on unmount.

  1. Routing config: backend is the required default and routes overrides per domain name.
  2. Own your handle: callers close with Domain.close(), usually as their own effect disposer.
  3. Close semantics: reject new writes, drain queued writes (events still emit), release the unit, then free the name; idempotent.
  4. Fallback: the facility closes domains still open at unmount, and closeAll() is the unload path.
  5. Diagnostics: get(name) is an untyped lookup, not a substitute for typed handles.
  6. Verify: read immediately after a write to see the new value, and confirm a rejected write leaves the old one.

DSH plugin storage troubleshooting

Bottom line: identify which layer refused, then separate routing, schema and concurrency order.

  1. backend-not-found / facet-unsupported — check routing and whether the backend implements kv.
  2. invalid-record — check schema versus stored data; derived data may use backup-and-skip.
  3. missing-key — confirm the key exists before update, or use put.
  4. Memory diverging from medium — you mutated the returned object; replace through put/update.
  5. Concurrent writes out of order — units do not serialize them; ordering is the caller's job.
  6. Repair status, stated honestly: the hub/backend/form split, DomainSpec fields, write-chain order and event semantics follow the official Storage page; confirm signatures against its Cordis API and your version.

Sources: Storage, backend.ts, dsh-storage-json README.

FAQ

Is DSH plugin storage the same thing as session persistence?

No: DSH plugin storage holds data that is not the session event log, which has its own persistence seam. Keeping them apart preserves append-only session semantics while other data can use KV or SQL backends.

Does ctx.storage read files or databases directly in a DSH plugin?

No: the DSH plugin storage hub is only a meeting point where backends own the medium and data forms own semantics. The hub performs no IO, and product packages never touch a backend directly.

How do I choose between single and per-record layout in a DSH plugin DomainSpec?

A DSH plugin DomainSpec with single stores the whole unit as one document, while per-record stores each record separately. Use per-record when records are large, sparse or individually disposable, such as a projection cache.

Is domain/changed emitted before or after the DSH plugin write, and can it roll back?

DSH plugin domain/changed is emitted strictly after the backend confirms durability, following the domain's write chain. It is a notification rather than a transaction participant, so a throwing listener is caught and logged without rejecting an already-durable write.

Can I mutate a record object returned by DSH plugin storage in place?

No: the DSH plugin record returned is the stored object itself, not a copy, so in-place mutation bypasses the write chain. Replace it through put or update or memory and medium will diverge.

Related Terms

StorageBackend
StorageBackend is a DSH plugin storage implementation that owns exactly one medium, offering optional facets and an idempotent close. It only lands data; semantics belong to data forms and product packages never call it directly.— https://deepseek-harness.github.io/deepseek-harness/reference/subsystems/storage
DomainSpec
DomainSpec is a domain owner's static declaration of identity, version and record layout, including name, version, layout, compatible versions, an optional global slot and table declarations. It is the single source of that domain's schema.— https://deepseek-harness.github.io/deepseek-harness/reference/subsystems/storage
KvUnit
KvUnit is a named unit opened by a backend, offering loadAll, putRecord, deleteRecord, setGlobal and close. Unit and table names must match UNIT_NAME_RE while record keys are arbitrary strings that never enter file paths.— https://deepseek-harness.github.io/deepseek-harness/reference/subsystems/storage
domain/changed
domain/changed is the DSH plugin storage change event emitted after durability in write-chain order. put carries the new snapshot rather than the old value, deleted is a valueless tombstone, and the event is in-process only.— https://deepseek-harness.github.io/deepseek-harness/reference/subsystems/storage

Sources