Files
openchamber/packages/ui/src/sync/sanitize.ts
T
Hristo KaramanlievandBohdan Triapitsyn 9685630436 fix: lighten session list payloads (#1538)
* lighten session list

* fetch full session on open

* sanitize session list

* sanitize global sessions

* preserve revert markers in session lists

* fix: preserve session metadata in list sanitizers

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-06-11 23:06:19 +03:00

182 lines
5.3 KiB
TypeScript

// ---------------------------------------------------------------------------
// Payload sanitization — strip oversized diff snapshot fields client-side.
//
// OpenCode session/message snapshots may carry large full-content diff fields
// (legacy before/after or from/to). Revert snapshots and diff text can also
// carry large file snapshots. The UI derives reverted-state behavior from the
// lightweight messageID/partID markers, so these blob fields are intentionally
// kept out of client stores.
//
// Applied at two points:
// 1. Event reducer — session.created/session.updated events
// 2. Message loading — fetchMessages response
// 3. Session list loading — list responses should not populate stores with
// detail-only revert/diff blobs
// ---------------------------------------------------------------------------
import type { Session, Message } from "@opencode-ai/sdk/v2/client"
type DiffEntry = {
file?: string
status?: string
additions?: number
deletions?: number
before?: string
after?: string
from?: string
to?: string
}
type SessionSummary = {
diffs?: DiffEntry[]
[key: string]: unknown
}
type SessionRevert = {
messageID?: string
partID?: string
snapshot?: string
diff?: string
[key: string]: unknown
}
const getSessionListRevertMarker = (revert: unknown): Pick<SessionRevert, "messageID" | "partID"> | undefined => {
if (!revert || typeof revert !== "object" || Array.isArray(revert)) {
return undefined
}
const marker: Pick<SessionRevert, "messageID" | "partID"> = {}
const record = revert as SessionRevert
if (typeof record.messageID === "string") {
marker.messageID = record.messageID
}
if (typeof record.partID === "string") {
marker.partID = record.partID
}
return Object.keys(marker).length > 0 ? marker : undefined
}
const hasSessionListRevertDetails = (revert: unknown): boolean => {
if (revert === undefined) {
return false
}
if (!revert || typeof revert !== "object" || Array.isArray(revert)) {
return true
}
return Object.keys(revert).some((key) => key !== "messageID" && key !== "partID")
}
const stripDiffSnapshotFields = <T extends DiffEntry>(diff: T, includePatch: boolean): T => {
if (!diff || typeof diff !== "object") {
return diff
}
const shouldStrip =
typeof diff.before === "string"
|| typeof diff.after === "string"
|| typeof diff.from === "string"
|| typeof diff.to === "string"
|| (includePatch && "patch" in (diff as T & { patch?: unknown }) && typeof (diff as T & { patch?: unknown }).patch === "string")
if (!shouldStrip) {
return diff
}
const rest = { ...diff } as T & { patch?: string }
delete rest.before
delete rest.after
delete rest.from
delete rest.to
if (includePatch) {
delete rest.patch
}
return rest
}
/** Strip oversized snapshot fields from summary.diffs on a session object */
export function stripSessionDiffSnapshots(session: Session): Session {
const summary = (session as { summary?: SessionSummary }).summary
const revert = (session as { revert?: SessionRevert }).revert
let nextSession: Session = session
let changed = false
if (revert && (typeof revert.snapshot === "string" || typeof revert.diff === "string")) {
const nextRevert = { ...revert }
delete nextRevert.snapshot
delete nextRevert.diff
nextSession = { ...nextSession, revert: nextRevert } as Session
changed = true
}
if (!summary?.diffs || !Array.isArray(summary.diffs)) return nextSession
const stripped = summary.diffs.map((d) => {
const nextDiff = stripDiffSnapshotFields(d, true)
if (nextDiff !== d) {
changed = true
}
return nextDiff
})
if (!changed) return nextSession
return { ...nextSession, summary: { ...summary, diffs: stripped } } as Session
}
/** Strip detail-only fields from session list records before storing them. */
export function stripSessionListDetails(session: Session): Session {
const record = session as Session & {
summary?: SessionSummary
revert?: unknown
permission?: unknown
}
const shouldStrip = hasSessionListRevertDetails(record.revert)
|| Array.isArray(record.summary?.diffs)
|| "permission" in record
if (!shouldStrip) {
return session
}
const stripped = stripSessionDiffSnapshots(session) as typeof record
const next: Record<string, unknown> = { ...stripped }
delete next.permission
const revertMarker = getSessionListRevertMarker(stripped.revert)
if (revertMarker) {
next.revert = revertMarker
} else {
delete next.revert
}
const summary = stripped.summary
if (summary && typeof summary === "object" && !Array.isArray(summary)) {
const summaryWithoutDiffs = { ...summary }
delete summaryWithoutDiffs.diffs
next.summary = summaryWithoutDiffs
}
return next as unknown as Session
}
/** Strip oversized snapshot fields from summary.diffs on a message object */
export function stripMessageDiffSnapshots(message: Message): Message {
const summary = (message as { summary?: SessionSummary }).summary
if (!summary?.diffs || !Array.isArray(summary.diffs)) return message
let changed = false
const stripped = summary.diffs.map((d) => {
const nextDiff = stripDiffSnapshotFields(d, false)
if (nextDiff !== d) {
changed = true
}
return nextDiff
})
if (!changed) return message
return { ...message, summary: { ...summary, diffs: stripped } } as Message
}