- Add paginated thread history with bounded snapshots and replay limits - Trim oversized wire payloads and support progressive mobile history loading
Showing the changes to packages/client-runtime/src/state/threadSnapshotHttp.ts (1 of 43 files). Show all
Unified · Split
| @@ -18,6 +18,30 @@ import { executeAuthenticatedEnvironmentHttpRequest } from "./environmentHttpAut · 17 unmodified lines | ||
| 18 | 18 | // delays the transition to live data on the first open, not the initial paint. |
| 19 | 19 | const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000; |
| 20 | 20 | |
| 21 | +/** Progressive history metadata returned by a bounded snapshot loader. */ | |
| 22 | +export type ThreadSnapshotHistoryMeta = { | |
| 23 | + readonly historyCursor: string | null; | |
| 24 | + readonly hasMoreHistory: boolean; | |
| 25 | + /** Max local turn ordinal from the full projection; optional on older servers. */ | |
| 26 | + readonly latestLocalTurnOrdinal?: number | null; | |
| 27 | +}; | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * Outcome of an HTTP thread-detail snapshot load. | |
| 31 | + * | |
| 32 | + * - `present`: snapshot body is available (seed projection, resume via socket). | |
| 33 | + * - `missing`: server definitively reported the thread does not exist (404). | |
| 34 | + * - `unavailable`: transport/timeout/5xx/etc.; fall back to the socket path. | |
| 35 | + */ | |
| 36 | +export type ThreadSnapshotLoadResult = | |
| 37 | + | { | |
| 38 | + readonly _tag: "present"; | |
| 39 | + readonly snapshot: OrchestrationV2ThreadDetailSnapshot; | |
| 40 | + readonly history?: ThreadSnapshotHistoryMeta; | |
| 41 | + } | |
| 42 | + | { readonly _tag: "missing" } | |
| 43 | + | { readonly _tag: "unavailable" }; | |
| 44 | + | |
| 21 | 45 | /** |
| 22 | 46 | * Load a thread's detail snapshot over HTTP instead of embedding it in the |
| 23 | 47 | * WebSocket subscription's first frame. The response is gzip-compressible by |
| @@ -49,10 +73,12 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( · 25 unmodified lines | ||
| 49 | 73 | export type FetchEnvironmentThreadSnapshotError = RemoteEnvironmentRequestError; |
| 50 | 74 | |
| 51 | 75 | /** |
| 52 | − * Loads a thread's detail snapshot over HTTP, returning `Option.none()` when it | |
| 53 | − * cannot be loaded (so the caller falls back to the socket-embedded snapshot). | |
| 54 | − * Decouples the thread state machine from the underlying HTTP + DPoP details and | |
| 55 | − * keeps them out of test contexts. | |
| 76 | + * Loads a thread's detail snapshot over HTTP. | |
| 77 | + * | |
| 78 | + * Distinguishes a definitive missing thread (HTTP 404) from transient snapshot | |
| 79 | + * unavailability so the thread state machine can mark the thread deleted without | |
| 80 | + * opening a socket subscription, while still falling back to the socket when the | |
| 81 | + * HTTP path is merely unavailable. | |
| 56 | 82 | */ |
| 57 | 83 | export class ThreadSnapshotLoader extends Context.Service< |
| 58 | 84 | ThreadSnapshotLoader, |
| @@ -60,7 +86,7 @@ export class ThreadSnapshotLoader extends Context.Service< · 1 unmodified line | ||
| 60 | 86 | readonly load: ( |
| 61 | 87 | prepared: PreparedConnection, |
| 62 | 88 | threadId: ThreadId, |
| 63 | − ) => Effect.Effect<Option.Option<OrchestrationV2ThreadDetailSnapshot>>; | |
| 89 | + ) => Effect.Effect<ThreadSnapshotLoadResult>; | |
| 64 | 90 | } |
| 65 | 91 | >()("@t3tools/client-runtime/state/threadSnapshotHttp/ThreadSnapshotLoader") {} |
| 66 | 92 | |
| @@ -80,19 +106,22 @@ export const threadSnapshotLoaderLayer: Layer.Layer< · 13 unmodified lines | ||
| 80 | 106 | return ThreadSnapshotLoader.of({ |
| 81 | 107 | load: (prepared: PreparedConnection, threadId: ThreadId) => |
| 82 | 108 | fetchEnvironmentThreadSnapshot({ prepared, threadId, signer }).pipe( |
| 83 | − Effect.map(Option.some<OrchestrationV2ThreadDetailSnapshot>), | |
| 109 | + Effect.map( | |
| 110 | + (snapshot): ThreadSnapshotLoadResult => ({ | |
| 111 | + _tag: "present", | |
| 112 | + snapshot, | |
| 113 | + }), | |
| 114 | + ), | |
| 84 | 115 | Effect.provideService(HttpClient.HttpClient, httpClient), |
| 85 | − // A genuinely missing thread (404) is expected — the socket | |
| 86 | − // subscription is the source of truth for thread existence and will | |
| 87 | − // surface the deletion — so don't treat it as an error worth warning | |
| 88 | − // about; just defer to the socket path. | |
| 116 | + // A genuinely missing thread (404) is definitive: do not fall back to | |
| 117 | + // the socket or retry. Callers mark the thread deleted and clear cache. | |
| 89 | 118 | Effect.catchTags({ |
| 90 | 119 | EnvironmentResourceNotFoundError: () => |
| 91 | 120 | Effect.logDebug( |
| 92 | − "Thread snapshot not found over HTTP; deferring to the socket subscription.", | |
| 121 | + "Thread snapshot not found over HTTP; treating the thread as deleted.", | |
| 93 | 122 | ).pipe( |
| 94 | 123 | Effect.annotateLogs({ threadId }), |
| 95 | − Effect.as(Option.none<OrchestrationV2ThreadDetailSnapshot>()), | |
| 124 | + Effect.as({ _tag: "missing" } satisfies ThreadSnapshotLoadResult), | |
| 96 | 125 | ), |
| 97 | 126 | }), |
| 98 | 127 | Effect.catchCause((cause) => |
| @@ -100,7 +129,7 @@ export const threadSnapshotLoaderLayer: Layer.Layer< · 1 unmodified line | ||
| 100 | 129 | "Could not load the thread snapshot over HTTP; using the socket snapshot instead.", |
| 101 | 130 | ).pipe( |
| 102 | 131 | Effect.annotateLogs({ threadId, cause: Cause.pretty(cause) }), |
| 103 | − Effect.as(Option.none<OrchestrationV2ThreadDetailSnapshot>()), | |
| 132 | + Effect.as({ _tag: "unavailable" } satisfies ThreadSnapshotLoadResult), | |
| 104 | 133 | ), |
| 105 | 134 | ), |
| 106 | 135 | ), |