@langchain/langgraph-sdk
Version:
Client library for interacting with the LangGraph API
1 lines • 16 kB
Source Map (JSON)
{"version":3,"file":"message-metadata-tracker.cjs","names":["StreamStore","#pendingCheckpointByNamespace","#pendingOptimisticIds","namespaceKey"],"sources":["../../src/stream/message-metadata-tracker.ts"],"sourcesContent":["/**\n * Per-message checkpoint metadata projection.\n *\n * # What this module is\n *\n * The protocol emits a `checkpoints` event immediately *before* its\n * companion `values` event for the same superstep:\n *\n * 1. `checkpoints` — `{ id, parent_id?, step?, source? }`\n * 2. `values` — `{ messages, ... }` (same namespace)\n *\n * Both events carry the same `seq` ordering but live on different\n * channels, so the controller can't atomically observe them. This\n * tracker bridges the gap by buffering each `checkpoints` envelope\n * keyed on its namespace, then consuming it when the matching values\n * payload arrives. Once paired, the consumer (typically the\n * controller) writes a {@link MessageMetadata} record under each\n * message id.\n *\n * # Why fork / edit flows need this\n *\n * Surfacing `parentCheckpointId` per-message lets UI flows like\n * \"edit a message and re-run\" call\n * `submit(input, { forkFrom: checkpointId })` without making the\n * caller juggle thread state. Each message remembers the checkpoint\n * it was first observed at, so a \"fork from this message\" UI can read\n * `useMessageMetadata(stream, msg.id)` directly.\n *\n * # Lifecycle\n *\n * - `bufferCheckpoint(ns, data)` — store the envelope until the\n * companion values event arrives.\n * - `consumeCheckpoint(ns)` — read-and-clear the envelope when\n * the values event lands. Returning `undefined` signals \"no\n * metadata to attach\" — older snapshots without a paired\n * checkpoint are still applied to the store, just without\n * `parentCheckpointId`.\n * - `recordMessages(msgs, meta)` — write metadata for the supplied\n * message ids if it differs from what's already stored.\n * - `reset()` — clear everything (called on\n * thread rebind / dispose).\n *\n * The buffer is read-and-cleared on consumption so a values event that\n * arrives without a fresh checkpoint envelope doesn't reuse stale\n * metadata from a previous superstep.\n */\nimport { StreamStore } from \"./store.js\";\nimport { namespaceKey } from \"./namespace.js\";\n\n/**\n * Optimistic lifecycle status for a message that originated from a\n * local {@link StreamController.submit} before the server echoed it.\n *\n * - `\"pending\"` — applied optimistically; the run is in flight and\n * the server has not yet echoed this id in a `values` snapshot.\n * - `\"sent\"` — the server echoed the id (run progressed); the\n * message is now server-authoritative.\n * - `\"failed\"` — the run failed before the id was echoed. The\n * message is kept (so UIs can show it with a retry affordance) but\n * is dropped on the next {@link StreamController.hydrate} because\n * it was never persisted server-side.\n *\n * Server-originated messages (history, streamed assistant turns) never\n * carry a status — `undefined` means \"not optimistic\".\n */\nexport type OptimisticStatus = \"pending\" | \"sent\" | \"failed\";\n\n/**\n * Metadata tracked per message id. Surfaced to applications via\n * `useMessageMetadata(stream, messageId)`.\n */\nexport interface MessageMetadata {\n /**\n * Checkpoint id the message's *parent* was at when this message was\n * observed. Drives fork / edit flows\n * (`submit(input, { forkFrom: checkpointId })`).\n *\n * `undefined` when the message was observed without a paired\n * checkpoint envelope (e.g. before checkpoints rolled out, or when\n * the caller stripped them upstream).\n */\n readonly parentCheckpointId: string | undefined;\n\n /**\n * Optimistic lifecycle status, present only for messages applied\n * locally by an optimistic `submit()`. `undefined` for ordinary\n * server-originated messages. See {@link OptimisticStatus}.\n */\n readonly optimisticStatus?: OptimisticStatus;\n}\n\n/**\n * Read-only map exposed via {@link MessageMetadataTracker.store}.\n */\nexport type MessageMetadataMap = ReadonlyMap<string, MessageMetadata>;\n\n/**\n * Lightweight envelope mirroring the on-wire `checkpoints` event.\n *\n * The protocol payload may include additional fields (`step`,\n * `source`, etc.) — we only carry what the per-message metadata\n * actually needs.\n */\nexport interface CheckpointEnvelope {\n /** Checkpoint id this superstep wrote. */\n readonly id: string;\n /**\n * Parent checkpoint id, when present. Becomes\n * {@link MessageMetadata.parentCheckpointId} on the next values event.\n */\n readonly parent_id?: string;\n /**\n * Monotonic superstep counter for the checkpoint. Used by the root\n * message projection to distinguish a fresh/live `values` snapshot\n * from an older one replayed by the content pump on reconnect, so a\n * stale replay can't remove tail messages the authoritative\n * `getState()` seed already established.\n */\n readonly step?: number;\n}\n\n/**\n * Frozen empty map used as the store's initial value. Keeping the\n * reference stable avoids spurious `setSnapshot` notifications on\n * `reset()` for consumers that haven't observed any metadata yet.\n */\nconst EMPTY_METADATA_MAP: MessageMetadataMap = new Map();\n\n/**\n * Tracks checkpoint-derived metadata for messages.\n *\n * Owns one {@link StreamStore} mapping `messageId → MessageMetadata`\n * plus a per-namespace buffer of pending checkpoint envelopes. The\n * controller wires it up via three call sites:\n *\n * 1. `controller.#onRootEvent(\"checkpoints\")`\n * → `bufferCheckpoint(namespace, data)`\n * 2. `controller.#onRootEvent(\"values\")`\n * → `consumeCheckpoint(namespace)` then\n * `recordMessages(values.messages, { parentCheckpointId })`\n * 3. `controller.#teardownThread`\n * → `reset()`\n *\n * @see useMessageMetadata - The framework hook that reads from\n * {@link MessageMetadataTracker.store}.\n */\nexport class MessageMetadataTracker {\n /** Observable map of messageId → metadata for framework consumers. */\n readonly store = new StreamStore<MessageMetadataMap>(EMPTY_METADATA_MAP);\n\n /**\n * Pending checkpoint envelopes awaiting their companion values\n * event. Keyed by `namespaceKey(namespace)` so a deeply-nested\n * checkpoint at one namespace doesn't collide with a root-level\n * checkpoint emitted in the same tick.\n */\n readonly #pendingCheckpointByNamespace = new Map<\n string,\n CheckpointEnvelope\n >();\n\n /**\n * Ids of messages currently in the `\"pending\"` optimistic state.\n * Maintained alongside the metadata map so the controller can cheaply\n * (a) flip ids to `\"sent\"` when the server echoes them and (b) flip\n * any leftover ids to `\"sent\"` / `\"failed\"` at run terminal, without\n * scanning the whole metadata map.\n */\n readonly #pendingOptimisticIds = new Set<string>();\n\n /**\n * Drop all buffered checkpoints and reset the metadata map to the\n * shared empty instance. Called on thread rebind / dispose so a new\n * thread's metadata can't bleed into the old one.\n */\n reset(): void {\n this.#pendingCheckpointByNamespace.clear();\n this.#pendingOptimisticIds.clear();\n this.store.setState(() => EMPTY_METADATA_MAP);\n }\n\n /**\n * Buffer a `checkpoints` event for later pairing with its values\n * companion.\n *\n * Defensive against missing / malformed payloads:\n *\n * - `data == null` → no-op (some upstream nodes elide the\n * payload entirely; we keep the previous buffered envelope so\n * the next consume call still wins).\n * - `id` not a string → no-op.\n * - `parent_id` not a string → omitted from the envelope.\n *\n * @param namespace - Event namespace (used as the buffer key).\n * @param data - Raw checkpoints payload.\n */\n bufferCheckpoint(\n namespace: readonly string[],\n data: { id?: unknown; parent_id?: unknown; step?: unknown } | null\n ): void {\n if (data == null || typeof data.id !== \"string\") return;\n const envelope: CheckpointEnvelope = { id: data.id };\n if (typeof data.parent_id === \"string\") {\n (envelope as { parent_id?: string }).parent_id = data.parent_id;\n }\n if (typeof data.step === \"number\") {\n (envelope as { step?: number }).step = data.step;\n }\n this.#pendingCheckpointByNamespace.set(namespaceKey(namespace), envelope);\n }\n\n /**\n * Read-and-clear the buffered checkpoint envelope for `namespace`.\n *\n * Always pairs with a single {@link bufferCheckpoint} call: a values\n * event without a matching buffered checkpoint returns `undefined`\n * (meaning \"no metadata to attach\"), and the next checkpoint event\n * starts fresh rather than reusing stale data.\n *\n * @param namespace - Event namespace to consume.\n * @returns The buffered envelope, or `undefined` when none was buffered.\n */\n consumeCheckpoint(\n namespace: readonly string[]\n ): CheckpointEnvelope | undefined {\n const key = namespaceKey(namespace);\n const checkpoint = this.#pendingCheckpointByNamespace.get(key);\n if (checkpoint != null) this.#pendingCheckpointByNamespace.delete(key);\n return checkpoint;\n }\n\n /**\n * Record metadata for a list of messages.\n *\n * Skips messages whose existing entry already matches `metadata`;\n * those without an `id` (or with a non-string id) are silently\n * ignored — there's nothing to key the metadata on. The store is\n * only updated when at least one entry actually changed, so\n * reapplying the same values snapshot is cheap.\n *\n * @param messages - Messages from the latest values payload.\n * @param metadata - Metadata to attach (currently just\n * `parentCheckpointId`).\n */\n recordMessages(\n messages: Array<{ id?: string }>,\n metadata: MessageMetadata\n ): void {\n const current = this.store.getSnapshot();\n let changed = false;\n const next = new Map(current);\n for (const msg of messages) {\n const id = msg?.id;\n if (typeof id !== \"string\" || id.length === 0) continue;\n const prev = next.get(id);\n if (\n prev != null &&\n prev.parentCheckpointId === metadata.parentCheckpointId\n ) {\n continue;\n }\n next.set(id, { ...prev, ...metadata });\n changed = true;\n }\n if (changed) this.store.setState(() => next);\n }\n\n /**\n * Mark a set of message ids as optimistically `\"pending\"`.\n *\n * Called from {@link StreamController}'s optimistic submit path right\n * after the messages are appended to the projection, so a UI can\n * render a \"sending…\" affordance via\n * `useMessageMetadata(stream, id).optimisticStatus`.\n *\n * @param ids - Message ids that were just applied optimistically.\n */\n markPending(ids: Iterable<string>): void {\n let changed = false;\n const next = new Map(this.store.getSnapshot());\n for (const id of ids) {\n if (typeof id !== \"string\" || id.length === 0) continue;\n this.#pendingOptimisticIds.add(id);\n const prev = next.get(id);\n if (prev?.optimisticStatus === \"pending\") continue;\n next.set(id, {\n parentCheckpointId: prev?.parentCheckpointId,\n ...prev,\n optimisticStatus: \"pending\",\n });\n changed = true;\n }\n if (changed) this.store.setState(() => next);\n }\n\n /**\n * Transition the given ids out of `\"pending\"`.\n *\n * Only ids currently tracked as pending are affected, so passing a\n * full server `values.messages` id list (to flip echoed messages to\n * `\"sent\"`) never stamps a status onto ordinary server messages.\n *\n * @param ids - Candidate ids (e.g. all ids in a server snapshot,\n * or the ids echoed by a single submit).\n * @param status - Terminal optimistic status (`\"sent\"` / `\"failed\"`).\n */\n resolvePending(ids: Iterable<string>, status: OptimisticStatus): void {\n let changed = false;\n const next = new Map(this.store.getSnapshot());\n for (const id of ids) {\n if (!this.#pendingOptimisticIds.has(id)) continue;\n this.#pendingOptimisticIds.delete(id);\n const prev = next.get(id);\n next.set(id, {\n parentCheckpointId: prev?.parentCheckpointId,\n ...prev,\n optimisticStatus: status,\n });\n changed = true;\n }\n if (changed) this.store.setState(() => next);\n }\n\n /**\n * Snapshot of ids whose optimistic status is `\"pending\"` or\n * `\"failed\"` — i.e. messages applied locally that the server has not\n * echoed. Used by {@link StreamController.hydrate} to drop\n * never-persisted optimistic messages so a reload converges to\n * server truth.\n */\n unpersistedOptimisticIds(): Set<string> {\n const ids = new Set<string>(this.#pendingOptimisticIds);\n for (const [id, meta] of this.store.getSnapshot()) {\n if (meta.optimisticStatus === \"failed\") ids.add(id);\n }\n return ids;\n }\n\n /**\n * Drop all metadata for the given ids. Called after never-persisted\n * optimistic messages are removed from the projection on\n * {@link StreamController.hydrate}, so their status doesn't linger.\n *\n * @param ids - Message ids to forget.\n */\n forget(ids: Iterable<string>): void {\n let changed = false;\n const next = new Map(this.store.getSnapshot());\n for (const id of ids) {\n this.#pendingOptimisticIds.delete(id);\n if (next.delete(id)) changed = true;\n }\n if (changed) this.store.setState(() => next);\n }\n}\n"],"mappings":"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"}