import { STREAMS_EVENT_TYPES, StreamsEvent, StreamsEventType } from "@secondlayer/shared/streams-rows"; /** * A consumer sink: the destination adapter a consume loop writes through. * * Without a sink, the user owns the three hard parts of a durable indexer — * checkpoint persistence, rows+cursor atomicity, and reorg rollback — and * the reasoning that makes them safe lives in doc comments. A sink owns all * three: the loop loads its committed cursor, hands the handler a * transaction, commits rows AND cursor atomically, and rolls back reorgs * without any user code. Crucially, a sink also closes the silent-skip * hazard where omitting `onReorg` meant reorgs were ignored forever: with a * sink attached, rollback is unconditional. * * The interface is dependency-free on purpose: implementations with real * database drivers live behind subpath exports (`@secondlayer/sdk/sinks/*`) * so the root entry ships no DB dependency. * * ## The contract * * Binding on every implementation; the conformance kit * (`@secondlayer/sdk/sinks/testing`) probes each one. Violations are SILENT * in production — they surface as gaps or duplicates weeks later, never as * errors at the violation site — which is why they are spelled out here and * mechanically tested. * * What the loop guarantees the sink: * * 1. **Init-before-first-page.** `loadCursor` is called exactly once, before * the first fetch — even when the caller passed an explicit `fromCursor`. * It is the sink's init hook: create checkpoint storage, validate * rollback preconditions. * 2. **Deterministic replay.** Re-reading from the same cursor yields the * same rows in the same order. `cursor` is therefore a valid idempotency * key: an append-only sink can dedup on it with no contract change. * 3. **At-least-once delivery.** A batch whose commit outcome was lost (crash * after commit, before the loop observed it) is re-committed on restart * with the SAME cursor and the same rows. * * What the sink must guarantee — `commitBatch`: * * 4. **Rows+cursor atomicity.** The handler's writes and `cursor` commit in * ONE transaction. Committing them separately is the classic torn-batch * bug: a crash between the two either re-delivers (duplicates) or skips * (gap) depending on the order. * 5. **Abort on throw.** A throw from `write` aborts the WHOLE transaction — * neither rows nor cursor land — so a crashed batch is simply re-read. * 6. **The lent transaction is the real one.** `write(tx)` receives the same * live transaction the cursor commits in; every handler write must go * through it. Writes outside `tx` escape atomicity AND rollback. * 7. **Replay safety.** Committing the same cursor twice must not corrupt * state (per #3 the rows are identical; upsert/dedup make it exact). * * What the sink must guarantee — `rollback`: * * 8. **Delete+rewind atomicity.** Undoing rows at/above the fork and * committing `rewindCursor` happen in ONE transaction. A crash between * them resumes above the fork and the deleted range is never re-read — * the silent-gap bug. * 9. **Inclusive fork point.** Undo AT OR ABOVE `forkPointHeight` (`>=`) — * the new canonical chain re-supplies the fork block itself. * 10. **Idempotent.** The loop may re-apply the same rollback after a crash * (reorgs are deduped in memory only); a second application must be a * harmless no-op. * 11. **Scope by height, not cursor.** On a page reporting several forks the * loop calls `rollback` once per fork: each call carries its own * `forkPointHeight` but ALL carry the same `rewindCursor` (the lowest * fork point). Derive the undo range from `forkPointHeight` only; a * sink that derives it from `rewindCursor` under-deletes silently. * * Error semantics and concurrency: * * 12. **Throw, don't swallow; no internal retry.** The loop owns retry * policy. A swallowed commit error advances the loop past unwritten * data; an internal retry can double-apply around a partial failure. * 13. **Single writer per checkpoint id.** The loop assumes one live * consumer per checkpoint identity. A sink that can detect a second * writer (e.g. a lock) must fail loudly — never block or interleave. */ interface ConsumerSink { /** Phantom marker carrying `Tx` in a directly-inferable position, so the * consume loops' `TTx` type parameter resolves from the `sink` option * (never set at runtime). */ readonly _tx?: Tx; /** Static capabilities, read by the loop before the first fetch to fail * fast on impossible pairings. */ readonly capabilities?: { /** This sink cannot undo committed rows (append-only store, ClickHouse, * parquet, …), so `rollback` is unimplementable and following the * unfinalized tip would corrupt it on the first fork. The loop throws * loudly unless consuming with `finalizedOnly: true` — in that mode * reorgs never reach the sink and `rollback` may simply throw. */ finalizedOnly?: boolean; }; /** The committed checkpoint, or `null` on first run. Called once, before * the first page. Implementations may create their checkpoint storage * here and SHOULD validate their rollback preconditions (e.g. that every * declared table carries the height column). */ loadCursor(): Promise; /** * Apply one batch atomically: open a transaction, run `write(tx)` (the * user's inserts), and commit the rows AND `cursor` together. A throw * from `write` must abort the whole transaction — leaving neither rows * nor cursor, so a crashed batch is simply re-read on restart. */ commitBatch(cursor: string, write: (tx: Tx) => Promise | void): Promise; /** * Roll the projection back to the fork: delete everything AT OR ABOVE * `forkPointHeight` (inclusive `>=` — the new chain re-supplies the fork * block) and commit `rewindCursor` in the SAME transaction. Deleting * without the rewound cursor is the classic silent-gap bug: a crash * between the two writes resumes above the fork and the deleted range is * never re-read. * * On a multi-fork page this is called once PER fork, every call with the * same `rewindCursor` (the lowest fork point) but its own * `forkPointHeight` — scope the undo by `forkPointHeight` only (contract * invariant #11), and expect re-application after a crash (#10). * * `rewindCursor` is `null` only for a fork at genesis: clear the * checkpoint so the next run starts from the first event. */ rollback(forkPointHeight: number, rewindCursor: string | null): Promise; } /** `ctx` gains `tx` exactly when a sink is attached; without one the shape * is unchanged (an intersection with `unknown` is an identity, so * contextual typing of `onBatch` callbacks never degrades to a union). */ type WithSinkTx = [TTx] extends [never] ? unknown : { tx: TTx; }; type IndexEventBase = { cursor: string; block_height: number; block_time?: string | null; tx_id: string; tx_index: number; event_index: number; contract_id: string | null; /** Submitting-transaction context, present only when `txContext: true` was * requested. `tx_sender` is the real tx sender — distinct from a transfer's * asset `sender`, and the only place a `print` event's sender is available. * Lets a consumer build per-event tx context without a `/v1/index/transactions` * call per event. */ tx_sender?: string | null; tx_type?: string | null; tx_status?: string | null; tx_contract_id?: string | null; tx_function_name?: string | null; }; type IndexFtTransfer = IndexEventBase & { event_type: "ft_transfer"; asset_identifier: string; sender: string; recipient: string; amount: string; }; type IndexNftTransfer = IndexEventBase & { event_type: "nft_transfer"; asset_identifier: string; sender: string; recipient: string; value: string; }; type IndexStxTransfer = IndexEventBase & { event_type: "stx_transfer"; sender: string; recipient: string; amount: string; memo: string | null; }; type IndexStxMint = IndexEventBase & { event_type: "stx_mint"; recipient: string; amount: string; }; type IndexStxBurn = IndexEventBase & { event_type: "stx_burn"; sender: string; amount: string; }; type IndexStxLock = IndexEventBase & { event_type: "stx_lock"; sender: string; amount: string; payload: { unlock_height: string | null; }; }; type IndexFtMint = IndexEventBase & { event_type: "ft_mint"; asset_identifier: string; recipient: string; amount: string; }; type IndexFtBurn = IndexEventBase & { event_type: "ft_burn"; asset_identifier: string; sender: string; amount: string; }; type IndexNftMint = IndexEventBase & { event_type: "nft_mint"; asset_identifier: string; recipient: string; value: string; }; type IndexNftBurn = IndexEventBase & { event_type: "nft_burn"; asset_identifier: string; sender: string; value: string; }; type IndexPrint = IndexEventBase & { event_type: "print"; payload: { topic: string | null; value: unknown; raw_value: string | null; }; }; /** Decoded chain event, discriminated by `event_type`. */ type IndexEvent = IndexFtTransfer | IndexNftTransfer | IndexStxTransfer | IndexStxMint | IndexStxBurn | IndexStxLock | IndexFtMint | IndexFtBurn | IndexNftMint | IndexNftBurn | IndexPrint; import { StreamsEventPayload } from "@secondlayer/shared/streams-rows"; type StreamsTip = { block_height: number; block_hash: string; burn_block_height: number; /** * Highest Stacks block past the burn-confirmation finality boundary. * Optional for back-compat; the API always sets it. */ finalized_height?: number; lag_seconds: number; /** * Oldest height still seekable on the live API for the caller's tier * (`tip - retention`). `null` = unlimited retention. Older reads must use the * cold dumps lane. Optional for back-compat. */ oldest_seekable_height?: number | null; /** Oldest seekable cursor (`:0`); `null` = unlimited. */ oldest_cursor?: string | null; }; type StreamsCanonicalBlock = { block_height: number; block_hash: string; burn_block_height: number; burn_block_hash: string | null; is_canonical: true; }; type StreamsReorg = { detected_at: string; fork_point_height: number; orphaned_range: { from: string; to: string; }; /** * First position of the new canonical chain at the fork, `fork:0` * (INCLUSIVE). Not an exclusive resume token — resuming directly from it * skips `fork:0`. The consumer rewinds to the foot of `fork_point_height` * (`Cursor.atHeight`) to re-read the new run from `fork:0` inclusive. */ new_canonical_tip: string; }; type StreamsEventsEnvelope = { events: TEvent[]; next_cursor: string | null; tip: StreamsTip; reorgs: StreamsReorg[]; }; type StreamsEventsListEnvelope = Omit; /** The StreamsEvent union narrowed to a `types` selection — what a * const-generic `types: ["ft_transfer"]` buys at the type level. */ type StreamsEventOfTypes = Extract; type StreamsReorgsListParams = { since: string; limit?: number; }; type StreamsReorgsListEnvelope = { reorgs: StreamsReorg[]; next_since: string | null; }; /** A filter that matches a single value or any value in a list. */ type StreamsFilterValue = string | readonly string[]; /** * One labelled filter group. Fields inside a group AND together; groups in a * map OR together server-side. */ type StreamsLabelledFilter = { types?: readonly StreamsEventType[]; contractId?: StreamsFilterValue; sender?: StreamsFilterValue; recipient?: StreamsFilterValue; assetIdentifier?: string; }; /** A map of label → filter group. Labels are yours; the server echoes them. */ type StreamsFilterMap = Record; /** * The events one label can yield. A group that declares `types` narrows to * exactly those variants, so a per-label handler needs no `event_type` guard; * a group without `types` keeps the full union. */ type StreamsEventForFilter< F, D extends boolean = false > = F extends { types: readonly (infer T extends StreamsEventType)[]; } ? D extends true ? Extract : Extract : D extends true ? IndexEvent : StreamsEvent; type StreamsEventsListParams = { cursor?: string | null; fromHeight?: number; toHeight?: number; types?: readonly StreamsEventType[]; /** Event types to exclude (applied after `types`). */ notTypes?: readonly StreamsEventType[]; contractId?: StreamsFilterValue; sender?: StreamsFilterValue; recipient?: StreamsFilterValue; assetIdentifier?: string; /** * Labelled filter groups. The groups OR together in one scan and each * returned event carries the labels it matched (`event.matched`) — two * unrelated concerns share one page, one cursor, one checkpoint. */ filters?: StreamsFilterMap; limit?: number; }; type StreamsEventsStreamParams = { fromCursor?: string | null; types?: readonly StreamsEventType[]; notTypes?: readonly StreamsEventType[]; contractId?: StreamsFilterValue; sender?: StreamsFilterValue; recipient?: StreamsFilterValue; assetIdentifier?: string; /** Labelled filter groups; see {@link StreamsEventsListParams.filters}. */ filters?: StreamsFilterMap; batchSize?: number; emptyBackoffMs?: number; maxPages?: number; maxEmptyPolls?: number; signal?: AbortSignal; }; type StreamsEventsSubscribeParams = { /** Resume strictly after this cursor; omit to live-tail from the tip. */ fromCursor?: string | null; types?: readonly StreamsEventType[]; notTypes?: readonly StreamsEventType[]; contractId?: StreamsFilterValue; sender?: StreamsFilterValue; recipient?: StreamsFilterValue; assetIdentifier?: string; /** Labelled filter groups; see {@link StreamsEventsListParams.filters}. */ filters?: StreamsFilterMap; /** Abort to unsubscribe (the returned function does the same). */ signal?: AbortSignal; /** * Called for each pushed event, in order. The resume cursor advances only * after this resolves, so a handler that throws sees the same event again * on reconnect (at-least-once). Key durable writes by `cursor`. */ onEvent: (event: StreamsEvent) => void | Promise; /** * Called on every failure. Transport errors (dropped socket, 5xx, 429, a * stale connection, a throwing handler) reconnect from the last handled * cursor with exponential backoff. Errors that a retry cannot fix (401, * 4xx, a bad signature) end the subscription and reject its `done`. */ onError?: (err: unknown) => void; /** * First reconnect pause in ms (default 1000). Doubles per consecutive * failure up to 30 s with jitter, resets once a frame arrives, and never * undercuts a `Retry-After`. */ reconnectDelayMs?: number; /** * Reconnect when no frame (pings included) arrives for this long (default * 60000, three server heartbeats). Catches a half-open socket that never * errors; set it above your instance's heartbeat interval. */ staleAfterMs?: number; }; /** * Handle for a live subscription: call it to unsubscribe. `done` resolves * after unsubscribe (or the signal aborting) and rejects with the error that * ended the loop when a retry could not fix it, so a worker can `await` * it instead of polling for silence. */ type StreamsSubscription = { (): void; done: Promise; }; /** * The checkpoint the SDK computes for a batch. Persist `cursor` inside the same * transaction as your projection writes, then resume from it via `fromCursor`. * It is the position to advance to: `next_cursor` normally, or the last * finalized event when `finalizedOnly` is set. */ /** * What the loop knows at the end of a page, handed to `onBatch`. * * `cursor` is the position to commit. The rest answers "how far along am I, * and am I still moving" — the question every deployed consumer has to expose * for a health check. * * Progress is reported here rather than left to callers because only the loop * can get it right: `height` has to survive empty pages (normal at the tip) * and has to roll back to `fork_point_height - 1` after a reorg rewind. * Deriving it from `cursor` is wrong — `Cursor.atHeight` encodes the foot of a * block as `${height - 1}:`, so a rewound cursor parses to a * position that was never reached. */ type ConsumerBatchContext< TTip = unknown, TReorg = unknown > = { /** Checkpoint to commit alongside your rows. */ cursor: string | null; /** Highest canonical block a row was DELIVERED from. `null` before the * first row of a fresh consume. With a sparse filter this parks at the * last matching event — it measures event recency, not progress. */ height: number | null; /** Highest canonical block the sweep has VERIFIED through: an empty page * is the server confirming nothing matches between the cursor and the * tip, so a caught-up tail scans to the tip even when `height` is far * below it. Rolls back with reorg rewinds. */ scannedHeight: number | null; /** Chain tip as of this page's read. */ tipHeight: number; /** `tipHeight - scannedHeight`, floored at 0 — the consumer's actual * backlog. `null` until a position is known. A caught-up consumer on a * quiet contract reads ~0 here while `tipHeight - height` grows — that * difference is event age, not lag. */ blocksBehind: number | null; /** * The full tip for this page — `StreamsTip` or `IndexTip` depending on the * surface. Exposed here so a handler never needs the `envelope` parameter, * which is the only way to reach it today and is also how the worst * `finalizedOnly` mistake becomes reachable (`return envelope.next_cursor` * jumps the cursor past events that were filtered out and never delivered). */ tip: TTip; /** * Reorgs reported alongside this page; empty when none. Rollback still * belongs in `onReorg` — this is for observability inside a batch. */ reorgs: readonly TReorg[]; }; /** @deprecated Use {@link ConsumerBatchContext} — the Index and Streams loops * share one shape now. Kept as an alias so existing annotations compile. */ type StreamsBatchContext = ConsumerBatchContext; /** * The checkpoint for a reorg rollback. Persist `cursor` (the rewind position) * inside the same transaction as your rollback so the two commit atomically. */ /** `cursor` is the rewind position the loop resumes from after `onReorg`: * the foot of the fork point, or `null` for a fork at genesis. Persist it * in the same transaction as the rollback. */ type StreamsReorgContext = { cursor: string | null; }; type StreamsEventsConsumeParams< TTx = never, D extends boolean = false, F extends StreamsFilterMap = Record > = { fromCursor?: string | null; /** * Deliver events pre-decoded as the flat, `event_type`-discriminated rows * Index serves (`IndexEvent`), so Streams consumption reads identically * to Index consumption — no guard+decode pairs in your handler. */ decoded?: D; /** * Destination adapter that owns the checkpoint + rollback transaction * (e.g. `kyselySink` from `@secondlayer/sdk/sinks/kysely`). With a sink: * the loop resumes from the sink's committed cursor, `onBatch` receives * `ctx.tx` and must write ONLY through it (rows and cursor commit in one * transaction), reorg rollback is automatic, and `onBatch`'s return value * is ignored. Folds (balances) invert in the sink's `onRollback`, not in * `onReorg`. */ sink?: ConsumerSink; /** Fires once per page, before `onBatch` and before any early return. */ onProgress?: (ctx: ConsumerBatchContext) => void; mode?: "tail" | "bounded"; /** * Emit only finalized (immutable) events and never surface reorgs. The SDK * checkpoints at the last finalized event and re-reads the unfinalized tail * until it settles. Trades finality lag for zero reorg handling; `onReorg` is * ignored. */ finalizedOnly?: boolean; types?: readonly StreamsEventType[]; notTypes?: readonly StreamsEventType[]; contractId?: StreamsFilterValue; sender?: StreamsFilterValue; recipient?: StreamsFilterValue; assetIdentifier?: string; /** * Labelled filter groups. The groups OR together in ONE server-side scan, * so two unrelated concerns share one page, one cursor, and one checkpoint * instead of two consume loops. Pair with `on` to handle each label. */ filters?: F; /** * One handler per label in `filters`. Every label must be handled, so * adding a label is a compile error until its handler exists. * * A label's declared `types` narrows its handler's events, so `payload` is * typed without an `event_type` guard. Labels are dispatched in declaration * order: a label's own events stay in cursor order, but events of different * labels do not interleave within a page — add `onBatch` when you need * strict global order across labels. */ on?: { [K in keyof F] : (events: StreamsEventForFilter[], ctx: StreamsBatchContext & WithSinkTx) => void | Promise }; batchSize?: number; /** * Apply a page of canonical events. Persist `ctx.cursor` in the same * transaction as your writes. Returning a cursor overrides `ctx.cursor` as * the resume point (advanced manual control); returning nothing uses it. * * Optional only when `on` handles the page instead; a consume call with * neither throws. */ onBatch?: (events: D extends true ? IndexEvent[] : StreamsEvent[], envelope: StreamsEventsEnvelope, ctx: StreamsBatchContext & WithSinkTx) => void | string | null | undefined | Promise | Promise; /** * Roll your projection back to `reorg.fork_point_height`, persisting * `ctx.cursor` in the same transaction. Called once per *new* reorg at or * below the checkpoint (deduped in-memory, fork-ascending) before the SDK * rewinds and re-reads the now-canonical events. A reorg whose fork sits * above the checkpoint is not a rollback (nothing past it was written) and * is skipped. Omit it to ignore reorgs (events stay canonical, but stale * rows from an orphaned fork are left in place). * * With `onReorg` or a `sink` attached, every empty page also polls * `reorgs.list` (one extra request per idle poll) so a fork that lands * while the consumer idles at the tip is rolled back too. */ onReorg?: (reorg: StreamsReorg, ctx: StreamsReorgContext) => Promise | void; /** Deepest rewind one reorg may make below the checkpoint before the loop * refuses with `ValidationError`. Default 1000 blocks. The fork point is * server supplied and drives a delete on every declared table, so raise * this only for a source you trust. */ maxRollbackDepth?: number; /** Page-fetch retries after the first failure (429/5xx/network only — * 4xx and handler throws always propagate). Default 3; `0` disables. */ retryCount?: number; /** Base retry delay in ms; the n-th retry waits `retryDelay * n`. A server * `Retry-After` overrides it. Default 1000. */ retryDelay?: number; /** Void observer, called before each retry sleep (metrics/logging). */ onError?: (err: unknown, ctx: { attempt: number; retriesLeft: number; delayMs: number; }) => void; emptyBackoffMs?: number; maxPages?: number; maxEmptyPolls?: number; signal?: AbortSignal; }; /** * One yielded page from {@link StreamsClient.consume} — the * `GET /v1/streams/events` envelope verbatim, with `next_cursor` renamed to * `cursor` (the checkpoint to persist and resume from). */ type StreamsBatch = { /** Canonical events of this page, in cursor order. */ events: TEvent[]; /** Checkpoint after this page — pass back as `consume({ cursor })` to resume. */ cursor: string | null; tip: StreamsTip; /** Chain reorgs reported alongside this page; empty when none. */ reorgs: StreamsReorg[]; }; type StreamsConsumeParams = { /** Resume strictly after this cursor; omit to start from the oldest seekable page. */ cursor?: string | null; types?: readonly StreamsEventType[]; notTypes?: readonly StreamsEventType[]; contractId?: StreamsFilterValue; sender?: StreamsFilterValue; recipient?: StreamsFilterValue; assetIdentifier?: string; /** Labelled OR-groups — same semantics as `events.consume`; each returned * event echoes the labels it matched. */ filters?: StreamsFilterMap; /** Events per page (the `limit` query param). Default 100. */ batchSize?: number; /** Poll interval while caught up at the tip, in ms. Default 2000. */ intervalMs?: number; /** Abort to end the iteration. */ signal?: AbortSignal; }; type StreamsEventsConsumeResult = { cursor: string | null; pages: number; emptyPolls: number; }; type StreamsEventsReplayParams = { /** Start point: `"genesis"` (default) or a `:` cursor. */ from?: "genesis" | string; /** * Called once per finalized dump file, in block order, before live tailing. * Process the parquet with your own tooling (e.g. DuckDB) — the SDK does not * decode parquet. Use `client.dumps.download(file)` to fetch + verify bytes. * * Delivery is file-granular and at-least-once: a file whose range straddles * `from` is handed over whole. `ctx.from` is that cursor (`null` from * genesis); skip rows at or below it, or key rows by `cursor` so a re-run * is an idempotent no-op. Files ending at or below `from` are not handed * over at all. */ onDumpFile: (file: StreamsDumpFile, ctx: { from: string | null; }) => Promise | void; /** Called per live page after the dump phase, like `consume`. */ onBatch: (events: StreamsEvent[], envelope: StreamsEventsEnvelope) => Promise | string | null | undefined; /** * Called when the live-tail phase (after the dump backfill) crosses a reorg, * before the cursor rewinds and re-reads the now-canonical events — same * contract as `consume`. The dump-backfill phase is finalized and never * reorgs; omit this to leave stale rows from an orphaned fork in place. */ onReorg?: (reorg: StreamsReorg, ctx: StreamsReorgContext) => Promise | void; mode?: "tail" | "bounded"; batchSize?: number; emptyBackoffMs?: number; maxPages?: number; maxEmptyPolls?: number; signal?: AbortSignal; /** Narrow the LIVE TAIL after the dump phase. The dump files themselves are * always all-type (block-partitioned parquet) — filter those in your own * tooling while processing each file. */ types?: readonly StreamsEventType[]; notTypes?: readonly StreamsEventType[]; /** Labelled OR-groups for the live tail, like `consume`. */ filters?: StreamsFilterMap; }; type FetchLike2 = (input: string | URL | Request, init?: RequestInit) => Promise; /** One bulk parquet file in the dumps manifest. `path` is the object key under * the dumps base URL. */ type StreamsDumpFile = { path: string; from_block: number; to_block: number; min_cursor: string | null; max_cursor: string | null; row_count: number; byte_size: number; sha256: string; schema_version: number; created_at: string; }; type StreamsDumpsManifest = { dataset: string; network: string; version: string; schema_version: number; generated_at: string; producer_version: string; finality_lag_blocks: number; /** Cursor at the end of the finalized bulk coverage — hand to live tailing. */ latest_finalized_cursor: string | null; coverage: { from_block: number; to_block: number; }; files: StreamsDumpFile[]; /** ed25519 signature over the manifest's canonical bytes. Absent on legacy * unsigned manifests. Verified by `list()` when `verifyDumpsManifest` is on. */ signature?: string; /** Short id of the signing public key. */ key_id?: string; }; type StreamsDumps = { /** Fetch and parse the latest dumps manifest. */ list(): Promise; /** Absolute URL for a manifest file. */ fileUrl(file: StreamsDumpFile): string; /** Download a parquet file and verify its sha256 against the manifest. */ download(file: StreamsDumpFile): Promise; }; type StreamsClient = { /** * Follow Streams as an async iterator of page batches. * * Yields one {@link StreamsBatch} per `GET /v1/streams/events` page — the * existing envelope (`events`, `next_cursor` → `cursor`, `tip`, `reorgs`) * with zero extra API calls. Batches are chosen over per-block groupings * because the envelope is page-keyed, so every yield is exactly one fetch. * Empty pages are skipped; at the tip the iterator re-polls every * `intervalMs` (default 2000) until aborted via `signal`. * * Reorgs are surfaced on the batch (`batch.reorgs`) but the cursor is not * rewound automatically — use `events.consume` with `onReorg` for managed * rollback semantics. */ /** Narrowing overload: a literal `types` array narrows every batch's * event union to exactly those members. */ consume(params: StreamsConsumeParams & { types: T; }): AsyncIterableIterator>>; consume(params?: StreamsConsumeParams): AsyncIterableIterator; events: { /** Narrowing overload, matching `consume`. */ list(params: StreamsEventsListParams & { types: T; }): Promise>>; list(params?: StreamsEventsListParams): Promise; byTxId(txId: string): Promise; /** * Pull pages from Streams and call `onBatch` after each page. * * Use `consume` for indexers and ETL jobs that own checkpointing. Return * the checkpoint cursor from `onBatch`. Default `mode: "tail"` keeps * polling when caught up; `mode: "bounded"` exits on the first empty page. * The consumer also exits when `maxPages`, `maxEmptyPolls`, or `signal` * stops it. */ consume< const F extends StreamsFilterMap = Record, TTx = never, D extends boolean = false >(params: StreamsEventsConsumeParams & { sink?: ConsumerSink; }): Promise; /** * Backfill from bulk dumps, then continue live from the dump→live seam in * one call. Iterates finalized dump files (via `onDumpFile`) in block * order, then tails live from the manifest's `latest_finalized_cursor` * (exclusive input → no gap or duplicate at the seam). Requires * `dumpsBaseUrl`. */ replay(params: StreamsEventsReplayParams): Promise; /** * Follow Streams as an async iterator. * * Use `stream` for live processors and watch-style apps. It tails * indefinitely by default and stops when its `AbortSignal`, `maxPages`, or * `maxEmptyPolls` stops it. */ /** Narrowing overload, matching `consume`. */ stream(params: StreamsEventsStreamParams & { types: T; }): AsyncIterable>; stream(params?: StreamsEventsStreamParams): AsyncIterable; /** * Subscribe to the real-time SSE push surface. Calls `onEvent` for each new * canonical event as the server pushes it (chain cadence, not poll-bounded), * and verifies each frame's inline ed25519 signature when the client was * created with `verify`. Returns an unsubscribe function whose `done` * settles when the loop ends. Not reorg-aware: an event delivered then * orphaned is never retracted. Durable writers use `consume()`. */ subscribe(params: StreamsEventsSubscribeParams): StreamsSubscription; }; blocks: { events(heightOrHash: number | string): Promise; }; reorgs: { list(params: StreamsReorgsListParams): Promise; }; /** Bulk parquet dumps. Requires `dumpsBaseUrl` on the client. */ dumps: StreamsDumps; canonical(height: number): Promise; tip(): Promise; }; type CreateStreamsClientOptions = { apiKey?: string; baseUrl?: string; /** Deploy origin label sent as `x-sl-origin` (telemetry). Defaults to `cli`. */ origin?: "cli" | "mcp" | "session"; fetchImpl?: FetchLike2; /** * Public base URL for bulk parquet dumps (the R2/CDN bucket root). Required * to use `client.dumps`. See `GET /public/streams/dumps/manifest`. */ dumpsBaseUrl?: string; /** * Verify the ed25519 `X-Signature` on every REST response and per-frame SSE * signature. Three states: * - **default (omitted)** — *lenient*: verify when the server signs (the * hosted API signs every response), and pass through when no signature is * present (e.g. a self-hosted instance with no `STREAMS_SIGNING_PRIVATE_KEY`). * So verification is on by default against the hosted API without breaking * unsigned self-host deployments. An *invalid* signature always throws. * - **`true`** (or `{ publicKey }` to pin a known PEM) — *strict*: a missing * OR invalid signature throws `StreamsSignatureError`. Use this when you * require a portable, non-repudiable attestation and won't accept unsigned * data (it also closes the lenient mode's strip-the-header downgrade). * - **`false`** — off. * * The key is fetched once from `/public/streams/signing-key` (cached; a * rotated `X-Signature-KeyId` triggers one refresh) unless a PEM is pinned. */ verify?: boolean | { publicKey: string; }; /** * Verify the bulk dumps manifest's ed25519 signature in `client.dumps.list()` * before trusting any file sha256 (default ON). Uses the same key source as * `verify` (fetches `/public/streams/signing-key`, or a pinned PEM). Pass * `false` to opt out. A missing or invalid signature throws * `StreamsSignatureError`. */ verifyDumpsManifest?: boolean; }; declare function createStreamsClient(options: CreateStreamsClientOptions): StreamsClient; /** * Decode a raw Streams event into the SAME flat, `event_type`-discriminated * row shape Index serves — so Streams consumption reads identically to Index * consumption, with one call instead of eleven guard+decode pairs. * * ```ts * for (const event of envelope.events) { * const row = decode(event); * if (row.event_type === "ft_transfer") row.amount; // string, narrowed * } * ``` * * Prefer `decoded: true` on `streams.events.consume` — then decoding never * appears in your code at all. */ declare function decode(event: StreamsEvent): IndexEvent; /** Options accepted by every error in the family. */ interface SecondLayerErrorOptions { cause?: Error; details?: string; /** Stable machine-readable code (e.g. `UPGRADE_REQUIRED`). */ code?: string; /** Docs page that explains this failure and its fix. */ docsUrl?: string; /** Extra context lines appended to the message (DDL to run, next steps). */ metaMessages?: string[]; /** Whether retrying the SAME request can succeed (429/5xx/network). */ retryable?: boolean; /** Parsed `Retry-After`, in seconds — set on rate limits that carry it. */ retryAfterSeconds?: number; } /** * Root of the SDK error family. Implements the same protocol as `BaseError` * in `@secondlayer/stacks` (`shortMessage`, `cause`, `toJSON`) so a failed * chain read and a failed platform read serialize and introspect identically. * It intentionally does NOT extend that class: cross-package `instanceof` is * unreliable under per-package bundling anyway (match on `code`/`name` across * packages). * * `retryable` is the signal the consume loops act on: 429/5xx/network are * retryable, 4xx and body-serialization failures are not. */ declare class SecondLayerError extends Error { name: string; /** The one-line failure, without the appended context blocks. */ shortMessage: string; code?: string; docsUrl?: string; metaMessages?: string[]; retryable: boolean; retryAfterSeconds?: number; constructor(shortMessage: string, options?: SecondLayerErrorOptions); /** * Walk the cause chain. With a predicate, returns the first error matching * it (or `null`); without one, returns the deepest cause. Mirrors viem's * `error.walk()` so cross-library error handling reads the same. */ walk(fn?: (err: unknown) => boolean): unknown; toJSON(): { name: string; message: string; shortMessage: string; cause: string | undefined; code: string | undefined; docsUrl: string | undefined; retryable: boolean; retryAfterSeconds: number | undefined; }; } /** * Error thrown by {@link SecondLayer} when an API request fails. * Includes the HTTP status code for programmatic error handling. * * @example * ```ts * try { * await client.subgraphs.status("my-subgraph"); * } catch (err) { * if (err instanceof ApiError && err.status === 404) { * console.log("Subgraph not found"); * } * } * ``` */ declare class ApiError extends SecondLayerError { /** HTTP status code (0 for network errors). */ status: number; /** Raw response body (parsed JSON if possible) — preserved for callers that need error details. */ body?: unknown; constructor(status: number, message: string, body?: unknown, code?: string, options?: SecondLayerErrorOptions); } /** Thrown on a 401 by both the instance clients and Streams. Carries the * server's `{error, code}` envelope when it sent one, so a revoked token reads * differently from a missing one. */ declare class AuthError extends ApiError { readonly status: 401; constructor(message?: string, body?: unknown, code?: string); } /** Thrown on a 429 by both the instance clients and Streams. `retryable`, * with `retryAfterSeconds` parsed from the `Retry-After` header when sent. */ declare class RateLimitError extends ApiError { /** Raw `Retry-After` header value (seconds or HTTP-date). */ readonly retryAfter?: string | undefined; readonly status: 429; constructor(message?: string, retryAfter?: string | undefined, body?: unknown, code?: string); } /** Thrown on a 4xx the caller can fix (bad cursor, bad params). Never retried. * `code` is the server's machine-readable code when the envelope carried one. */ declare class ValidationError extends ApiError { constructor(message: string, status: number, body?: unknown, code?: string); } /** Thrown on a 5xx from the Streams API, and on a failed signing-key fetch. * `retryable`: the page retry policy tries again. */ declare class StreamsServerError extends ApiError { constructor(message: string, status: number, body?: unknown, code?: string); } /** Thrown when response signature verification is enabled and fails. */ declare class StreamsSignatureError extends SecondLayerError { constructor(message?: string); } import { decodeFtTransfer, isFtTransfer, decodeNftTransfer, isNftTransfer, decodeStxBurn, decodeStxLock, decodeStxMint, decodeStxTransfer, isStxBurn, isStxLock, isStxMint, isStxTransfer, decodeFtBurn, decodeFtMint, decodeNftBurn, decodeNftMint, isFtBurn, isFtMint, isNftBurn, isNftMint, decodePrint, isPrint } from "@secondlayer/shared/streams-rows"; import { DecodedEventColumns, DecodedEventRow, DecodedFtBurn, DecodedFtBurnPayload, DecodedFtMint, DecodedFtMintPayload, DecodedFtTransfer, DecodedFtTransferPayload, DecodedNftBurn, DecodedNftBurnPayload, DecodedNftMint, DecodedNftMintPayload, DecodedNftTransfer, DecodedNftTransferPayload, DecodedPrint, DecodedPrintPayload, DecodedPrintValue, DecodedStxBurn, DecodedStxBurnPayload, DecodedStxLock, DecodedStxLockPayload, DecodedStxMint, DecodedStxMintPayload, DecodedStxTransfer, DecodedStxTransferPayload, FtTransferEvent, FtTransferPayload as FtTransferPayload2, NftTransferEvent, NftTransferPayload as NftTransferPayload2 } from "@secondlayer/shared/streams-rows"; /** * Helpers for Streams cursors. A cursor is the opaque `:` string * that marks a position in the event stream; treat the format as an * implementation detail and go through these helpers instead of string-building * it at call sites. Encode/decode and the rewind sentinel come from the * canonical codec in `@secondlayer/shared` so the SDK cannot accept a spelling * the server would 400, or rewind to a different foot than Index/Streams. */ declare const Cursor: { /** * Cursor at the foot of `height` — a position that sorts strictly below the * first event of block `height` (`height:0`) and strictly above every event * of block `height - 1`. Cursors are exclusive (`(bh,ei) > after`), so * resuming from it re-reads the entire canonical run starting at `height:0` * inclusive. This is the position to rewind to after a reorg whose fork point * is `height`: the new canonical block at `height` carries a fresh first * event at `(height, 0)` that the consumer MUST re-read. * * Encoded as `blockEndCursor(height - 1)` rather than the seemingly-natural * `${height}:0` — that earlier form was an off-by-one: being exclusive, it * skipped `(height, 0)`, silently dropping the fork block's first row on * every reorg. The sentinel is int4 max (the `event_index`/`tx_index` column * type), larger than any real index, so nothing at `height - 1` survives the * keyset and the next returned row is exactly `(height, 0)`. * * `atHeight(0)` is `null`: the pre-genesis position both consume loops * accept as "read everything from the first event". There is no string * cursor below `0:0`, and `0:0` itself is exclusive, so returning it here * skipped the genesis event. */ atHeight(height: number): string | null; /** Parse a `:` cursor. Throws `ValidationError` if malformed. */ parse(cursor: string): { blockHeight: number; eventIndex: number; }; }; export { isStxTransfer, isStxMint, isStxLock, isStxBurn, isPrint, isNftTransfer, isNftMint, isNftBurn, isFtTransfer, isFtMint, isFtBurn, decodeStxTransfer, decodeStxMint, decodeStxLock, decodeStxBurn, decodePrint, decodeNftTransfer, decodeNftMint, decodeNftBurn, decodeFtTransfer, decodeFtMint, decodeFtBurn, decode, createStreamsClient, ValidationError, StreamsTip, StreamsSubscription, StreamsSignatureError, StreamsServerError, StreamsReorgsListParams, StreamsReorgsListEnvelope, StreamsReorgContext, StreamsReorg, StreamsEventsSubscribeParams, StreamsEventsStreamParams, StreamsEventsListParams, StreamsEventsListEnvelope, StreamsEventsEnvelope, StreamsEventsConsumeResult, StreamsEventsConsumeParams, StreamsEventType, StreamsEventPayload, StreamsEvent, StreamsDumpsManifest, StreamsDumps, StreamsDumpFile, StreamsConsumeParams, StreamsClient, StreamsCanonicalBlock, StreamsBatchContext, StreamsBatch, STREAMS_EVENT_TYPES, RateLimitError, NftTransferPayload2 as NftTransferPayload, NftTransferEvent, FtTransferPayload2 as FtTransferPayload, FtTransferEvent, FetchLike2 as FetchLike, DecodedStxTransferPayload, DecodedStxTransfer, DecodedStxMintPayload, DecodedStxMint, DecodedStxLockPayload, DecodedStxLock, DecodedStxBurnPayload, DecodedStxBurn, DecodedPrintValue, DecodedPrintPayload, DecodedPrint, DecodedNftTransferPayload, DecodedNftTransfer, DecodedNftMintPayload, DecodedNftMint, DecodedNftBurnPayload, DecodedNftBurn, DecodedFtTransferPayload, DecodedFtTransfer, DecodedFtMintPayload, DecodedFtMint, DecodedFtBurnPayload, DecodedFtBurn, DecodedEventRow, DecodedEventColumns, Cursor, ConsumerBatchContext, AuthError };