fix(session): snapshot seed + appended data at the boundary (review #31)

The source-level JSON-serializability invariant was only a preflight: the
Session constructor copied the seed array but shared every event/data
object with the caller, and append() stored the caller's `data` reference
verbatim. A post-create/post-append mutation could rewrite the durable
log or reintroduce a non-JSON-serializable value AFTER validation, so
session.events could diverge from what was validated / what a backend
can persist.

- ctor deep-clones each seed event after validation (not just the array).
- append() stores structuredClone(data) (serializability already checked,
  so the clone is safe); the returned event carries the same snapshot.

Regression tests: mutating the original seed / the passed append object
after the call leaves session.events unchanged. Adapted the dev-freeze
invariants test to assert on the logged clone (append no longer freezes
the caller's input). Documented isJsonValue's exact scope (own enumerable
string keys, matching JSON.stringify) and synced the README create()
signature with meta.createdAt.
This commit is contained in:
Tianyi Cui
2026-06-15 23:33:00 +08:00
parent 611791ba7f
commit 5299e43bed
5 changed files with 68 additions and 7 deletions

View File

@@ -90,7 +90,15 @@ export class Session {
throw new Error(`seed event "${event.type}" (seq ${event.seq}) carries non-JSON-serializable data`)
}
})
this.log = [...seed]
// Deep-clone each seed event, NOT just the array: the seed events and
// their `data` are still owned by the caller (or the source session of a
// fork), so keeping the references would let a post-create mutation of the
// original rewrite this session's durable log — or reintroduce a
// non-JSON-serializable value AFTER the validation above. Snapshotting at
// the boundary makes `session.events` independent and keeps it equal to
// what was validated. Serializability is guaranteed by the check above, so
// structuredClone can never hit a non-cloneable value here.
this.log = seed.map(event => structuredClone(event))
}
this.header = header ?? { version: 1, id, createdAt: Date.now() }
}
@@ -120,7 +128,16 @@ export class Session {
if (!isJsonValue(data)) {
throw new Error(`session event "${type}" carries non-JSON-serializable data`)
}
const event = { type, seq: this.log.length, time: Date.now(), data } as SessionEvent<T>
// Snapshot `data` into the log, NOT the caller's reference: the validation
// above proves it is JSON-serializable AT THIS MOMENT, but the caller still
// owns the object and could mutate it afterwards (before a persistence
// flush, or permanently in the in-memory history) — making `session.events`
// diverge from the value that passed validation, or reintroducing a
// non-serializable value. Cloning here keeps the log equal to what was
// validated. structuredClone is safe because serializability was just
// checked. The returned event carries the SAME snapshot, so a caller reading
// back `event.data` sees the logged value, not its own mutable input.
const event = { type, seq: this.log.length, time: Date.now(), data: structuredClone(data) } as SessionEvent<T>
this.log.push(event)
this.onAppend?.(event)
return event

View File

@@ -22,6 +22,14 @@
* 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.
*
* Scope — matches `JSON.stringify` exactly: only an object's OWN ENUMERABLE
* STRING-keyed properties are inspected (`Object.values`). Symbol-keyed and
* non-enumerable properties are NOT examined, because `JSON.stringify` likewise
* drops them — they never reach the durable form, so a non-serializable value
* hiding under a symbol/non-enumerable key cannot make the round-trip lossy.
* Getters are invoked during the check (again as `JSON.stringify` would), so the
* contract is for plain data records, not objects with side-effecting accessors.
*/
export function isJsonValue(value: unknown, seen: Set<object> = new Set()): boolean {
if (value === null) return true