Explorar o código

refactor(session): separate validation from snapshots

imccyu hai 1 mes
pai
achega
c02871b910
Modificáronse 2 ficheiros con 80 adicións e 20 borrados
  1. 40 20
      packages/core/session/src/index.ts
  2. 40 0
      packages/core/session/tests/session.spec.ts

+ 40 - 20
packages/core/session/src/index.ts

@@ -103,17 +103,16 @@ declare module 'cordis' {
   }
 }
 
-/** Detach, validate, and freeze the creation metadata published by a session. */
-function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
-  const input: unknown = source === undefined
-    ? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
-    : source
-  const snapshot = snapshotJsonValue(input)
-  if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
-  if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot)) {
+/** Validate and freeze one detached creation header in place. */
+function validateSessionHeader(id: SessionId, input: unknown): SessionHeader {
+  if (input === null || typeof input !== 'object' || Array.isArray(input)) {
     throw new Error('session header is not a plain JSON record')
   }
-  const record = snapshot as Record<string, unknown>
+  const prototype = Reflect.getPrototypeOf(input)
+  if (prototype !== Object.prototype && prototype !== null) {
+    throw new Error('session header is not a plain JSON record')
+  }
+  const record = input as Record<string, unknown>
   if (record.version !== SESSION_FORMAT_VERSION) {
     throw new Error(`session header version must be ${SESSION_FORMAT_VERSION}, got ${String(record.version)}`)
   }
@@ -148,31 +147,52 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
   return deepFreeze(record as unknown as SessionHeader)
 }
 
+/** Detach, validate, and freeze the creation metadata published by a session. */
+function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHeader {
+  const input: unknown = source === undefined
+    ? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
+    : source
+  const snapshot = snapshotJsonValue(input)
+  if (snapshot === undefined) throw new Error('session header is not losslessly JSON-serializable')
+  return validateSessionHeader(id, snapshot)
+}
+
 /**
- * Detach one event while preserving deep immutability for its identified message.
- * @param event - event imported across a query or persistence boundary.
- * @returns a detached event snapshot with a validated, deeply frozen message.
+ * Validate an exclusively owned event and deeply freeze its identified message
+ * without copying the event. The caller transfers an object graph that no
+ * producer retains and that shares no mutable children with another event.
+ * Use {@link snapshotSessionEvent} when exclusive ownership is not guaranteed.
+ * @param event - exclusively owned event imported across a trusted boundary.
+ * @returns the same event object with a validated, deeply frozen message.
  */
-export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
-  const snapshot = structuredClone(event)
+export function adoptSessionEvent<T extends SessionEvent>(event: T): T {
   assertMessageEventShape(
-    snapshot,
-    `session event at seq ${snapshot.seq}`,
+    event,
+    `session event at seq ${event.seq}`,
   )
-  switch (snapshot.type) {
+  switch (event.type) {
     case 'user/message':
-      deepFreeze(snapshot.data)
+      deepFreeze(event.data)
       break
     case 'assistant/message':
     case 'tool/result':
     case 'steering/message':
-      deepFreeze(snapshot.data.message)
+      deepFreeze(event.data.message)
       break
     default:
       // SessionEventMap is merge-extensible; plugin-owned events carry no core message.
       break
   }
-  return snapshot
+  return event
+}
+
+/**
+ * Detach one event while preserving deep immutability for its identified message.
+ * @param event - event imported across a query or persistence boundary.
+ * @returns a detached event snapshot with a validated, deeply frozen message.
+ */
+export function snapshotSessionEvent<T extends SessionEvent>(event: T): T {
+  return adoptSessionEvent(structuredClone(event))
 }
 
 /** Validate the fixed event envelope after one-pass JSON materialization. */

+ 40 - 0
packages/core/session/tests/session.spec.ts

@@ -2,6 +2,7 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest'
 import { Context } from 'cordis'
 import { createUserMessage, CallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
 import SessionStore, {
+  adoptSessionEvent,
   findLastMessageTurnEnd,
   SESSION_FORMAT_VERSION,
   Session,
@@ -389,6 +390,45 @@ describe('Session', () => {
       .toEqual([{ type: 'plugin-block', value: 1 }])
   })
 
+  it('adopts exclusively owned messages in place and keeps snapshots detached', () => {
+    const owned = {
+      type: 'user/message',
+      seq: 0,
+      time: 1,
+      surfaceOp: 'append',
+      data: {
+        id: 'owned-message',
+        role: 'user',
+        content: [{ type: 'text', text: 'owned' }],
+        source: { kind: 'user' },
+      },
+    } as SessionEvent<'user/message'>
+    expect(adoptSessionEvent(owned)).toBe(owned)
+    expect(Object.isFrozen(owned.data)).toBe(true)
+    expect(Object.isFrozen(owned.data.content)).toBe(true)
+
+    const source = structuredClone(owned)
+    const snapshot = snapshotSessionEvent(source)
+    expect(snapshot).not.toBe(source)
+    expect(snapshot.data).not.toBe(source.data)
+    expect(snapshot.data.content).not.toBe(source.data.content)
+  })
+
+  it('validates message shape before adopting ownership', () => {
+    const malformed = {
+      type: 'user/message',
+      seq: 0,
+      time: 1,
+      data: {
+        id: 'wrong-role',
+        role: 'assistant',
+        content: [],
+        source: { kind: 'user' },
+      },
+    } as unknown as SessionEvent
+    expect(() => adoptSessionEvent(malformed)).toThrow('message must have role "user"')
+  })
+
   it('round-trips a non-empty reasoning effort and rejects invalid durable values', () => {
     const valid = {
       type: 'request/header',