Query once, then follow live
A normal query followed by a separate stream connection has a race: a change
can commit after the query but before the stream starts. queryAndFollow()
returns the snapshot and its opaque change boundary atomically. Passing that
boundary to subscribe({ after }) closes the gap.
First follow the source installation, including the SDK setup. Stop any other node using API port 8080 before this example. The supplied profile retains the latest USDC/WETH pool state and a bounded change stream; its public Xatu history needs no provider key:
LEANI_SDK_DIR="$(mktemp -d)"cp "$LEANI_SOURCE/examples/sdk-stream/node.toml" "$LEANI_SDK_DIR/leani.toml"cd "$LEANI_SDK_DIR"leani doctor --jsonleani backfill --processor usdc-weth-latest --from 17000000 --to 17000999leani serveIn another terminal, from the repository root:
cd "$LEANI_SOURCE"bun examples/sdk-stream/index.tsExpect Loaded 1 pools. The process then waits for more changes; this
historical-only profile has no live source, so silence after the seed is expected.
Press Ctrl-C to stop. The example keeps the price map in memory; for a durable
destination, continue with PostgreSQL.
This historical-only profile lets you inspect the seed and resume boundary. For ongoing P2P updates, enable live execution and verified finality as described in the Uniswap subscription guide.
import { applyEntityChange, createLeaniClient, LeaniError, type GenericSnapshotPage, type LeaniClient, type UniswapPoolPrice,} from "@leani/sdk";
// Run examples/sdk-stream/node.toml first (see the guide).export async function followPrices( leani: LeaniClient = createLeaniClient({ baseUrl: "http://127.0.0.1:8080" }), prices = new Map<string, UniswapPoolPrice>(), signal?: AbortSignal,): Promise<void> { const processor = "usdc-weth-latest"; const collection = "uniswap.pools.current"; const target = { put: (key: string, value: UniswapPoolPrice) => { prices.set(key, value); }, delete: (key: string) => { prices.delete(key); }, }; while (!signal?.aborted) { try { const snapshot = await leani.processors.queryAndFollow<UniswapPoolPrice>( processor, collection, { signal }, ); const seed = new Map<string, UniswapPoolPrice>(); try { let page: GenericSnapshotPage<UniswapPoolPrice> = snapshot; while (true) { for (const entity of page.data) seed.set(entity.key, entity.data); if (!page.nextCursor) break; page = await leani.processors.queryEntities<UniswapPoolPrice>( processor, collection, { cursor: page.nextCursor, signal }, ); } } finally { // Cleanup gets its own deadline even if the subscription was cancelled. await leani.processors.releaseSnapshot(processor, snapshot.snapshotId) .catch((error: unknown) => { if (!(error instanceof LeaniError && error.status === 404)) throw error; }); } prices.clear(); for (const [key, value] of seed) prices.set(key, value); console.log(`Loaded ${prices.size} pools`); for await (const change of leani.processors.subscribe<UniswapPoolPrice>( processor, { after: snapshot.boundaryCursor, signal }, )) { // Both apply and undo carry the entity mutation to perform. await applyEntityChange(target, change); if (change.operation === "finalized") { console.log("finalized through", change.data.throughBlock); } } } catch (error) { if (signal?.aborted) return; // ResetRequiredError from SSE is also a LeaniError with cursor_expired. if (!(error instanceof LeaniError) || !["cursor_expired", "query_snapshot_expired"].includes(error.code)) throw error; console.log("Retention moved; rebuilding the snapshot"); } }}
if (import.meta.main) await followPrices();- Follow every
nextCursorto materialize the complete stable snapshot. - Release the snapshot when the local seed is committed.
- Start the resumable stream at
boundaryCursor. - Apply both
applyandundo; an undo envelope carries the inverse entity mutation. - Treat
finalizedas an explicit transition andreset_requiredas a rebuild instruction. The SDK throwsResetRequiredErroron an SSE reset; expired HTTP cursors throwLeaniErrorwithcode: "cursor_expired". Never infer either transition from elapsed time.
The page limit only controls transport size. Creating the first page copies
the entire matching collection under the store writer lock. Defaults cap each
snapshot at 100,000 rows / 64 MiB and all outstanding snapshots together at 32
snapshots / 128 MiB (including key and row overhead). Physical store admission
also applies. Release snapshots promptly; the node expires them after five
minutes and cleans expired rows every 30 seconds. query_snapshot_capacity
returns retryable HTTP 503 when aggregate admission is full. Very slow readers
can outlive the retained stream and must rebuild, as the example does.