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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user