@secondlayer/sdk 6.37.1 → 6.38.0

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.
@@ -0,0 +1,108 @@
1
+ import { Kysely, Transaction } from "kysely";
2
+ /**
3
+ * A consumer sink: the destination adapter a consume loop writes through.
4
+ *
5
+ * Without a sink, the user owns the three hard parts of a durable indexer —
6
+ * checkpoint persistence, rows+cursor atomicity, and reorg rollback — and
7
+ * the reasoning that makes them safe lives in doc comments. A sink owns all
8
+ * three: the loop loads its committed cursor, hands the handler a
9
+ * transaction, commits rows AND cursor atomically, and rolls back reorgs
10
+ * without any user code. Crucially, a sink also closes the silent-skip
11
+ * hazard where omitting `onReorg` meant reorgs were ignored forever: with a
12
+ * sink attached, rollback is unconditional.
13
+ *
14
+ * The interface is dependency-free on purpose: implementations with real
15
+ * database drivers live behind subpath exports (`@secondlayer/sdk/sinks/*`)
16
+ * so the root entry ships no DB dependency.
17
+ */
18
+ interface ConsumerSink<Tx = unknown> {
19
+ /** Phantom marker carrying `Tx` in a directly-inferable position, so the
20
+ * consume loops' `TTx` type parameter resolves from the `sink` option
21
+ * (never set at runtime). */
22
+ readonly _tx?: Tx;
23
+ /** The committed checkpoint, or `null` on first run. Called once, before
24
+ * the first page. Implementations may create their checkpoint storage
25
+ * here and SHOULD validate their rollback preconditions (e.g. that every
26
+ * declared table carries the height column). */
27
+ loadCursor(): Promise<string | null>;
28
+ /**
29
+ * Apply one batch atomically: open a transaction, run `write(tx)` (the
30
+ * user's inserts), and commit the rows AND `cursor` together. A throw
31
+ * from `write` must abort the whole transaction — leaving neither rows
32
+ * nor cursor, so a crashed batch is simply re-read on restart.
33
+ */
34
+ commitBatch(cursor: string, write: (tx: Tx) => Promise<void> | void): Promise<void>;
35
+ /**
36
+ * Roll the projection back to the fork: delete everything AT OR ABOVE
37
+ * `forkPointHeight` (inclusive `>=` — the new chain re-supplies the fork
38
+ * block) and commit `rewindCursor` in the SAME transaction. Deleting
39
+ * without the rewound cursor is the classic silent-gap bug: a crash
40
+ * between the two writes resumes above the fork and the deleted range is
41
+ * never re-read.
42
+ */
43
+ rollback(forkPointHeight: number, rewindCursor: string): Promise<void>;
44
+ }
45
+ /**
46
+ * Column present on EVERY declared table — `keyof` a union of row types is
47
+ * the intersection of their keys, so a column missing from any one table is
48
+ * a compile error at the `height:` option.
49
+ */
50
+ type CommonColumn<
51
+ DB,
52
+ T extends keyof DB
53
+ > = keyof DB[T] & string;
54
+ interface KyselySinkOptions<
55
+ DB,
56
+ T extends keyof DB & string
57
+ > {
58
+ /** Checkpoint identity AND concurrency key: the cursor row's primary key,
59
+ * and the input to `pg_try_advisory_xact_lock(hashtext(id))` — a second
60
+ * consumer with the same id fails loudly instead of double-writing. */
61
+ id: string;
62
+ /** Rollback scope. On a reorg, rows AT OR ABOVE the fork point are deleted
63
+ * from exactly these tables (inclusive `>=` — the new chain re-supplies
64
+ * the fork block). A table you write but don't declare keeps orphaned
65
+ * rows forever, so declare everything the handler touches. */
66
+ tables: readonly T[];
67
+ /** The block-height stamp column, present on every declared table (compile
68
+ * and first-use checked). Height-stamp rollback is correct for
69
+ * APPEND-ONLY projections (one row per event/call); aggregates that
70
+ * mutate rows (balances, counters) can't be rolled back this way — use a
71
+ * subgraph for those. */
72
+ height: CommonColumn<DB, T>;
73
+ /** Checkpoint table name. Default `sl_consumer_checkpoints` (created on
74
+ * first use). */
75
+ checkpointTable?: string;
76
+ }
77
+ /**
78
+ * A {@link ConsumerSink} over a Kysely Postgres database — keyed on the
79
+ * QUERY BUILDER (not the driver) so the `DB` schema generic flows through:
80
+ * `tables` is `(keyof DB)[]`, `height` must exist on every declared table,
81
+ * and `ctx.tx` inside `onBatch` is a fully typed `Transaction<DB>`.
82
+ *
83
+ * What it owns (so your indexer doesn't):
84
+ * - the checkpoint table + cursor load on start;
85
+ * - rows AND cursor committed in ONE transaction per batch — a handler
86
+ * throw aborts both, so a crashed batch is re-read on restart with no
87
+ * gaps and no double-writes;
88
+ * - reorg rollback: delete `>= fork_point_height` from every declared table
89
+ * and commit the rewound cursor atomically (the crash-between-the-two-
90
+ * writes gap is unrepresentable);
91
+ * - a per-id advisory lock, so two replicas of the same consumer can't
92
+ * interleave commits.
93
+ *
94
+ * ```ts
95
+ * const sink = kyselySink(db, { id: "sales", tables: ["sales"], height: "block_height" });
96
+ * await index.contractCalls.consume({
97
+ * contractId: MARKETPLACE, functionName: "purchase-asset", fromHeight: 0,
98
+ * sink,
99
+ * onBatch: (calls, _env, ctx) =>
100
+ * ctx.tx.insertInto("sales").values(calls.map(toSale)).execute(),
101
+ * });
102
+ * ```
103
+ */
104
+ declare function kyselySink<
105
+ DB,
106
+ T extends keyof DB & string
107
+ >(db: Kysely<DB>, options: KyselySinkOptions<DB, T>): ConsumerSink<Transaction<DB>>;
108
+ export { kyselySink, KyselySinkOptions };
@@ -0,0 +1,236 @@
1
+ // src/sinks/kysely.ts
2
+ import { sql } from "kysely";
3
+
4
+ // src/errors.ts
5
+ var DOCS_BASE = "https://www.secondlayer.tools";
6
+
7
+ class SecondLayerError extends Error {
8
+ name = "SecondLayerError";
9
+ shortMessage;
10
+ code;
11
+ docsUrl;
12
+ metaMessages;
13
+ retryable;
14
+ retryAfterSeconds;
15
+ constructor(shortMessage, options = {}) {
16
+ const detailBlocks = [
17
+ ...options.metaMessages ?? [],
18
+ ...options.details ? [options.details] : [],
19
+ ...options.docsUrl ? [`Docs: ${options.docsUrl}`] : []
20
+ ];
21
+ const message = [
22
+ shortMessage,
23
+ detailBlocks.length > 0 ? `
24
+ ${detailBlocks.join(`
25
+ `)}` : ""
26
+ ].join("");
27
+ super(message, options.cause ? { cause: options.cause } : undefined);
28
+ this.shortMessage = shortMessage;
29
+ this.code = options.code;
30
+ this.docsUrl = options.docsUrl;
31
+ this.metaMessages = options.metaMessages;
32
+ this.retryable = options.retryable ?? false;
33
+ this.retryAfterSeconds = options.retryAfterSeconds;
34
+ }
35
+ walk(fn) {
36
+ let err = this;
37
+ let last = this;
38
+ while (err != null) {
39
+ if (fn?.(err))
40
+ return err;
41
+ last = err;
42
+ err = err.cause;
43
+ }
44
+ return fn ? null : last;
45
+ }
46
+ toJSON() {
47
+ return {
48
+ name: this.name,
49
+ message: this.message,
50
+ shortMessage: this.shortMessage,
51
+ cause: this.cause instanceof Error ? this.cause.message : undefined,
52
+ code: this.code,
53
+ docsUrl: this.docsUrl,
54
+ retryable: this.retryable,
55
+ retryAfterSeconds: this.retryAfterSeconds
56
+ };
57
+ }
58
+ }
59
+ function statusRetryable(status) {
60
+ return status === 429 || status >= 500;
61
+ }
62
+ function docsFor(status, code) {
63
+ if (code === "UPGRADE_REQUIRED" || code === "PLAN_REQUIRED" || status === 402)
64
+ return `${DOCS_BASE}/docs/authentication#pay-as-you-go-credits`;
65
+ if (status === 429 || status === 401)
66
+ return `${DOCS_BASE}/docs/authentication`;
67
+ return;
68
+ }
69
+
70
+ class ApiError extends SecondLayerError {
71
+ status;
72
+ body;
73
+ constructor(status, message, body, code, options = {}) {
74
+ super(message, {
75
+ code,
76
+ retryable: options.retryable ?? statusRetryable(status),
77
+ docsUrl: options.docsUrl ?? docsFor(status, code),
78
+ ...options.cause ? { cause: options.cause } : {},
79
+ ...options.details ? { details: options.details } : {},
80
+ ...options.metaMessages ? { metaMessages: options.metaMessages } : {},
81
+ ...options.retryAfterSeconds !== undefined ? { retryAfterSeconds: options.retryAfterSeconds } : {}
82
+ });
83
+ this.status = status;
84
+ this.body = body;
85
+ this.name = "ApiError";
86
+ }
87
+ }
88
+
89
+ class AuthError extends ApiError {
90
+ status = 401;
91
+ constructor(message = "API key invalid or expired.") {
92
+ super(401, message);
93
+ this.name = "AuthError";
94
+ }
95
+ }
96
+
97
+ class RateLimitError extends ApiError {
98
+ retryAfter;
99
+ status = 429;
100
+ constructor(message = "Rate limited. Try again later.", retryAfter) {
101
+ super(429, message, undefined, undefined, {
102
+ retryable: true,
103
+ retryAfterSeconds: parseRetryAfter(retryAfter)
104
+ });
105
+ this.retryAfter = retryAfter;
106
+ this.name = "RateLimitError";
107
+ }
108
+ }
109
+
110
+ class ValidationError extends ApiError {
111
+ constructor(message, status, body) {
112
+ super(status, message, body, undefined, { retryable: false });
113
+ this.name = "ValidationError";
114
+ }
115
+ }
116
+ function parseRetryAfter(value) {
117
+ if (!value)
118
+ return;
119
+ const seconds = Number(value);
120
+ if (Number.isFinite(seconds) && seconds >= 0)
121
+ return seconds;
122
+ const date = Date.parse(value);
123
+ if (!Number.isNaN(date))
124
+ return Math.max(0, (date - Date.now()) / 1000);
125
+ return;
126
+ }
127
+
128
+ class VersionConflictError extends ApiError {
129
+ currentVersion;
130
+ expectedVersion;
131
+ constructor(currentVersion, expectedVersion, message = `Version conflict: expected ${expectedVersion}, current ${currentVersion}`) {
132
+ super(409, message, { currentVersion, expectedVersion });
133
+ this.currentVersion = currentVersion;
134
+ this.expectedVersion = expectedVersion;
135
+ this.name = "VersionConflictError";
136
+ }
137
+ }
138
+
139
+ class ByoBreakingChangeError extends ApiError {
140
+ details;
141
+ constructor(message, details) {
142
+ super(422, message, details, "BYO_BREAKING_CHANGE", {
143
+ metaMessages: [
144
+ "Review and run manually on your database:",
145
+ details.plan.dropStatement,
146
+ ...details.plan.statements
147
+ ]
148
+ });
149
+ this.name = "ByoBreakingChangeError";
150
+ this.details = details;
151
+ }
152
+ }
153
+ function isByoBreakingDetails(x) {
154
+ if (!x || typeof x !== "object")
155
+ return false;
156
+ const d = x;
157
+ const plan = d.plan;
158
+ return Array.isArray(d.reasons) && !!plan && typeof plan === "object" && typeof plan.dropStatement === "string";
159
+ }
160
+
161
+ // src/sinks/kysely.ts
162
+ function kyselySink(db, options) {
163
+ const checkpointTable = options.checkpointTable ?? "sl_consumer_checkpoints";
164
+ if (options.tables.length === 0) {
165
+ throw new ValidationError("kyselySink: `tables` is empty — declare every table the handler writes, or reorg rollback is a no-op and orphaned rows persist forever.", 400);
166
+ }
167
+ const ident = (name) => {
168
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
169
+ throw new ValidationError(`kyselySink: invalid identifier "${name}" (letters, digits, underscores only).`, 400);
170
+ }
171
+ return `"${name}"`;
172
+ };
173
+ const cp = ident(checkpointTable);
174
+ const heightCol = ident(options.height);
175
+ for (const table of options.tables)
176
+ ident(table);
177
+ async function acquireLock(tx) {
178
+ const locked = await sql`
179
+ SELECT pg_try_advisory_xact_lock(hashtext(${options.id})) AS "ok"
180
+ `.execute(tx);
181
+ if (!locked.rows[0]?.ok) {
182
+ throw new ValidationError(`kyselySink: another consumer holds the "${options.id}" lock — two replicas of the same consumer would interleave commits. Stop the other instance or use a distinct id.`, 409);
183
+ }
184
+ }
185
+ async function upsertCursor(tx, cursor) {
186
+ await sql`
187
+ INSERT INTO ${sql.raw(cp)} (id, cursor) VALUES (${options.id}, ${cursor})
188
+ ON CONFLICT (id) DO UPDATE SET cursor = EXCLUDED.cursor
189
+ `.execute(tx);
190
+ }
191
+ return {
192
+ async loadCursor() {
193
+ await sql.raw(`CREATE TABLE IF NOT EXISTS ${cp} (id text PRIMARY KEY, cursor text NOT NULL)`).execute(db);
194
+ for (const table of options.tables) {
195
+ const col = await sql`
196
+ SELECT EXISTS (
197
+ SELECT 1 FROM information_schema.columns
198
+ WHERE table_name = ${table} AND column_name = ${options.height}
199
+ ) AS "ok"
200
+ `.execute(db);
201
+ if (!col.rows[0]?.ok) {
202
+ throw new ValidationError(`kyselySink: table "${table}" has no "${String(options.height)}" column — the height stamp is what makes reorg rollback possible. Add the column (or fix \`height\`).`, 400);
203
+ }
204
+ }
205
+ const row = await sql`
206
+ SELECT cursor FROM ${sql.raw(cp)} WHERE id = ${options.id}
207
+ `.execute(db);
208
+ return row.rows[0]?.cursor ?? null;
209
+ },
210
+ async commitBatch(cursor, write) {
211
+ await db.transaction().execute(async (tx) => {
212
+ await acquireLock(tx);
213
+ await write(tx);
214
+ await upsertCursor(tx, cursor);
215
+ });
216
+ },
217
+ async rollback(forkPointHeight, rewindCursor) {
218
+ await db.transaction().execute(async (tx) => {
219
+ await acquireLock(tx);
220
+ for (const table of options.tables) {
221
+ await sql`
222
+ DELETE FROM ${sql.raw(ident(table))}
223
+ WHERE ${sql.raw(heightCol)} >= ${forkPointHeight}
224
+ `.execute(tx);
225
+ }
226
+ await upsertCursor(tx, rewindCursor);
227
+ });
228
+ }
229
+ };
230
+ }
231
+ export {
232
+ kyselySink
233
+ };
234
+
235
+ //# debugId=667BFBBF3BF09CCB64756E2164756E21
236
+ //# sourceMappingURL=kysely.js.map
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/sinks/kysely.ts", "../src/errors.ts"],
4
+ "sourcesContent": [
5
+ "import { type Kysely, type Transaction, sql } from \"kysely\";\nimport { ValidationError } from \"../errors.ts\";\nimport type { ConsumerSink } from \"./types.ts\";\n\n/**\n * Column present on EVERY declared table — `keyof` a union of row types is\n * the intersection of their keys, so a column missing from any one table is\n * a compile error at the `height:` option.\n */\ntype CommonColumn<DB, T extends keyof DB> = keyof DB[T] & string;\n\nexport interface KyselySinkOptions<DB, T extends keyof DB & string> {\n\t/** Checkpoint identity AND concurrency key: the cursor row's primary key,\n\t * and the input to `pg_try_advisory_xact_lock(hashtext(id))` — a second\n\t * consumer with the same id fails loudly instead of double-writing. */\n\tid: string;\n\t/** Rollback scope. On a reorg, rows AT OR ABOVE the fork point are deleted\n\t * from exactly these tables (inclusive `>=` — the new chain re-supplies\n\t * the fork block). A table you write but don't declare keeps orphaned\n\t * rows forever, so declare everything the handler touches. */\n\ttables: readonly T[];\n\t/** The block-height stamp column, present on every declared table (compile\n\t * and first-use checked). Height-stamp rollback is correct for\n\t * APPEND-ONLY projections (one row per event/call); aggregates that\n\t * mutate rows (balances, counters) can't be rolled back this way — use a\n\t * subgraph for those. */\n\theight: CommonColumn<DB, T>;\n\t/** Checkpoint table name. Default `sl_consumer_checkpoints` (created on\n\t * first use). */\n\tcheckpointTable?: string;\n}\n\n/**\n * A {@link ConsumerSink} over a Kysely Postgres database — keyed on the\n * QUERY BUILDER (not the driver) so the `DB` schema generic flows through:\n * `tables` is `(keyof DB)[]`, `height` must exist on every declared table,\n * and `ctx.tx` inside `onBatch` is a fully typed `Transaction<DB>`.\n *\n * What it owns (so your indexer doesn't):\n * - the checkpoint table + cursor load on start;\n * - rows AND cursor committed in ONE transaction per batch — a handler\n * throw aborts both, so a crashed batch is re-read on restart with no\n * gaps and no double-writes;\n * - reorg rollback: delete `>= fork_point_height` from every declared table\n * and commit the rewound cursor atomically (the crash-between-the-two-\n * writes gap is unrepresentable);\n * - a per-id advisory lock, so two replicas of the same consumer can't\n * interleave commits.\n *\n * ```ts\n * const sink = kyselySink(db, { id: \"sales\", tables: [\"sales\"], height: \"block_height\" });\n * await index.contractCalls.consume({\n * contractId: MARKETPLACE, functionName: \"purchase-asset\", fromHeight: 0,\n * sink,\n * onBatch: (calls, _env, ctx) =>\n * ctx.tx.insertInto(\"sales\").values(calls.map(toSale)).execute(),\n * });\n * ```\n */\nexport function kyselySink<DB, T extends keyof DB & string>(\n\tdb: Kysely<DB>,\n\toptions: KyselySinkOptions<DB, T>,\n): ConsumerSink<Transaction<DB>> {\n\tconst checkpointTable = options.checkpointTable ?? \"sl_consumer_checkpoints\";\n\tif (options.tables.length === 0) {\n\t\tthrow new ValidationError(\n\t\t\t\"kyselySink: `tables` is empty — declare every table the handler writes, or reorg rollback is a no-op and orphaned rows persist forever.\",\n\t\t\t400,\n\t\t);\n\t}\n\n\t/** `sql.raw`-safe identifier (we interpolate table/column names). */\n\tconst ident = (name: string): string => {\n\t\tif (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {\n\t\t\tthrow new ValidationError(\n\t\t\t\t`kyselySink: invalid identifier \"${name}\" (letters, digits, underscores only).`,\n\t\t\t\t400,\n\t\t\t);\n\t\t}\n\t\treturn `\"${name}\"`;\n\t};\n\tconst cp = ident(checkpointTable);\n\tconst heightCol = ident(options.height as string);\n\tfor (const table of options.tables) ident(table);\n\n\tasync function acquireLock(tx: Transaction<DB>): Promise<void> {\n\t\tconst locked = await sql<{ ok: boolean }>`\n\t\t\tSELECT pg_try_advisory_xact_lock(hashtext(${options.id})) AS \"ok\"\n\t\t`.execute(tx);\n\t\tif (!locked.rows[0]?.ok) {\n\t\t\tthrow new ValidationError(\n\t\t\t\t`kyselySink: another consumer holds the \"${options.id}\" lock — two replicas of the same consumer would interleave commits. Stop the other instance or use a distinct id.`,\n\t\t\t\t409,\n\t\t\t);\n\t\t}\n\t}\n\n\tasync function upsertCursor(\n\t\ttx: Transaction<DB>,\n\t\tcursor: string,\n\t): Promise<void> {\n\t\t// Identifiers via validated sql.raw; VALUES via bound parameters.\n\t\tawait sql`\n\t\t\tINSERT INTO ${sql.raw(cp)} (id, cursor) VALUES (${options.id}, ${cursor})\n\t\t\tON CONFLICT (id) DO UPDATE SET cursor = EXCLUDED.cursor\n\t\t`.execute(tx);\n\t}\n\n\treturn {\n\t\tasync loadCursor() {\n\t\t\tawait sql\n\t\t\t\t.raw(\n\t\t\t\t\t`CREATE TABLE IF NOT EXISTS ${cp} (id text PRIMARY KEY, cursor text NOT NULL)`,\n\t\t\t\t)\n\t\t\t\t.execute(db);\n\t\t\t// First-use validation of the rollback precondition: every declared\n\t\t\t// table must exist and carry the height column. Failing here beats a\n\t\t\t// rollback that silently deletes nothing during a reorg.\n\t\t\tfor (const table of options.tables) {\n\t\t\t\tconst col = await sql<{ ok: boolean }>`\n\t\t\t\t\tSELECT EXISTS (\n\t\t\t\t\t\tSELECT 1 FROM information_schema.columns\n\t\t\t\t\t\tWHERE table_name = ${table} AND column_name = ${options.height as string}\n\t\t\t\t\t) AS \"ok\"\n\t\t\t\t`.execute(db);\n\t\t\t\tif (!col.rows[0]?.ok) {\n\t\t\t\t\tthrow new ValidationError(\n\t\t\t\t\t\t`kyselySink: table \"${table}\" has no \"${String(options.height)}\" column — the height stamp is what makes reorg rollback possible. Add the column (or fix \\`height\\`).`,\n\t\t\t\t\t\t400,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst row = await sql<{ cursor: string }>`\n\t\t\t\tSELECT cursor FROM ${sql.raw(cp)} WHERE id = ${options.id}\n\t\t\t`.execute(db);\n\t\t\treturn row.rows[0]?.cursor ?? null;\n\t\t},\n\n\t\tasync commitBatch(cursor, write) {\n\t\t\tawait db.transaction().execute(async (tx) => {\n\t\t\t\tawait acquireLock(tx);\n\t\t\t\tawait write(tx);\n\t\t\t\tawait upsertCursor(tx, cursor);\n\t\t\t});\n\t\t},\n\n\t\tasync rollback(forkPointHeight, rewindCursor) {\n\t\t\tawait db.transaction().execute(async (tx) => {\n\t\t\t\tawait acquireLock(tx);\n\t\t\t\t// INCLUSIVE of the fork block: the new canonical chain re-supplies\n\t\t\t\t// it, and the consumer rewinds to re-read from `fork:0`.\n\t\t\t\tfor (const table of options.tables) {\n\t\t\t\t\t// Identifiers via validated sql.raw; the height bound as a parameter.\n\t\t\t\t\tawait sql`\n\t\t\t\t\t\tDELETE FROM ${sql.raw(ident(table))}\n\t\t\t\t\t\tWHERE ${sql.raw(heightCol)} >= ${forkPointHeight}\n\t\t\t\t\t`.execute(tx);\n\t\t\t\t}\n\t\t\t\tawait upsertCursor(tx, rewindCursor);\n\t\t\t});\n\t\t},\n\t};\n}\n",
6
+ "import type { ByoBreakingChangeDetails } from \"@secondlayer/shared/errors\";\n\nexport type { ByoBreakingChangeDetails };\n\nconst DOCS_BASE = \"https://www.secondlayer.tools\";\n\n/** Options accepted by every error in the family. */\nexport interface SecondLayerErrorOptions {\n\tcause?: Error;\n\tdetails?: string;\n\t/** Stable machine-readable code (e.g. `UPGRADE_REQUIRED`, `BYO_BREAKING_CHANGE`). */\n\tcode?: string;\n\t/** Docs page that explains this failure and its fix. */\n\tdocsUrl?: string;\n\t/** Extra context lines appended to the message (DDL to run, next steps). */\n\tmetaMessages?: string[];\n\t/** Whether retrying the SAME request can succeed (429/5xx/network). */\n\tretryable?: boolean;\n\t/** Parsed `Retry-After`, in seconds — set on rate limits that carry it. */\n\tretryAfterSeconds?: number;\n}\n\n/**\n * Root of the SDK error family. Implements the same protocol as `BaseError`\n * in `@secondlayer/stacks` (`shortMessage`, `cause`, `toJSON`) so a failed\n * chain read and a failed platform read serialize and introspect identically.\n * It intentionally does NOT extend that class: `BaseError.details` is a\n * `string`, while {@link ByoBreakingChangeError.details} is the documented\n * structured migration plan — and cross-package `instanceof` is unreliable\n * under per-package bundling anyway (match on `code`/`name` across packages).\n *\n * `retryable` is the signal the consume loops act on: 429/5xx/network are\n * retryable, 4xx and body-serialization failures are not.\n */\nexport class SecondLayerError extends Error {\n\toverride name = \"SecondLayerError\";\n\t/** The one-line failure, without the appended context blocks. */\n\tshortMessage: string;\n\tcode?: string;\n\tdocsUrl?: string;\n\tmetaMessages?: string[];\n\tretryable: boolean;\n\tretryAfterSeconds?: number;\n\n\tconstructor(shortMessage: string, options: SecondLayerErrorOptions = {}) {\n\t\tconst detailBlocks = [\n\t\t\t...(options.metaMessages ?? []),\n\t\t\t...(options.details ? [options.details] : []),\n\t\t\t...(options.docsUrl ? [`Docs: ${options.docsUrl}`] : []),\n\t\t];\n\t\tconst message = [\n\t\t\tshortMessage,\n\t\t\tdetailBlocks.length > 0 ? `\\n${detailBlocks.join(\"\\n\")}` : \"\",\n\t\t].join(\"\");\n\t\tsuper(message, options.cause ? { cause: options.cause } : undefined);\n\t\tthis.shortMessage = shortMessage;\n\t\tthis.code = options.code;\n\t\tthis.docsUrl = options.docsUrl;\n\t\tthis.metaMessages = options.metaMessages;\n\t\tthis.retryable = options.retryable ?? false;\n\t\tthis.retryAfterSeconds = options.retryAfterSeconds;\n\t}\n\n\t/**\n\t * Walk the cause chain. With a predicate, returns the first error matching\n\t * it (or `null`); without one, returns the deepest cause. Mirrors viem's\n\t * `error.walk()` so cross-library error handling reads the same.\n\t */\n\twalk(fn?: (err: unknown) => boolean): unknown {\n\t\t// biome-ignore lint/suspicious/noExplicitAny: cause chains are untyped by nature\n\t\tlet err: any = this;\n\t\tlet last: unknown = this;\n\t\twhile (err != null) {\n\t\t\tif (fn?.(err)) return err;\n\t\t\tlast = err;\n\t\t\terr = err.cause;\n\t\t}\n\t\treturn fn ? null : last;\n\t}\n\n\ttoJSON(): {\n\t\tname: string;\n\t\tmessage: string;\n\t\tshortMessage: string;\n\t\tcause: string | undefined;\n\t\tcode: string | undefined;\n\t\tdocsUrl: string | undefined;\n\t\tretryable: boolean;\n\t\tretryAfterSeconds: number | undefined;\n\t} {\n\t\treturn {\n\t\t\tname: this.name,\n\t\t\tmessage: this.message,\n\t\t\tshortMessage: this.shortMessage,\n\t\t\tcause: this.cause instanceof Error ? this.cause.message : undefined,\n\t\t\tcode: this.code,\n\t\t\tdocsUrl: this.docsUrl,\n\t\t\tretryable: this.retryable,\n\t\t\tretryAfterSeconds: this.retryAfterSeconds,\n\t\t};\n\t}\n}\n\n/** Retryability of an HTTP status: 429 and 5xx are transient. */\nfunction statusRetryable(status: number): boolean {\n\treturn status === 429 || status >= 500;\n}\n\n/** Docs page for a status/code pair, when one exists. */\nfunction docsFor(status: number, code?: string): string | undefined {\n\tif (code === \"UPGRADE_REQUIRED\" || code === \"PLAN_REQUIRED\" || status === 402)\n\t\treturn `${DOCS_BASE}/docs/authentication#pay-as-you-go-credits`;\n\tif (status === 429 || status === 401)\n\t\treturn `${DOCS_BASE}/docs/authentication`;\n\treturn undefined;\n}\n\n/**\n * Error thrown by {@link SecondLayer} when an API request fails.\n * Includes the HTTP status code for programmatic error handling.\n *\n * @example\n * ```ts\n * try {\n * await client.subgraphs.get(\"my-subgraph\");\n * } catch (err) {\n * if (err instanceof ApiError && err.status === 404) {\n * console.log(\"Subgraph not found\");\n * }\n * }\n * ```\n */\nexport class ApiError extends SecondLayerError {\n\tconstructor(\n\t\t/** HTTP status code (0 for network errors). */\n\t\tpublic status: number,\n\t\tmessage: string,\n\t\t/** Raw response body (parsed JSON if possible) — preserved for callers that need error details. */\n\t\tpublic body?: unknown,\n\t\t/** Stable machine-readable code from the API's `{error, code}` error envelope. */\n\t\tcode?: string,\n\t\toptions: SecondLayerErrorOptions = {},\n\t) {\n\t\tsuper(message, {\n\t\t\tcode,\n\t\t\tretryable: options.retryable ?? statusRetryable(status),\n\t\t\tdocsUrl: options.docsUrl ?? docsFor(status, code),\n\t\t\t...(options.cause ? { cause: options.cause } : {}),\n\t\t\t...(options.details ? { details: options.details } : {}),\n\t\t\t...(options.metaMessages ? { metaMessages: options.metaMessages } : {}),\n\t\t\t...(options.retryAfterSeconds !== undefined\n\t\t\t\t? { retryAfterSeconds: options.retryAfterSeconds }\n\t\t\t\t: {}),\n\t\t});\n\t\tthis.name = \"ApiError\";\n\t}\n}\n\n/** Thrown on a 401 — by both the platform clients and Streams. */\nexport class AuthError extends ApiError {\n\toverride readonly status = 401 as const;\n\n\tconstructor(message = \"API key invalid or expired.\") {\n\t\tsuper(401, message);\n\t\tthis.name = \"AuthError\";\n\t}\n}\n\n/** Thrown on a 429 — by both the platform clients and Streams. `retryable`,\n * with `retryAfterSeconds` parsed from the `Retry-After` header when sent. */\nexport class RateLimitError extends ApiError {\n\toverride readonly status = 429 as const;\n\n\tconstructor(\n\t\tmessage = \"Rate limited. Try again later.\",\n\t\t/** Raw `Retry-After` header value (seconds or HTTP-date). */\n\t\treadonly retryAfter?: string,\n\t) {\n\t\tsuper(429, message, undefined, undefined, {\n\t\t\tretryable: true,\n\t\t\tretryAfterSeconds: parseRetryAfter(retryAfter),\n\t\t});\n\t\tthis.name = \"RateLimitError\";\n\t}\n}\n\n/** Thrown on a 4xx the caller can fix (bad cursor, bad params). Never retried. */\nexport class ValidationError extends ApiError {\n\tconstructor(message: string, status: number, body?: unknown) {\n\t\tsuper(status, message, body, undefined, { retryable: false });\n\t\tthis.name = \"ValidationError\";\n\t}\n}\n\n/** Parse a `Retry-After` header: delta-seconds or an HTTP-date. */\nexport function parseRetryAfter(value?: string | null): number | undefined {\n\tif (!value) return undefined;\n\tconst seconds = Number(value);\n\tif (Number.isFinite(seconds) && seconds >= 0) return seconds;\n\tconst date = Date.parse(value);\n\tif (!Number.isNaN(date)) return Math.max(0, (date - Date.now()) / 1000);\n\treturn undefined;\n}\n\n/**\n * Thrown on optimistic-concurrency conflict when a deploy supplies an\n * `expectedVersion` that no longer matches the server's stored version.\n */\nexport class VersionConflictError extends ApiError {\n\tconstructor(\n\t\tpublic currentVersion: string,\n\t\tpublic expectedVersion: string,\n\t\tmessage = `Version conflict: expected ${expectedVersion}, current ${currentVersion}`,\n\t) {\n\t\tsuper(409, message, { currentVersion, expectedVersion });\n\t\tthis.name = \"VersionConflictError\";\n\t}\n}\n\n/**\n * Thrown when a BYO subgraph deploy is refused for a breaking schema change.\n * The deploy did NOT run — `details.plan` carries the DROP + rebuild DDL to run\n * manually on your own database, plus the breaking `reasons` and the `diff`.\n * The same DDL is surfaced on `metaMessages`, so a bare `console.error(err)`\n * already shows the operator what to run.\n *\n * @example\n * ```ts\n * try {\n * await client.subgraphs.deploy(bundle);\n * } catch (err) {\n * if (err instanceof ByoBreakingChangeError) {\n * console.log(err.details.plan.dropStatement);\n * console.log(err.details.plan.statements.join(\";\\n\"));\n * }\n * }\n * ```\n */\nexport class ByoBreakingChangeError extends ApiError {\n\treadonly details: ByoBreakingChangeDetails;\n\tconstructor(message: string, details: ByoBreakingChangeDetails) {\n\t\tsuper(422, message, details, \"BYO_BREAKING_CHANGE\", {\n\t\t\tmetaMessages: [\n\t\t\t\t\"Review and run manually on your database:\",\n\t\t\t\tdetails.plan.dropStatement,\n\t\t\t\t...details.plan.statements,\n\t\t\t],\n\t\t});\n\t\tthis.name = \"ByoBreakingChangeError\";\n\t\tthis.details = details;\n\t}\n}\n\n/** Narrow an unknown error body's `details` to {@link ByoBreakingChangeDetails}. */\nexport function isByoBreakingDetails(\n\tx: unknown,\n): x is ByoBreakingChangeDetails {\n\tif (!x || typeof x !== \"object\") return false;\n\tconst d = x as Record<string, unknown>;\n\tconst plan = d.plan as Record<string, unknown> | undefined;\n\treturn (\n\t\tArray.isArray(d.reasons) &&\n\t\t!!plan &&\n\t\ttypeof plan === \"object\" &&\n\t\ttypeof plan.dropStatement === \"string\"\n\t);\n}\n"
7
+ ],
8
+ "mappings": "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",
9
+ "debugId": "667BFBBF3BF09CCB64756E2164756E21",
10
+ "names": []
11
+ }
@@ -1,4 +1,145 @@
1
1
  import { STREAMS_EVENT_TYPES, StreamsEventType } from "@secondlayer/shared";
2
+ /**
3
+ * A consumer sink: the destination adapter a consume loop writes through.
4
+ *
5
+ * Without a sink, the user owns the three hard parts of a durable indexer —
6
+ * checkpoint persistence, rows+cursor atomicity, and reorg rollback — and
7
+ * the reasoning that makes them safe lives in doc comments. A sink owns all
8
+ * three: the loop loads its committed cursor, hands the handler a
9
+ * transaction, commits rows AND cursor atomically, and rolls back reorgs
10
+ * without any user code. Crucially, a sink also closes the silent-skip
11
+ * hazard where omitting `onReorg` meant reorgs were ignored forever: with a
12
+ * sink attached, rollback is unconditional.
13
+ *
14
+ * The interface is dependency-free on purpose: implementations with real
15
+ * database drivers live behind subpath exports (`@secondlayer/sdk/sinks/*`)
16
+ * so the root entry ships no DB dependency.
17
+ */
18
+ interface ConsumerSink<Tx = unknown> {
19
+ /** Phantom marker carrying `Tx` in a directly-inferable position, so the
20
+ * consume loops' `TTx` type parameter resolves from the `sink` option
21
+ * (never set at runtime). */
22
+ readonly _tx?: Tx;
23
+ /** The committed checkpoint, or `null` on first run. Called once, before
24
+ * the first page. Implementations may create their checkpoint storage
25
+ * here and SHOULD validate their rollback preconditions (e.g. that every
26
+ * declared table carries the height column). */
27
+ loadCursor(): Promise<string | null>;
28
+ /**
29
+ * Apply one batch atomically: open a transaction, run `write(tx)` (the
30
+ * user's inserts), and commit the rows AND `cursor` together. A throw
31
+ * from `write` must abort the whole transaction — leaving neither rows
32
+ * nor cursor, so a crashed batch is simply re-read on restart.
33
+ */
34
+ commitBatch(cursor: string, write: (tx: Tx) => Promise<void> | void): Promise<void>;
35
+ /**
36
+ * Roll the projection back to the fork: delete everything AT OR ABOVE
37
+ * `forkPointHeight` (inclusive `>=` — the new chain re-supplies the fork
38
+ * block) and commit `rewindCursor` in the SAME transaction. Deleting
39
+ * without the rewound cursor is the classic silent-gap bug: a crash
40
+ * between the two writes resumes above the fork and the deleted range is
41
+ * never re-read.
42
+ */
43
+ rollback(forkPointHeight: number, rewindCursor: string): Promise<void>;
44
+ }
45
+ /** `ctx` gains `tx` exactly when a sink is attached; without one the shape
46
+ * is unchanged (an intersection with `unknown` is an identity, so
47
+ * contextual typing of `onBatch` callbacks never degrades to a union). */
48
+ type WithSinkTx<TTx> = [TTx] extends [never] ? unknown : {
49
+ tx: TTx
50
+ };
51
+ type IndexEventBase = {
52
+ cursor: string
53
+ block_height: number
54
+ block_time?: string | null
55
+ tx_id: string
56
+ tx_index: number
57
+ event_index: number
58
+ contract_id: string | null
59
+ /** Submitting-transaction context, present only when `txContext: true` was
60
+ * requested. `tx_sender` is the real tx sender — distinct from a transfer's
61
+ * asset `sender`, and the only place a `print` event's sender is available.
62
+ * Lets a consumer build per-event tx context without a `/v1/index/transactions`
63
+ * call per event. */
64
+ tx_sender?: string | null
65
+ tx_type?: string | null
66
+ tx_status?: string | null
67
+ tx_contract_id?: string | null
68
+ tx_function_name?: string | null
69
+ };
70
+ type IndexFtTransfer = IndexEventBase & {
71
+ event_type: "ft_transfer"
72
+ asset_identifier: string
73
+ sender: string
74
+ recipient: string
75
+ amount: string
76
+ };
77
+ type IndexNftTransfer = IndexEventBase & {
78
+ event_type: "nft_transfer"
79
+ asset_identifier: string
80
+ sender: string
81
+ recipient: string
82
+ value: string
83
+ };
84
+ type IndexStxTransfer = IndexEventBase & {
85
+ event_type: "stx_transfer"
86
+ sender: string
87
+ recipient: string
88
+ amount: string
89
+ memo: string | null
90
+ };
91
+ type IndexStxMint = IndexEventBase & {
92
+ event_type: "stx_mint"
93
+ recipient: string
94
+ amount: string
95
+ };
96
+ type IndexStxBurn = IndexEventBase & {
97
+ event_type: "stx_burn"
98
+ sender: string
99
+ amount: string
100
+ };
101
+ type IndexStxLock = IndexEventBase & {
102
+ event_type: "stx_lock"
103
+ sender: string
104
+ amount: string
105
+ payload: {
106
+ unlock_height: string | null
107
+ }
108
+ };
109
+ type IndexFtMint = IndexEventBase & {
110
+ event_type: "ft_mint"
111
+ asset_identifier: string
112
+ recipient: string
113
+ amount: string
114
+ };
115
+ type IndexFtBurn = IndexEventBase & {
116
+ event_type: "ft_burn"
117
+ asset_identifier: string
118
+ sender: string
119
+ amount: string
120
+ };
121
+ type IndexNftMint = IndexEventBase & {
122
+ event_type: "nft_mint"
123
+ asset_identifier: string
124
+ recipient: string
125
+ value: string
126
+ };
127
+ type IndexNftBurn = IndexEventBase & {
128
+ event_type: "nft_burn"
129
+ asset_identifier: string
130
+ sender: string
131
+ value: string
132
+ };
133
+ type IndexPrint = IndexEventBase & {
134
+ event_type: "print"
135
+ payload: {
136
+ topic: string | null
137
+ value: unknown
138
+ raw_value: string | null
139
+ }
140
+ };
141
+ /** Decoded chain event, discriminated by `event_type`. */
142
+ type IndexEvent = IndexFtTransfer | IndexNftTransfer | IndexStxTransfer | IndexStxMint | IndexStxBurn | IndexStxLock | IndexFtMint | IndexFtBurn | IndexNftMint | IndexNftBurn | IndexPrint;
2
143
  /** A Clarity value as Streams serves it: the canonical hex string, a typed
3
144
  * object carrying that hex (`{ hex }`), or a decoded Clarity-JSON object.
4
145
  * Decode helpers (`decodeNftTransfer`, etc.) resolve it to a concrete value. */
@@ -243,8 +384,28 @@ type StreamsBatchContext = ConsumerBatchContext;
243
384
  type StreamsReorgContext = {
244
385
  cursor: string
245
386
  };
246
- type StreamsEventsConsumeParams = {
387
+ type StreamsEventsConsumeParams<
388
+ TTx = never,
389
+ D extends boolean = false
390
+ > = {
247
391
  fromCursor?: string | null
392
+ /**
393
+ * Deliver events pre-decoded as the flat, `event_type`-discriminated rows
394
+ * Index serves (`IndexEvent`), so Streams consumption reads identically
395
+ * to Index consumption — no guard+decode pairs in your handler.
396
+ */
397
+ decoded?: D
398
+ /**
399
+ * Destination adapter that owns the checkpoint + rollback transaction
400
+ * (e.g. `kyselySink` from `@secondlayer/sdk/sinks/kysely`). With a sink:
401
+ * the loop resumes from the sink's committed cursor, `onBatch` receives
402
+ * `ctx.tx` and must write ONLY through it (rows and cursor commit in one
403
+ * transaction), reorg rollback is automatic, and `onBatch`'s return value
404
+ * is ignored.
405
+ */
406
+ sink?: ConsumerSink<TTx>
407
+ /** Fires once per page, before `onBatch` and before any early return. */
408
+ onProgress?: (ctx: ConsumerBatchContext) => void
248
409
  mode?: "tail" | "bounded"
249
410
  /**
250
411
  * Emit only finalized (immutable) events and never surface reorgs. The SDK
@@ -265,7 +426,7 @@ type StreamsEventsConsumeParams = {
265
426
  * transaction as your writes. Returning a cursor overrides `ctx.cursor` as
266
427
  * the resume point (advanced manual control); returning nothing uses it.
267
428
  */
268
- onBatch: (events: StreamsEvent[], envelope: StreamsEventsEnvelope, ctx: StreamsBatchContext) => void | string | null | undefined | Promise<void> | Promise<string | null | undefined>
429
+ onBatch: (events: D extends true ? IndexEvent[] : StreamsEvent[], envelope: StreamsEventsEnvelope, ctx: StreamsBatchContext & WithSinkTx<TTx>) => void | string | null | undefined | Promise<void> | Promise<string | null | undefined>
269
430
  /**
270
431
  * Roll your projection back to `reorg.fork_point_height`, persisting
271
432
  * `ctx.cursor` in the same transaction. Called once per *new* reorg
@@ -351,7 +512,7 @@ type StreamsEventsReplayParams = {
351
512
  maxEmptyPolls?: number
352
513
  signal?: AbortSignal
353
514
  };
354
- type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
515
+ type FetchLike2 = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
355
516
  /** One bulk parquet file in the dumps manifest. `path` is the object key under
356
517
  * the dumps base URL. */
357
518
  type StreamsDumpFile = {
@@ -423,7 +584,12 @@ type StreamsClient = {
423
584
  * The consumer also exits when `maxPages`, `maxEmptyPolls`, or `signal`
424
585
  * stops it.
425
586
  */
426
- consume(params: StreamsEventsConsumeParams): Promise<StreamsEventsConsumeResult>
587
+ consume<
588
+ TTx = never,
589
+ D extends boolean = false
590
+ >(params: StreamsEventsConsumeParams<TTx, D> & {
591
+ sink?: ConsumerSink<TTx>
592
+ }): Promise<StreamsEventsConsumeResult>
427
593
  /**
428
594
  * Backfill from bulk dumps, then continue live from the dump→live seam in
429
595
  * one call. Iterates finalized dump files (via `onDumpFile`) in block
@@ -476,7 +642,7 @@ type StreamsUsage = {
476
642
  type CreateStreamsClientOptions = {
477
643
  apiKey: string
478
644
  baseUrl?: string
479
- fetchImpl?: FetchLike
645
+ fetchImpl?: FetchLike2
480
646
  /**
481
647
  * Public base URL for bulk parquet dumps (the R2/CDN bucket root). Required
482
648
  * to use `client.dumps`. See `GET /public/streams/dumps/manifest`.
@@ -512,6 +678,22 @@ type CreateStreamsClientOptions = {
512
678
  verifyDumpsManifest?: boolean
513
679
  };
514
680
  declare function createStreamsClient(options: CreateStreamsClientOptions): StreamsClient;
681
+ /**
682
+ * Decode a raw Streams event into the SAME flat, `event_type`-discriminated
683
+ * row shape Index serves — so Streams consumption reads identically to Index
684
+ * consumption, with one call instead of eleven guard+decode pairs.
685
+ *
686
+ * ```ts
687
+ * for (const event of envelope.events) {
688
+ * const row = decode(event);
689
+ * if (row.event_type === "ft_transfer") row.amount; // string, narrowed
690
+ * }
691
+ * ```
692
+ *
693
+ * Prefer `decoded: true` on `streams.events.consume` — then decoding never
694
+ * appears in your code at all.
695
+ */
696
+ declare function decode(event: StreamsEvent): IndexEvent;
515
697
  /** Options accepted by every error in the family. */
516
698
  interface SecondLayerErrorOptions {
517
699
  cause?: Error;
@@ -893,4 +1075,4 @@ declare const Cursor: {
893
1075
  }
894
1076
  };
895
1077
  type DecodedEventRow = DecodedFtTransfer | DecodedNftTransfer | DecodedStxTransfer | DecodedStxMint | DecodedStxBurn | DecodedStxLock | DecodedFtMint | DecodedFtBurn | DecodedNftMint | DecodedNftBurn | DecodedPrint;
896
- export { isStxTransfer, isStxMint, isStxLock, isStxBurn, isPrint, isNftTransfer, isNftMint, isNftBurn, isFtTransfer, isFtMint, isFtBurn, decodeStxTransfer, decodeStxMint, decodeStxLock, decodeStxBurn, decodePrint, decodeNftTransfer, decodeNftMint, decodeNftBurn, decodeFtTransfer, decodeFtMint, decodeFtBurn, createStreamsClient, ValidationError, StreamsUsage, StreamsTip, StreamsSignatureError, StreamsServerError, StreamsReorgsListParams, StreamsReorgsListEnvelope, StreamsReorgContext, StreamsReorg, StreamsEventsSubscribeParams, StreamsEventsStreamParams, StreamsEventsListParams, StreamsEventsListEnvelope, StreamsEventsEnvelope, StreamsEventsConsumeResult, StreamsEventsConsumeParams, StreamsEventType, StreamsEventPayload, StreamsEvent, StreamsDumpsManifest, StreamsDumps, StreamsDumpFile, StreamsConsumeParams, StreamsClient, StreamsCanonicalBlock, StreamsBatchContext, StreamsBatch, STREAMS_EVENT_TYPES, RateLimitError, NftTransferPayload, NftTransferEvent, FtTransferPayload, FtTransferEvent, FetchLike, DecodedStxTransferPayload, DecodedStxTransfer, DecodedStxMintPayload, DecodedStxMint, DecodedStxLockPayload, DecodedStxLock, DecodedStxBurnPayload, DecodedStxBurn, DecodedPrintValue, DecodedPrintPayload, DecodedPrint, DecodedNftTransferPayload, DecodedNftTransfer, DecodedNftMintPayload, DecodedNftMint, DecodedNftBurnPayload, DecodedNftBurn, DecodedFtTransferPayload, DecodedFtTransfer, DecodedFtMintPayload, DecodedFtMint, DecodedFtBurnPayload, DecodedFtBurn, DecodedEventRow, DecodedEventColumns, Cursor, ConsumerBatchContext, AuthError };
1078
+ export { isStxTransfer, isStxMint, isStxLock, isStxBurn, isPrint, isNftTransfer, isNftMint, isNftBurn, isFtTransfer, isFtMint, isFtBurn, decodeStxTransfer, decodeStxMint, decodeStxLock, decodeStxBurn, decodePrint, decodeNftTransfer, decodeNftMint, decodeNftBurn, decodeFtTransfer, decodeFtMint, decodeFtBurn, decode, createStreamsClient, ValidationError, StreamsUsage, StreamsTip, StreamsSignatureError, StreamsServerError, StreamsReorgsListParams, StreamsReorgsListEnvelope, StreamsReorgContext, StreamsReorg, StreamsEventsSubscribeParams, StreamsEventsStreamParams, StreamsEventsListParams, StreamsEventsListEnvelope, StreamsEventsEnvelope, StreamsEventsConsumeResult, StreamsEventsConsumeParams, StreamsEventType, StreamsEventPayload, StreamsEvent, StreamsDumpsManifest, StreamsDumps, StreamsDumpFile, StreamsConsumeParams, StreamsClient, StreamsCanonicalBlock, StreamsBatchContext, StreamsBatch, STREAMS_EVENT_TYPES, RateLimitError, NftTransferPayload, NftTransferEvent, FtTransferPayload, FtTransferEvent, FetchLike2 as FetchLike, DecodedStxTransferPayload, DecodedStxTransfer, DecodedStxMintPayload, DecodedStxMint, DecodedStxLockPayload, DecodedStxLock, DecodedStxBurnPayload, DecodedStxBurn, DecodedPrintValue, DecodedPrintPayload, DecodedPrint, DecodedNftTransferPayload, DecodedNftTransfer, DecodedNftMintPayload, DecodedNftMint, DecodedNftBurnPayload, DecodedNftBurn, DecodedFtTransferPayload, DecodedFtTransfer, DecodedFtMintPayload, DecodedFtMint, DecodedFtBurnPayload, DecodedFtBurn, DecodedEventRow, DecodedEventColumns, Cursor, ConsumerBatchContext, AuthError };