@secondlayer/sdk 16.2.0 → 16.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +3 -3
- package/dist/index.js.map +1 -1
- package/dist/subgraphs/index.d.ts +3 -3
- package/dist/subgraphs/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.d.ts
CHANGED
|
@@ -3775,9 +3775,9 @@ declare class Webhooks extends BaseClient {
|
|
|
3775
3775
|
/** Get a webhook's hourly delivery counts for the last 7 days.
|
|
3776
3776
|
*
|
|
3777
3777
|
* Each hour has delivered, waiting, and gave-up counts, plus the current
|
|
3778
|
-
* queue depth, failed attempts per hour,
|
|
3779
|
-
*
|
|
3780
|
-
* the
|
|
3778
|
+
* queue depth, failed attempts per hour, the usual band, and 30 daily
|
|
3779
|
+
* totals. This is the events chart on the detail page. `{ days: 30 }` is
|
|
3780
|
+
* accepted for older servers, which only send the daily totals when asked. */
|
|
3781
3781
|
activity(id: string, opts?: {
|
|
3782
3782
|
days?: 30;
|
|
3783
3783
|
}): Promise<WebhookActivity>;
|
package/dist/index.js.map
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"import { ed25519 } from \"@secondlayer/shared\";\nimport { buildQuery } from \"../base.ts\";\nimport {\n\tStreamsServerError,\n\tStreamsSignatureError,\n\tmapStreamsError,\n} from \"./errors.ts\";\nimport type {\n\tFetchLike,\n\tStreamsEvent,\n\tStreamsFilterMap,\n\tStreamsFilterValue,\n\tStreamsSubscription,\n} from \"./types.ts\";\n\n/**\n * The wire-level shape this loop actually reads off `params`: every field\n * from both the Stacks and Bitcoin `StreamsEventsSubscribeParams` branches,\n * flattened into one type so the loop needs no chain narrowing. The public\n * `StreamsClient.events.subscribe` overloads are the real (narrower,\n * discriminated) surface; `client.ts` bridges the two with a boundary cast,\n * the same pattern `listEvents` uses for `events.list`.\n */\nexport type StreamsEventsSubscribeWireParams = {\n\tchain?: \"stacks\" | \"bitcoin\";\n\tfromCursor?: string | null;\n\ttypes?: readonly string[];\n\tnotTypes?: readonly string[];\n\tcontractId?: StreamsFilterValue;\n\tsender?: StreamsFilterValue;\n\trecipient?: StreamsFilterValue;\n\tassetIdentifier?: string;\n\trune?: string;\n\taddress?: string;\n\tfilters?: StreamsFilterMap;\n\tsignal?: AbortSignal;\n\tonEvent: (event: StreamsEvent) => void | Promise<void>;\n\tonError?: (err: unknown) => void;\n\treconnectDelayMs?: number;\n\tstaleAfterMs?: number;\n};\n\ntype VerificationKey = {\n\tkeyId: string;\n\tpublicKey: ReturnType<typeof ed25519.loadEd25519PublicKey>;\n};\n\n/** Backoff ceiling: a subscriber that keeps failing asks once per half\n * minute, which a restarting instance can absorb from every worker at once. */\nexport const SUBSCRIBE_MAX_RECONNECT_DELAY_MS = 30_000;\n/** Three server heartbeats (20 s each) with no frame means the socket is\n * half-open, not idle. */\nexport const SUBSCRIBE_DEFAULT_STALE_AFTER_MS = 60_000;\n\n/**\n * Pause before reconnect attempt `attempt` (0 = first retry): `base * 2^attempt`\n * capped at 30 s, scaled by a jitter in [0.5, 1] so a fleet that dropped\n * together does not reconnect together. Never shorter than a `Retry-After`\n * the server sent, since that is the one number it is sure about.\n */\nexport function reconnectDelay(\n\tattempt: number,\n\tbaseMs: number,\n\tretryAfterSeconds?: number,\n\trandom: () => number = Math.random,\n): number {\n\tconst capped = Math.min(\n\t\tbaseMs * 2 ** Math.max(0, attempt),\n\t\tSUBSCRIBE_MAX_RECONNECT_DELAY_MS,\n\t);\n\tconst jittered = capped * (0.5 + random() * 0.5);\n\tconst floor = (retryAfterSeconds ?? 0) * 1000;\n\treturn Math.max(jittered, floor);\n}\n\n/** An error the loop stops on: a retry cannot change the answer. */\nfunction isTerminal(err: unknown): boolean {\n\tif (err instanceof StreamsSignatureError) return true;\n\treturn (err as { retryable?: boolean } | null)?.retryable === false;\n}\n\n/**\n * Subscribe to the Streams SSE push surface (`GET /v1/streams/events/stream`).\n *\n * A fetch-based reader (not `EventSource`) so it can send `Authorization` when\n * a key is present, which `EventSource` cannot. Works in browsers and Node 18+.\n * Reconnects from the last handled cursor after a dropped connection, a clean\n * server close, or a stale socket, with exponential backoff; stops on an error\n * a retry cannot fix and rejects the handle's `done`.\n */\nexport function subscribeStreamsEvents(opts: {\n\tbaseUrl: string;\n\t/** Request headers for every connection (bearer token, `x-sl-origin`). */\n\theaders?: Record<string, string>;\n\tfetchImpl: FetchLike;\n\t/**\n\t * `off` skips verification; `lenient` (default) verifies a frame when it\n\t * carries a `sig` and delivers it unverified when it doesn't (unsigned\n\t * self-host); `strict` requires every frame to be signed. An invalid `sig`\n\t * always throws regardless of mode.\n\t */\n\tverify: \"off\" | \"lenient\" | \"strict\";\n\t/** Resolve the key a frame names by `key_id` (a fetched key refreshes once\n\t * on an unknown id; a pinned key fails closed), or the cached key when\n\t * the frame carries none. */\n\tloadKey: (keyId?: string | null) => Promise<VerificationKey>;\n\t/** First reconnect pause; see {@link StreamsEventsSubscribeWireParams.reconnectDelayMs}. */\n\treconnectDelayMs?: number;\n\t/** Idle limit before the socket is treated as dead; see\n\t * {@link StreamsEventsSubscribeWireParams.staleAfterMs}. */\n\tstaleAfterMs?: number;\n\t/** Jitter source, injectable for tests. */\n\trandom?: () => number;\n\tparams: StreamsEventsSubscribeWireParams;\n}): StreamsSubscription {\n\tconst { params } = opts;\n\tconst controller = new AbortController();\n\tconst external = params.signal;\n\tif (external) {\n\t\tif (external.aborted) controller.abort();\n\t\telse\n\t\t\texternal.addEventListener(\"abort\", () => controller.abort(), {\n\t\t\t\tonce: true,\n\t\t\t});\n\t}\n\tlet cursor = params.fromCursor ?? null;\n\tconst baseDelayMs = opts.reconnectDelayMs ?? params.reconnectDelayMs ?? 1000;\n\tconst staleAfterMs =\n\t\topts.staleAfterMs ??\n\t\tparams.staleAfterMs ??\n\t\tSUBSCRIBE_DEFAULT_STALE_AFTER_MS;\n\t// Consecutive failures since the last frame; drives the backoff exponent.\n\tlet attempt = 0;\n\n\tconst run = async (): Promise<void> => {\n\t\twhile (!controller.signal.aborted) {\n\t\t\ttry {\n\t\t\t\tconst url = `${opts.baseUrl}/v1/streams/events/stream${buildQuery({\n\t\t\t\t\tchain: params.chain === \"bitcoin\" ? \"bitcoin\" : undefined,\n\t\t\t\t\tfrom_cursor: cursor ?? undefined,\n\t\t\t\t\ttypes: params.types,\n\t\t\t\t\tnot_types: params.notTypes,\n\t\t\t\t\tcontract_id: params.contractId,\n\t\t\t\t\tsender: params.sender,\n\t\t\t\t\trecipient: params.recipient,\n\t\t\t\t\tasset_identifier: params.assetIdentifier,\n\t\t\t\t\t// `chain: \"bitcoin\"` only — missing here meant a\n\t\t\t\t\t// bitcoin subscribe silently ignored these filters.\n\t\t\t\t\trune: params.rune,\n\t\t\t\t\taddress: params.address,\n\t\t\t\t\t// JSON in a query param, so SSE keeps plain GET semantics:\n\t\t\t\t\t// no POST-and-stream variant of this route.\n\t\t\t\t\tfilters: params.filters ? JSON.stringify(params.filters) : undefined,\n\t\t\t\t})}`;\n\t\t\t\tawait readSse({\n\t\t\t\t\turl,\n\t\t\t\t\theaders: opts.headers ?? {},\n\t\t\t\t\tsignal: controller.signal,\n\t\t\t\t\tfetchImpl: opts.fetchImpl,\n\t\t\t\t\tstaleAfterMs,\n\t\t\t\t\tmapError: mapStreamsError,\n\t\t\t\t\tonFrame: async (frame) => {\n\t\t\t\t\t\t// Any frame, ping included, proves the socket is live.\n\t\t\t\t\t\tattempt = 0;\n\t\t\t\t\t\tif (frame.event === \"ping\" || !frame.data) return;\n\t\t\t\t\t\tlet parsed: { event?: StreamsEvent; sig?: string; key_id?: string };\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tparsed = JSON.parse(frame.data);\n\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\treturn; // ignore non-JSON frames\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (!parsed.event) return;\n\t\t\t\t\t\tif (opts.verify !== \"off\") {\n\t\t\t\t\t\t\tif (!parsed.sig) {\n\t\t\t\t\t\t\t\t// Strict requires a signed frame; lenient (default) delivers an\n\t\t\t\t\t\t\t\t// unsigned frame (e.g. self-host with no signing key).\n\t\t\t\t\t\t\t\tif (opts.verify === \"strict\") {\n\t\t\t\t\t\t\t\t\tthrow new StreamsSignatureError(\n\t\t\t\t\t\t\t\t\t\t\"Streams SSE frame signature is missing.\",\n\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t} else {\n\t\t\t\t\t\t\t\t// The frame names its key, so a rotation mid-stream is a\n\t\t\t\t\t\t\t\t// one-time refresh instead of a reconnect loop that fails\n\t\t\t\t\t\t\t\t// every frame forever.\n\t\t\t\t\t\t\t\tconst key = await opts.loadKey(parsed.key_id);\n\t\t\t\t\t\t\t\t// A signature is present, so verify it in either mode; an\n\t\t\t\t\t\t\t\t// invalid signature always fails closed.\n\t\t\t\t\t\t\t\tif (\n\t\t\t\t\t\t\t\t\t!ed25519.verifyEd25519(\n\t\t\t\t\t\t\t\t\t\tJSON.stringify(parsed.event),\n\t\t\t\t\t\t\t\t\t\tparsed.sig,\n\t\t\t\t\t\t\t\t\t\tkey.publicKey,\n\t\t\t\t\t\t\t\t\t)\n\t\t\t\t\t\t\t\t) {\n\t\t\t\t\t\t\t\t\tthrow new StreamsSignatureError(\n\t\t\t\t\t\t\t\t\t\t\"Streams SSE frame signature is invalid.\",\n\t\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t\t// The cursor moves only once the handler has taken the event.\n\t\t\t\t\t\t// A throw here reconnects from the previous cursor, so this\n\t\t\t\t\t\t// event is delivered again rather than skipped.\n\t\t\t\t\t\tawait params.onEvent(parsed.event);\n\t\t\t\t\t\tcursor = (parsed.event as { cursor?: string }).cursor ?? cursor;\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t\t// Clean end (server closed the stream): reconnect from `cursor`\n\t\t\t\t// after the same pause as an error, so a proxy that answers 200 and\n\t\t\t\t// closes at once cannot spin a hot loop.\n\t\t\t\tawait sleep(\n\t\t\t\t\treconnectDelay(attempt, baseDelayMs, undefined, opts.random),\n\t\t\t\t\tcontroller.signal,\n\t\t\t\t);\n\t\t\t\tattempt += 1;\n\t\t\t} catch (err) {\n\t\t\t\tif (controller.signal.aborted) return;\n\t\t\t\tparams.onError?.(err);\n\t\t\t\tif (isTerminal(err)) throw err;\n\t\t\t\tconst retryAfterSeconds = (err as { retryAfterSeconds?: number })\n\t\t\t\t\t.retryAfterSeconds;\n\t\t\t\tawait sleep(\n\t\t\t\t\treconnectDelay(attempt, baseDelayMs, retryAfterSeconds, opts.random),\n\t\t\t\t\tcontroller.signal,\n\t\t\t\t);\n\t\t\t\tattempt += 1;\n\t\t\t}\n\t\t}\n\t};\n\tconst done = run();\n\t// The caller may never look at `done`; a terminal error already reached\n\t// `onError`, so an unobserved rejection must not crash the process.\n\tdone.catch(() => {});\n\tconst unsubscribe = () => controller.abort();\n\treturn Object.assign(unsubscribe, { done });\n}\n\n/** One parsed `text/event-stream` frame. */\nexport type SseFrame = { event?: string; data?: string };\n\n/**\n * Open one SSE connection and hand every frame to `onFrame` until the server\n * closes the stream or `signal` aborts. Fetch-based rather than `EventSource`\n * so the request can carry `Authorization`. A non-OK response throws through\n * `mapError` (default: a retryable `StreamsServerError`); a thrown `onFrame`\n * propagates and cancels the body. With `staleAfterMs`, a read that sees no\n * frame for that long cancels the body and throws a retryable\n * `StreamsServerError` (`STREAM_STALE`). Reconnecting is the caller's job, so\n * each surface keeps its own cursor rule.\n */\nexport async function readSse(opts: {\n\turl: string;\n\theaders?: Record<string, string>;\n\tsignal: AbortSignal;\n\tfetchImpl: FetchLike;\n\tonFrame: (frame: SseFrame) => void | Promise<void>;\n\tstaleAfterMs?: number;\n\tmapError?: (res: Response) => Promise<never>;\n}): Promise<void> {\n\tconst res = await opts.fetchImpl(opts.url, {\n\t\theaders: { ...(opts.headers ?? {}), Accept: \"text/event-stream\" },\n\t\tsignal: opts.signal,\n\t});\n\tif (!res.ok) {\n\t\tif (opts.mapError) await opts.mapError(res);\n\t\tthrow new StreamsServerError(\n\t\t\t`Streams SSE returned ${res.status}.`,\n\t\t\tres.status,\n\t\t);\n\t}\n\tif (!res.body) {\n\t\tthrow new StreamsServerError(\"Streams SSE response has no body.\", 0);\n\t}\n\tfor await (const frame of parseSseFrames(\n\t\tres.body,\n\t\topts.signal,\n\t\topts.staleAfterMs,\n\t)) {\n\t\tawait opts.onFrame(frame);\n\t}\n}\n\nfunction sleep(ms: number, signal: AbortSignal): Promise<void> {\n\treturn new Promise((resolve) => {\n\t\tif (signal.aborted) return resolve();\n\t\tconst onAbort = () => {\n\t\t\tclearTimeout(timer);\n\t\t\tresolve();\n\t\t};\n\t\tconst timer = setTimeout(() => {\n\t\t\tsignal.removeEventListener(\"abort\", onAbort);\n\t\t\tresolve();\n\t\t}, ms);\n\t\tsignal.addEventListener(\"abort\", onAbort, { once: true });\n\t});\n}\n\n/** `reader.read()` raced against an idle timer; a timeout throws a retryable\n * error so the caller's reconnect path takes over. */\nasync function readWithStaleTimer<R extends { read(): Promise<unknown> }>(\n\treader: R,\n\tstaleAfterMs: number | undefined,\n): Promise<Awaited<ReturnType<R[\"read\"]>>> {\n\tif (staleAfterMs === undefined || !Number.isFinite(staleAfterMs)) {\n\t\treturn reader.read() as Promise<Awaited<ReturnType<R[\"read\"]>>>;\n\t}\n\tlet timer: ReturnType<typeof setTimeout> | undefined;\n\tconst stale = new Promise<never>((_, reject) => {\n\t\ttimer = setTimeout(\n\t\t\t() =>\n\t\t\t\treject(\n\t\t\t\t\tnew StreamsServerError(\n\t\t\t\t\t\t`Streams SSE went quiet for ${staleAfterMs} ms; reconnecting.`,\n\t\t\t\t\t\t0,\n\t\t\t\t\t\tundefined,\n\t\t\t\t\t\t\"STREAM_STALE\",\n\t\t\t\t\t),\n\t\t\t\t),\n\t\t\tstaleAfterMs,\n\t\t);\n\t});\n\ttry {\n\t\treturn (await Promise.race([reader.read(), stale])) as Awaited<\n\t\t\tReturnType<R[\"read\"]>\n\t\t>;\n\t} finally {\n\t\tclearTimeout(timer);\n\t}\n}\n\nasync function* parseSseFrames(\n\tbody: ReadableStream<Uint8Array>,\n\tsignal: AbortSignal,\n\tstaleAfterMs?: number,\n): AsyncGenerator<SseFrame> {\n\tconst reader = body.getReader();\n\tconst decoder = new TextDecoder();\n\tlet buffer = \"\";\n\ttry {\n\t\twhile (!signal.aborted) {\n\t\t\tconst { value, done } = await readWithStaleTimer(reader, staleAfterMs);\n\t\t\tif (done) break;\n\t\t\tbuffer += decoder.decode(value, { stream: true });\n\t\t\tlet sep = buffer.indexOf(\"\\n\\n\");\n\t\t\twhile (sep !== -1) {\n\t\t\t\tyield parseFrame(buffer.slice(0, sep));\n\t\t\t\tbuffer = buffer.slice(sep + 2);\n\t\t\t\tsep = buffer.indexOf(\"\\n\\n\");\n\t\t\t}\n\t\t}\n\t} finally {\n\t\ttry {\n\t\t\tawait reader.cancel();\n\t\t} catch {\n\t\t\t// best-effort\n\t\t}\n\t}\n}\n\nfunction parseFrame(raw: string): SseFrame {\n\tlet event: string | undefined;\n\tconst data: string[] = [];\n\tfor (const line of raw.split(\"\\n\")) {\n\t\tif (line.startsWith(\"data:\")) {\n\t\t\tdata.push(line.slice(line.startsWith(\"data: \") ? 6 : 5));\n\t\t} else if (line.startsWith(\"event:\")) {\n\t\t\tevent = line.slice(line.startsWith(\"event: \") ? 7 : 6).trim();\n\t\t}\n\t}\n\treturn { event, data: data.length > 0 ? data.join(\"\\n\") : undefined };\n}\n",
|
|
19
19
|
"/**\n * The canonical row shape names its system columns `_id`, `_blockHeight`,\n * `_txId`, `_createdAt`. These map to the snake_case DB columns the query\n * params use.\n */\nconst SYSTEM_COLUMN_MAP: Record<string, string> = {\n\t_blockHeight: \"_block_height\",\n\t_txId: \"_tx_id\",\n\t_createdAt: \"_created_at\",\n\t_id: \"_id\",\n};\n\n/**\n * Unprefixed shorthands for the same system columns. They are a convenience,\n * not reserved names: a subgraph is free to declare its own `id` or\n * `blockHeight` column, and when it does, the shorthand means that column.\n */\nconst SYSTEM_COLUMN_ALIASES: Record<string, string> = {\n\tblockHeight: \"_block_height\",\n\ttxId: \"_tx_id\",\n\tcreatedAt: \"_created_at\",\n\tid: \"_id\",\n};\n\n/** The declared column names of one table, when the caller knows them. */\nexport type DeclaredColumns = ReadonlySet<string>;\n\n/**\n * Resolve a where/orderBy key to the DB column name. Canonical `_x` names\n * always map. An unprefixed alias maps only when the table does not declare a\n * column of that name; with no declared column set (an untyped call) the\n * alias still maps, which is the pre-existing behavior.\n */\nfunction resolveColumn(col: string, columns?: DeclaredColumns): string {\n\tconst canonical = SYSTEM_COLUMN_MAP[col];\n\tif (canonical) return canonical;\n\tif (columns?.has(col)) return col;\n\treturn SYSTEM_COLUMN_ALIASES[col] ?? col;\n}\n\n/**\n * Serializes a WhereInput object into the flat filter map expected by\n * SubgraphQueryParams.filters (and the REST API query string).\n *\n * Scalar values → `{ column: \"value\" }`\n * Comparison objects → `{ \"column.gte\": \"100\", \"column.lt\": \"200\" }`\n * System columns → `_blockHeight` always → `_block_height`; the unprefixed\n * `blockHeight` too, unless `columns` declares a user column of that name.\n */\nexport function serializeWhere(\n\twhere: Record<string, unknown>,\n\tcolumns?: DeclaredColumns,\n): Record<string, string> {\n\tconst filters: Record<string, string> = {};\n\n\tfor (const [column, value] of Object.entries(where)) {\n\t\tif (value === null || value === undefined) continue;\n\n\t\tconst col = resolveColumn(column, columns);\n\n\t\tif (typeof value === \"object\" && !Array.isArray(value)) {\n\t\t\tconst ops = value as Record<string, unknown>;\n\t\t\tfor (const [op, opValue] of Object.entries(ops)) {\n\t\t\t\tif (opValue === null || opValue === undefined) continue;\n\t\t\t\tif (op === \"eq\") {\n\t\t\t\t\tfilters[col] = String(opValue);\n\t\t\t\t} else if (op === \"in\" || op === \"notIn\") {\n\t\t\t\t\t// Array → comma list. Values can't contain commas (principals,\n\t\t\t\t\t// numbers, hashes don't) — the server splits on `,`.\n\t\t\t\t\tconst arr = Array.isArray(opValue) ? opValue : [opValue];\n\t\t\t\t\tfilters[`${col}.${op}`] = arr.map((v) => String(v)).join(\",\");\n\t\t\t\t} else if ([\"neq\", \"gt\", \"gte\", \"lt\", \"lte\", \"like\"].includes(op)) {\n\t\t\t\t\tfilters[`${col}.${op}`] = String(opValue);\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\tfilters[col] = String(value);\n\t\t}\n\t}\n\n\treturn filters;\n}\n\n/**\n * Resolves an orderBy column name (either alias or canonical) to the DB column\n * name, with the same declared-column rule as {@link serializeWhere}.\n */\nexport function resolveOrderByColumn(\n\tcol: string,\n\tcolumns?: DeclaredColumns,\n): string {\n\treturn resolveColumn(col, columns);\n}\n",
|
|
20
20
|
"import type {\n\tReindexResponse,\n\tSubgraphAggregateParams,\n\tSubgraphAggregateResponse,\n\tSubgraphDetail,\n\tSubgraphGapsResponse,\n\tSubgraphQueryParams,\n\tSubgraphSummary,\n} from \"@secondlayer/shared/schemas\";\nimport type {\n\tDeploySubgraphRequest,\n\tDeploySubgraphResponse,\n} from \"@secondlayer/shared/schemas/subgraphs\";\nimport type {\n\tSubgraphAgentSchema,\n\tSubgraphSpecOptions,\n} from \"@secondlayer/shared/subgraphs/spec\";\nimport type {\n\tAggregateResult,\n\tAggregateSpec,\n\tFindManyOptions,\n\tFindManyPage,\n\tInferSubgraphClient,\n\tSubscribeOptions,\n\tWhereInput,\n} from \"@secondlayer/subgraphs\";\nimport { BaseClient, buildQuery, seg } from \"../base.ts\";\nimport { readSse } from \"../streams/subscribe.ts\";\nimport {\n\ttype DeclaredColumns,\n\tresolveOrderByColumn,\n\tserializeWhere,\n} from \"./serialize.ts\";\n\n/** A deployed subgraph's source code, handler bundle, and pin. */\nexport interface SubgraphSource {\n\tname: string;\n\tversion: string;\n\tsourceCode: string | null;\n\t/** The bundled handler the runtime executes; null for local deploys. */\n\thandlerCode?: string | null;\n\t/** sha256 of `pinPreimage`; null for local deploys. */\n\tpin?: string | null;\n\t/** The canonical JSON the pin hashes (handler hash, network, runtime,\n\t * schema hash, startBlock); null before it was stored. */\n\tpinPreimage?: string | null;\n\treadOnly: boolean;\n\treason?: string;\n\tupdatedAt: string;\n}\n\n/** Status of a tracked reindex/backfill operation (poll until terminal). */\nexport interface SubgraphOperationStatus {\n\tid: string;\n\tsubgraphName: string;\n\tkind: \"reindex\" | \"backfill\";\n\tstatus: \"queued\" | \"running\" | \"completed\" | \"failed\" | \"cancelled\";\n\tfromBlock: number | null;\n\ttoBlock: number | null;\n\tprocessedBlocks: number | null;\n\t/** 0–1 fraction; null when no denominator is known yet. 1 when completed. */\n\tprogress: number | null;\n\terror: string | null;\n\tstartedAt: string | null;\n\tfinishedAt: string | null;\n\tcreatedAt: string;\n\tupdatedAt: string;\n}\n\n/** /v1 cursor envelope for subgraph table reads. */\nexport interface SubgraphRowsEnvelope<T = unknown> {\n\trows: T[];\n\t/** Set on every page that returned rows; `null` only on an empty page. */\n\tnext_cursor: string | null;\n\ttip: {\n\t\tblock_height: number;\n\t\tsubgraph_height: number;\n\t\tblocks_behind: number;\n\t};\n}\n\n/** A deployed subgraph's bundled sources, schema, and handler code. */\nexport interface BundleSubgraphResponse {\n\tok: true;\n\tname: string;\n\tdescription: string | null;\n\tsources: Record<string, Record<string, unknown>>;\n\tschema: Record<string, unknown>;\n\thandlerCode: string;\n\tsourceCode: string;\n\tbundleSize: number;\n}\n\nconst SUBSCRIBE_RECONNECT_DELAY_MS = 1000;\n\n/** Column names a `defineSubgraph()` table declares, or undefined when the\n * schema entry is not in the `{ columns: {...} }` shape. */\nfunction declaredColumns(table: unknown): DeclaredColumns | undefined {\n\tif (!table || typeof table !== \"object\") return undefined;\n\tconst columns = (table as { columns?: unknown }).columns;\n\tif (!columns || typeof columns !== \"object\") return undefined;\n\treturn new Set(Object.keys(columns));\n}\n\n/** Block height of a streamed row. The server writes rows in their wire\n * shape (`_block_height`, bigint columns as strings); a caller-shaped row\n * may carry `_blockHeight`. Either form advances the reconnect cursor. */\nfunction rowBlockHeight(row: unknown): number | undefined {\n\tconst r = row as { _block_height?: unknown; _blockHeight?: unknown };\n\tconst raw = r._block_height ?? r._blockHeight;\n\tif (typeof raw !== \"number\" && typeof raw !== \"string\") return undefined;\n\tconst height = Number(raw);\n\treturn Number.isFinite(height) ? height : undefined;\n}\n\nfunction sleep(ms: number, signal: AbortSignal): Promise<void> {\n\treturn new Promise((resolve) => {\n\t\tif (signal.aborted) return resolve();\n\t\tconst onAbort = () => {\n\t\t\tclearTimeout(timer);\n\t\t\tresolve();\n\t\t};\n\t\tconst timer = setTimeout(() => {\n\t\t\tsignal.removeEventListener(\"abort\", onAbort);\n\t\t\tresolve();\n\t\t}, ms);\n\t\tsignal.addEventListener(\"abort\", onAbort, { once: true });\n\t});\n}\n\nfunction buildSubgraphQueryString(\n\tparams: Omit<SubgraphQueryParams, \"offset\">,\n): string {\n\treturn buildQuery({\n\t\t_sort: params.sort,\n\t\t_order: params.order,\n\t\t_limit: params.limit,\n\t\t_fields: params.fields,\n\t\t...params.filters,\n\t});\n}\n\nfunction buildAggregateQueryString(params: SubgraphAggregateParams): string {\n\treturn buildQuery({\n\t\t...params.filters,\n\t\t_count: params.count ? \"true\" : undefined, // emit only when truthy\n\t\t_countDistinct: params.countDistinct, // empty array → \"\" → skipped\n\t\t_sum: params.sum,\n\t\t_min: params.min,\n\t\t_max: params.max,\n\t});\n}\n\nfunction buildSpecQueryString(options?: SubgraphSpecOptions): string {\n\treturn buildQuery({ server: options?.serverUrl });\n}\n\nexport class Subgraphs extends BaseClient {\n\t/**\n\t * List the subgraphs deployed to this instance.\n\t *\n\t * Each summary has the status, the last processed block against the chain tip, progress, `gapCount`, and an `integrity` flag.\n\t */\n\tasync list(): Promise<{ data: SubgraphSummary[] }> {\n\t\treturn this.request<{ data: SubgraphSummary[] }>(\"GET\", \"/api/subgraphs\");\n\t}\n\n\t/**\n\t * Get a subgraph's health, sync position, and tables.\n\t *\n\t * The verify step after a deploy, reindex, or backfill: poll it until\n\t * `sync` catches the tip.\n\t */\n\tasync status(name: string): Promise<SubgraphDetail> {\n\t\treturn this.request<SubgraphDetail>(\"GET\", `/api/subgraphs/${seg(name)}`);\n\t}\n\n\t/**\n\t * Get a subgraph's OpenAPI spec.\n\t *\n\t * Describes the REST routes generated for each table. Pass `serverUrl` to set the server the spec advertises.\n\t */\n\tasync openapi(\n\t\tname: string,\n\t\toptions?: SubgraphSpecOptions,\n\t): Promise<Record<string, unknown>> {\n\t\treturn this.request<Record<string, unknown>>(\n\t\t\t\"GET\",\n\t\t\t`/api/subgraphs/${seg(name)}/openapi.json${buildSpecQueryString(options)}`,\n\t\t);\n\t}\n\n\t/**\n\t * Get a subgraph's schema as JSON, shaped for agents.\n\t *\n\t * Per table: the endpoints, columns, indexes, which fields are sortable, selectable, searchable, or filterable, and a ready example query. Pass `serverUrl` to set the server the endpoints use.\n\t */\n\tasync schema(\n\t\tname: string,\n\t\toptions?: SubgraphSpecOptions,\n\t): Promise<SubgraphAgentSchema> {\n\t\treturn this.request<SubgraphAgentSchema>(\n\t\t\t\"GET\",\n\t\t\t`/api/subgraphs/${seg(name)}/schema.json${buildSpecQueryString(options)}`,\n\t\t);\n\t}\n\n\t/**\n\t * Get a subgraph's generated API docs as Markdown text.\n\t *\n\t * Pass `serverUrl` to set the server the examples use.\n\t */\n\tasync markdown(name: string, options?: SubgraphSpecOptions): Promise<string> {\n\t\treturn this.requestText(\n\t\t\t\"GET\",\n\t\t\t`/api/subgraphs/${seg(name)}/docs.md${buildSpecQueryString(options)}`,\n\t\t);\n\t}\n\n\t/**\n\t * Rebuild a subgraph from scratch.\n\t *\n\t * A reindex always drops and rebuilds the whole subgraph, so it takes no\n\t * block range; the API rejects one with `REINDEX_RANGE_NOT_SUPPORTED`.\n\t * Use {@link backfill} to process a specific range. While a reindex or\n\t * backfill is already running the API answers 409 with code\n\t * `OPERATION_IN_PROGRESS`; poll {@link operations} until it finishes.\n\t */\n\tasync reindex(name: string): Promise<ReindexResponse> {\n\t\treturn this.request<ReindexResponse>(\n\t\t\t\"POST\",\n\t\t\t`/api/subgraphs/${seg(name)}/reindex`,\n\t\t);\n\t}\n\n\t/**\n\t * Stop a running reindex or backfill.\n\t *\n\t * Requests the cancel and resolves with the `operationId` and a `cancel_requested` status. Poll {@link getOperation} until `status` is terminal. Throws a 404 with code `NO_OPERATION` when nothing is running.\n\t */\n\tasync stop(\n\t\tname: string,\n\t): Promise<{ message: string; operationId?: string; status?: string }> {\n\t\treturn this.request<{\n\t\t\tmessage: string;\n\t\t\toperationId?: string;\n\t\t\tstatus?: string;\n\t\t}>(\"POST\", `/api/subgraphs/${seg(name)}/stop`);\n\t}\n\n\t/** Process one block range. 409 `OPERATION_IN_PROGRESS` while another\n\t * reindex or backfill runs on this subgraph. */\n\tasync backfill(\n\t\tname: string,\n\t\toptions: { fromBlock: number; toBlock: number },\n\t): Promise<ReindexResponse> {\n\t\treturn this.request<ReindexResponse>(\n\t\t\t\"POST\",\n\t\t\t`/api/subgraphs/${seg(name)}/backfill`,\n\t\t\toptions,\n\t\t);\n\t}\n\n\t/**\n\t * List the block ranges a subgraph is missing.\n\t *\n\t * Each gap has its `start`, `end`, `size`, `reason`, and when it was detected and resolved. `resolved` filters to resolved or open gaps; page with `limit` and `offset`. `meta` reports the total gap count and total missing blocks.\n\t */\n\tasync gaps(\n\t\tname: string,\n\t\topts?: { limit?: number; offset?: number; resolved?: boolean },\n\t): Promise<SubgraphGapsResponse> {\n\t\tconst qs = buildQuery({\n\t\t\t_limit: opts?.limit,\n\t\t\t_offset: opts?.offset,\n\t\t\tresolved: opts?.resolved,\n\t\t});\n\t\treturn this.request<SubgraphGapsResponse>(\n\t\t\t\"GET\",\n\t\t\t`/api/subgraphs/${seg(name)}/gaps${qs}`,\n\t\t);\n\t}\n\n\t/**\n\t * Delete a subgraph and its tables.\n\t *\n\t * Permanent. The API cancels any running reindex or backfill first, waits up to 5 seconds for it to stop, then drops the subgraph's tables and registry entry. `force: true` is sent as `?force=true`.\n\t */\n\tasync delete(\n\t\tname: string,\n\t\toptions?: { force?: boolean },\n\t): Promise<{ message: string }> {\n\t\tconst qs = buildQuery({ force: options?.force ? true : undefined });\n\t\treturn this.request<{ message: string }>(\n\t\t\t\"DELETE\",\n\t\t\t`/api/subgraphs/${seg(name)}${qs}`,\n\t\t);\n\t}\n\n\t/**\n\t * Read a table's rows, one cursor page at a time.\n\t *\n\t * Anon works on an open instance; pass an apiKey on the client where reads\n\t * are closed. `next_cursor` is set\n\t * on every page that returned rows and is `null` only on an empty page, so a\n\t * poller tails with `cursor = page.next_cursor ?? cursor`. A paging loop\n\t * ends on an empty page (or one shorter than `limit`).\n\t */\n\tasync rows<T = unknown>(\n\t\tname: string,\n\t\ttable: string,\n\t\tparams: Omit<SubgraphQueryParams, \"offset\"> & {\n\t\t\tcursor?: string;\n\t\t} = {},\n\t): Promise<SubgraphRowsEnvelope<T>> {\n\t\tconst { cursor, ...rest } = params;\n\t\tconst qs = buildSubgraphQueryString(rest);\n\t\tconst sep = qs ? \"&\" : \"?\";\n\t\tconst cursorQs = cursor ? `${sep}cursor=${encodeURIComponent(cursor)}` : \"\";\n\t\treturn this.request<SubgraphRowsEnvelope<T>>(\n\t\t\t\"GET\",\n\t\t\t`/v1/subgraphs/${seg(name)}/${seg(table)}${qs}${cursorQs}`,\n\t\t);\n\t}\n\n\t/** Recent reindex/backfill operations for a subgraph, newest first. */\n\tasync operations(\n\t\tname: string,\n\t): Promise<{ operations: SubgraphOperationStatus[] }> {\n\t\treturn this.request<{ operations: SubgraphOperationStatus[] }>(\n\t\t\t\"GET\",\n\t\t\t`/api/subgraphs/${seg(name)}/operations`,\n\t\t);\n\t}\n\n\t/** Get the status of one reindex, backfill, or stop operation.\n\t *\n\t * Poll the `operationId` returned by `reindex`, `backfill`, or `stop`\n\t * until `status` is terminal. */\n\tasync getOperation(\n\t\tname: string,\n\t\toperationId: string,\n\t): Promise<SubgraphOperationStatus> {\n\t\treturn this.request<SubgraphOperationStatus>(\n\t\t\t\"GET\",\n\t\t\t`/api/subgraphs/${seg(name)}/operations/${seg(operationId)}`,\n\t\t);\n\t}\n\n\t/** Create or update a subgraph. A deploy that needs a rebuild queues one;\n\t * if a reindex or backfill is already running the API answers 409 with\n\t * code `OPERATION_IN_PROGRESS` (an `ApiError`, not a dedicated class). */\n\tasync deploy(data: DeploySubgraphRequest): Promise<DeploySubgraphResponse> {\n\t\treturn this.request<DeploySubgraphResponse>(\"POST\", \"/api/subgraphs\", data);\n\t}\n\n\t/**\n\t * Get a deployed subgraph's source.\n\t *\n\t * Returns `sourceCode`, the bundled `handlerCode` the runtime executes, and the `pin` hash of what you deployed. `handlerCode` and `pin` are `null` for local deploys. A subgraph deployed before source was captured returns `sourceCode: null` and `readOnly: true` with a `reason`; redeploy it to capture the source.\n\t */\n\tasync getSource(name: string): Promise<SubgraphSource> {\n\t\treturn this.request<SubgraphSource>(\n\t\t\t\"GET\",\n\t\t\t`/api/subgraphs/${seg(name)}/source`,\n\t\t);\n\t}\n\n\t/**\n\t * Bundle a TypeScript subgraph source on the server. Used by the web chat\n\t * authoring loop so Vercel's serverless runtime doesn't have to run esbuild.\n\t */\n\tasync bundle(data: { code: string }): Promise<BundleSubgraphResponse> {\n\t\treturn this.request<BundleSubgraphResponse>(\n\t\t\t\"POST\",\n\t\t\t\"/api/subgraphs/bundle\",\n\t\t\tdata,\n\t\t);\n\t}\n\n\t/** Count the rows in a table that match a filter.\n\t *\n\t * The count is exact. Same auth posture as {@link rows}. */\n\tasync count(\n\t\tname: string,\n\t\ttable: string,\n\t\tparams: Pick<SubgraphQueryParams, \"filters\"> = {},\n\t): Promise<{ count: number }> {\n\t\treturn this.request<{ count: number }>(\n\t\t\t\"GET\",\n\t\t\t`/v1/subgraphs/${seg(name)}/${seg(table)}/count${buildSubgraphQueryString(params)}`,\n\t\t);\n\t}\n\n\t/** Compute scalar aggregates over a table's matching rows.\n\t *\n\t * Same auth posture as {@link rows}. */\n\tasync aggregate(\n\t\tname: string,\n\t\ttable: string,\n\t\tparams: SubgraphAggregateParams = {},\n\t): Promise<SubgraphAggregateResponse> {\n\t\treturn this.request<SubgraphAggregateResponse>(\n\t\t\t\"GET\",\n\t\t\t`/v1/subgraphs/${seg(name)}/${seg(table)}/aggregate${buildAggregateQueryString(params)}`,\n\t\t);\n\t}\n\n\t/**\n\t * Returns a typed client for a subgraph defined with `defineSubgraph()`.\n\t * Row types are inferred from the subgraph's schema literal types.\n\t *\n\t * @example\n\t * ```ts\n\t * import mySubgraph from './subgraphs/my-token-subgraph'\n\t * const client = sl.subgraphs.typed(mySubgraph)\n\t * const page = await client.transfers.findMany({ where: { sender: 'SP...' } })\n\t * // page.rows: InferTableRow<typeof mySubgraph.schema.transfers>[]\n\t * // page.nextCursor: set whenever rows came back, null on an empty page.\n\t * // Tail new rows: cursor = page.nextCursor ?? cursor\n\t * ```\n\t */\n\ttyped<T extends { name: string; schema: Record<string, unknown> }>(\n\t\tdef: T,\n\t): InferSubgraphClient<T> {\n\t\tconst result: Record<string, unknown> = {};\n\n\t\tfor (const [tableName, table] of Object.entries(def.schema)) {\n\t\t\tresult[tableName] = this.createTableClient(\n\t\t\t\tdef.name,\n\t\t\t\ttableName,\n\t\t\t\tdeclaredColumns(table),\n\t\t\t);\n\t\t}\n\n\t\treturn result as InferSubgraphClient<T>;\n\t}\n\n\t/**\n\t * `columns` is the table's declared column set from `defineSubgraph()`.\n\t * Filters and orderBy always accept the canonical system names\n\t * `_id` / `_blockHeight` / `_txId` / `_createdAt`; the unprefixed\n\t * shorthands (`id`, `blockHeight`, ...) mean the system column only when\n\t * the table declares no column of that name.\n\t */\n\tprivate createTableClient(\n\t\tsubgraphName: string,\n\t\ttableName: string,\n\t\tcolumns?: DeclaredColumns,\n\t) {\n\t\tconst self = this;\n\n\t\treturn {\n\t\t\tasync findMany<TRow>(\n\t\t\t\toptions: Omit<FindManyOptions<TRow>, \"fields\"> & {\n\t\t\t\t\tfields?: readonly (keyof TRow & string)[];\n\t\t\t\t} = {},\n\t\t\t): Promise<FindManyPage<TRow>> {\n\t\t\t\tconst filters = options.where\n\t\t\t\t\t? serializeWhere(options.where as Record<string, unknown>, columns)\n\t\t\t\t\t: undefined;\n\n\t\t\t\tlet sort: string | undefined;\n\t\t\t\tlet order: string | undefined;\n\t\t\t\tif (options.orderBy) {\n\t\t\t\t\t// Single-column object form only — `/v1`'s keyset cursor pairs one\n\t\t\t\t\t// sort column with `_id`, so a second key can't be served.\n\t\t\t\t\tconst entries = Object.entries(options.orderBy) as [\n\t\t\t\t\t\tstring,\n\t\t\t\t\t\t\"asc\" | \"desc\",\n\t\t\t\t\t][];\n\t\t\t\t\tif (entries.length > 1) {\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t`findMany orderBy accepts a single column; got ${entries.length}: ${entries.map(([col]) => col).join(\", \")}. Sort one column — the cursor pairs it with _id as a tiebreaker.`,\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t\tconst entry = entries[0];\n\t\t\t\t\tif (entry) {\n\t\t\t\t\t\tconst [col, dir] = entry;\n\t\t\t\t\t\tsort = resolveOrderByColumn(col, columns);\n\t\t\t\t\t\torder = dir ?? \"asc\";\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tconst result = await self.rows<TRow>(subgraphName, tableName, {\n\t\t\t\t\tsort,\n\t\t\t\t\torder,\n\t\t\t\t\tlimit: options.limit,\n\t\t\t\t\tfields: options.fields?.join(\",\"),\n\t\t\t\t\tfilters,\n\t\t\t\t\tcursor: options.cursor,\n\t\t\t\t});\n\t\t\t\treturn {\n\t\t\t\t\trows: result.rows,\n\t\t\t\t\tnextCursor: result.next_cursor,\n\t\t\t\t\ttip: result.tip,\n\t\t\t\t};\n\t\t\t},\n\n\t\t\tasync count<TRow>(where?: WhereInput<TRow>): Promise<number> {\n\t\t\t\tconst filters = where\n\t\t\t\t\t? serializeWhere(where as Record<string, unknown>, columns)\n\t\t\t\t\t: undefined;\n\n\t\t\t\tconst result = await self.count(subgraphName, tableName, {\n\t\t\t\t\tfilters,\n\t\t\t\t});\n\t\t\t\treturn result.count;\n\t\t\t},\n\n\t\t\tasync aggregate<TRow, const A extends AggregateSpec<TRow>>(\n\t\t\t\tspec: A,\n\t\t\t): Promise<AggregateResult<TRow, A>> {\n\t\t\t\tconst filters = spec.where\n\t\t\t\t\t? serializeWhere(spec.where as Record<string, unknown>, columns)\n\t\t\t\t\t: undefined;\n\n\t\t\t\tconst result = await self.aggregate(subgraphName, tableName, {\n\t\t\t\t\tfilters,\n\t\t\t\t\tcount: spec.count,\n\t\t\t\t\tcountDistinct: spec.countDistinct,\n\t\t\t\t\tsum: spec.sum,\n\t\t\t\t\tmin: spec.min,\n\t\t\t\t\tmax: spec.max,\n\t\t\t\t});\n\t\t\t\treturn result as AggregateResult<TRow, A>;\n\t\t\t},\n\n\t\t\t/**\n\t\t\t * Tail rows as the subgraph writes them. Reads `/v1`, so it is keyless\n\t\t\t * on loopback like `rows`; fetch-based SSE, so it carries the client's\n\t\t\t * bearer token once the API is bound past loopback. Reconnects after a\n\t\t\t * dropped connection from the last delivered row's block height; rows\n\t\t\t * at that height can be delivered again, so key durable writes by\n\t\t\t * `_id`. Frames are unsigned rows in the server's wire shape\n\t\t\t * (`_block_height`, not `_blockHeight`).\n\t\t\t */\n\t\t\tsubscribe<TRow>(\n\t\t\t\tonRow: (row: TRow) => void,\n\t\t\t\toptions: SubscribeOptions<TRow> = {},\n\t\t\t): () => void {\n\t\t\t\tconst filters = options.where\n\t\t\t\t\t? serializeWhere(options.where as Record<string, unknown>, columns)\n\t\t\t\t\t: {};\n\t\t\t\tconst controller = new AbortController();\n\t\t\t\tlet since: number | undefined = options.since ?? undefined;\n\n\t\t\t\tconst run = async (): Promise<void> => {\n\t\t\t\t\twhile (!controller.signal.aborted) {\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tconst qs = buildQuery({ ...filters, since });\n\t\t\t\t\t\t\tawait readSse({\n\t\t\t\t\t\t\t\turl: `${self.baseUrl}/v1/subgraphs/${seg(subgraphName)}/${seg(tableName)}/stream${qs}`,\n\t\t\t\t\t\t\t\theaders: {\n\t\t\t\t\t\t\t\t\t...BaseClient.authHeaders(self.apiKey),\n\t\t\t\t\t\t\t\t\t\"x-sl-origin\": self.origin,\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\tsignal: controller.signal,\n\t\t\t\t\t\t\t\tfetchImpl: self.fetchImpl,\n\t\t\t\t\t\t\t\tonFrame: (frame) => {\n\t\t\t\t\t\t\t\t\tif (frame.event === \"ping\" || !frame.data) return;\n\t\t\t\t\t\t\t\t\tlet row: TRow;\n\t\t\t\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\t\t\t\trow = JSON.parse(frame.data) as TRow;\n\t\t\t\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\t\t\t\treturn; // ignore non-JSON frames (e.g. heartbeats)\n\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\tconst height = rowBlockHeight(row);\n\t\t\t\t\t\t\t\t\tif (height !== undefined) since = height;\n\t\t\t\t\t\t\t\t\tonRow(row);\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t// Clean end (server closed the stream): reconnect from `since`\n\t\t\t\t\t\t\t// after the same pause as an error, so a server that closes at\n\t\t\t\t\t\t\t// once (proxy timeout, empty 200) cannot spin a hot loop.\n\t\t\t\t\t\t\tawait sleep(SUBSCRIBE_RECONNECT_DELAY_MS, controller.signal);\n\t\t\t\t\t\t} catch (err) {\n\t\t\t\t\t\t\tif (controller.signal.aborted) return;\n\t\t\t\t\t\t\toptions.onError?.(err);\n\t\t\t\t\t\t\tawait sleep(SUBSCRIBE_RECONNECT_DELAY_MS, controller.signal);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t};\n\t\t\t\tvoid run();\n\t\t\t\treturn () => controller.abort();\n\t\t\t},\n\t\t};\n\t}\n}\n",
|
|
21
|
-
"import type {\n\tCreateWebhookRequest,\n\tCreateWebhookResponse,\n\tDeadRow,\n\tDeliveryRow,\n\tReplayResult,\n\tRotateSecretResponse,\n\tUpdateWebhookRequest,\n\tWebhookActivity,\n\tWebhookDeliveryDetail,\n\tWebhookDetail,\n\tWebhookSummary,\n\tWebhookTestResult,\n} from \"@secondlayer/shared/schemas/webhooks\";\nimport { BaseClient, buildQuery, seg } from \"../base.ts\";\n\nexport type {\n\tChainTrigger,\n\tChainTriggerType,\n\tCreateWebhookRequest,\n\tCreateWebhookResponse,\n\tDeadRow,\n\tDeliveryRow,\n\tReplayResult,\n\tRotateSecretResponse,\n\tWebhookActivity,\n\tWebhookActivityHour,\n\tWebhookAuth,\n\tWebhookDeliveryDetail,\n\tWebhookDetail,\n\tWebhookFormat,\n\tWebhookKind,\n\tWebhookStatus,\n\tWebhookSummary,\n\tWebhookTestResult,\n\tUpdateWebhookRequest,\n} from \"@secondlayer/shared/schemas/webhooks\";\n\n// `trigger.*` chain-trigger builders for chain webhooks\n// (`create({ triggers: [trigger.contractCall({ ... })] })`).\nexport { trigger } from \"@secondlayer/shared/schemas/webhooks\";\n\nexport class Webhooks extends BaseClient {\n\t/**\n\t * List your webhooks.\n\t *\n\t * Each summary has the status, format, URL, last delivery and last success, and the circuit breaker state (`circuitFailures`, `circuitOpenedAt`).\n\t */\n\tasync list(): Promise<{ data: WebhookSummary[] }> {\n\t\treturn this.request<{ data: WebhookSummary[] }>(\"GET\", \"/api/webhooks\");\n\t}\n\n\t/**\n\t * Get one webhook's full configuration.\n\t *\n\t * Adds the triggers, the receiver's auth (redacted to header names and whether a secret is set), the retry and timeout limits, `lastError`, and a `warning` when this instance's trigger evaluator is not running.\n\t */\n\tasync get(id: string): Promise<WebhookDetail> {\n\t\treturn this.request<WebhookDetail>(\"GET\", `/api/webhooks/${seg(id)}`);\n\t}\n\n\t/**\n\t * Create a webhook that POSTs chain events to your URL.\n\t *\n\t * Takes a `name`, a `url`, and `triggers` built with `trigger.*`; each trigger is validated before anything is provisioned, and an invalid one is a 400. The signing secret comes back once in `signingSecret`, so store it. A webhook starts at the chain tip and never backfills: use {@link replay} for history. Hosted accounts cap `maxRetries` at 7 and `timeoutMs` at 30000.\n\t */\n\tasync create(input: CreateWebhookRequest): Promise<CreateWebhookResponse> {\n\t\treturn this.request<CreateWebhookResponse>(\"POST\", \"/api/webhooks\", input);\n\t}\n\n\t/**\n\t * Change a webhook's name, URL, format, auth, or delivery limits.\n\t *\n\t * Send only the fields to change: `name`, `url`, `format`, `authConfig`, `maxRetries`, `timeoutMs`, `concurrency`. Triggers are not part of an update. Resolves to the updated webhook.\n\t */\n\tasync update(\n\t\tid: string,\n\t\tpatch: UpdateWebhookRequest,\n\t): Promise<WebhookDetail> {\n\t\treturn this.request<WebhookDetail>(\n\t\t\t\"PATCH\",\n\t\t\t`/api/webhooks/${seg(id)}`,\n\t\t\tpatch,\n\t\t);\n\t}\n\n\t/**\n\t * Stop deliveries without deleting the webhook.\n\t *\n\t * Resolves to the updated webhook. {@link resume} turns it back on.\n\t */\n\tasync pause(id: string): Promise<WebhookDetail> {\n\t\treturn this.request<WebhookDetail>(\n\t\t\t\"POST\",\n\t\t\t`/api/webhooks/${seg(id)}/pause`,\n\t\t);\n\t}\n\n\t/**\n\t * Start delivering again after a pause.\n\t *\n\t * Also clears a circuit-breaker pause, which happens after 20 straight failed deliveries. Resolves to the updated webhook.\n\t */\n\tasync resume(id: string): Promise<WebhookDetail> {\n\t\treturn this.request<WebhookDetail>(\n\t\t\t\"POST\",\n\t\t\t`/api/webhooks/${seg(id)}/resume`,\n\t\t);\n\t}\n\n\t/**\n\t * Delete a webhook.\n\t *\n\t * Resolves to `{ ok: true }`. Throws a 404 when the webhook does not exist.\n\t */\n\tasync delete(id: string): Promise<{ ok: true }> {\n\t\treturn this.request<{ ok: true }>(\"DELETE\", `/api/webhooks/${seg(id)}`);\n\t}\n\n\t/**\n\t * Issue a new signing secret.\n\t *\n\t * The new secret comes back once in `signingSecret`. Update the receiver that calls `verifyWebhookSignature` with it: deliveries are signed with the new secret from now on.\n\t */\n\tasync rotateSecret(id: string): Promise<RotateSecretResponse> {\n\t\treturn this.request<RotateSecretResponse>(\n\t\t\t\"POST\",\n\t\t\t`/api/webhooks/${seg(id)}/rotate-secret`,\n\t\t);\n\t}\n\n\t/** Send a one-off test delivery to a webhook's URL.\n\t *\n\t * Built for the webhook's format and SSRF-guarded. Logged as a delivery\n\t * row, visible via `deliveries`. */\n\tasync test(id: string): Promise<WebhookTestResult> {\n\t\treturn this.request<WebhookTestResult>(\n\t\t\t\"POST\",\n\t\t\t`/api/webhooks/${seg(id)}/test`,\n\t\t);\n\t}\n\n\t/** The last 100 delivery attempts, newest first. The server caps the\n\t * result; there is nothing to page. Pass `window` (ISO timestamps, at most\n\t * 31 days wide) to read only the attempts dispatched in `[from, to)`, for\n\t * example one hour of the heat strip; `total` then counts that window. */\n\tasync deliveries(\n\t\tid: string,\n\t\twindow?: { from: string; to: string },\n\t): Promise<{ total: number; data: DeliveryRow[] }> {\n\t\treturn this.request<{ total: number; data: DeliveryRow[] }>(\n\t\t\t\"GET\",\n\t\t\t`/api/webhooks/${seg(id)}/deliveries${buildQuery({ from: window?.from, to: window?.to })}`,\n\t\t);\n\t}\n\n\t/** Get a webhook's hourly delivery counts for the last 7 days.\n\t *\n\t * Each hour has delivered, waiting, and gave-up counts, plus the current\n\t * queue depth, failed attempts per hour,
|
|
21
|
+
"import type {\n\tCreateWebhookRequest,\n\tCreateWebhookResponse,\n\tDeadRow,\n\tDeliveryRow,\n\tReplayResult,\n\tRotateSecretResponse,\n\tUpdateWebhookRequest,\n\tWebhookActivity,\n\tWebhookDeliveryDetail,\n\tWebhookDetail,\n\tWebhookSummary,\n\tWebhookTestResult,\n} from \"@secondlayer/shared/schemas/webhooks\";\nimport { BaseClient, buildQuery, seg } from \"../base.ts\";\n\nexport type {\n\tChainTrigger,\n\tChainTriggerType,\n\tCreateWebhookRequest,\n\tCreateWebhookResponse,\n\tDeadRow,\n\tDeliveryRow,\n\tReplayResult,\n\tRotateSecretResponse,\n\tWebhookActivity,\n\tWebhookActivityHour,\n\tWebhookAuth,\n\tWebhookDeliveryDetail,\n\tWebhookDetail,\n\tWebhookFormat,\n\tWebhookKind,\n\tWebhookStatus,\n\tWebhookSummary,\n\tWebhookTestResult,\n\tUpdateWebhookRequest,\n} from \"@secondlayer/shared/schemas/webhooks\";\n\n// `trigger.*` chain-trigger builders for chain webhooks\n// (`create({ triggers: [trigger.contractCall({ ... })] })`).\nexport { trigger } from \"@secondlayer/shared/schemas/webhooks\";\n\nexport class Webhooks extends BaseClient {\n\t/**\n\t * List your webhooks.\n\t *\n\t * Each summary has the status, format, URL, last delivery and last success, and the circuit breaker state (`circuitFailures`, `circuitOpenedAt`).\n\t */\n\tasync list(): Promise<{ data: WebhookSummary[] }> {\n\t\treturn this.request<{ data: WebhookSummary[] }>(\"GET\", \"/api/webhooks\");\n\t}\n\n\t/**\n\t * Get one webhook's full configuration.\n\t *\n\t * Adds the triggers, the receiver's auth (redacted to header names and whether a secret is set), the retry and timeout limits, `lastError`, and a `warning` when this instance's trigger evaluator is not running.\n\t */\n\tasync get(id: string): Promise<WebhookDetail> {\n\t\treturn this.request<WebhookDetail>(\"GET\", `/api/webhooks/${seg(id)}`);\n\t}\n\n\t/**\n\t * Create a webhook that POSTs chain events to your URL.\n\t *\n\t * Takes a `name`, a `url`, and `triggers` built with `trigger.*`; each trigger is validated before anything is provisioned, and an invalid one is a 400. The signing secret comes back once in `signingSecret`, so store it. A webhook starts at the chain tip and never backfills: use {@link replay} for history. Hosted accounts cap `maxRetries` at 7 and `timeoutMs` at 30000.\n\t */\n\tasync create(input: CreateWebhookRequest): Promise<CreateWebhookResponse> {\n\t\treturn this.request<CreateWebhookResponse>(\"POST\", \"/api/webhooks\", input);\n\t}\n\n\t/**\n\t * Change a webhook's name, URL, format, auth, or delivery limits.\n\t *\n\t * Send only the fields to change: `name`, `url`, `format`, `authConfig`, `maxRetries`, `timeoutMs`, `concurrency`. Triggers are not part of an update. Resolves to the updated webhook.\n\t */\n\tasync update(\n\t\tid: string,\n\t\tpatch: UpdateWebhookRequest,\n\t): Promise<WebhookDetail> {\n\t\treturn this.request<WebhookDetail>(\n\t\t\t\"PATCH\",\n\t\t\t`/api/webhooks/${seg(id)}`,\n\t\t\tpatch,\n\t\t);\n\t}\n\n\t/**\n\t * Stop deliveries without deleting the webhook.\n\t *\n\t * Resolves to the updated webhook. {@link resume} turns it back on.\n\t */\n\tasync pause(id: string): Promise<WebhookDetail> {\n\t\treturn this.request<WebhookDetail>(\n\t\t\t\"POST\",\n\t\t\t`/api/webhooks/${seg(id)}/pause`,\n\t\t);\n\t}\n\n\t/**\n\t * Start delivering again after a pause.\n\t *\n\t * Also clears a circuit-breaker pause, which happens after 20 straight failed deliveries. Resolves to the updated webhook.\n\t */\n\tasync resume(id: string): Promise<WebhookDetail> {\n\t\treturn this.request<WebhookDetail>(\n\t\t\t\"POST\",\n\t\t\t`/api/webhooks/${seg(id)}/resume`,\n\t\t);\n\t}\n\n\t/**\n\t * Delete a webhook.\n\t *\n\t * Resolves to `{ ok: true }`. Throws a 404 when the webhook does not exist.\n\t */\n\tasync delete(id: string): Promise<{ ok: true }> {\n\t\treturn this.request<{ ok: true }>(\"DELETE\", `/api/webhooks/${seg(id)}`);\n\t}\n\n\t/**\n\t * Issue a new signing secret.\n\t *\n\t * The new secret comes back once in `signingSecret`. Update the receiver that calls `verifyWebhookSignature` with it: deliveries are signed with the new secret from now on.\n\t */\n\tasync rotateSecret(id: string): Promise<RotateSecretResponse> {\n\t\treturn this.request<RotateSecretResponse>(\n\t\t\t\"POST\",\n\t\t\t`/api/webhooks/${seg(id)}/rotate-secret`,\n\t\t);\n\t}\n\n\t/** Send a one-off test delivery to a webhook's URL.\n\t *\n\t * Built for the webhook's format and SSRF-guarded. Logged as a delivery\n\t * row, visible via `deliveries`. */\n\tasync test(id: string): Promise<WebhookTestResult> {\n\t\treturn this.request<WebhookTestResult>(\n\t\t\t\"POST\",\n\t\t\t`/api/webhooks/${seg(id)}/test`,\n\t\t);\n\t}\n\n\t/** The last 100 delivery attempts, newest first. The server caps the\n\t * result; there is nothing to page. Pass `window` (ISO timestamps, at most\n\t * 31 days wide) to read only the attempts dispatched in `[from, to)`, for\n\t * example one hour of the heat strip; `total` then counts that window. */\n\tasync deliveries(\n\t\tid: string,\n\t\twindow?: { from: string; to: string },\n\t): Promise<{ total: number; data: DeliveryRow[] }> {\n\t\treturn this.request<{ total: number; data: DeliveryRow[] }>(\n\t\t\t\"GET\",\n\t\t\t`/api/webhooks/${seg(id)}/deliveries${buildQuery({ from: window?.from, to: window?.to })}`,\n\t\t);\n\t}\n\n\t/** Get a webhook's hourly delivery counts for the last 7 days.\n\t *\n\t * Each hour has delivered, waiting, and gave-up counts, plus the current\n\t * queue depth, failed attempts per hour, the usual band, and 30 daily\n\t * totals. This is the events chart on the detail page. `{ days: 30 }` is\n\t * accepted for older servers, which only send the daily totals when asked. */\n\tasync activity(id: string, opts?: { days?: 30 }): Promise<WebhookActivity> {\n\t\treturn this.request<WebhookActivity>(\n\t\t\t\"GET\",\n\t\t\t`/api/webhooks/${seg(id)}/activity${buildQuery({ days: opts?.days })}`,\n\t\t);\n\t}\n\n\t/** Get one delivery attempt with its payload and response.\n\t *\n\t * Includes the outbox context (payload, response headers). This is the\n\t * delivery card on the detail page. */\n\tasync delivery(\n\t\tid: string,\n\t\tdeliveryId: string,\n\t): Promise<WebhookDeliveryDetail> {\n\t\treturn this.request<WebhookDeliveryDetail>(\n\t\t\t\"GET\",\n\t\t\t`/api/webhooks/${seg(id)}/deliveries/${seg(deliveryId)}`,\n\t\t);\n\t}\n\n\t/**\n\t * Re-deliver historical events over an existing webhook.\n\t *\n\t * Pass `fromBlock` and `toBlock`; the range is capped at 100,000 blocks. A replay is historical only and never moves the live cursor. It is idempotent: the same range twice is a no-op, so pass any string as `force` to re-deliver it. Replays carry `is_replay` and use the standard `chain.{type}.apply` envelope. Resolves to `replayId`, `enqueuedCount`, and `scannedCount`. A second replay while one is running is a 409. `sbtc_withdrawal_swept_confirmed` is forward-only and never replayed. Hosted accounts get 5 replays an hour.\n\t */\n\tasync replay(\n\t\tid: string,\n\t\trange: { fromBlock: number; toBlock: number; force?: string },\n\t): Promise<ReplayResult> {\n\t\treturn this.request<ReplayResult>(\n\t\t\t\"POST\",\n\t\t\t`/api/webhooks/${seg(id)}/replay`,\n\t\t\trange,\n\t\t);\n\t}\n\n\t/**\n\t * List the events a webhook gave up delivering.\n\t *\n\t * Returns up to 100 dead-lettered events, newest failure first. Each row's `id` is what {@link requeue} takes.\n\t */\n\tasync dead(id: string): Promise<{ data: DeadRow[] }> {\n\t\treturn this.request<{ data: DeadRow[] }>(\n\t\t\t\"GET\",\n\t\t\t`/api/webhooks/${seg(id)}/dead`,\n\t\t);\n\t}\n\n\t/** Push one dead-lettered event back onto the delivery queue. `outboxId` is\n\t * the `id` of a row from {@link dead}. */\n\tasync requeue(id: string, outboxId: string): Promise<{ ok: true }> {\n\t\treturn this.request<{ ok: true }>(\n\t\t\t\"POST\",\n\t\t\t`/api/webhooks/${seg(id)}/dead/${seg(outboxId)}/requeue`,\n\t\t);\n\t}\n}\n",
|
|
22
22
|
"import type { InstanceDiagnosis } from \"@secondlayer/shared/archive/instance-diagnosis\";\nimport type { SubgraphSummary } from \"@secondlayer/shared/schemas\";\nimport { createArchiveClient } from \"./archive/client.ts\";\nimport {\n\ttype InstanceClient,\n\tcreateArchiveVerify,\n\tcreateInstanceClient,\n} from \"./archive/instance.ts\";\nimport type { SecondLayerArchive } from \"./archive/types.ts\";\nimport { BaseClient } from \"./base.ts\";\nimport type { SecondLayerOptions } from \"./base.ts\";\nimport { Contracts } from \"./contracts/client.ts\";\nimport { ApiError, SecondLayerError } from \"./errors.ts\";\nimport { Index } from \"./index-api/client.ts\";\nimport type { IndexTip } from \"./index-api/client.ts\";\nimport { createStreamsClient } from \"./streams/client.ts\";\nimport type { StreamsClient, StreamsTip } from \"./streams/types.ts\";\nimport { Subgraphs } from \"./subgraphs/client.ts\";\nimport type { SubgraphOperationStatus } from \"./subgraphs/client.ts\";\nimport { Webhooks } from \"./webhooks/client.ts\";\n\nexport interface ContextAccount {\n\temail: string;\n}\n\nexport interface ActiveSubgraphOperation {\n\tsubgraph: string;\n\toperationId: string;\n\tkind: SubgraphOperationStatus[\"kind\"];\n\tstatus: SubgraphOperationStatus[\"status\"];\n\tprogress: number | null;\n}\n\n/** Why one snapshot field could not be read. Serialized from the SDK error\n * family so a snapshot stays plain data. */\nexport interface ContextFieldError {\n\tmessage: string;\n\t/** Stable code from the API envelope (`UNAUTHORIZED`, `INDEX_NOT_READY`)\n\t * or the SDK (`REQUEST_TIMEOUT`); absent for a bare transport failure. */\n\tcode?: string;\n\t/** HTTP status; `0` when the API could not be reached. */\n\tstatus?: number;\n\t/** Whether the same read can succeed on a retry (429, 5xx, network). */\n\tretryable: boolean;\n}\n\n/** One snapshot field: the value, or `null` plus the error that produced it.\n * `value: null` with no `error` means the read succeeded and found nothing. */\nexport interface ContextField<T> {\n\tvalue: T | null;\n\terror?: ContextFieldError;\n}\n\n/**\n * A point-in-time orientation snapshot: live tips and what this instance holds.\n *\n * Every field is a `ContextField`, so a missing value says why it is missing\n * (API unreachable, token rejected, index still catching up) instead of a bare\n * `null`. `context()` itself never throws.\n */\nexport interface ContextSnapshot {\n\t/** Token identity from `/api/accounts/me`. A self-hosted instance has no\n\t * account system: the read 404s and the field carries that error. */\n\taccount: ContextField<ContextAccount>;\n\tstreamsTip: ContextField<StreamsTip>;\n\tindexTip: ContextField<IndexTip>;\n\tsubgraphs: ContextField<SubgraphSummary[]>;\n\twebhooks: ContextField<{\n\t\tcount: number;\n\t\tbyStatus: Record<string, number>;\n\t}>;\n\t/** In-flight reindex operations (bounded to subgraphs reporting `reindexing`). */\n\tactiveOperations: ContextField<ActiveSubgraphOperation[]>;\n\t/** Decoder lag / empty-index from `GET /public/status`. */\n\tinstance: ContextField<InstanceDiagnosis>;\n}\n\n/** Fold one read into a {@link ContextField}: the value, or `null` with the\n * failure described. Exported for callers that assemble their own snapshot\n * from extra reads and want the same shape. */\nexport async function contextField<T>(\n\tread: Promise<T>,\n): Promise<ContextField<T>> {\n\ttry {\n\t\treturn { value: await read };\n\t} catch (err) {\n\t\treturn { value: null, error: describeContextError(err) };\n\t}\n}\n\nfunction describeContextError(err: unknown): ContextFieldError {\n\tif (err instanceof SecondLayerError) {\n\t\treturn {\n\t\t\tmessage: err.shortMessage,\n\t\t\t...(err.code !== undefined ? { code: err.code } : {}),\n\t\t\t...(err instanceof ApiError ? { status: err.status } : {}),\n\t\t\tretryable: err.retryable,\n\t\t};\n\t}\n\t// A bare TypeError is fetch's own network failure (Streams reads are not\n\t// wrapped): unreachable, and worth another try, same as the consume loops.\n\tif (err instanceof TypeError) {\n\t\treturn { message: err.message, status: 0, retryable: true };\n\t}\n\treturn {\n\t\tmessage: err instanceof Error ? err.message : String(err),\n\t\tretryable: false,\n\t};\n}\n\nexport class SecondLayer extends BaseClient {\n\treadonly streams: StreamsClient;\n\treadonly index: Index;\n\treadonly contracts: Contracts;\n\treadonly subgraphs: Subgraphs;\n\treadonly webhooks: Webhooks;\n\treadonly archive: SecondLayerArchive;\n\treadonly instance: InstanceClient;\n\n\tconstructor(options: Partial<SecondLayerOptions> = {}) {\n\t\tsuper(options);\n\t\tthis.streams = createStreamsClient({\n\t\t\t// Already resolved by BaseClient (an explicit \"\" stays keyless), so the\n\t\t\t// env precedence runs, and warns, once per client.\n\t\t\tapiKey: this.apiKey,\n\t\t\torigin: this.origin,\n\t\t\tbaseUrl: options.baseUrl,\n\t\t\tfetchImpl: options.fetchImpl,\n\t\t\tdumpsBaseUrl: options.dumpsBaseUrl,\n\t\t\tverify: options.verify,\n\t\t\tverifyDumpsManifest: options.verifyDumpsManifest,\n\t\t});\n\t\tthis.index = new Index(options);\n\t\tthis.contracts = new Contracts(options);\n\t\tthis.subgraphs = new Subgraphs(options);\n\t\tthis.webhooks = new Webhooks(options);\n\t\tconst request = <T>(method: string, path: string, body?: unknown) =>\n\t\t\tthis.request<T>(method, path, body);\n\t\tthis.archive = {\n\t\t\t...createArchiveClient({\n\t\t\t\taccountKey: options.accountKey,\n\t\t\t\tfetchImpl: options.fetchImpl,\n\t\t\t\tarchiveBaseUrl: options.archiveBaseUrl,\n\t\t\t\tarchiveOpsUrl: options.archiveOpsUrl,\n\t\t\t}),\n\t\t\tverify: createArchiveVerify(request),\n\t\t};\n\t\tthis.instance = createInstanceClient(request);\n\t}\n\n\t/**\n\t * Run up to 10 public reads in one round trip (`POST /v1/batch`).\n\t *\n\t * Each item is authorized on its own; the client's bearer token applies to\n\t * every item. Results come back in order as `{ path, status, body }`. Only\n\t * paths under `/v1/index/`, `/v1/subgraphs`, `/v1/streams/`, and\n\t * `/v1/contracts` are allowed; anything else is a per-item 400.\n\t */\n\tasync batch(\n\t\trequests: Array<{\n\t\t\tpath: string;\n\t\t\tparams?: Record<string, string | number | boolean>;\n\t\t}>,\n\t): Promise<{\n\t\tresults: Array<{ path: string | null; status: number; body: unknown }>;\n\t}> {\n\t\treturn this.request(\"POST\", \"/v1/batch\", { requests });\n\t}\n\n\t/**\n\t * Get a one-call snapshot of your account and instance.\n\t *\n\t * Returns a {@link ContextSnapshot}: the same orientation an MCP agent reads\n\t * from `secondlayer://context`, available to any SDK or CLI consumer. Reads\n\t * run concurrently; a failed read lands as `{ value: null, error }` on its\n\t * field rather than rejecting the whole snapshot.\n\t */\n\tasync context(): Promise<ContextSnapshot> {\n\t\tconst [account, streamsTip, indexEnv, subgraphsRes, webhooksRes, instance] =\n\t\t\tawait Promise.all([\n\t\t\t\tcontextField(this.request<ContextAccount>(\"GET\", \"/api/accounts/me\")),\n\t\t\t\tcontextField(this.streams.tip()),\n\t\t\t\tcontextField(this.index.canonical.list({ limit: 1 })),\n\t\t\t\tcontextField(this.subgraphs.list()),\n\t\t\t\tcontextField(this.webhooks.list()),\n\t\t\t\tcontextField(this.instance.diagnose()),\n\t\t\t]);\n\n\t\tconst subgraphs: ContextField<SubgraphSummary[]> = {\n\t\t\tvalue: subgraphsRes.value?.data ?? null,\n\t\t\t...(subgraphsRes.error ? { error: subgraphsRes.error } : {}),\n\t\t};\n\n\t\tconst webhooks: ContextSnapshot[\"webhooks\"] = {\n\t\t\tvalue: null,\n\t\t\t...(webhooksRes.error ? { error: webhooksRes.error } : {}),\n\t\t};\n\t\tif (webhooksRes.value) {\n\t\t\tconst byStatus: Record<string, number> = {};\n\t\t\tfor (const s of webhooksRes.value.data) {\n\t\t\t\tbyStatus[s.status] = (byStatus[s.status] ?? 0) + 1;\n\t\t\t}\n\t\t\twebhooks.value = {\n\t\t\t\tcount: webhooksRes.value.data.length,\n\t\t\t\tbyStatus,\n\t\t\t};\n\t\t}\n\n\t\t// In-flight ops: only probe subgraphs that report `reindexing`, so this\n\t\t// stays cheap (usually zero extra calls) instead of N+1 over every subgraph.\n\t\tlet activeOperations: ContextField<ActiveSubgraphOperation[]> = {\n\t\t\tvalue: null,\n\t\t\terror: {\n\t\t\t\tmessage:\n\t\t\t\t\t\"Not probed: in-flight operations are read per subgraph, and the subgraph list did not resolve.\",\n\t\t\t\tretryable: subgraphs.error?.retryable ?? false,\n\t\t\t},\n\t\t};\n\t\tif (subgraphs.value) {\n\t\t\tconst probed = await Promise.all(\n\t\t\t\tsubgraphs.value\n\t\t\t\t\t.filter((s) => s.status === \"reindexing\")\n\t\t\t\t\t.map(async (s) => {\n\t\t\t\t\t\tconst res = await contextField(this.subgraphs.operations(s.name));\n\t\t\t\t\t\tconst op = res.value?.operations.find(\n\t\t\t\t\t\t\t(o) => o.status === \"queued\" || o.status === \"running\",\n\t\t\t\t\t\t);\n\t\t\t\t\t\treturn op\n\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\tsubgraph: s.name,\n\t\t\t\t\t\t\t\t\toperationId: op.id,\n\t\t\t\t\t\t\t\t\tkind: op.kind,\n\t\t\t\t\t\t\t\t\tstatus: op.status,\n\t\t\t\t\t\t\t\t\tprogress: op.progress,\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t: null;\n\t\t\t\t\t}),\n\t\t\t);\n\t\t\tactiveOperations = {\n\t\t\t\tvalue: probed.filter((o): o is ActiveSubgraphOperation => o !== null),\n\t\t\t};\n\t\t}\n\n\t\treturn {\n\t\t\taccount,\n\t\t\tstreamsTip,\n\t\t\tindexTip: {\n\t\t\t\tvalue: indexEnv.value?.tip ?? null,\n\t\t\t\t...(indexEnv.error ? { error: indexEnv.error } : {}),\n\t\t\t},\n\t\t\tsubgraphs,\n\t\t\twebhooks,\n\t\t\tactiveOperations,\n\t\t\tinstance,\n\t\t};\n\t}\n}\n",
|
|
23
23
|
"import type { InferSubgraphClient } from \"@secondlayer/subgraphs\";\nimport type { SecondLayerOptions } from \"../base.ts\";\nimport { SecondLayer } from \"../client.ts\";\nimport { Subgraphs } from \"./client.ts\";\n\n/**\n * Returns a typed client for a subgraph defined with `defineSubgraph()`.\n *\n * Accepts a plain options object, a `SecondLayer` instance, or a `Subgraphs` instance.\n *\n * @example\n * ```ts\n * import mySubgraph from './subgraphs/my-subgraph'\n * import { getSubgraph } from '@secondlayer/sdk'\n *\n * const client = getSubgraph(mySubgraph, { apiKey: 'sl_...' })\n * const page = await client.transfers.findMany({ where: { sender: 'SP...' } })\n * ```\n */\nexport function getSubgraph<\n\tT extends { name: string; schema: Record<string, unknown> },\n>(\n\tdef: T,\n\toptions: Partial<SecondLayerOptions> | SecondLayer | Subgraphs = {},\n): InferSubgraphClient<T> {\n\tif (options instanceof Subgraphs) {\n\t\treturn options.typed(def);\n\t}\n\tif (options instanceof SecondLayer) {\n\t\treturn options.subgraphs.typed(def);\n\t}\n\treturn new Subgraphs(options).typed(def);\n}\n",
|
|
24
24
|
"import {\n\tDOWN_MIN_CONSECUTIVE,\n\tLAG_MIN_SAMPLES,\n\tLAG_THRESHOLD_MS,\n\tLAG_WINDOW,\n\tRATE_LIMITED_MIN_ATTEMPTS,\n\tRATE_LIMITED_MIN_RATIO,\n\tREJECTS_MIN_CONSECUTIVE,\n\tSLOW_MIN_SAMPLES,\n\tSLOW_TIMEOUT_RATIO,\n\tSLOW_WINDOW,\n} from \"@secondlayer/shared/insights/thresholds\";\nimport type {\n\tDeadRow,\n\tDeliveryRow,\n\tWebhookDetail,\n\tWebhookSummary,\n} from \"@secondlayer/shared/schemas/webhooks\";\n\n/**\n * Webhook diagnosis, shared by `secondlayer webhooks doctor` and the\n * dashboard's webhook detail page. Moved out of the CLI (unchanged behavior)\n * so the web can build the same judgment without printing CLI-command text.\n *\n * Five deterministic, numbers-only detectors sit on top of the\n * existing flag checks: no model, no history beyond the deliveries window\n * already fetched. Thresholds are named constants below, conservative on\n * purpose — a wrong insight costs more trust than a missed one.\n */\n\nexport function isSuccessDelivery(row: DeliveryRow): boolean {\n\treturn (\n\t\trow.statusCode !== null && row.statusCode >= 200 && row.statusCode < 300\n\t);\n}\n\nexport type DoctorSeverity = \"bad\" | \"warn\" | \"calm\";\n\nexport interface DoctorEvidence {\n\tlabel: string;\n\tvalue: string;\n}\n\nexport interface DoctorFix {\n\ttext: string;\n\t/** A `secondlayer webhooks ...` command the reader can copy. */\n\tcommand?: string;\n\t/** Anchor into the docs' webhooks troubleshooting section. */\n\tdocsPath?: string;\n}\n\nexport type DoctorIssueCode =\n\t| \"warning\"\n\t| \"paused\"\n\t| \"last_error\"\n\t| \"circuit\"\n\t| \"dead_letters\"\n\t| \"no_deliveries\"\n\t| \"receiver_rate_limited\"\n\t| \"receiver_down\"\n\t| \"receiver_rejects\"\n\t| \"receiver_slow\"\n\t| \"delivery_lag\";\n\nexport interface DoctorIssue {\n\tcode: DoctorIssueCode;\n\tdetail?: string;\n\tseverity: DoctorSeverity;\n\t/** 2-4 numbers-only facts backing the issue. Only the five new detectors\n\t * and the list-summary circuit issue carry this — the older flag checks\n\t * keep their plain `detail` string. */\n\tevidence?: DoctorEvidence[];\n\tfix?: DoctorFix;\n}\n\nexport interface DoctorReport {\n\twebhook: WebhookDetail;\n\tdeliverySummary: {\n\t\ttotal: number;\n\t\tsuccessful: number;\n\t\tfailed: number;\n\t\tlast: DeliveryRow | null;\n\t};\n\tdeadCount: number;\n\t/** CLI-facing next steps, one per issue, in the same order as `issues`. */\n\thints: string[];\n\tissues: DoctorIssue[];\n\t/** The single most severe issue (bad > warn > calm, then rule priority),\n\t * or `null` when there are none. The UI leads with this and collapses\n\t * the rest underneath. */\n\tprimary: DoctorIssue | null;\n}\n\n// ── Detector thresholds ─────────────────────────────────────────────\n// Named constants, not config. Tune from real data; until then these stay\n// conservative.\n\n// Thresholds live in shared so the server-side insight rules use the same\n// numbers. `DOWN_MIN_CONSECUTIVE` is re-exported: the status pill's\n// \"auto-failing\" rule reuses it instead of duplicating the number.\nexport { DOWN_MIN_CONSECUTIVE };\nconst ERROR_SNIPPET_MAX = 200;\n\nfunction median(values: number[]): number {\n\tif (values.length === 0) return 0;\n\tconst sorted = [...values].sort((a, b) => a - b);\n\treturn sorted[Math.floor(sorted.length / 2)] ?? 0;\n}\n\nfunction percentile(values: number[], p: number): number {\n\tif (values.length === 0) return 0;\n\tconst sorted = [...values].sort((a, b) => a - b);\n\tconst idx = Math.min(sorted.length - 1, Math.floor(p * sorted.length));\n\treturn sorted[idx] ?? 0;\n}\n\nfunction formatMs(ms: number): string {\n\treturn ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`;\n}\n\n/** \"2026-04-23 00:00 UTC\" — date, hour:minute, and an explicit zone so an\n * evidence value never reads as local time. */\nfunction formatShortDate(iso: string): string {\n\treturn `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;\n}\n\n/** The longest run from the start of `rows` (newest-first) matching\n * `predicate` — the current losing (or rejecting) streak. */\nfunction consecutiveFromStart(\n\trows: DeliveryRow[],\n\tpredicate: (row: DeliveryRow) => boolean,\n): DeliveryRow[] {\n\tconst out: DeliveryRow[] = [];\n\tfor (const row of rows) {\n\t\tif (!predicate(row)) break;\n\t\tout.push(row);\n\t}\n\treturn out;\n}\n\nfunction summarizeStatusBreakdown(rows: DeliveryRow[]): string {\n\tconst counts = new Map<string, number>();\n\tfor (const row of rows) {\n\t\tconst key =\n\t\t\trow.statusCode === null ? \"timeout/no response\" : String(row.statusCode);\n\t\tcounts.set(key, (counts.get(key) ?? 0) + 1);\n\t}\n\treturn [...counts.entries()].map(([key, n]) => `${n}× ${key}`).join(\", \");\n}\n\nconst RETRY_AFTER_RE = /retry-after:?\\s*(\\d+)/i;\n\n/** The most recent 429's `Retry-After`, if the receiver's error text carried\n * one — we never invent a value the rows didn't report. */\nfunction extractRetryAfterSeconds(rows: DeliveryRow[]): string | null {\n\tfor (const row of rows) {\n\t\tif (row.statusCode !== 429 || !row.errorMessage) continue;\n\t\tconst match = row.errorMessage.match(RETRY_AFTER_RE);\n\t\tif (match?.[1]) return match[1];\n\t}\n\treturn null;\n}\n\n/** ≥10 attempts in the window, ≥50% of them 429 — the receiver is\n * rate-limiting us, not failing. */\nfunction detectReceiverRateLimited(\n\twebhook: WebhookDetail,\n\tdeliveries: DeliveryRow[],\n): DoctorIssue | null {\n\tif (deliveries.length < RATE_LIMITED_MIN_ATTEMPTS) return null;\n\tconst count429 = deliveries.filter((d) => d.statusCode === 429).length;\n\tif (count429 / deliveries.length < RATE_LIMITED_MIN_RATIO) return null;\n\n\tconst oldest = deliveries[deliveries.length - 1];\n\tconst newest = deliveries[0];\n\tconst evidence: DoctorEvidence[] = [\n\t\t{\n\t\t\tlabel: \"429 responses\",\n\t\t\tvalue: `${count429} of ${deliveries.length} attempts`,\n\t\t},\n\t\t{\n\t\t\tlabel: \"window\",\n\t\t\tvalue:\n\t\t\t\toldest && newest\n\t\t\t\t\t? `${formatShortDate(oldest.dispatchedAt)} → ${formatShortDate(newest.dispatchedAt)}`\n\t\t\t\t\t: \"unknown\",\n\t\t},\n\t];\n\tconst retryAfter = extractRetryAfterSeconds(deliveries);\n\tif (retryAfter) {\n\t\tevidence.push({ label: \"latest Retry-After\", value: `${retryAfter}s` });\n\t}\n\n\tconst suggestedConcurrency = Math.max(1, Math.floor(webhook.concurrency / 2));\n\treturn {\n\t\tcode: \"receiver_rate_limited\",\n\t\tseverity: \"warn\",\n\t\tevidence,\n\t\tfix: {\n\t\t\ttext: \"Lower concurrency, or honor the Retry-After header on your receiver.\",\n\t\t\tcommand: `secondlayer webhooks update ${webhook.id} --concurrency ${suggestedConcurrency}`,\n\t\t\tdocsPath: \"/docs/webhooks#receiver-rate-limited\",\n\t\t},\n\t};\n}\n\n/** ≥5 most recent attempts are 5xx or got no response at all (timeout /\n * connection failure) — the receiver looks down, not just slow. */\nfunction detectReceiverDown(\n\twebhook: WebhookDetail,\n\tdeliveries: DeliveryRow[],\n): DoctorIssue | null {\n\tconst streak = consecutiveFromStart(\n\t\tdeliveries,\n\t\t(d) => d.statusCode === null || d.statusCode >= 500,\n\t);\n\tif (streak.length < DOWN_MIN_CONSECUTIVE) return null;\n\n\treturn {\n\t\tcode: \"receiver_down\",\n\t\tseverity: \"bad\",\n\t\tevidence: [\n\t\t\t{ label: \"consecutive failures\", value: String(streak.length) },\n\t\t\t{ label: \"breakdown\", value: summarizeStatusBreakdown(streak) },\n\t\t\t{\n\t\t\t\tlabel: \"last success\",\n\t\t\t\tvalue: webhook.lastSuccessAt\n\t\t\t\t\t? formatShortDate(webhook.lastSuccessAt)\n\t\t\t\t\t: \"never\",\n\t\t\t},\n\t\t],\n\t\tfix: {\n\t\t\ttext: \"Check your receiver's health and logs, then send a test event.\",\n\t\t\tcommand: `secondlayer webhooks test ${webhook.id}`,\n\t\t\tdocsPath: \"/docs/webhooks#receiver-down\",\n\t\t},\n\t};\n}\n\n/** ≥5 most recent attempts are 4xx other than 429 — the receiver is\n * reachable but actively rejecting us (auth, signature, bad request). */\nfunction detectReceiverRejects(\n\twebhook: WebhookDetail,\n\tdeliveries: DeliveryRow[],\n): DoctorIssue | null {\n\tconst streak = consecutiveFromStart(\n\t\tdeliveries,\n\t\t(d) =>\n\t\t\td.statusCode !== null &&\n\t\t\td.statusCode >= 400 &&\n\t\t\td.statusCode < 500 &&\n\t\t\td.statusCode !== 429,\n\t);\n\tif (streak.length < REJECTS_MIN_CONSECUTIVE) return null;\n\n\tconst evidence: DoctorEvidence[] = [\n\t\t{ label: \"consecutive rejects\", value: String(streak.length) },\n\t\t{ label: \"breakdown\", value: summarizeStatusBreakdown(streak) },\n\t];\n\tconst withText = streak.find((d) => d.errorMessage || d.responseBody);\n\tconst snippet = withText\n\t\t? (withText.errorMessage ?? withText.responseBody ?? \"\").slice(\n\t\t\t\t0,\n\t\t\t\tERROR_SNIPPET_MAX,\n\t\t\t)\n\t\t: null;\n\tif (snippet) evidence.push({ label: \"first error\", value: snippet });\n\n\treturn {\n\t\tcode: \"receiver_rejects\",\n\t\tseverity: \"bad\",\n\t\tevidence,\n\t\tfix: {\n\t\t\ttext: \"Check your receiver's auth config or signature verification.\",\n\t\t\tcommand: `secondlayer webhooks test ${webhook.id}`,\n\t\t\tdocsPath: \"/docs/webhooks#receiver-rejects\",\n\t\t},\n\t};\n}\n\n/** Median response time of the newest 20 attempts is at least half the\n * webhook's timeout — deliveries are slow, not failing. */\nfunction detectReceiverSlow(\n\twebhook: WebhookDetail,\n\tdeliveries: DeliveryRow[],\n): DoctorIssue | null {\n\tconst durations = deliveries\n\t\t.slice(0, SLOW_WINDOW)\n\t\t.map((d) => d.durationMs)\n\t\t.filter((d): d is number => d !== null);\n\tif (durations.length < SLOW_MIN_SAMPLES) return null;\n\n\tconst med = median(durations);\n\tif (med < webhook.timeoutMs * SLOW_TIMEOUT_RATIO) return null;\n\n\treturn {\n\t\tcode: \"receiver_slow\",\n\t\tseverity: \"warn\",\n\t\tevidence: [\n\t\t\t{ label: \"median response time\", value: formatMs(med) },\n\t\t\t{\n\t\t\t\tlabel: \"p95 response time\",\n\t\t\t\tvalue: formatMs(percentile(durations, 0.95)),\n\t\t\t},\n\t\t\t{ label: \"timeout\", value: formatMs(webhook.timeoutMs) },\n\t\t],\n\t\tfix: {\n\t\t\ttext: \"Speed up your receiver, or raise the timeout (up to 30s hosted).\",\n\t\t\tcommand: `secondlayer webhooks update ${webhook.id} --timeout-ms 30000`,\n\t\t\tdocsPath: \"/docs/webhooks#receiver-slow\",\n\t\t},\n\t};\n}\n\n/** Median (dispatch time − block time) of the newest 20 attempts with a\n * known block time exceeds a minute — we're behind on delivery. This is\n * our-side latency, not something the receiver can fix, so it carries no\n * actionable command. */\nfunction detectDeliveryLag(deliveries: DeliveryRow[]): DoctorIssue | null {\n\tconst window = deliveries\n\t\t.slice(0, LAG_WINDOW)\n\t\t.filter(\n\t\t\t(d): d is DeliveryRow & { blockTime: string } => d.blockTime !== null,\n\t\t);\n\tif (window.length < LAG_MIN_SAMPLES) return null;\n\n\tconst lags = window.map(\n\t\t(d) => new Date(d.dispatchedAt).getTime() - new Date(d.blockTime).getTime(),\n\t);\n\tconst med = median(lags);\n\tif (med <= LAG_THRESHOLD_MS) return null;\n\n\tconst blockTimes = window.map((d) => d.blockTime).sort();\n\tconst oldest = blockTimes[0];\n\tconst newest = blockTimes[blockTimes.length - 1];\n\n\treturn {\n\t\tcode: \"delivery_lag\",\n\t\tseverity: \"warn\",\n\t\tevidence: [\n\t\t\t{ label: \"median lag\", value: formatMs(med) },\n\t\t\t{\n\t\t\t\tlabel: \"block time range\",\n\t\t\t\tvalue:\n\t\t\t\t\toldest && newest\n\t\t\t\t\t\t? `${formatShortDate(oldest)} → ${formatShortDate(newest)}`\n\t\t\t\t\t\t: \"unknown\",\n\t\t\t},\n\t\t],\n\t\tfix: {\n\t\t\ttext: \"We're behind on delivery; no action needed on your end.\",\n\t\t},\n\t};\n}\n\nconst SEVERITY_RANK: Record<DoctorSeverity, number> = {\n\tbad: 0,\n\twarn: 1,\n\tcalm: 2,\n};\n\n/** Tie-break within a severity tier when more than one issue fires at\n * once — most specific / most actionable first. */\nconst CODE_PRIORITY: DoctorIssueCode[] = [\n\t\"circuit\",\n\t\"receiver_down\",\n\t\"receiver_rejects\",\n\t\"dead_letters\",\n\t\"paused\",\n\t\"last_error\",\n\t\"warning\",\n\t\"receiver_rate_limited\",\n\t\"receiver_slow\",\n\t\"delivery_lag\",\n\t\"no_deliveries\",\n];\n\nfunction pickPrimary(issues: DoctorIssue[]): DoctorIssue | null {\n\tif (issues.length === 0) return null;\n\treturn (\n\t\t[...issues].sort((a, b) => {\n\t\t\tconst sevDiff = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];\n\t\t\tif (sevDiff !== 0) return sevDiff;\n\t\t\treturn CODE_PRIORITY.indexOf(a.code) - CODE_PRIORITY.indexOf(b.code);\n\t\t})[0] ?? null\n\t);\n}\n\nfunction hintFor(issue: DoctorIssue): string {\n\tif (!issue.fix) return issue.detail ?? issue.code;\n\treturn issue.fix.command\n\t\t? `${issue.fix.text} ${issue.fix.command}`\n\t\t: issue.fix.text;\n}\n\nexport function buildDoctorReport(input: {\n\twebhook: WebhookDetail;\n\tdeliveries: DeliveryRow[];\n\tdead: DeadRow[];\n}): DoctorReport {\n\tconst successful = input.deliveries.filter(isSuccessDelivery).length;\n\tconst failed = input.deliveries.length - successful;\n\n\tconst hints: string[] = [];\n\tconst issues: DoctorIssue[] = [];\n\tif (input.webhook.warning) {\n\t\thints.push(input.webhook.warning);\n\t\tissues.push({\n\t\t\tcode: \"warning\",\n\t\t\tdetail: input.webhook.warning,\n\t\t\tseverity: \"warn\",\n\t\t});\n\t}\n\tif (input.webhook.status === \"paused\") {\n\t\thints.push(\n\t\t\t`Resume when the receiver is healthy: secondlayer webhooks resume ${input.webhook.id}`,\n\t\t);\n\t\tissues.push({ code: \"paused\", severity: \"warn\" });\n\t}\n\tif (input.webhook.lastError) {\n\t\thints.push(\n\t\t\t\"Run secondlayer webhooks test to reproduce the receiver request.\",\n\t\t);\n\t\tissues.push({\n\t\t\tcode: \"last_error\",\n\t\t\tdetail: input.webhook.lastError,\n\t\t\tseverity: \"warn\",\n\t\t});\n\t}\n\tif (input.webhook.circuitOpenedAt || input.webhook.circuitFailures > 0) {\n\t\thints.push(\n\t\t\t\"Circuit breaker has failures; inspect receiver logs and delivery status codes.\",\n\t\t);\n\t\tissues.push({ code: \"circuit\", severity: \"bad\" });\n\t}\n\tif (input.dead.length > 0) {\n\t\thints.push(\n\t\t\t`Dead-letter rows exist; inspect with secondlayer webhooks dead ${input.webhook.id} and requeue selected rows.`,\n\t\t);\n\t\tissues.push({ code: \"dead_letters\", severity: \"bad\" });\n\t}\n\n\tfor (const detector of [\n\t\tdetectReceiverRateLimited,\n\t\tdetectReceiverDown,\n\t\tdetectReceiverRejects,\n\t\tdetectReceiverSlow,\n\t]) {\n\t\tconst issue = detector(input.webhook, input.deliveries);\n\t\tif (issue) {\n\t\t\tissues.push(issue);\n\t\t\thints.push(hintFor(issue));\n\t\t}\n\t}\n\tconst lagIssue = detectDeliveryLag(input.deliveries);\n\tif (lagIssue) {\n\t\tissues.push(lagIssue);\n\t\thints.push(hintFor(lagIssue));\n\t}\n\n\tif (input.deliveries.length === 0) {\n\t\thints.push(\n\t\t\t\"No deliveries yet; confirm a chain event matching one of the triggers has happened since the webhook was created.\",\n\t\t);\n\t\tissues.push({ code: \"no_deliveries\", severity: \"calm\" });\n\t}\n\tif (hints.length === 0) {\n\t\thints.push(\"No immediate action needed.\");\n\t}\n\n\treturn {\n\t\twebhook: input.webhook,\n\t\tdeliverySummary: {\n\t\t\ttotal: input.deliveries.length,\n\t\t\tsuccessful,\n\t\t\tfailed,\n\t\t\tlast: input.deliveries[0] ?? null,\n\t\t},\n\t\tdeadCount: input.dead.length,\n\t\thints,\n\t\tissues,\n\t\tprimary: pickPrimary(issues),\n\t};\n}\n\n/**\n * The list page's own reduced detector: computed from `WebhookSummary` alone\n * (no deliveries, no dead-letter rows — the list endpoint returns neither,\n * and fetching per-row detail or deliveries here would fan out one request\n * per row). Only `status` and `circuitOpenedAt` distinguish a real problem\n * from a manual pause at this level; deliveries-based rules (rate limits,\n * downtime, rejects, slowness, lag) only ever show on the detail page.\n */\nexport function buildListIssue(\n\twebhook: Pick<WebhookSummary, \"status\" | \"circuitOpenedAt\" | \"lastSuccessAt\">,\n): DoctorIssue | null {\n\tif (webhook.status !== \"paused\") return null;\n\tif (webhook.circuitOpenedAt) {\n\t\treturn {\n\t\t\tcode: \"circuit\",\n\t\t\tseverity: \"bad\",\n\t\t\tevidence: [\n\t\t\t\t{\n\t\t\t\t\tlabel: \"circuit opened\",\n\t\t\t\t\tvalue: formatShortDate(webhook.circuitOpenedAt),\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tlabel: \"last success\",\n\t\t\t\t\tvalue: webhook.lastSuccessAt\n\t\t\t\t\t\t? formatShortDate(webhook.lastSuccessAt)\n\t\t\t\t\t\t: \"never\",\n\t\t\t\t},\n\t\t\t],\n\t\t\tfix: {\n\t\t\t\ttext: \"Check your receiver's health and logs, then send a test event.\",\n\t\t\t\tdocsPath: \"/docs/webhooks#receiver-down\",\n\t\t\t},\n\t\t};\n\t}\n\treturn { code: \"paused\", severity: \"warn\" };\n}\n",
|
|
@@ -3653,9 +3653,9 @@ declare class Webhooks extends BaseClient {
|
|
|
3653
3653
|
/** Get a webhook's hourly delivery counts for the last 7 days.
|
|
3654
3654
|
*
|
|
3655
3655
|
* Each hour has delivered, waiting, and gave-up counts, plus the current
|
|
3656
|
-
* queue depth, failed attempts per hour,
|
|
3657
|
-
*
|
|
3658
|
-
* the
|
|
3656
|
+
* queue depth, failed attempts per hour, the usual band, and 30 daily
|
|
3657
|
+
* totals. This is the events chart on the detail page. `{ days: 30 }` is
|
|
3658
|
+
* accepted for older servers, which only send the daily totals when asked. */
|
|
3659
3659
|
activity(id: string, opts?: {
|
|
3660
3660
|
days?: 30;
|
|
3661
3661
|
}): Promise<WebhookActivity>;
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"import { ed25519 } from \"@secondlayer/shared\";\nimport { buildQuery, resolveApiKey, resolveBaseUrl } from \"../base.ts\";\nimport type { IndexEvent } from \"../index-api/client.ts\";\nimport type { ConsumerSink } from \"../sinks/types.ts\";\nimport {\n\ttype StreamsEventsFetcher,\n\tconsumeStreamsEvents,\n\titerateStreamsBatches,\n\tstreamStreamsEvents,\n} from \"./consumer.ts\";\nimport { Cursor } from \"./cursor.ts\";\nimport { decode } from \"./decode.ts\";\nimport { createStreamsDumps } from \"./dumps.ts\";\nimport {\n\tStreamsServerError,\n\tStreamsSignatureError,\n\tValidationError,\n\tmapStreamsError,\n} from \"./errors.ts\";\nimport {\n\ttype StreamsEventsSubscribeWireParams,\n\tsubscribeStreamsEvents,\n} from \"./subscribe.ts\";\nimport type {\n\tFetchLike,\n\tStreamsBatch,\n\tStreamsCanonicalBlock,\n\tStreamsClient,\n\tStreamsConsumeParams,\n\tStreamsEvent,\n\tStreamsEventsConsumeBitcoinParams,\n\tStreamsEventsConsumeParams,\n\tStreamsEventsConsumeResult,\n\tStreamsEventsEnvelope,\n\tStreamsEventsListEnvelope,\n\tStreamsEventsListParams,\n\tStreamsEventsReplayParams,\n\tStreamsEventsStreamParams,\n\tStreamsEventsSubscribeParams,\n\tStreamsFilterMap,\n\tStreamsFilterValue,\n\tStreamsReorgsListEnvelope,\n\tStreamsReorgsListParams,\n\tStreamsSubscription,\n\tStreamsTip,\n} from \"./types.ts\";\n\n/** Parse a `<block>:<index>` cursor; null sorts before genesis. */\nfunction cursorTuple(cursor: string | null): [number, number] {\n\tif (!cursor) return [-1, -1];\n\tconst { blockHeight, eventIndex } = Cursor.parse(cursor);\n\treturn [blockHeight, eventIndex];\n}\n\n/** The greater of two cursors (later in the stream). */\nfunction maxCursor(a: string | null, b: string | null): string | null {\n\tconst [ah, ai] = cursorTuple(a);\n\tconst [bh, bi] = cursorTuple(b);\n\treturn ah > bh || (ah === bh && ai >= bi) ? a : b;\n}\n\n/**\n * The narrowing set every Streams read forwards verbatim to the consumer\n * loops. Kept in one place because each field is optional: a forgotten one is\n * a type-checkable object that silently widens the stream toward the full\n * firehose — which is billed per row, and has happened. `events.replay()`\n * deliberately forwards a subset and stays explicit.\n */\nfunction streamsFilters(params: {\n\tchain?: \"stacks\" | \"bitcoin\";\n\t// `readonly string[]`, not `StreamsEventType[]`: this helper is shared by\n\t// the Stacks and Bitcoin branches, whose `types` element types (\n\t// `StreamsEventType` vs `RuneEventType`) are disjoint. It forwards the\n\t// field verbatim with no type-based logic, so the wider type costs nothing.\n\ttypes?: readonly string[];\n\tnotTypes?: readonly string[];\n\tcontractId?: StreamsFilterValue;\n\tsender?: StreamsFilterValue;\n\trecipient?: StreamsFilterValue;\n\tassetIdentifier?: string;\n\trune?: string;\n\taddress?: string;\n\tfilters?: StreamsFilterMap;\n}) {\n\treturn {\n\t\tchain: params.chain,\n\t\ttypes: params.types,\n\t\tnotTypes: params.notTypes,\n\t\tcontractId: params.contractId,\n\t\tsender: params.sender,\n\t\trecipient: params.recipient,\n\t\tassetIdentifier: params.assetIdentifier,\n\t\trune: params.rune,\n\t\taddress: params.address,\n\t\tfilters: params.filters,\n\t};\n}\n\nexport type CreateStreamsClientOptions = {\n\tapiKey?: string;\n\tbaseUrl?: string;\n\t/** Deploy origin label sent as `x-sl-origin` (telemetry). Defaults to `cli`. */\n\torigin?: \"cli\" | \"mcp\" | \"session\";\n\tfetchImpl?: FetchLike;\n\t/**\n\t * Public base URL for bulk parquet dumps (the R2/CDN bucket root). Required\n\t * to use `client.dumps`. See `GET /public/streams/dumps/manifest`.\n\t */\n\tdumpsBaseUrl?: string;\n\t/**\n\t * Verify the ed25519 `X-Signature` on every REST response and per-frame SSE\n\t * signature. Three states:\n\t * - **default (omitted)** — *lenient*: verify when the server signs (the\n\t * hosted API signs every response), and pass through when no signature is\n\t * present (e.g. a self-hosted instance with no `STREAMS_SIGNING_PRIVATE_KEY`).\n\t * So verification is on by default against the hosted API without breaking\n\t * unsigned self-host deployments. An *invalid* signature always throws.\n\t * - **`true`** (or `{ publicKey }` to pin a known PEM) — *strict*: a missing\n\t * OR invalid signature throws `StreamsSignatureError`. Use this when you\n\t * require a portable, non-repudiable attestation and won't accept unsigned\n\t * data (it also closes the lenient mode's strip-the-header downgrade).\n\t * - **`false`** — off.\n\t *\n\t * The key is fetched once from `/public/streams/signing-key` (cached; a\n\t * rotated `X-Signature-KeyId` triggers one refresh) unless a PEM is pinned.\n\t */\n\tverify?: boolean | { publicKey: string };\n\t/**\n\t * Verify the bulk dumps manifest's ed25519 signature in `client.dumps.list()`\n\t * before trusting any file sha256 (default ON). Uses the same key source as\n\t * `verify` (fetches `/public/streams/signing-key`, or a pinned PEM). Pass\n\t * `false` to opt out. A missing or invalid signature throws\n\t * `StreamsSignatureError`.\n\t */\n\tverifyDumpsManifest?: boolean;\n};\n\nfunction normalizeBaseUrl(baseUrl: string): string {\n\treturn baseUrl.replace(/\\/+$/, \"\");\n}\n\n/** Loopback hosts, where plain http carries no more exposure than the box\n * itself. Anywhere else, http means a network hop anyone on the path can\n * rewrite, key endpoint included. */\nfunction isLoopbackUrl(baseUrl: string): boolean {\n\ttry {\n\t\tconst { hostname } = new URL(baseUrl);\n\t\treturn (\n\t\t\thostname === \"localhost\" ||\n\t\t\thostname === \"127.0.0.1\" ||\n\t\t\thostname === \"::1\" ||\n\t\t\thostname === \"[::1]\" ||\n\t\t\thostname.startsWith(\"127.\")\n\t\t);\n\t} catch {\n\t\treturn false;\n\t}\n}\n\nexport function createStreamsClient(\n\toptions: CreateStreamsClientOptions,\n): StreamsClient {\n\tconst baseUrl = normalizeBaseUrl(\n\t\tresolveBaseUrl(options.baseUrl, options.apiKey),\n\t);\n\t// Same credential rule as every other client (explicit option, then the\n\t// host's env key), so the same code authenticates the same way whether it\n\t// enters through `new SecondLayer()` or here.\n\tconst apiKey = resolveApiKey(options.apiKey, baseUrl);\n\tconst origin = options.origin ?? \"cli\";\n\tconst authHeaders = (): Record<string, string> => ({\n\t\t\"x-sl-origin\": origin,\n\t\t...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),\n\t});\n\tconst fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));\n\tconst verify = options.verify;\n\t// On by default, but lenient: the hosted API signs every response, while a\n\t// self-hosted instance without STREAMS_SIGNING_PRIVATE_KEY serves none.\n\t// Lenient verifies when a signature is present and passes through when it is\n\t// absent — so the default neither skips hosted verification nor breaks OSS.\n\t// `verify: true` / `{ publicKey }` is strict (missing signature throws);\n\t// `verify: false` is off. An invalid signature always throws.\n\tconst verifyMode: \"off\" | \"lenient\" | \"strict\" =\n\t\tverify === false ? \"off\" : verify === undefined ? \"lenient\" : \"strict\";\n\t// Strict mode promises the response came from the holder of a known key.\n\t// A key fetched over plain http from a host off the box is whatever the\n\t// path served, so the promise only holds with a pinned PEM there.\n\tif (\n\t\tverify === true &&\n\t\tbaseUrl.startsWith(\"http:\") &&\n\t\t!isLoopbackUrl(baseUrl)\n\t) {\n\t\tthrow new ValidationError(\n\t\t\t`verify: true over plain http to ${baseUrl} fetches the signing key from the same unprotected path it verifies. Pin the key with verify: { publicKey } (see GET /public/streams/signing-key), or use https.`,\n\t\t\t400,\n\t\t);\n\t}\n\n\t// Lazily resolve and cache the verification key alongside its id, so a\n\t// rotation (signalled by a changed `X-Signature-KeyId`) can be detected.\n\ttype VerificationKey = {\n\t\tkeyId: string;\n\t\tpublicKeyPem: string;\n\t\tpublicKey: ReturnType<typeof ed25519.loadEd25519PublicKey>;\n\t};\n\t// Only a successful fetch stays cached. A 5xx or a dropped socket on the\n\t// key endpoint used to park a rejected promise here for the life of the\n\t// client; now it surfaces as a retryable error and the next call refetches.\n\tlet keyPromise: Promise<VerificationKey> | null = null;\n\tfunction loadKey(): Promise<VerificationKey> {\n\t\tif (keyPromise) return keyPromise;\n\t\tconst pending = (async () => {\n\t\t\tif (typeof verify === \"object\") {\n\t\t\t\treturn {\n\t\t\t\t\tkeyId: ed25519.ed25519KeyId(verify.publicKey),\n\t\t\t\t\tpublicKeyPem: verify.publicKey,\n\t\t\t\t\tpublicKey: ed25519.loadEd25519PublicKey(verify.publicKey),\n\t\t\t\t};\n\t\t\t}\n\t\t\tlet res: Response;\n\t\t\ttry {\n\t\t\t\tres = await fetchImpl(`${baseUrl}/public/streams/signing-key`);\n\t\t\t} catch {\n\t\t\t\tthrow new StreamsServerError(\n\t\t\t\t\t`Could not reach the signing key at ${baseUrl}/public/streams/signing-key. Retryable.`,\n\t\t\t\t\t0,\n\t\t\t\t\tundefined,\n\t\t\t\t\t\"SIGNING_KEY_UNAVAILABLE\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (!res.ok) {\n\t\t\t\tif (res.status >= 500 || res.status === 429) {\n\t\t\t\t\tthrow new StreamsServerError(\n\t\t\t\t\t\t`Could not fetch signing key (${res.status}). Retryable.`,\n\t\t\t\t\t\tres.status,\n\t\t\t\t\t\tundefined,\n\t\t\t\t\t\t\"SIGNING_KEY_UNAVAILABLE\",\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\tthrow new StreamsSignatureError(\n\t\t\t\t\t`Could not fetch signing key (${res.status}).`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst body = (await res.json()) as {\n\t\t\t\tpublic_key_pem?: string;\n\t\t\t\tkey_id?: string;\n\t\t\t};\n\t\t\tif (!body.public_key_pem) {\n\t\t\t\tthrow new StreamsSignatureError(\"Signing key response missing key.\");\n\t\t\t}\n\t\t\treturn {\n\t\t\t\tkeyId: body.key_id ?? ed25519.ed25519KeyId(body.public_key_pem),\n\t\t\t\tpublicKeyPem: body.public_key_pem,\n\t\t\t\tpublicKey: ed25519.loadEd25519PublicKey(body.public_key_pem),\n\t\t\t};\n\t\t})();\n\t\tkeyPromise = pending;\n\t\tpending.catch(() => {\n\t\t\tif (keyPromise === pending) keyPromise = null;\n\t\t});\n\t\treturn pending;\n\t}\n\t/** Resolve the key a response claims to be signed with. A fetched key\n\t * refreshes once on an unknown id; a pinned key never changes, so an\n\t * unknown id is a failure either way. Shared by REST reads and SSE frames. */\n\tasync function loadKeyFor(\n\t\tresponseKeyId: string | null | undefined,\n\t): Promise<VerificationKey> {\n\t\tlet key = await loadKey();\n\t\tif (!responseKeyId || responseKeyId === key.keyId) return key;\n\t\tif (typeof verify === \"object\") {\n\t\t\tthrow new StreamsSignatureError(\n\t\t\t\t`Response signed with key '${responseKeyId}', expected pinned key '${key.keyId}'.`,\n\t\t\t);\n\t\t}\n\t\t// Refresh once. A still-mismatched id (no re-loop) means the endpoint\n\t\t// does not serve the signing key: fail closed.\n\t\tkeyPromise = null;\n\t\tkey = await loadKey();\n\t\tif (responseKeyId !== key.keyId) {\n\t\t\tthrow new StreamsSignatureError(\n\t\t\t\t`Response signed with key '${responseKeyId}' not served by the signing-key endpoint.`,\n\t\t\t);\n\t\t}\n\t\treturn key;\n\t}\n\n\tconst dumps = createStreamsDumps({\n\t\tbaseUrl: options.dumpsBaseUrl,\n\t\tfetchImpl,\n\t\tverifyManifest: options.verifyDumpsManifest ?? true,\n\t\tloadPublicKeyPem: async () => (await loadKey()).publicKeyPem,\n\t});\n\n\tasync function request<T>(path: string): Promise<T> {\n\t\tconst response = await fetchImpl(`${baseUrl}${path}`, {\n\t\t\theaders: authHeaders(),\n\t\t});\n\t\tif (!response.ok) await mapStreamsError(response);\n\t\tconst text = await response.text();\n\t\tif (verifyMode !== \"off\") {\n\t\t\tconst signature = response.headers.get(\"X-Signature\");\n\t\t\tif (!signature) {\n\t\t\t\t// Strict requires a signature; lenient (default) lets an unsigned\n\t\t\t\t// response through — e.g. self-host with no signing key configured.\n\t\t\t\tif (verifyMode === \"strict\") {\n\t\t\t\t\tthrow new StreamsSignatureError(\"Response is missing X-Signature.\");\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tconst key = await loadKeyFor(response.headers.get(\"X-Signature-KeyId\"));\n\t\t\t\t// A signature is present, so verify it regardless of strict/lenient —\n\t\t\t\t// an invalid signature always fails closed.\n\t\t\t\tif (!ed25519.verifyEd25519(text, signature, key.publicKey)) {\n\t\t\t\t\tthrow new StreamsSignatureError();\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn JSON.parse(text) as T;\n\t}\n\n\t// StreamsEventsFetchParams was a strict subset of StreamsEventsListParams\n\t// pre-059; `chain: \"bitcoin\"` narrows `types` to RuneEventType there, so a\n\t// plain cast replaces the old structural assignment for this one generic\n\t// entry point shared by consume/stream (untyped across chains — see\n\t// `StreamsEventsStreamParams.chain`'s doc comment).\n\tconst fetchEvents: StreamsEventsFetcher = (params) =>\n\t\tlistEvents(params as StreamsEventsListParams);\n\n\tfunction byTxId(txId: string, opts?: { chain?: \"stacks\" | \"bitcoin\" }) {\n\t\treturn request<StreamsEventsListEnvelope>(\n\t\t\t`/v1/streams/events/${encodeURIComponent(txId)}${buildQuery({\n\t\t\t\tchain: opts?.chain === \"bitcoin\" ? \"bitcoin\" : undefined,\n\t\t\t})}`,\n\t\t);\n\t}\n\n\tfunction listReorgs(\n\t\tparams: StreamsReorgsListParams & { chain?: \"stacks\" | \"bitcoin\" },\n\t) {\n\t\treturn request<StreamsReorgsListEnvelope>(\n\t\t\t`/v1/streams/reorgs${buildQuery({\n\t\t\t\tchain: params.chain === \"bitcoin\" ? \"bitcoin\" : undefined,\n\t\t\t\tsince: params.since,\n\t\t\t\tlimit: params.limit,\n\t\t\t})}`,\n\t\t);\n\t}\n\t// Idle-tip reorg detection for the consume loop (one request per empty\n\t// page); the public `reorgs.list` is the same call.\n\tconst fetchReorgs = (params: { since: string }) => listReorgs(params);\n\n\tfunction encodeFilters(filters: StreamsFilterMap | undefined) {\n\t\treturn filters ? JSON.stringify(filters) : undefined;\n\t}\n\n\tasync function listEvents(\n\t\tparams: StreamsEventsListParams = {},\n\t): Promise<StreamsEventsEnvelope> {\n\t\tif (params.chain === \"bitcoin\") {\n\t\t\treturn request<StreamsEventsEnvelope>(\n\t\t\t\t`/v1/streams/events${buildQuery({\n\t\t\t\t\tchain: \"bitcoin\",\n\t\t\t\t\tcursor: params.cursor,\n\t\t\t\t\tfrom_height: params.fromHeight,\n\t\t\t\t\tto_height: params.toHeight,\n\t\t\t\t\tlimit: params.limit,\n\t\t\t\t\ttypes: params.types,\n\t\t\t\t\tnot_types: params.notTypes,\n\t\t\t\t\trune: params.rune,\n\t\t\t\t\taddress: params.address,\n\t\t\t\t})}`,\n\t\t\t);\n\t\t}\n\t\treturn request<StreamsEventsEnvelope>(\n\t\t\t`/v1/streams/events${buildQuery({\n\t\t\t\tcursor: params.cursor,\n\t\t\t\tfrom_height: params.fromHeight,\n\t\t\t\tto_height: params.toHeight,\n\t\t\t\tlimit: params.limit,\n\t\t\t\tcontract_id: params.contractId,\n\t\t\t\tsender: params.sender,\n\t\t\t\trecipient: params.recipient,\n\t\t\t\tasset_identifier: params.assetIdentifier,\n\t\t\t\ttypes: params.types,\n\t\t\t\tnot_types: params.notTypes,\n\t\t\t\t// `vm` reads ordinal; omitted = Streams 1.0 (server default).\n\t\t\t\tclock: params.clock === \"vm\" ? \"vm\" : undefined,\n\t\t\t\tfilters: encodeFilters(params.filters),\n\t\t\t})}`,\n\t\t);\n\t}\n\n\t/**\n\t * One signature, two shapes: a flat filter with `onBatch`, or a labelled\n\t * `filters` map with a handler per label (or both). Deliberately NOT\n\t * overloaded — an overloaded interface member strips contextual typing from\n\t * every stub and alternate implementation of `StreamsClient`.\n\t */\n\tfunction consumeEvents<\n\t\tconst F extends StreamsFilterMap = Record<never, never>,\n\t\tTTx = never,\n\t\tD extends boolean = false,\n\t>(\n\t\t// Redundant `sink` member: direct TTx inference site (inference\n\t\t// does not traverse the params alias).\n\t\tparams: StreamsEventsConsumeParams<TTx, D, F> & {\n\t\t\tsink?: ConsumerSink<TTx>;\n\t\t},\n\t): Promise<StreamsEventsConsumeResult> {\n\t\tconst labels = params.filters ? Object.keys(params.filters) : [];\n\t\t// The \"at least one handler\" rule is conditional on `filters`, so it\n\t\t// can't live in the type without making `F` uninferrable. Fail loudly\n\t\t// instead of consuming pages into nothing.\n\t\tif (labels.length === 0 && !params.onBatch) {\n\t\t\tthrow new ValidationError(\n\t\t\t\t\"consume requires onBatch, or filters + on handlers.\",\n\t\t\t\t400,\n\t\t\t);\n\t\t}\n\t\tif (labels.length > 0 && !params.on && !params.onBatch) {\n\t\t\tthrow new ValidationError(\n\t\t\t\t\"consume with filters requires on handlers (or onBatch).\",\n\t\t\t\t400,\n\t\t\t);\n\t\t}\n\t\tconst dispatch = params.on as\n\t\t\t| Record<\n\t\t\t\t\tstring,\n\t\t\t\t\t(\n\t\t\t\t\t\tevents: StreamsEvent[] | IndexEvent[],\n\t\t\t\t\t\tctx: Parameters<\n\t\t\t\t\t\t\tNonNullable<StreamsEventsConsumeParams<TTx>[\"onBatch\"]>\n\t\t\t\t\t\t>[1],\n\t\t\t\t\t) => void | Promise<void>\n\t\t\t >\n\t\t\t| undefined;\n\n\t\t// With `decoded: true`, events arrive as the flat Index-shaped rows —\n\t\t// decoding never appears in the handler.\n\t\tconst project = (events: StreamsEvent[]): StreamsEvent[] | IndexEvent[] =>\n\t\t\tparams.decoded ? events.map(decode) : events;\n\n\t\tconst onBatch: StreamsEventsConsumeParams<TTx>[\"onBatch\"] = async (\n\t\t\tevents,\n\t\t\tctx,\n\t\t) => {\n\t\t\t// Label dispatch reads the wire `matched` array, so it has to run\n\t\t\t// BEFORE decode — the decoded row shape does not carry it.\n\t\t\tfor (const label of labels) {\n\t\t\t\tconst forLabel = events.filter((event) =>\n\t\t\t\t\tevent.matched?.includes(label),\n\t\t\t\t);\n\t\t\t\tif (forLabel.length === 0) continue;\n\t\t\t\tawait dispatch?.[label]?.(project(forLabel), ctx);\n\t\t\t}\n\t\t\t// The `decoded` flag is a runtime value here, so `project`'s return\n\t\t\t// can't be narrowed to the D-conditional the declared handler wants.\n\t\t\tconst userOnBatch = params.onBatch as StreamsEventsConsumeParams<\n\t\t\t\tTTx,\n\t\t\t\tboolean\n\t\t\t>[\"onBatch\"];\n\t\t\t// `onBatch`'s several legal return shapes collapse to one here: the\n\t\t\t// consume loop only ever reads a cursor string or nothing.\n\t\t\treturn (await userOnBatch?.(project(events), ctx)) as\n\t\t\t\t| string\n\t\t\t\t| null\n\t\t\t\t| undefined;\n\t\t};\n\n\t\treturn consumeStreamsEvents<TTx>({\n\t\t\tfromCursor: params.fromCursor,\n\t\t\tsink: params.sink,\n\t\t\tonProgress: params.onProgress,\n\t\t\tmode: params.mode,\n\t\t\tfinalizedOnly: params.finalizedOnly,\n\t\t\t...streamsFilters(params),\n\t\t\tbatchSize: params.batchSize ?? 100,\n\t\t\tfetchEvents,\n\t\t\tfetchReorgs,\n\t\t\tonBatch,\n\t\t\tonReorg: params.onReorg,\n\t\t\tmaxRollbackDepth: params.maxRollbackDepth,\n\t\t\temptyBackoffMs: params.emptyBackoffMs,\n\t\t\tmaxPages: params.maxPages,\n\t\t\tmaxEmptyPolls: params.maxEmptyPolls,\n\t\t\tsignal: params.signal,\n\t\t\tretryCount: params.retryCount,\n\t\t\tretryDelay: params.retryDelay,\n\t\t\tonError: params.onError,\n\t\t\twake: params.wake,\n\t\t});\n\t}\n\n\t// Top-level `consume` (page-batch iterator). One implementation shared by\n\t// both chains — `iterateStreamsBatches` isn't generic over the event type\n\t// (it just forwards whatever the wire returns), so this returns the wide\n\t// `AsyncGenerator<StreamsBatch>` and the public field below casts it to\n\t// `StreamsClient[\"consume\"]`'s richer, chain-narrowed overloads. Same\n\t// bridging pattern `listEvents` uses for `events.list`.\n\tfunction consumeBatches(\n\t\tparams: StreamsConsumeParams = {},\n\t): AsyncGenerator<StreamsBatch> {\n\t\treturn iterateStreamsBatches({\n\t\t\tfromCursor: params.cursor,\n\t\t\tbatchSize: params.batchSize ?? 100,\n\t\t\tintervalMs: params.intervalMs ?? 2000,\n\t\t\t...streamsFilters(params),\n\t\t\tsignal: params.signal,\n\t\t\tfetchEvents,\n\t\t});\n\t}\n\n\tfunction streamEvents(\n\t\tparams: StreamsEventsStreamParams = {},\n\t): AsyncIterable<StreamsEvent> {\n\t\treturn streamStreamsEvents({\n\t\t\tfromCursor: params.fromCursor,\n\t\t\t// Includes the labelled OR-groups: dropping them here silently\n\t\t\t// widened the stream to the FULL firehose (billed per row).\n\t\t\t...streamsFilters(params),\n\t\t\tbatchSize: params.batchSize ?? 100,\n\t\t\temptyBackoffMs: params.emptyBackoffMs,\n\t\t\tmaxPages: params.maxPages,\n\t\t\tmaxEmptyPolls: params.maxEmptyPolls,\n\t\t\tsignal: params.signal,\n\t\t\tfetchEvents,\n\t\t});\n\t}\n\n\tfunction subscribeEvents(\n\t\tparams: StreamsEventsSubscribeParams,\n\t): StreamsSubscription {\n\t\treturn subscribeStreamsEvents({\n\t\t\tbaseUrl,\n\t\t\theaders: authHeaders(),\n\t\t\tfetchImpl,\n\t\t\tverify: verifyMode,\n\t\t\tloadKey: loadKeyFor,\n\t\t\treconnectDelayMs: params.reconnectDelayMs,\n\t\t\tstaleAfterMs: params.staleAfterMs,\n\t\t\t// `StreamsEventsSubscribeParams` (Stacks | Bitcoin) narrows `onEvent`'s\n\t\t\t// parameter type per branch; the loop below reads every field off one\n\t\t\t// flat wire shape regardless of chain — see\n\t\t\t// `StreamsEventsSubscribeWireParams`'s doc comment.\n\t\t\tparams: params as unknown as StreamsEventsSubscribeWireParams,\n\t\t});\n\t}\n\n\t/**\n\t * `events.consume`'s bitcoin branch: a separate, simpler implementation\n\t * (no `filters`/`on`/`decoded`) rather than widening `consumeEvents`'s\n\t * generics — see {@link StreamsEventsConsumeBitcoinParams}'s doc comment.\n\t * Runtime loop is `consumeStreamsEvents`, same as the Stacks branch.\n\t */\n\tfunction consumeBitcoinEvents<TTx = never>(\n\t\tparams: StreamsEventsConsumeBitcoinParams<TTx> & {\n\t\t\tsink?: ConsumerSink<TTx>;\n\t\t},\n\t): Promise<StreamsEventsConsumeResult> {\n\t\treturn consumeStreamsEvents<TTx>({\n\t\t\tfromCursor: params.fromCursor,\n\t\t\tsink: params.sink,\n\t\t\tonProgress: params.onProgress,\n\t\t\tmode: params.mode,\n\t\t\tfinalizedOnly: params.finalizedOnly,\n\t\t\tchain: \"bitcoin\",\n\t\t\ttypes: params.types,\n\t\t\tnotTypes: params.notTypes,\n\t\t\trune: params.rune,\n\t\t\taddress: params.address,\n\t\t\tbatchSize: params.batchSize ?? 100,\n\t\t\tfetchEvents,\n\t\t\tfetchReorgs,\n\t\t\tonBatch: params.onBatch as unknown as NonNullable<\n\t\t\t\tStreamsEventsConsumeParams<TTx>[\"onBatch\"]\n\t\t\t>,\n\t\t\tonReorg: params.onReorg,\n\t\t\tmaxRollbackDepth: params.maxRollbackDepth,\n\t\t\temptyBackoffMs: params.emptyBackoffMs,\n\t\t\tmaxPages: params.maxPages,\n\t\t\tmaxEmptyPolls: params.maxEmptyPolls,\n\t\t\tsignal: params.signal,\n\t\t\tretryCount: params.retryCount,\n\t\t\tretryDelay: params.retryDelay,\n\t\t\tonError: params.onError,\n\t\t\twake: params.wake,\n\t\t});\n\t}\n\n\t/** Dispatches `events.consume` by `chain` to the Stacks (`consumeEvents`,\n\t * filters/on/decoded-capable) or Bitcoin (`consumeBitcoinEvents`, plain\n\t * `onBatch` only) implementation. Cast to the public overloaded type at\n\t * the field assignment below, same as every other chain-narrowed field. */\n\tfunction dispatchEventsConsume<TTx = never>(\n\t\tparams:\n\t\t\t| (StreamsEventsConsumeBitcoinParams<TTx> & { sink?: ConsumerSink<TTx> })\n\t\t\t| (StreamsEventsConsumeParams<TTx, boolean, StreamsFilterMap> & {\n\t\t\t\t\tsink?: ConsumerSink<TTx>;\n\t\t\t }),\n\t): Promise<StreamsEventsConsumeResult> {\n\t\tif (params.chain === \"bitcoin\") return consumeBitcoinEvents(params);\n\t\treturn consumeEvents(params);\n\t}\n\n\treturn {\n\t\tconsume: consumeBatches as StreamsClient[\"consume\"],\n\t\tevents: {\n\t\t\t// One wire call; the overloads narrow the row type by `clock`/`types`.\n\t\t\tlist: listEvents as StreamsClient[\"events\"][\"list\"],\n\t\t\tbyTxId: byTxId as StreamsClient[\"events\"][\"byTxId\"],\n\t\t\tconsume: dispatchEventsConsume as StreamsClient[\"events\"][\"consume\"],\n\t\t\tstream: streamEvents as StreamsClient[\"events\"][\"stream\"],\n\t\t\tsubscribe: subscribeEvents as StreamsClient[\"events\"][\"subscribe\"],\n\t\t\tasync replay(params: StreamsEventsReplayParams) {\n\t\t\t\tconst fromCursor =\n\t\t\t\t\tparams.from === \"genesis\" ? null : (params.from ?? null);\n\t\t\t\tconst fromBlock = fromCursor ? cursorTuple(fromCursor)[0] : 0;\n\t\t\t\tconst manifest = await dumps.list();\n\n\t\t\t\t// Hydrate finalized history from dumps, in block order. Files\n\t\t\t\t// ending at or below `from` carry nothing new; a file straddling\n\t\t\t\t// it is handed over whole, with `from` so the handler can skip\n\t\t\t\t// the rows at or below the checkpoint (file-granular delivery).\n\t\t\t\tconst files = manifest.files\n\t\t\t\t\t.filter((file) => file.to_block >= fromBlock)\n\t\t\t\t\t.filter(\n\t\t\t\t\t\t(file) =>\n\t\t\t\t\t\t\tfromCursor === null ||\n\t\t\t\t\t\t\tfile.max_cursor === null ||\n\t\t\t\t\t\t\tmaxCursor(fromCursor, file.max_cursor) !== fromCursor,\n\t\t\t\t\t)\n\t\t\t\t\t.sort(\n\t\t\t\t\t\t(a, b) => a.from_block - b.from_block || a.to_block - b.to_block,\n\t\t\t\t\t);\n\t\t\t\tfor (const file of files) {\n\t\t\t\t\tif (params.signal?.aborted) break;\n\t\t\t\t\tawait params.onDumpFile(file, { from: fromCursor });\n\t\t\t\t}\n\n\t\t\t\t// Seam: tail live from just past the dumped coverage. Cursor input is\n\t\t\t\t// exclusive, so the first live event is strictly after the last dump.\n\t\t\t\tconst seam = maxCursor(fromCursor, manifest.latest_finalized_cursor);\n\t\t\t\treturn consumeStreamsEvents({\n\t\t\t\t\tfromCursor: seam,\n\t\t\t\t\tmode: params.mode ?? \"tail\",\n\t\t\t\t\tbatchSize: params.batchSize ?? 100,\n\t\t\t\t\t// Live-tail narrowing only — the dump phase above hands over\n\t\t\t\t\t// whole all-type files by design.\n\t\t\t\t\ttypes: params.types,\n\t\t\t\t\tnotTypes: params.notTypes,\n\t\t\t\t\tfilters: params.filters,\n\t\t\t\t\tfetchEvents,\n\t\t\t\t\tonBatch: params.onBatch,\n\t\t\t\t\tonReorg: params.onReorg,\n\t\t\t\t\temptyBackoffMs: params.emptyBackoffMs,\n\t\t\t\t\tmaxPages: params.maxPages,\n\t\t\t\t\tmaxEmptyPolls: params.maxEmptyPolls,\n\t\t\t\t\tsignal: params.signal,\n\t\t\t\t});\n\t\t\t},\n\t\t},\n\t\tblocks: {\n\t\t\tevents: ((\n\t\t\t\theightOrHash: number | string,\n\t\t\t\topts?: { chain?: \"stacks\" | \"bitcoin\" },\n\t\t\t) =>\n\t\t\t\trequest<StreamsEventsListEnvelope>(\n\t\t\t\t\t`/v1/streams/blocks/${encodeURIComponent(String(heightOrHash))}/events${buildQuery(\n\t\t\t\t\t\t{ chain: opts?.chain === \"bitcoin\" ? \"bitcoin\" : undefined },\n\t\t\t\t\t)}`,\n\t\t\t\t)) as StreamsClient[\"blocks\"][\"events\"],\n\t\t},\n\t\treorgs: { list: listReorgs as StreamsClient[\"reorgs\"][\"list\"] },\n\t\tdumps,\n\t\tcanonical: ((height: number, opts?: { chain?: \"stacks\" | \"bitcoin\" }) =>\n\t\t\trequest<StreamsCanonicalBlock>(\n\t\t\t\t`/v1/streams/canonical/${height}${buildQuery({\n\t\t\t\t\tchain: opts?.chain === \"bitcoin\" ? \"bitcoin\" : undefined,\n\t\t\t\t})}`,\n\t\t\t)) as StreamsClient[\"canonical\"],\n\t\ttip: ((opts?: { chain?: \"stacks\" | \"bitcoin\" }) =>\n\t\t\trequest<StreamsTip>(\n\t\t\t\t`/v1/streams/tip${buildQuery({\n\t\t\t\t\tchain: opts?.chain === \"bitcoin\" ? \"bitcoin\" : undefined,\n\t\t\t\t})}`,\n\t\t\t)) as StreamsClient[\"tip\"],\n\t};\n}\n",
|
|
19
19
|
"import {\n\toptionalString,\n\trequireAmountField,\n\trequireHexValue,\n\trequireString,\n} from \"@secondlayer/shared/streams-rows\";\nimport type { IndexEvent } from \"../index-api/client.ts\";\nimport type { StreamsEvent } from \"./types.ts\";\n\n/**\n * Decode a raw Streams event into the SAME flat, `event_type`-discriminated\n * row shape Index serves — so Streams consumption reads identically to Index\n * consumption, with one call instead of eleven guard+decode pairs.\n *\n * ```ts\n * for (const event of envelope.events) {\n * const row = decode(event);\n * if (row.event_type === \"ft_transfer\") row.amount; // string, narrowed\n * }\n * ```\n *\n * Prefer `decoded: true` on `streams.events.consume` — then decoding never\n * appears in your code at all.\n */\nexport function decode(event: StreamsEvent): IndexEvent {\n\tconst base = {\n\t\tcursor: event.cursor,\n\t\tblock_height: event.block_height,\n\t\tblock_time: event.ts,\n\t\ttx_id: event.tx_id,\n\t\ttx_index: event.tx_index,\n\t\tevent_index: event.event_index,\n\t\tcontract_id: event.contract_id,\n\t};\n\t// Payloads are validated field-by-field (never trusted casts): a malformed\n\t// payload throws with the event type and field named.\n\tconst p = event.payload as Record<string, unknown>;\n\tswitch (event.event_type) {\n\t\tcase \"stx_transfer\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\tevent_type: \"stx_transfer\",\n\t\t\t\tsender: requireString(p, \"sender\", event.event_type),\n\t\t\t\trecipient: requireString(p, \"recipient\", event.event_type),\n\t\t\t\tamount: requireAmountField(p, \"amount\", event.event_type),\n\t\t\t\tmemo: optionalString(p.memo),\n\t\t\t};\n\t\tcase \"stx_mint\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\tevent_type: \"stx_mint\",\n\t\t\t\trecipient: requireString(p, \"recipient\", event.event_type),\n\t\t\t\tamount: requireAmountField(p, \"amount\", event.event_type),\n\t\t\t};\n\t\tcase \"stx_burn\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\tevent_type: \"stx_burn\",\n\t\t\t\tsender: requireString(p, \"sender\", event.event_type),\n\t\t\t\tamount: requireAmountField(p, \"amount\", event.event_type),\n\t\t\t};\n\t\tcase \"stx_lock\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\tevent_type: \"stx_lock\",\n\t\t\t\tsender: requireString(p, \"locked_address\", event.event_type),\n\t\t\t\tamount: requireAmountField(p, \"locked_amount\", event.event_type),\n\t\t\t\tpayload: { unlock_height: optionalString(p.unlock_height) },\n\t\t\t};\n\t\tcase \"ft_transfer\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\tevent_type: \"ft_transfer\",\n\t\t\t\tasset_identifier: requireString(\n\t\t\t\t\tp,\n\t\t\t\t\t\"asset_identifier\",\n\t\t\t\t\tevent.event_type,\n\t\t\t\t),\n\t\t\t\tsender: requireString(p, \"sender\", event.event_type),\n\t\t\t\trecipient: requireString(p, \"recipient\", event.event_type),\n\t\t\t\tamount: requireAmountField(p, \"amount\", event.event_type),\n\t\t\t};\n\t\tcase \"ft_mint\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\tevent_type: \"ft_mint\",\n\t\t\t\tasset_identifier: requireString(\n\t\t\t\t\tp,\n\t\t\t\t\t\"asset_identifier\",\n\t\t\t\t\tevent.event_type,\n\t\t\t\t),\n\t\t\t\trecipient: requireString(p, \"recipient\", event.event_type),\n\t\t\t\tamount: requireAmountField(p, \"amount\", event.event_type),\n\t\t\t};\n\t\tcase \"ft_burn\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\tevent_type: \"ft_burn\",\n\t\t\t\tasset_identifier: requireString(\n\t\t\t\t\tp,\n\t\t\t\t\t\"asset_identifier\",\n\t\t\t\t\tevent.event_type,\n\t\t\t\t),\n\t\t\t\tsender: requireString(p, \"sender\", event.event_type),\n\t\t\t\tamount: requireAmountField(p, \"amount\", event.event_type),\n\t\t\t};\n\t\tcase \"nft_transfer\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\tevent_type: \"nft_transfer\",\n\t\t\t\tasset_identifier: requireString(\n\t\t\t\t\tp,\n\t\t\t\t\t\"asset_identifier\",\n\t\t\t\t\tevent.event_type,\n\t\t\t\t),\n\t\t\t\tsender: requireString(p, \"sender\", event.event_type),\n\t\t\t\trecipient: requireString(p, \"recipient\", event.event_type),\n\t\t\t\tvalue: requireHexValue(p, event.event_type),\n\t\t\t};\n\t\tcase \"nft_mint\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\tevent_type: \"nft_mint\",\n\t\t\t\tasset_identifier: requireString(\n\t\t\t\t\tp,\n\t\t\t\t\t\"asset_identifier\",\n\t\t\t\t\tevent.event_type,\n\t\t\t\t),\n\t\t\t\trecipient: requireString(p, \"recipient\", event.event_type),\n\t\t\t\tvalue: requireHexValue(p, event.event_type),\n\t\t\t};\n\t\tcase \"nft_burn\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\tevent_type: \"nft_burn\",\n\t\t\t\tasset_identifier: requireString(\n\t\t\t\t\tp,\n\t\t\t\t\t\"asset_identifier\",\n\t\t\t\t\tevent.event_type,\n\t\t\t\t),\n\t\t\t\tsender: requireString(p, \"sender\", event.event_type),\n\t\t\t\tvalue: requireHexValue(p, event.event_type),\n\t\t\t};\n\t\tcase \"print\":\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\tevent_type: \"print\",\n\t\t\t\tpayload: {\n\t\t\t\t\ttopic: optionalString(p.topic),\n\t\t\t\t\tvalue: p.value,\n\t\t\t\t\traw_value: optionalString(p.raw_value),\n\t\t\t\t},\n\t\t\t};\n\t}\n}\n",
|
|
20
20
|
"import { createHash } from \"node:crypto\";\nimport { verifyStreamsBulkManifestSignature } from \"@secondlayer/shared/streams-bulk-manifest\";\nimport { defaultSleep, fetchPageWithRetry } from \"./consumer.ts\";\nimport { StreamsServerError, StreamsSignatureError } from \"./errors.ts\";\nimport type {\n\tFetchLike,\n\tStreamsDumpFile,\n\tStreamsDumps,\n\tStreamsDumpsManifest,\n} from \"./types.ts\";\n\n/**\n * Bulk parquet dumps: the cold backfill path for \"download all the raw data\".\n *\n * The manifest lives at `<dumpsBaseUrl>/manifest/latest.json` and each file's\n * `path` is the object key under the same base. Downloads are verified against\n * the manifest sha256 so a truncated or tampered file is rejected.\n *\n * When `verifyManifest` is set (the default), the manifest's own ed25519\n * signature is checked (against the published Streams key) BEFORE any file sha256\n * is trusted — a sha256 is only as trustworthy as the manifest it came from.\n */\nexport function createStreamsDumps(opts: {\n\tbaseUrl?: string;\n\tfetchImpl: FetchLike;\n\tverifyManifest?: boolean;\n\tloadPublicKeyPem?: () => Promise<string>;\n}): StreamsDumps {\n\tconst baseUrl = opts.baseUrl?.replace(/\\/+$/, \"\");\n\n\tfunction requireBaseUrl(): string {\n\t\tif (!baseUrl) {\n\t\t\tthrow new StreamsServerError(\n\t\t\t\t\"Streams dumps require `dumpsBaseUrl` on createStreamsClient.\",\n\t\t\t\t0,\n\t\t\t);\n\t\t}\n\t\treturn baseUrl;\n\t}\n\n\tfunction fileUrl(file: StreamsDumpFile): string {\n\t\tconst base = requireBaseUrl();\n\t\tconst path = file.path.replace(/^\\/+/, \"\");\n\t\t// Manifest file paths are bucket-root-absolute (they embed the dataset\n\t\t// prefix), while `baseUrl` already ends with that prefix — strip the\n\t\t// overlap so both the manifest and its files resolve from one base URL.\n\t\ttry {\n\t\t\tconst basePath = new URL(base).pathname.replace(/^\\/+|\\/+$/g, \"\");\n\t\t\tif (basePath && path.startsWith(`${basePath}/`)) {\n\t\t\t\treturn `${base}/${path.slice(basePath.length + 1)}`;\n\t\t\t}\n\t\t} catch {\n\t\t\t// Non-URL base (tests, relative proxies): fall through to plain join.\n\t\t}\n\t\treturn `${base}/${path}`;\n\t}\n\n\tasync function list(): Promise<StreamsDumpsManifest> {\n\t\tconst url = `${requireBaseUrl()}/manifest/latest.json`;\n\t\tconst res = await opts.fetchImpl(url);\n\t\tif (!res.ok) {\n\t\t\tthrow new StreamsServerError(\n\t\t\t\t`Could not fetch dumps manifest (${res.status}).`,\n\t\t\t\tres.status,\n\t\t\t);\n\t\t}\n\t\tconst manifest = (await res.json()) as StreamsDumpsManifest;\n\t\tif (opts.verifyManifest) {\n\t\t\tif (!opts.loadPublicKeyPem) {\n\t\t\t\tthrow new StreamsSignatureError(\n\t\t\t\t\t\"Manifest verification is on but no signing key source is configured.\",\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst publicKeyPem = await opts.loadPublicKeyPem();\n\t\t\tif (!verifyStreamsBulkManifestSignature(manifest, publicKeyPem)) {\n\t\t\t\tthrow new StreamsSignatureError(\n\t\t\t\t\t\"Dumps manifest signature is missing or invalid.\",\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t\treturn manifest;\n\t}\n\n\t/** Fetch one file and hash it as it arrives. Each chunk goes through the\n\t * digest once and is kept once, so a multi-hundred-MB parquet costs its\n\t * own size in memory rather than double. A dropped socket or 5xx retries\n\t * the whole file under the same policy `consume()` uses for pages. */\n\tasync function download(file: StreamsDumpFile): Promise<Uint8Array> {\n\t\treturn fetchPageWithRetry(() => downloadOnce(file), {\n\t\t\tsleep: defaultSleep,\n\t\t});\n\t}\n\n\tasync function downloadOnce(file: StreamsDumpFile): Promise<Uint8Array> {\n\t\tconst res = await opts.fetchImpl(fileUrl(file));\n\t\tif (!res.ok) {\n\t\t\tthrow new StreamsServerError(\n\t\t\t\t`Could not download dump ${file.path} (${res.status}).`,\n\t\t\t\tres.status,\n\t\t\t);\n\t\t}\n\t\tif (!res.body) {\n\t\t\tthrow new StreamsServerError(\n\t\t\t\t`Dump ${file.path} response has no body.`,\n\t\t\t\t0,\n\t\t\t);\n\t\t}\n\t\tconst hash = createHash(\"sha256\");\n\t\tconst chunks: Uint8Array[] = [];\n\t\tlet total = 0;\n\t\tconst reader = res.body.getReader();\n\t\ttry {\n\t\t\twhile (true) {\n\t\t\t\tconst { value, done } = await reader.read();\n\t\t\t\tif (done) break;\n\t\t\t\thash.update(value);\n\t\t\t\tchunks.push(value);\n\t\t\t\ttotal += value.byteLength;\n\t\t\t}\n\t\t} catch {\n\t\t\t// A body that dies mid-transfer is the same transient as a socket\n\t\t\t// that never answered: let the retry policy take it.\n\t\t\tthrow new StreamsServerError(\n\t\t\t\t`Download of dump ${file.path} was interrupted after ${total} bytes.`,\n\t\t\t\t0,\n\t\t\t);\n\t\t}\n\t\tconst digest = hash.digest(\"hex\");\n\t\tif (digest !== file.sha256) {\n\t\t\tthrow new StreamsSignatureError(\n\t\t\t\t`Dump ${file.path} sha256 mismatch (expected ${file.sha256}, got ${digest}).`,\n\t\t\t);\n\t\t}\n\t\tif (chunks.length === 1 && chunks[0]) return chunks[0];\n\t\tconst bytes = new Uint8Array(total);\n\t\tlet offset = 0;\n\t\tfor (const chunk of chunks) {\n\t\t\tbytes.set(chunk, offset);\n\t\t\toffset += chunk.byteLength;\n\t\t}\n\t\treturn bytes;\n\t}\n\n\treturn { list, fileUrl, download };\n}\n",
|
|
21
|
-
"import type {\n\tCreateWebhookRequest,\n\tCreateWebhookResponse,\n\tDeadRow,\n\tDeliveryRow,\n\tReplayResult,\n\tRotateSecretResponse,\n\tUpdateWebhookRequest,\n\tWebhookActivity,\n\tWebhookDeliveryDetail,\n\tWebhookDetail,\n\tWebhookSummary,\n\tWebhookTestResult,\n} from \"@secondlayer/shared/schemas/webhooks\";\nimport { BaseClient, buildQuery, seg } from \"../base.ts\";\n\nexport type {\n\tChainTrigger,\n\tChainTriggerType,\n\tCreateWebhookRequest,\n\tCreateWebhookResponse,\n\tDeadRow,\n\tDeliveryRow,\n\tReplayResult,\n\tRotateSecretResponse,\n\tWebhookActivity,\n\tWebhookActivityHour,\n\tWebhookAuth,\n\tWebhookDeliveryDetail,\n\tWebhookDetail,\n\tWebhookFormat,\n\tWebhookKind,\n\tWebhookStatus,\n\tWebhookSummary,\n\tWebhookTestResult,\n\tUpdateWebhookRequest,\n} from \"@secondlayer/shared/schemas/webhooks\";\n\n// `trigger.*` chain-trigger builders for chain webhooks\n// (`create({ triggers: [trigger.contractCall({ ... })] })`).\nexport { trigger } from \"@secondlayer/shared/schemas/webhooks\";\n\nexport class Webhooks extends BaseClient {\n\t/**\n\t * List your webhooks.\n\t *\n\t * Each summary has the status, format, URL, last delivery and last success, and the circuit breaker state (`circuitFailures`, `circuitOpenedAt`).\n\t */\n\tasync list(): Promise<{ data: WebhookSummary[] }> {\n\t\treturn this.request<{ data: WebhookSummary[] }>(\"GET\", \"/api/webhooks\");\n\t}\n\n\t/**\n\t * Get one webhook's full configuration.\n\t *\n\t * Adds the triggers, the receiver's auth (redacted to header names and whether a secret is set), the retry and timeout limits, `lastError`, and a `warning` when this instance's trigger evaluator is not running.\n\t */\n\tasync get(id: string): Promise<WebhookDetail> {\n\t\treturn this.request<WebhookDetail>(\"GET\", `/api/webhooks/${seg(id)}`);\n\t}\n\n\t/**\n\t * Create a webhook that POSTs chain events to your URL.\n\t *\n\t * Takes a `name`, a `url`, and `triggers` built with `trigger.*`; each trigger is validated before anything is provisioned, and an invalid one is a 400. The signing secret comes back once in `signingSecret`, so store it. A webhook starts at the chain tip and never backfills: use {@link replay} for history. Hosted accounts cap `maxRetries` at 7 and `timeoutMs` at 30000.\n\t */\n\tasync create(input: CreateWebhookRequest): Promise<CreateWebhookResponse> {\n\t\treturn this.request<CreateWebhookResponse>(\"POST\", \"/api/webhooks\", input);\n\t}\n\n\t/**\n\t * Change a webhook's name, URL, format, auth, or delivery limits.\n\t *\n\t * Send only the fields to change: `name`, `url`, `format`, `authConfig`, `maxRetries`, `timeoutMs`, `concurrency`. Triggers are not part of an update. Resolves to the updated webhook.\n\t */\n\tasync update(\n\t\tid: string,\n\t\tpatch: UpdateWebhookRequest,\n\t): Promise<WebhookDetail> {\n\t\treturn this.request<WebhookDetail>(\n\t\t\t\"PATCH\",\n\t\t\t`/api/webhooks/${seg(id)}`,\n\t\t\tpatch,\n\t\t);\n\t}\n\n\t/**\n\t * Stop deliveries without deleting the webhook.\n\t *\n\t * Resolves to the updated webhook. {@link resume} turns it back on.\n\t */\n\tasync pause(id: string): Promise<WebhookDetail> {\n\t\treturn this.request<WebhookDetail>(\n\t\t\t\"POST\",\n\t\t\t`/api/webhooks/${seg(id)}/pause`,\n\t\t);\n\t}\n\n\t/**\n\t * Start delivering again after a pause.\n\t *\n\t * Also clears a circuit-breaker pause, which happens after 20 straight failed deliveries. Resolves to the updated webhook.\n\t */\n\tasync resume(id: string): Promise<WebhookDetail> {\n\t\treturn this.request<WebhookDetail>(\n\t\t\t\"POST\",\n\t\t\t`/api/webhooks/${seg(id)}/resume`,\n\t\t);\n\t}\n\n\t/**\n\t * Delete a webhook.\n\t *\n\t * Resolves to `{ ok: true }`. Throws a 404 when the webhook does not exist.\n\t */\n\tasync delete(id: string): Promise<{ ok: true }> {\n\t\treturn this.request<{ ok: true }>(\"DELETE\", `/api/webhooks/${seg(id)}`);\n\t}\n\n\t/**\n\t * Issue a new signing secret.\n\t *\n\t * The new secret comes back once in `signingSecret`. Update the receiver that calls `verifyWebhookSignature` with it: deliveries are signed with the new secret from now on.\n\t */\n\tasync rotateSecret(id: string): Promise<RotateSecretResponse> {\n\t\treturn this.request<RotateSecretResponse>(\n\t\t\t\"POST\",\n\t\t\t`/api/webhooks/${seg(id)}/rotate-secret`,\n\t\t);\n\t}\n\n\t/** Send a one-off test delivery to a webhook's URL.\n\t *\n\t * Built for the webhook's format and SSRF-guarded. Logged as a delivery\n\t * row, visible via `deliveries`. */\n\tasync test(id: string): Promise<WebhookTestResult> {\n\t\treturn this.request<WebhookTestResult>(\n\t\t\t\"POST\",\n\t\t\t`/api/webhooks/${seg(id)}/test`,\n\t\t);\n\t}\n\n\t/** The last 100 delivery attempts, newest first. The server caps the\n\t * result; there is nothing to page. Pass `window` (ISO timestamps, at most\n\t * 31 days wide) to read only the attempts dispatched in `[from, to)`, for\n\t * example one hour of the heat strip; `total` then counts that window. */\n\tasync deliveries(\n\t\tid: string,\n\t\twindow?: { from: string; to: string },\n\t): Promise<{ total: number; data: DeliveryRow[] }> {\n\t\treturn this.request<{ total: number; data: DeliveryRow[] }>(\n\t\t\t\"GET\",\n\t\t\t`/api/webhooks/${seg(id)}/deliveries${buildQuery({ from: window?.from, to: window?.to })}`,\n\t\t);\n\t}\n\n\t/** Get a webhook's hourly delivery counts for the last 7 days.\n\t *\n\t * Each hour has delivered, waiting, and gave-up counts, plus the current\n\t * queue depth, failed attempts per hour,
|
|
21
|
+
"import type {\n\tCreateWebhookRequest,\n\tCreateWebhookResponse,\n\tDeadRow,\n\tDeliveryRow,\n\tReplayResult,\n\tRotateSecretResponse,\n\tUpdateWebhookRequest,\n\tWebhookActivity,\n\tWebhookDeliveryDetail,\n\tWebhookDetail,\n\tWebhookSummary,\n\tWebhookTestResult,\n} from \"@secondlayer/shared/schemas/webhooks\";\nimport { BaseClient, buildQuery, seg } from \"../base.ts\";\n\nexport type {\n\tChainTrigger,\n\tChainTriggerType,\n\tCreateWebhookRequest,\n\tCreateWebhookResponse,\n\tDeadRow,\n\tDeliveryRow,\n\tReplayResult,\n\tRotateSecretResponse,\n\tWebhookActivity,\n\tWebhookActivityHour,\n\tWebhookAuth,\n\tWebhookDeliveryDetail,\n\tWebhookDetail,\n\tWebhookFormat,\n\tWebhookKind,\n\tWebhookStatus,\n\tWebhookSummary,\n\tWebhookTestResult,\n\tUpdateWebhookRequest,\n} from \"@secondlayer/shared/schemas/webhooks\";\n\n// `trigger.*` chain-trigger builders for chain webhooks\n// (`create({ triggers: [trigger.contractCall({ ... })] })`).\nexport { trigger } from \"@secondlayer/shared/schemas/webhooks\";\n\nexport class Webhooks extends BaseClient {\n\t/**\n\t * List your webhooks.\n\t *\n\t * Each summary has the status, format, URL, last delivery and last success, and the circuit breaker state (`circuitFailures`, `circuitOpenedAt`).\n\t */\n\tasync list(): Promise<{ data: WebhookSummary[] }> {\n\t\treturn this.request<{ data: WebhookSummary[] }>(\"GET\", \"/api/webhooks\");\n\t}\n\n\t/**\n\t * Get one webhook's full configuration.\n\t *\n\t * Adds the triggers, the receiver's auth (redacted to header names and whether a secret is set), the retry and timeout limits, `lastError`, and a `warning` when this instance's trigger evaluator is not running.\n\t */\n\tasync get(id: string): Promise<WebhookDetail> {\n\t\treturn this.request<WebhookDetail>(\"GET\", `/api/webhooks/${seg(id)}`);\n\t}\n\n\t/**\n\t * Create a webhook that POSTs chain events to your URL.\n\t *\n\t * Takes a `name`, a `url`, and `triggers` built with `trigger.*`; each trigger is validated before anything is provisioned, and an invalid one is a 400. The signing secret comes back once in `signingSecret`, so store it. A webhook starts at the chain tip and never backfills: use {@link replay} for history. Hosted accounts cap `maxRetries` at 7 and `timeoutMs` at 30000.\n\t */\n\tasync create(input: CreateWebhookRequest): Promise<CreateWebhookResponse> {\n\t\treturn this.request<CreateWebhookResponse>(\"POST\", \"/api/webhooks\", input);\n\t}\n\n\t/**\n\t * Change a webhook's name, URL, format, auth, or delivery limits.\n\t *\n\t * Send only the fields to change: `name`, `url`, `format`, `authConfig`, `maxRetries`, `timeoutMs`, `concurrency`. Triggers are not part of an update. Resolves to the updated webhook.\n\t */\n\tasync update(\n\t\tid: string,\n\t\tpatch: UpdateWebhookRequest,\n\t): Promise<WebhookDetail> {\n\t\treturn this.request<WebhookDetail>(\n\t\t\t\"PATCH\",\n\t\t\t`/api/webhooks/${seg(id)}`,\n\t\t\tpatch,\n\t\t);\n\t}\n\n\t/**\n\t * Stop deliveries without deleting the webhook.\n\t *\n\t * Resolves to the updated webhook. {@link resume} turns it back on.\n\t */\n\tasync pause(id: string): Promise<WebhookDetail> {\n\t\treturn this.request<WebhookDetail>(\n\t\t\t\"POST\",\n\t\t\t`/api/webhooks/${seg(id)}/pause`,\n\t\t);\n\t}\n\n\t/**\n\t * Start delivering again after a pause.\n\t *\n\t * Also clears a circuit-breaker pause, which happens after 20 straight failed deliveries. Resolves to the updated webhook.\n\t */\n\tasync resume(id: string): Promise<WebhookDetail> {\n\t\treturn this.request<WebhookDetail>(\n\t\t\t\"POST\",\n\t\t\t`/api/webhooks/${seg(id)}/resume`,\n\t\t);\n\t}\n\n\t/**\n\t * Delete a webhook.\n\t *\n\t * Resolves to `{ ok: true }`. Throws a 404 when the webhook does not exist.\n\t */\n\tasync delete(id: string): Promise<{ ok: true }> {\n\t\treturn this.request<{ ok: true }>(\"DELETE\", `/api/webhooks/${seg(id)}`);\n\t}\n\n\t/**\n\t * Issue a new signing secret.\n\t *\n\t * The new secret comes back once in `signingSecret`. Update the receiver that calls `verifyWebhookSignature` with it: deliveries are signed with the new secret from now on.\n\t */\n\tasync rotateSecret(id: string): Promise<RotateSecretResponse> {\n\t\treturn this.request<RotateSecretResponse>(\n\t\t\t\"POST\",\n\t\t\t`/api/webhooks/${seg(id)}/rotate-secret`,\n\t\t);\n\t}\n\n\t/** Send a one-off test delivery to a webhook's URL.\n\t *\n\t * Built for the webhook's format and SSRF-guarded. Logged as a delivery\n\t * row, visible via `deliveries`. */\n\tasync test(id: string): Promise<WebhookTestResult> {\n\t\treturn this.request<WebhookTestResult>(\n\t\t\t\"POST\",\n\t\t\t`/api/webhooks/${seg(id)}/test`,\n\t\t);\n\t}\n\n\t/** The last 100 delivery attempts, newest first. The server caps the\n\t * result; there is nothing to page. Pass `window` (ISO timestamps, at most\n\t * 31 days wide) to read only the attempts dispatched in `[from, to)`, for\n\t * example one hour of the heat strip; `total` then counts that window. */\n\tasync deliveries(\n\t\tid: string,\n\t\twindow?: { from: string; to: string },\n\t): Promise<{ total: number; data: DeliveryRow[] }> {\n\t\treturn this.request<{ total: number; data: DeliveryRow[] }>(\n\t\t\t\"GET\",\n\t\t\t`/api/webhooks/${seg(id)}/deliveries${buildQuery({ from: window?.from, to: window?.to })}`,\n\t\t);\n\t}\n\n\t/** Get a webhook's hourly delivery counts for the last 7 days.\n\t *\n\t * Each hour has delivered, waiting, and gave-up counts, plus the current\n\t * queue depth, failed attempts per hour, the usual band, and 30 daily\n\t * totals. This is the events chart on the detail page. `{ days: 30 }` is\n\t * accepted for older servers, which only send the daily totals when asked. */\n\tasync activity(id: string, opts?: { days?: 30 }): Promise<WebhookActivity> {\n\t\treturn this.request<WebhookActivity>(\n\t\t\t\"GET\",\n\t\t\t`/api/webhooks/${seg(id)}/activity${buildQuery({ days: opts?.days })}`,\n\t\t);\n\t}\n\n\t/** Get one delivery attempt with its payload and response.\n\t *\n\t * Includes the outbox context (payload, response headers). This is the\n\t * delivery card on the detail page. */\n\tasync delivery(\n\t\tid: string,\n\t\tdeliveryId: string,\n\t): Promise<WebhookDeliveryDetail> {\n\t\treturn this.request<WebhookDeliveryDetail>(\n\t\t\t\"GET\",\n\t\t\t`/api/webhooks/${seg(id)}/deliveries/${seg(deliveryId)}`,\n\t\t);\n\t}\n\n\t/**\n\t * Re-deliver historical events over an existing webhook.\n\t *\n\t * Pass `fromBlock` and `toBlock`; the range is capped at 100,000 blocks. A replay is historical only and never moves the live cursor. It is idempotent: the same range twice is a no-op, so pass any string as `force` to re-deliver it. Replays carry `is_replay` and use the standard `chain.{type}.apply` envelope. Resolves to `replayId`, `enqueuedCount`, and `scannedCount`. A second replay while one is running is a 409. `sbtc_withdrawal_swept_confirmed` is forward-only and never replayed. Hosted accounts get 5 replays an hour.\n\t */\n\tasync replay(\n\t\tid: string,\n\t\trange: { fromBlock: number; toBlock: number; force?: string },\n\t): Promise<ReplayResult> {\n\t\treturn this.request<ReplayResult>(\n\t\t\t\"POST\",\n\t\t\t`/api/webhooks/${seg(id)}/replay`,\n\t\t\trange,\n\t\t);\n\t}\n\n\t/**\n\t * List the events a webhook gave up delivering.\n\t *\n\t * Returns up to 100 dead-lettered events, newest failure first. Each row's `id` is what {@link requeue} takes.\n\t */\n\tasync dead(id: string): Promise<{ data: DeadRow[] }> {\n\t\treturn this.request<{ data: DeadRow[] }>(\n\t\t\t\"GET\",\n\t\t\t`/api/webhooks/${seg(id)}/dead`,\n\t\t);\n\t}\n\n\t/** Push one dead-lettered event back onto the delivery queue. `outboxId` is\n\t * the `id` of a row from {@link dead}. */\n\tasync requeue(id: string, outboxId: string): Promise<{ ok: true }> {\n\t\treturn this.request<{ ok: true }>(\n\t\t\t\"POST\",\n\t\t\t`/api/webhooks/${seg(id)}/dead/${seg(outboxId)}/requeue`,\n\t\t);\n\t}\n}\n",
|
|
22
22
|
"import type { InstanceDiagnosis } from \"@secondlayer/shared/archive/instance-diagnosis\";\nimport type { SubgraphSummary } from \"@secondlayer/shared/schemas\";\nimport { createArchiveClient } from \"./archive/client.ts\";\nimport {\n\ttype InstanceClient,\n\tcreateArchiveVerify,\n\tcreateInstanceClient,\n} from \"./archive/instance.ts\";\nimport type { SecondLayerArchive } from \"./archive/types.ts\";\nimport { BaseClient } from \"./base.ts\";\nimport type { SecondLayerOptions } from \"./base.ts\";\nimport { Contracts } from \"./contracts/client.ts\";\nimport { ApiError, SecondLayerError } from \"./errors.ts\";\nimport { Index } from \"./index-api/client.ts\";\nimport type { IndexTip } from \"./index-api/client.ts\";\nimport { createStreamsClient } from \"./streams/client.ts\";\nimport type { StreamsClient, StreamsTip } from \"./streams/types.ts\";\nimport { Subgraphs } from \"./subgraphs/client.ts\";\nimport type { SubgraphOperationStatus } from \"./subgraphs/client.ts\";\nimport { Webhooks } from \"./webhooks/client.ts\";\n\nexport interface ContextAccount {\n\temail: string;\n}\n\nexport interface ActiveSubgraphOperation {\n\tsubgraph: string;\n\toperationId: string;\n\tkind: SubgraphOperationStatus[\"kind\"];\n\tstatus: SubgraphOperationStatus[\"status\"];\n\tprogress: number | null;\n}\n\n/** Why one snapshot field could not be read. Serialized from the SDK error\n * family so a snapshot stays plain data. */\nexport interface ContextFieldError {\n\tmessage: string;\n\t/** Stable code from the API envelope (`UNAUTHORIZED`, `INDEX_NOT_READY`)\n\t * or the SDK (`REQUEST_TIMEOUT`); absent for a bare transport failure. */\n\tcode?: string;\n\t/** HTTP status; `0` when the API could not be reached. */\n\tstatus?: number;\n\t/** Whether the same read can succeed on a retry (429, 5xx, network). */\n\tretryable: boolean;\n}\n\n/** One snapshot field: the value, or `null` plus the error that produced it.\n * `value: null` with no `error` means the read succeeded and found nothing. */\nexport interface ContextField<T> {\n\tvalue: T | null;\n\terror?: ContextFieldError;\n}\n\n/**\n * A point-in-time orientation snapshot: live tips and what this instance holds.\n *\n * Every field is a `ContextField`, so a missing value says why it is missing\n * (API unreachable, token rejected, index still catching up) instead of a bare\n * `null`. `context()` itself never throws.\n */\nexport interface ContextSnapshot {\n\t/** Token identity from `/api/accounts/me`. A self-hosted instance has no\n\t * account system: the read 404s and the field carries that error. */\n\taccount: ContextField<ContextAccount>;\n\tstreamsTip: ContextField<StreamsTip>;\n\tindexTip: ContextField<IndexTip>;\n\tsubgraphs: ContextField<SubgraphSummary[]>;\n\twebhooks: ContextField<{\n\t\tcount: number;\n\t\tbyStatus: Record<string, number>;\n\t}>;\n\t/** In-flight reindex operations (bounded to subgraphs reporting `reindexing`). */\n\tactiveOperations: ContextField<ActiveSubgraphOperation[]>;\n\t/** Decoder lag / empty-index from `GET /public/status`. */\n\tinstance: ContextField<InstanceDiagnosis>;\n}\n\n/** Fold one read into a {@link ContextField}: the value, or `null` with the\n * failure described. Exported for callers that assemble their own snapshot\n * from extra reads and want the same shape. */\nexport async function contextField<T>(\n\tread: Promise<T>,\n): Promise<ContextField<T>> {\n\ttry {\n\t\treturn { value: await read };\n\t} catch (err) {\n\t\treturn { value: null, error: describeContextError(err) };\n\t}\n}\n\nfunction describeContextError(err: unknown): ContextFieldError {\n\tif (err instanceof SecondLayerError) {\n\t\treturn {\n\t\t\tmessage: err.shortMessage,\n\t\t\t...(err.code !== undefined ? { code: err.code } : {}),\n\t\t\t...(err instanceof ApiError ? { status: err.status } : {}),\n\t\t\tretryable: err.retryable,\n\t\t};\n\t}\n\t// A bare TypeError is fetch's own network failure (Streams reads are not\n\t// wrapped): unreachable, and worth another try, same as the consume loops.\n\tif (err instanceof TypeError) {\n\t\treturn { message: err.message, status: 0, retryable: true };\n\t}\n\treturn {\n\t\tmessage: err instanceof Error ? err.message : String(err),\n\t\tretryable: false,\n\t};\n}\n\nexport class SecondLayer extends BaseClient {\n\treadonly streams: StreamsClient;\n\treadonly index: Index;\n\treadonly contracts: Contracts;\n\treadonly subgraphs: Subgraphs;\n\treadonly webhooks: Webhooks;\n\treadonly archive: SecondLayerArchive;\n\treadonly instance: InstanceClient;\n\n\tconstructor(options: Partial<SecondLayerOptions> = {}) {\n\t\tsuper(options);\n\t\tthis.streams = createStreamsClient({\n\t\t\t// Already resolved by BaseClient (an explicit \"\" stays keyless), so the\n\t\t\t// env precedence runs, and warns, once per client.\n\t\t\tapiKey: this.apiKey,\n\t\t\torigin: this.origin,\n\t\t\tbaseUrl: options.baseUrl,\n\t\t\tfetchImpl: options.fetchImpl,\n\t\t\tdumpsBaseUrl: options.dumpsBaseUrl,\n\t\t\tverify: options.verify,\n\t\t\tverifyDumpsManifest: options.verifyDumpsManifest,\n\t\t});\n\t\tthis.index = new Index(options);\n\t\tthis.contracts = new Contracts(options);\n\t\tthis.subgraphs = new Subgraphs(options);\n\t\tthis.webhooks = new Webhooks(options);\n\t\tconst request = <T>(method: string, path: string, body?: unknown) =>\n\t\t\tthis.request<T>(method, path, body);\n\t\tthis.archive = {\n\t\t\t...createArchiveClient({\n\t\t\t\taccountKey: options.accountKey,\n\t\t\t\tfetchImpl: options.fetchImpl,\n\t\t\t\tarchiveBaseUrl: options.archiveBaseUrl,\n\t\t\t\tarchiveOpsUrl: options.archiveOpsUrl,\n\t\t\t}),\n\t\t\tverify: createArchiveVerify(request),\n\t\t};\n\t\tthis.instance = createInstanceClient(request);\n\t}\n\n\t/**\n\t * Run up to 10 public reads in one round trip (`POST /v1/batch`).\n\t *\n\t * Each item is authorized on its own; the client's bearer token applies to\n\t * every item. Results come back in order as `{ path, status, body }`. Only\n\t * paths under `/v1/index/`, `/v1/subgraphs`, `/v1/streams/`, and\n\t * `/v1/contracts` are allowed; anything else is a per-item 400.\n\t */\n\tasync batch(\n\t\trequests: Array<{\n\t\t\tpath: string;\n\t\t\tparams?: Record<string, string | number | boolean>;\n\t\t}>,\n\t): Promise<{\n\t\tresults: Array<{ path: string | null; status: number; body: unknown }>;\n\t}> {\n\t\treturn this.request(\"POST\", \"/v1/batch\", { requests });\n\t}\n\n\t/**\n\t * Get a one-call snapshot of your account and instance.\n\t *\n\t * Returns a {@link ContextSnapshot}: the same orientation an MCP agent reads\n\t * from `secondlayer://context`, available to any SDK or CLI consumer. Reads\n\t * run concurrently; a failed read lands as `{ value: null, error }` on its\n\t * field rather than rejecting the whole snapshot.\n\t */\n\tasync context(): Promise<ContextSnapshot> {\n\t\tconst [account, streamsTip, indexEnv, subgraphsRes, webhooksRes, instance] =\n\t\t\tawait Promise.all([\n\t\t\t\tcontextField(this.request<ContextAccount>(\"GET\", \"/api/accounts/me\")),\n\t\t\t\tcontextField(this.streams.tip()),\n\t\t\t\tcontextField(this.index.canonical.list({ limit: 1 })),\n\t\t\t\tcontextField(this.subgraphs.list()),\n\t\t\t\tcontextField(this.webhooks.list()),\n\t\t\t\tcontextField(this.instance.diagnose()),\n\t\t\t]);\n\n\t\tconst subgraphs: ContextField<SubgraphSummary[]> = {\n\t\t\tvalue: subgraphsRes.value?.data ?? null,\n\t\t\t...(subgraphsRes.error ? { error: subgraphsRes.error } : {}),\n\t\t};\n\n\t\tconst webhooks: ContextSnapshot[\"webhooks\"] = {\n\t\t\tvalue: null,\n\t\t\t...(webhooksRes.error ? { error: webhooksRes.error } : {}),\n\t\t};\n\t\tif (webhooksRes.value) {\n\t\t\tconst byStatus: Record<string, number> = {};\n\t\t\tfor (const s of webhooksRes.value.data) {\n\t\t\t\tbyStatus[s.status] = (byStatus[s.status] ?? 0) + 1;\n\t\t\t}\n\t\t\twebhooks.value = {\n\t\t\t\tcount: webhooksRes.value.data.length,\n\t\t\t\tbyStatus,\n\t\t\t};\n\t\t}\n\n\t\t// In-flight ops: only probe subgraphs that report `reindexing`, so this\n\t\t// stays cheap (usually zero extra calls) instead of N+1 over every subgraph.\n\t\tlet activeOperations: ContextField<ActiveSubgraphOperation[]> = {\n\t\t\tvalue: null,\n\t\t\terror: {\n\t\t\t\tmessage:\n\t\t\t\t\t\"Not probed: in-flight operations are read per subgraph, and the subgraph list did not resolve.\",\n\t\t\t\tretryable: subgraphs.error?.retryable ?? false,\n\t\t\t},\n\t\t};\n\t\tif (subgraphs.value) {\n\t\t\tconst probed = await Promise.all(\n\t\t\t\tsubgraphs.value\n\t\t\t\t\t.filter((s) => s.status === \"reindexing\")\n\t\t\t\t\t.map(async (s) => {\n\t\t\t\t\t\tconst res = await contextField(this.subgraphs.operations(s.name));\n\t\t\t\t\t\tconst op = res.value?.operations.find(\n\t\t\t\t\t\t\t(o) => o.status === \"queued\" || o.status === \"running\",\n\t\t\t\t\t\t);\n\t\t\t\t\t\treturn op\n\t\t\t\t\t\t\t? {\n\t\t\t\t\t\t\t\t\tsubgraph: s.name,\n\t\t\t\t\t\t\t\t\toperationId: op.id,\n\t\t\t\t\t\t\t\t\tkind: op.kind,\n\t\t\t\t\t\t\t\t\tstatus: op.status,\n\t\t\t\t\t\t\t\t\tprogress: op.progress,\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t: null;\n\t\t\t\t\t}),\n\t\t\t);\n\t\t\tactiveOperations = {\n\t\t\t\tvalue: probed.filter((o): o is ActiveSubgraphOperation => o !== null),\n\t\t\t};\n\t\t}\n\n\t\treturn {\n\t\t\taccount,\n\t\t\tstreamsTip,\n\t\t\tindexTip: {\n\t\t\t\tvalue: indexEnv.value?.tip ?? null,\n\t\t\t\t...(indexEnv.error ? { error: indexEnv.error } : {}),\n\t\t\t},\n\t\t\tsubgraphs,\n\t\t\twebhooks,\n\t\t\tactiveOperations,\n\t\t\tinstance,\n\t\t};\n\t}\n}\n",
|
|
23
23
|
"import type { InferSubgraphClient } from \"@secondlayer/subgraphs\";\nimport type { SecondLayerOptions } from \"../base.ts\";\nimport { SecondLayer } from \"../client.ts\";\nimport { Subgraphs } from \"./client.ts\";\n\n/**\n * Returns a typed client for a subgraph defined with `defineSubgraph()`.\n *\n * Accepts a plain options object, a `SecondLayer` instance, or a `Subgraphs` instance.\n *\n * @example\n * ```ts\n * import mySubgraph from './subgraphs/my-subgraph'\n * import { getSubgraph } from '@secondlayer/sdk'\n *\n * const client = getSubgraph(mySubgraph, { apiKey: 'sl_...' })\n * const page = await client.transfers.findMany({ where: { sender: 'SP...' } })\n * ```\n */\nexport function getSubgraph<\n\tT extends { name: string; schema: Record<string, unknown> },\n>(\n\tdef: T,\n\toptions: Partial<SecondLayerOptions> | SecondLayer | Subgraphs = {},\n): InferSubgraphClient<T> {\n\tif (options instanceof Subgraphs) {\n\t\treturn options.typed(def);\n\t}\n\tif (options instanceof SecondLayer) {\n\t\treturn options.subgraphs.typed(def);\n\t}\n\treturn new Subgraphs(options).typed(def);\n}\n"
|
|
24
24
|
],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@secondlayer/sdk",
|
|
3
|
-
"version": "16.2.
|
|
3
|
+
"version": "16.2.1",
|
|
4
4
|
"description": "Decoded Stacks data in your own database — stream rows into your schema, or query the tables you defined.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -64,13 +64,13 @@
|
|
|
64
64
|
"dev": "bunup --watch",
|
|
65
65
|
"openpkg": "bun scripts/sync-openpkg.ts",
|
|
66
66
|
"test": "bun test",
|
|
67
|
-
"typecheck": "
|
|
67
|
+
"typecheck": "bun check",
|
|
68
68
|
"prepublishOnly": "bun run build"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@secondlayer/shared": "^15.0.
|
|
71
|
+
"@secondlayer/shared": "^15.0.2",
|
|
72
72
|
"@secondlayer/stacks": "^6.5.3",
|
|
73
|
-
"@secondlayer/subgraphs": "^8.0.
|
|
73
|
+
"@secondlayer/subgraphs": "^8.0.3"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
76
|
"@openpkg-ts/cli": "^0.13.2",
|