feat(session): metadata seam + JSON-serializability invariant
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
This commit is contained in:
63
packages/session/src/json.ts
Normal file
63
packages/session/src/json.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* JSON-serializability validation for session event data.
|
||||
*
|
||||
* The session event log is the durable source of truth (ADR 0003/0016): every
|
||||
* `event.data` must round-trip losslessly through JSON so any persistence
|
||||
* backend can store and reload it byte-identically. This invariant belongs to
|
||||
* the log itself — `Session.append` enforces it at the source, so a
|
||||
* non-serializable event never enters `session.events` and the live log can
|
||||
* never diverge from what a backend can persist. Backends re-use the same
|
||||
* predicate to validate their own `append(events)` entry point (replay/fork
|
||||
* paths that do not go through a live `Session`).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session/json
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers,
|
||||
* booleans, strings, plain arrays, and plain objects of such values. Rejects
|
||||
* `BigInt`, function, symbol, `undefined`, non-finite numbers (`NaN`/`Infinity`,
|
||||
* which `JSON.stringify` turns into `null`), and exotic objects (`Map`/`Set`/
|
||||
* `Date`/class instances) — anything `JSON.stringify` would drop, throw on, or
|
||||
* convert lossily. Sparse arrays are rejected too: a hole serializes to `null`,
|
||||
* so `[1, , 3]` would not round-trip. Detects circular references (which would
|
||||
* throw) and reports them as non-serializable rather than propagating the throw.
|
||||
*/
|
||||
export function isJsonValue(value: unknown, seen: Set<object> = new Set()): boolean {
|
||||
if (value === null) return true
|
||||
switch (typeof value) {
|
||||
case 'boolean':
|
||||
case 'string':
|
||||
return true
|
||||
case 'number':
|
||||
return Number.isFinite(value)
|
||||
case 'bigint':
|
||||
case 'function':
|
||||
case 'symbol':
|
||||
case 'undefined':
|
||||
return false
|
||||
case 'object':
|
||||
break // handled below
|
||||
}
|
||||
// object
|
||||
if (seen.has(value)) return false // circular
|
||||
seen.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
// Reject sparse arrays: a hole is skipped by `every`/`forEach` but
|
||||
// JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip
|
||||
// lossily. Require every index 0..length-1 to be an OWN property.
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (!Object.prototype.hasOwnProperty.call(value, i)) return false
|
||||
if (!isJsonValue(value[i], seen)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
// Plain object only (reject Map/Set/Date/class instances).
|
||||
const proto = Object.getPrototypeOf(value) as unknown
|
||||
if (proto !== Object.prototype && proto !== null) return false
|
||||
return Object.values(value).every(v => isJsonValue(v, seen))
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user