@secondlayer/sdk 6.46.1 → 6.48.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,158 @@
1
+ import { Database } from "bun:sqlite";
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
+ * ## The contract
19
+ *
20
+ * Binding on every implementation; the conformance kit
21
+ * (`@secondlayer/sdk/sinks/testing`) probes each one. Violations are SILENT
22
+ * in production — they surface as gaps or duplicates weeks later, never as
23
+ * errors at the violation site — which is why they are spelled out here and
24
+ * mechanically tested.
25
+ *
26
+ * What the loop guarantees the sink:
27
+ *
28
+ * 1. **Init-before-first-page.** `loadCursor` is called exactly once, before
29
+ * the first fetch — even when the caller passed an explicit `fromCursor`.
30
+ * It is the sink's init hook: create checkpoint storage, validate
31
+ * rollback preconditions.
32
+ * 2. **Deterministic replay.** Re-reading from the same cursor yields the
33
+ * same rows in the same order. `cursor` is therefore a valid idempotency
34
+ * key: an append-only sink can dedup on it with no contract change.
35
+ * 3. **At-least-once delivery.** A batch whose commit outcome was lost (crash
36
+ * after commit, before the loop observed it) is re-committed on restart
37
+ * with the SAME cursor and the same rows.
38
+ *
39
+ * What the sink must guarantee — `commitBatch`:
40
+ *
41
+ * 4. **Rows+cursor atomicity.** The handler's writes and `cursor` commit in
42
+ * ONE transaction. Committing them separately is the classic torn-batch
43
+ * bug: a crash between the two either re-delivers (duplicates) or skips
44
+ * (gap) depending on the order.
45
+ * 5. **Abort on throw.** A throw from `write` aborts the WHOLE transaction —
46
+ * neither rows nor cursor land — so a crashed batch is simply re-read.
47
+ * 6. **The lent transaction is the real one.** `write(tx)` receives the same
48
+ * live transaction the cursor commits in; every handler write must go
49
+ * through it. Writes outside `tx` escape atomicity AND rollback.
50
+ * 7. **Replay safety.** Committing the same cursor twice must not corrupt
51
+ * state (per #3 the rows are identical; upsert/dedup make it exact).
52
+ *
53
+ * What the sink must guarantee — `rollback`:
54
+ *
55
+ * 8. **Delete+rewind atomicity.** Undoing rows at/above the fork and
56
+ * committing `rewindCursor` happen in ONE transaction. A crash between
57
+ * them resumes above the fork and the deleted range is never re-read —
58
+ * the silent-gap bug.
59
+ * 9. **Inclusive fork point.** Undo AT OR ABOVE `forkPointHeight` (`>=`) —
60
+ * the new canonical chain re-supplies the fork block itself.
61
+ * 10. **Idempotent.** The loop may re-apply the same rollback after a crash
62
+ * (reorgs are deduped in memory only); a second application must be a
63
+ * harmless no-op.
64
+ * 11. **Scope by height, not cursor.** On a page reporting several forks the
65
+ * loop calls `rollback` once per fork: each call carries its own
66
+ * `forkPointHeight` but ALL carry the same `rewindCursor` (the lowest
67
+ * fork point). Derive the undo range from `forkPointHeight` only; a
68
+ * sink that derives it from `rewindCursor` under-deletes silently.
69
+ *
70
+ * Error semantics and concurrency:
71
+ *
72
+ * 12. **Throw, don't swallow; no internal retry.** The loop owns retry
73
+ * policy. A swallowed commit error advances the loop past unwritten
74
+ * data; an internal retry can double-apply around a partial failure.
75
+ * 13. **Single writer per checkpoint id.** The loop assumes one live
76
+ * consumer per checkpoint identity. A sink that can detect a second
77
+ * writer (e.g. a lock) must fail loudly — never block or interleave.
78
+ */
79
+ interface ConsumerSink<Tx = unknown> {
80
+ /** Phantom marker carrying `Tx` in a directly-inferable position, so the
81
+ * consume loops' `TTx` type parameter resolves from the `sink` option
82
+ * (never set at runtime). */
83
+ readonly _tx?: Tx;
84
+ /** Static capabilities, read by the loop before the first fetch to fail
85
+ * fast on impossible pairings. */
86
+ readonly capabilities?: {
87
+ /** This sink cannot undo committed rows (append-only store, ClickHouse,
88
+ * parquet, …), so `rollback` is unimplementable and following the
89
+ * unfinalized tip would corrupt it on the first fork. The loop throws
90
+ * loudly unless consuming with `finalizedOnly: true` — in that mode
91
+ * reorgs never reach the sink and `rollback` may simply throw. */
92
+ finalizedOnly?: boolean;
93
+ };
94
+ /** The committed checkpoint, or `null` on first run. Called once, before
95
+ * the first page. Implementations may create their checkpoint storage
96
+ * here and SHOULD validate their rollback preconditions (e.g. that every
97
+ * declared table carries the height column). */
98
+ loadCursor(): Promise<string | null>;
99
+ /**
100
+ * Apply one batch atomically: open a transaction, run `write(tx)` (the
101
+ * user's inserts), and commit the rows AND `cursor` together. A throw
102
+ * from `write` must abort the whole transaction — leaving neither rows
103
+ * nor cursor, so a crashed batch is simply re-read on restart.
104
+ */
105
+ commitBatch(cursor: string, write: (tx: Tx) => Promise<void> | void): Promise<void>;
106
+ /**
107
+ * Roll the projection back to the fork: delete everything AT OR ABOVE
108
+ * `forkPointHeight` (inclusive `>=` — the new chain re-supplies the fork
109
+ * block) and commit `rewindCursor` in the SAME transaction. Deleting
110
+ * without the rewound cursor is the classic silent-gap bug: a crash
111
+ * between the two writes resumes above the fork and the deleted range is
112
+ * never re-read.
113
+ *
114
+ * On a multi-fork page this is called once PER fork, every call with the
115
+ * same `rewindCursor` (the lowest fork point) but its own
116
+ * `forkPointHeight` — scope the undo by `forkPointHeight` only (contract
117
+ * invariant #11), and expect re-application after a crash (#10).
118
+ */
119
+ rollback(forkPointHeight: number, rewindCursor: string): Promise<void>;
120
+ }
121
+ interface BunSqliteSinkOptions {
122
+ /** Checkpoint identity: the cursor row's primary key. */
123
+ id: string;
124
+ /** Rollback scope — every table the handler writes (see kyselySink). */
125
+ tables: readonly string[];
126
+ /** The block-height stamp column, present on every declared table
127
+ * (first-use checked). Append-only projections only. */
128
+ height: string;
129
+ /** Checkpoint table name. Default `sl_consumer_checkpoints`. */
130
+ checkpointTable?: string;
131
+ }
132
+ /**
133
+ * A {@link ConsumerSink} over a `bun:sqlite` database — the zero-dependency,
134
+ * zero-docker sink: one file on disk, nothing to install, nothing to run.
135
+ * `ctx.tx` inside `onBatch` is the `Database` itself, inside an open
136
+ * transaction.
137
+ *
138
+ * Batches commit under `BEGIN IMMEDIATE`, which takes SQLite's single write
139
+ * lock up front — so the single-writer requirement (contract invariant #13)
140
+ * is enforced by the file lock itself: a second writer process fails with
141
+ * SQLITE_BUSY instead of interleaving.
142
+ *
143
+ * ```ts
144
+ * import { Database } from "bun:sqlite";
145
+ * const db = new Database("sales.db");
146
+ * const sink = bunSqliteSink(db, { id: "sales", tables: ["sales"], height: "block_height" });
147
+ * await index.contractCalls.consume({
148
+ * contractId: MARKETPLACE, functionName: "purchase-asset", fromHeight: 0,
149
+ * sink,
150
+ * onBatch: (calls, _env, ctx) => {
151
+ * for (const call of calls)
152
+ * ctx.tx.run("INSERT OR IGNORE INTO sales (tx_id, block_height) VALUES (?, ?)", [call.tx_id, call.block_height]);
153
+ * },
154
+ * });
155
+ * ```
156
+ */
157
+ declare function bunSqliteSink(db: Database, options: BunSqliteSinkOptions): ConsumerSink<Database>;
158
+ export { bunSqliteSink, BunSqliteSinkOptions };
@@ -0,0 +1,255 @@
1
+ // src/errors.ts
2
+ var DOCS_BASE = "https://www.secondlayer.tools";
3
+
4
+ class SecondLayerError extends Error {
5
+ name = "SecondLayerError";
6
+ shortMessage;
7
+ code;
8
+ docsUrl;
9
+ metaMessages;
10
+ retryable;
11
+ retryAfterSeconds;
12
+ constructor(shortMessage, options = {}) {
13
+ const detailBlocks = [
14
+ ...options.metaMessages ?? [],
15
+ ...options.details ? [options.details] : [],
16
+ ...options.docsUrl ? [`Docs: ${options.docsUrl}`] : []
17
+ ];
18
+ const message = [
19
+ shortMessage,
20
+ detailBlocks.length > 0 ? `
21
+ ${detailBlocks.join(`
22
+ `)}` : ""
23
+ ].join("");
24
+ super(message, options.cause ? { cause: options.cause } : undefined);
25
+ this.shortMessage = shortMessage;
26
+ this.code = options.code;
27
+ this.docsUrl = options.docsUrl;
28
+ this.metaMessages = options.metaMessages;
29
+ this.retryable = options.retryable ?? false;
30
+ this.retryAfterSeconds = options.retryAfterSeconds;
31
+ }
32
+ walk(fn) {
33
+ let err = this;
34
+ let last = this;
35
+ while (err != null) {
36
+ if (fn?.(err))
37
+ return err;
38
+ last = err;
39
+ err = err.cause;
40
+ }
41
+ return fn ? null : last;
42
+ }
43
+ toJSON() {
44
+ return {
45
+ name: this.name,
46
+ message: this.message,
47
+ shortMessage: this.shortMessage,
48
+ cause: this.cause instanceof Error ? this.cause.message : undefined,
49
+ code: this.code,
50
+ docsUrl: this.docsUrl,
51
+ retryable: this.retryable,
52
+ retryAfterSeconds: this.retryAfterSeconds
53
+ };
54
+ }
55
+ }
56
+ function statusRetryable(status) {
57
+ return status === 429 || status >= 500;
58
+ }
59
+ function docsFor(status, code) {
60
+ if (code === "UPGRADE_REQUIRED" || code === "PLAN_REQUIRED" || status === 402)
61
+ return `${DOCS_BASE}/docs/authentication#pay-as-you-go-credits`;
62
+ if (status === 429 || status === 401)
63
+ return `${DOCS_BASE}/docs/authentication`;
64
+ return;
65
+ }
66
+
67
+ class ApiError extends SecondLayerError {
68
+ status;
69
+ body;
70
+ constructor(status, message, body, code, options = {}) {
71
+ super(message, {
72
+ code,
73
+ retryable: options.retryable ?? statusRetryable(status),
74
+ docsUrl: options.docsUrl ?? docsFor(status, code),
75
+ ...options.cause ? { cause: options.cause } : {},
76
+ ...options.details ? { details: options.details } : {},
77
+ ...options.metaMessages ? { metaMessages: options.metaMessages } : {},
78
+ ...options.retryAfterSeconds !== undefined ? { retryAfterSeconds: options.retryAfterSeconds } : {}
79
+ });
80
+ this.status = status;
81
+ this.body = body;
82
+ this.name = "ApiError";
83
+ }
84
+ }
85
+
86
+ class AuthError extends ApiError {
87
+ status = 401;
88
+ constructor(message = "API key invalid or expired.") {
89
+ super(401, message);
90
+ this.name = "AuthError";
91
+ }
92
+ }
93
+
94
+ class RateLimitError extends ApiError {
95
+ retryAfter;
96
+ status = 429;
97
+ constructor(message = "Rate limited. Try again later.", retryAfter) {
98
+ super(429, message, undefined, undefined, {
99
+ retryable: true,
100
+ retryAfterSeconds: parseRetryAfter(retryAfter)
101
+ });
102
+ this.retryAfter = retryAfter;
103
+ this.name = "RateLimitError";
104
+ }
105
+ }
106
+
107
+ class ValidationError extends ApiError {
108
+ constructor(message, status, body) {
109
+ super(status, message, body, undefined, { retryable: false });
110
+ this.name = "ValidationError";
111
+ }
112
+ }
113
+ function parseRetryAfter(value) {
114
+ if (!value)
115
+ return;
116
+ const seconds = Number(value);
117
+ if (Number.isFinite(seconds) && seconds >= 0)
118
+ return seconds;
119
+ const date = Date.parse(value);
120
+ if (!Number.isNaN(date))
121
+ return Math.max(0, (date - Date.now()) / 1000);
122
+ return;
123
+ }
124
+
125
+ class VersionConflictError extends ApiError {
126
+ currentVersion;
127
+ expectedVersion;
128
+ constructor(currentVersion, expectedVersion, message = `Version conflict: expected ${expectedVersion}, current ${currentVersion}`) {
129
+ super(409, message, { currentVersion, expectedVersion });
130
+ this.currentVersion = currentVersion;
131
+ this.expectedVersion = expectedVersion;
132
+ this.name = "VersionConflictError";
133
+ }
134
+ }
135
+
136
+ class ByoBreakingChangeError extends ApiError {
137
+ details;
138
+ constructor(message, details) {
139
+ super(422, message, details, "BYO_BREAKING_CHANGE", {
140
+ metaMessages: [
141
+ "Review and run manually on your database:",
142
+ details.plan.dropStatement,
143
+ ...details.plan.statements
144
+ ]
145
+ });
146
+ this.name = "ByoBreakingChangeError";
147
+ this.details = details;
148
+ }
149
+ }
150
+ function isByoBreakingDetails(x) {
151
+ if (!x || typeof x !== "object")
152
+ return false;
153
+ const d = x;
154
+ const plan = d.plan;
155
+ return Array.isArray(d.reasons) && !!plan && typeof plan === "object" && typeof plan.dropStatement === "string";
156
+ }
157
+
158
+ // src/sinks/core.ts
159
+ var DEFAULT_CHECKPOINT_TABLE = "sl_consumer_checkpoints";
160
+ function assertSqlIdentifier(name, label) {
161
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
162
+ throw new ValidationError(`${label}: invalid identifier "${name}" (letters, digits, underscores only).`, 400);
163
+ }
164
+ }
165
+ function quoteIdent(name) {
166
+ return `"${name}"`;
167
+ }
168
+ function createSink(driver, options) {
169
+ const { label } = options;
170
+ const checkpointTable = options.checkpointTable ?? DEFAULT_CHECKPOINT_TABLE;
171
+ if (options.tables.length === 0) {
172
+ throw new ValidationError(`${label}: \`tables\` is empty — declare every table the handler writes, or reorg rollback is a no-op and orphaned rows persist forever.`, 400);
173
+ }
174
+ assertSqlIdentifier(checkpointTable, label);
175
+ assertSqlIdentifier(options.height, label);
176
+ for (const table of options.tables)
177
+ assertSqlIdentifier(table, label);
178
+ return {
179
+ capabilities: options.capabilities,
180
+ async loadCursor() {
181
+ await driver.ensureCheckpointStore();
182
+ for (const table of options.tables) {
183
+ if (!await driver.hasColumn(table, options.height)) {
184
+ throw new ValidationError(`${label}: table "${table}" has no "${options.height}" column — the height stamp is what makes reorg rollback possible. Add the column (or fix \`height\`).`, 400);
185
+ }
186
+ }
187
+ return driver.readCursor();
188
+ },
189
+ async commitBatch(cursor, write) {
190
+ await driver.transact(async (tx) => {
191
+ await driver.acquireLock?.(tx);
192
+ await write(tx);
193
+ await driver.writeCursor(tx, cursor);
194
+ });
195
+ },
196
+ async rollback(forkPointHeight, rewindCursor) {
197
+ await driver.transact(async (tx) => {
198
+ await driver.acquireLock?.(tx);
199
+ for (const table of options.tables) {
200
+ await driver.deleteAtOrAbove(tx, table, forkPointHeight);
201
+ }
202
+ await driver.writeCursor(tx, rewindCursor);
203
+ });
204
+ }
205
+ };
206
+ }
207
+
208
+ // src/sinks/bun-sqlite.ts
209
+ function bunSqliteSink(db, options) {
210
+ const checkpointTable = options.checkpointTable ?? DEFAULT_CHECKPOINT_TABLE;
211
+ const cp = quoteIdent(checkpointTable);
212
+ const driver = {
213
+ async transact(fn) {
214
+ db.exec("BEGIN IMMEDIATE");
215
+ try {
216
+ const result = await fn(db);
217
+ db.exec("COMMIT");
218
+ return result;
219
+ } catch (err) {
220
+ db.exec("ROLLBACK");
221
+ throw err;
222
+ }
223
+ },
224
+ async ensureCheckpointStore() {
225
+ db.exec(`CREATE TABLE IF NOT EXISTS ${cp} (id TEXT PRIMARY KEY, cursor TEXT NOT NULL)`);
226
+ },
227
+ async readCursor() {
228
+ const row = db.query(`SELECT cursor FROM ${cp} WHERE id = ?`).get(options.id);
229
+ return row?.cursor ?? null;
230
+ },
231
+ async writeCursor(tx, cursor) {
232
+ tx.query(`INSERT INTO ${cp} (id, cursor) VALUES (?, ?) ON CONFLICT (id) DO UPDATE SET cursor = excluded.cursor`).run(options.id, cursor);
233
+ },
234
+ async deleteAtOrAbove(tx, table, height) {
235
+ tx.query(`DELETE FROM ${quoteIdent(table)} WHERE ${quoteIdent(options.height)} >= ?`).run(height);
236
+ },
237
+ async hasColumn(table, column) {
238
+ const row = db.query("SELECT 1 AS ok FROM pragma_table_info(?) WHERE name = ?").get(table, column);
239
+ return row !== null;
240
+ }
241
+ };
242
+ return createSink(driver, {
243
+ label: "bunSqliteSink",
244
+ id: options.id,
245
+ tables: options.tables,
246
+ height: options.height,
247
+ checkpointTable
248
+ });
249
+ }
250
+ export {
251
+ bunSqliteSink
252
+ };
253
+
254
+ //# debugId=A476996AA991DF6164756E2164756E21
255
+ //# sourceMappingURL=bun-sqlite.js.map
@@ -0,0 +1,12 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["src/errors.ts", "src/sinks/core.ts", "src/sinks/bun-sqlite.ts"],
4
+ "sourcesContent": [
5
+ "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",
6
+ "import { ValidationError } from \"../errors.ts\";\nimport type { ConsumerSink } from \"./types.ts\";\n\n/** Default checkpoint table name, shared by every SQL-backed sink so two\n * sinks on the same database land in one table keyed by `id`. */\nexport const DEFAULT_CHECKPOINT_TABLE = \"sl_consumer_checkpoints\";\n\n/**\n * The driver surface a database adapter implements — the ~7 methods that\n * actually differ between SQL stores. Everything above them (the\n * commit/rollback call sequences, the guards, identifier validation, the\n * checkpoint schema) is policy owned by {@link createSink}, so a driver\n * cannot get the ordering wrong: it never sees a cursor write outside the\n * transaction that carries the rows, and never sees a delete without the\n * rewound cursor riding along.\n *\n * Identifier args (`table`, `column`) arrive pre-validated against\n * `[A-Za-z_][A-Za-z0-9_]*` — safe to interpolate after quoting (see\n * {@link quoteIdent}). Values (cursor, height, id) must still be bound as\n * parameters.\n */\nexport interface SinkDriver<Tx> {\n\t/** Run `fn` inside ONE transaction: begin, run, commit — a throw from\n\t * `fn` must roll the whole transaction back and re-throw. */\n\ttransact<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;\n\t/** Create the checkpoint store if missing: `(id primary key, cursor)`.\n\t * Must be safe under concurrent first start (two consumers racing the\n\t * same CREATE). */\n\tensureCheckpointStore(): Promise<void>;\n\t/** The committed cursor for this sink's `id`, or `null` if none. */\n\treadCursor(): Promise<string | null>;\n\t/** Upsert this sink's cursor row inside `tx`. */\n\twriteCursor(tx: Tx, cursor: string): Promise<void>;\n\t/** Delete rows with `height column >= height` from `table`, inside `tx`. */\n\tdeleteAtOrAbove(tx: Tx, table: string, height: number): Promise<void>;\n\t/** Whether `table` exists AND carries `column` — the first-use check that\n\t * keeps reorg rollback from being a silent no-op. */\n\thasColumn(table: string, column: string): Promise<boolean>;\n\t/** Optional: assert this consumer is the only live writer for its `id`,\n\t * inside `tx`. Must FAIL LOUDLY (throw) when another writer holds it —\n\t * never block or interleave (contract invariant #13). */\n\tacquireLock?(tx: Tx): Promise<void>;\n}\n\n/** Options for {@link createSink} — the policy inputs, driver-agnostic.\n * Concrete sinks re-expose these with schema-typed `tables`/`height`. */\nexport interface CreateSinkOptions {\n\t/** Error-message prefix naming the concrete sink (e.g. `\"kyselySink\"`),\n\t * so a thrown guard points at the thing the user actually constructed. */\n\tlabel: string;\n\t/** Checkpoint identity AND concurrency key. */\n\tid: string;\n\t/** Rollback scope: on a reorg, rows at/above the fork point are deleted\n\t * from exactly these tables. */\n\ttables: readonly string[];\n\t/** The block-height stamp column, present on every declared table. */\n\theight: string;\n\t/** Checkpoint table name. Default {@link DEFAULT_CHECKPOINT_TABLE}. */\n\tcheckpointTable?: string;\n\t/** Forwarded to {@link ConsumerSink.capabilities}. */\n\tcapabilities?: ConsumerSink[\"capabilities\"];\n}\n\n/** Throw unless `name` is a bare SQL identifier (letters, digits,\n * underscores; no leading digit) — the guard that makes interpolating\n * table/column names into DDL and DELETE statements safe. */\nexport function assertSqlIdentifier(name: string, label: string): void {\n\tif (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {\n\t\tthrow new ValidationError(\n\t\t\t`${label}: invalid identifier \"${name}\" (letters, digits, underscores only).`,\n\t\t\t400,\n\t\t);\n\t}\n}\n\n/** Double-quote a (pre-validated) identifier for interpolation. */\nexport function quoteIdent(name: string): string {\n\treturn `\"${name}\"`;\n}\n\n/**\n * Build a {@link ConsumerSink} from a {@link SinkDriver}: the portable ~90\n * lines every SQL sink otherwise re-implements — and re-risks. Owns the two\n * transaction sequences (begin → lock → write rows → write cursor;\n * begin → lock → delete `>=` fork → write rewound cursor), the empty-`tables`\n * guard, identifier validation, and the first-use height-column check.\n *\n * A driver implemented against this base cannot violate contract invariants\n * #4, #5, #8, #9, or #11 (see {@link ConsumerSink}) without breaking\n * `transact` itself — which is exactly what the conformance kit probes.\n */\nexport function createSink<Tx>(\n\tdriver: SinkDriver<Tx>,\n\toptions: CreateSinkOptions,\n): ConsumerSink<Tx> {\n\tconst { label } = options;\n\tconst checkpointTable = options.checkpointTable ?? DEFAULT_CHECKPOINT_TABLE;\n\tif (options.tables.length === 0) {\n\t\tthrow new ValidationError(\n\t\t\t`${label}: \\`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\tassertSqlIdentifier(checkpointTable, label);\n\tassertSqlIdentifier(options.height, label);\n\tfor (const table of options.tables) assertSqlIdentifier(table, label);\n\n\treturn {\n\t\tcapabilities: options.capabilities,\n\n\t\tasync loadCursor() {\n\t\t\tawait driver.ensureCheckpointStore();\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\tif (!(await driver.hasColumn(table, options.height))) {\n\t\t\t\t\tthrow new ValidationError(\n\t\t\t\t\t\t`${label}: table \"${table}\" has no \"${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\treturn driver.readCursor();\n\t\t},\n\n\t\tasync commitBatch(cursor, write) {\n\t\t\tawait driver.transact(async (tx) => {\n\t\t\t\tawait driver.acquireLock?.(tx);\n\t\t\t\tawait write(tx);\n\t\t\t\tawait driver.writeCursor(tx, cursor);\n\t\t\t});\n\t\t},\n\n\t\tasync rollback(forkPointHeight, rewindCursor) {\n\t\t\tawait driver.transact(async (tx) => {\n\t\t\t\tawait driver.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\tawait driver.deleteAtOrAbove(tx, table, forkPointHeight);\n\t\t\t\t}\n\t\t\t\tawait driver.writeCursor(tx, rewindCursor);\n\t\t\t});\n\t\t},\n\t};\n}\n",
7
+ "import type { Database } from \"bun:sqlite\";\nimport {\n\tDEFAULT_CHECKPOINT_TABLE,\n\ttype SinkDriver,\n\tcreateSink,\n\tquoteIdent,\n} from \"./core.ts\";\nimport type { ConsumerSink } from \"./types.ts\";\n\nexport interface BunSqliteSinkOptions {\n\t/** Checkpoint identity: the cursor row's primary key. */\n\tid: string;\n\t/** Rollback scope — every table the handler writes (see kyselySink). */\n\ttables: readonly string[];\n\t/** The block-height stamp column, present on every declared table\n\t * (first-use checked). Append-only projections only. */\n\theight: string;\n\t/** Checkpoint table name. Default `sl_consumer_checkpoints`. */\n\tcheckpointTable?: string;\n}\n\n/**\n * A {@link ConsumerSink} over a `bun:sqlite` database — the zero-dependency,\n * zero-docker sink: one file on disk, nothing to install, nothing to run.\n * `ctx.tx` inside `onBatch` is the `Database` itself, inside an open\n * transaction.\n *\n * Batches commit under `BEGIN IMMEDIATE`, which takes SQLite's single write\n * lock up front — so the single-writer requirement (contract invariant #13)\n * is enforced by the file lock itself: a second writer process fails with\n * SQLITE_BUSY instead of interleaving.\n *\n * ```ts\n * import { Database } from \"bun:sqlite\";\n * const db = new Database(\"sales.db\");\n * const sink = bunSqliteSink(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 * for (const call of calls)\n * ctx.tx.run(\"INSERT OR IGNORE INTO sales (tx_id, block_height) VALUES (?, ?)\", [call.tx_id, call.block_height]);\n * },\n * });\n * ```\n */\nexport function bunSqliteSink(\n\tdb: Database,\n\toptions: BunSqliteSinkOptions,\n): ConsumerSink<Database> {\n\tconst checkpointTable = options.checkpointTable ?? DEFAULT_CHECKPOINT_TABLE;\n\tconst cp = quoteIdent(checkpointTable);\n\n\tconst driver: SinkDriver<Database> = {\n\t\t// Manual BEGIN/COMMIT rather than db.transaction(): the handler is\n\t\t// async, and a sync transaction wrapper would COMMIT at the first\n\t\t// await — writes after it would land in autocommit, outside the\n\t\t// atomicity the contract requires.\n\t\tasync transact(fn) {\n\t\t\tdb.exec(\"BEGIN IMMEDIATE\");\n\t\t\ttry {\n\t\t\t\tconst result = await fn(db);\n\t\t\t\tdb.exec(\"COMMIT\");\n\t\t\t\treturn result;\n\t\t\t} catch (err) {\n\t\t\t\tdb.exec(\"ROLLBACK\");\n\t\t\t\tthrow err;\n\t\t\t}\n\t\t},\n\n\t\tasync ensureCheckpointStore() {\n\t\t\tdb.exec(\n\t\t\t\t`CREATE TABLE IF NOT EXISTS ${cp} (id TEXT PRIMARY KEY, cursor TEXT NOT NULL)`,\n\t\t\t);\n\t\t},\n\n\t\tasync readCursor() {\n\t\t\tconst row = db\n\t\t\t\t.query<{ cursor: string }, [string]>(\n\t\t\t\t\t`SELECT cursor FROM ${cp} WHERE id = ?`,\n\t\t\t\t)\n\t\t\t\t.get(options.id);\n\t\t\treturn row?.cursor ?? null;\n\t\t},\n\n\t\tasync writeCursor(tx, cursor) {\n\t\t\ttx.query(\n\t\t\t\t`INSERT INTO ${cp} (id, cursor) VALUES (?, ?) ON CONFLICT (id) DO UPDATE SET cursor = excluded.cursor`,\n\t\t\t).run(options.id, cursor);\n\t\t},\n\n\t\tasync deleteAtOrAbove(tx, table, height) {\n\t\t\ttx.query(\n\t\t\t\t`DELETE FROM ${quoteIdent(table)} WHERE ${quoteIdent(options.height)} >= ?`,\n\t\t\t).run(height);\n\t\t},\n\n\t\tasync hasColumn(table, column) {\n\t\t\tconst row = db\n\t\t\t\t.query(\"SELECT 1 AS ok FROM pragma_table_info(?) WHERE name = ?\")\n\t\t\t\t.get(table, column);\n\t\t\treturn row !== null;\n\t\t},\n\t};\n\n\treturn createSink(driver, {\n\t\tlabel: \"bunSqliteSink\",\n\t\tid: options.id,\n\t\ttables: options.tables,\n\t\theight: options.height,\n\t\tcheckpointTable,\n\t});\n}\n"
8
+ ],
9
+ "mappings": "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",
10
+ "debugId": "A476996AA991DF6164756E2164756E21",
11
+ "names": []
12
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * A consumer sink: the destination adapter a consume loop writes through.
3
+ *
4
+ * Without a sink, the user owns the three hard parts of a durable indexer —
5
+ * checkpoint persistence, rows+cursor atomicity, and reorg rollback — and
6
+ * the reasoning that makes them safe lives in doc comments. A sink owns all
7
+ * three: the loop loads its committed cursor, hands the handler a
8
+ * transaction, commits rows AND cursor atomically, and rolls back reorgs
9
+ * without any user code. Crucially, a sink also closes the silent-skip
10
+ * hazard where omitting `onReorg` meant reorgs were ignored forever: with a
11
+ * sink attached, rollback is unconditional.
12
+ *
13
+ * The interface is dependency-free on purpose: implementations with real
14
+ * database drivers live behind subpath exports (`@secondlayer/sdk/sinks/*`)
15
+ * so the root entry ships no DB dependency.
16
+ *
17
+ * ## The contract
18
+ *
19
+ * Binding on every implementation; the conformance kit
20
+ * (`@secondlayer/sdk/sinks/testing`) probes each one. Violations are SILENT
21
+ * in production — they surface as gaps or duplicates weeks later, never as
22
+ * errors at the violation site — which is why they are spelled out here and
23
+ * mechanically tested.
24
+ *
25
+ * What the loop guarantees the sink:
26
+ *
27
+ * 1. **Init-before-first-page.** `loadCursor` is called exactly once, before
28
+ * the first fetch — even when the caller passed an explicit `fromCursor`.
29
+ * It is the sink's init hook: create checkpoint storage, validate
30
+ * rollback preconditions.
31
+ * 2. **Deterministic replay.** Re-reading from the same cursor yields the
32
+ * same rows in the same order. `cursor` is therefore a valid idempotency
33
+ * key: an append-only sink can dedup on it with no contract change.
34
+ * 3. **At-least-once delivery.** A batch whose commit outcome was lost (crash
35
+ * after commit, before the loop observed it) is re-committed on restart
36
+ * with the SAME cursor and the same rows.
37
+ *
38
+ * What the sink must guarantee — `commitBatch`:
39
+ *
40
+ * 4. **Rows+cursor atomicity.** The handler's writes and `cursor` commit in
41
+ * ONE transaction. Committing them separately is the classic torn-batch
42
+ * bug: a crash between the two either re-delivers (duplicates) or skips
43
+ * (gap) depending on the order.
44
+ * 5. **Abort on throw.** A throw from `write` aborts the WHOLE transaction —
45
+ * neither rows nor cursor land — so a crashed batch is simply re-read.
46
+ * 6. **The lent transaction is the real one.** `write(tx)` receives the same
47
+ * live transaction the cursor commits in; every handler write must go
48
+ * through it. Writes outside `tx` escape atomicity AND rollback.
49
+ * 7. **Replay safety.** Committing the same cursor twice must not corrupt
50
+ * state (per #3 the rows are identical; upsert/dedup make it exact).
51
+ *
52
+ * What the sink must guarantee — `rollback`:
53
+ *
54
+ * 8. **Delete+rewind atomicity.** Undoing rows at/above the fork and
55
+ * committing `rewindCursor` happen in ONE transaction. A crash between
56
+ * them resumes above the fork and the deleted range is never re-read —
57
+ * the silent-gap bug.
58
+ * 9. **Inclusive fork point.** Undo AT OR ABOVE `forkPointHeight` (`>=`) —
59
+ * the new canonical chain re-supplies the fork block itself.
60
+ * 10. **Idempotent.** The loop may re-apply the same rollback after a crash
61
+ * (reorgs are deduped in memory only); a second application must be a
62
+ * harmless no-op.
63
+ * 11. **Scope by height, not cursor.** On a page reporting several forks the
64
+ * loop calls `rollback` once per fork: each call carries its own
65
+ * `forkPointHeight` but ALL carry the same `rewindCursor` (the lowest
66
+ * fork point). Derive the undo range from `forkPointHeight` only; a
67
+ * sink that derives it from `rewindCursor` under-deletes silently.
68
+ *
69
+ * Error semantics and concurrency:
70
+ *
71
+ * 12. **Throw, don't swallow; no internal retry.** The loop owns retry
72
+ * policy. A swallowed commit error advances the loop past unwritten
73
+ * data; an internal retry can double-apply around a partial failure.
74
+ * 13. **Single writer per checkpoint id.** The loop assumes one live
75
+ * consumer per checkpoint identity. A sink that can detect a second
76
+ * writer (e.g. a lock) must fail loudly — never block or interleave.
77
+ */
78
+ interface ConsumerSink<Tx = unknown> {
79
+ /** Phantom marker carrying `Tx` in a directly-inferable position, so the
80
+ * consume loops' `TTx` type parameter resolves from the `sink` option
81
+ * (never set at runtime). */
82
+ readonly _tx?: Tx;
83
+ /** Static capabilities, read by the loop before the first fetch to fail
84
+ * fast on impossible pairings. */
85
+ readonly capabilities?: {
86
+ /** This sink cannot undo committed rows (append-only store, ClickHouse,
87
+ * parquet, …), so `rollback` is unimplementable and following the
88
+ * unfinalized tip would corrupt it on the first fork. The loop throws
89
+ * loudly unless consuming with `finalizedOnly: true` — in that mode
90
+ * reorgs never reach the sink and `rollback` may simply throw. */
91
+ finalizedOnly?: boolean;
92
+ };
93
+ /** The committed checkpoint, or `null` on first run. Called once, before
94
+ * the first page. Implementations may create their checkpoint storage
95
+ * here and SHOULD validate their rollback preconditions (e.g. that every
96
+ * declared table carries the height column). */
97
+ loadCursor(): Promise<string | null>;
98
+ /**
99
+ * Apply one batch atomically: open a transaction, run `write(tx)` (the
100
+ * user's inserts), and commit the rows AND `cursor` together. A throw
101
+ * from `write` must abort the whole transaction — leaving neither rows
102
+ * nor cursor, so a crashed batch is simply re-read on restart.
103
+ */
104
+ commitBatch(cursor: string, write: (tx: Tx) => Promise<void> | void): Promise<void>;
105
+ /**
106
+ * Roll the projection back to the fork: delete everything AT OR ABOVE
107
+ * `forkPointHeight` (inclusive `>=` — the new chain re-supplies the fork
108
+ * block) and commit `rewindCursor` in the SAME transaction. Deleting
109
+ * without the rewound cursor is the classic silent-gap bug: a crash
110
+ * between the two writes resumes above the fork and the deleted range is
111
+ * never re-read.
112
+ *
113
+ * On a multi-fork page this is called once PER fork, every call with the
114
+ * same `rewindCursor` (the lowest fork point) but its own
115
+ * `forkPointHeight` — scope the undo by `forkPointHeight` only (contract
116
+ * invariant #11), and expect re-application after a crash (#10).
117
+ */
118
+ rollback(forkPointHeight: number, rewindCursor: string): Promise<void>;
119
+ }
120
+ /** Default checkpoint table name, shared by every SQL-backed sink so two
121
+ * sinks on the same database land in one table keyed by `id`. */
122
+ declare const DEFAULT_CHECKPOINT_TABLE = "sl_consumer_checkpoints";
123
+ /**
124
+ * The driver surface a database adapter implements — the ~7 methods that
125
+ * actually differ between SQL stores. Everything above them (the
126
+ * commit/rollback call sequences, the guards, identifier validation, the
127
+ * checkpoint schema) is policy owned by {@link createSink}, so a driver
128
+ * cannot get the ordering wrong: it never sees a cursor write outside the
129
+ * transaction that carries the rows, and never sees a delete without the
130
+ * rewound cursor riding along.
131
+ *
132
+ * Identifier args (`table`, `column`) arrive pre-validated against
133
+ * `[A-Za-z_][A-Za-z0-9_]*` — safe to interpolate after quoting (see
134
+ * {@link quoteIdent}). Values (cursor, height, id) must still be bound as
135
+ * parameters.
136
+ */
137
+ interface SinkDriver<Tx> {
138
+ /** Run `fn` inside ONE transaction: begin, run, commit — a throw from
139
+ * `fn` must roll the whole transaction back and re-throw. */
140
+ transact<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
141
+ /** Create the checkpoint store if missing: `(id primary key, cursor)`.
142
+ * Must be safe under concurrent first start (two consumers racing the
143
+ * same CREATE). */
144
+ ensureCheckpointStore(): Promise<void>;
145
+ /** The committed cursor for this sink's `id`, or `null` if none. */
146
+ readCursor(): Promise<string | null>;
147
+ /** Upsert this sink's cursor row inside `tx`. */
148
+ writeCursor(tx: Tx, cursor: string): Promise<void>;
149
+ /** Delete rows with `height column >= height` from `table`, inside `tx`. */
150
+ deleteAtOrAbove(tx: Tx, table: string, height: number): Promise<void>;
151
+ /** Whether `table` exists AND carries `column` — the first-use check that
152
+ * keeps reorg rollback from being a silent no-op. */
153
+ hasColumn(table: string, column: string): Promise<boolean>;
154
+ /** Optional: assert this consumer is the only live writer for its `id`,
155
+ * inside `tx`. Must FAIL LOUDLY (throw) when another writer holds it —
156
+ * never block or interleave (contract invariant #13). */
157
+ acquireLock?(tx: Tx): Promise<void>;
158
+ }
159
+ /** Options for {@link createSink} — the policy inputs, driver-agnostic.
160
+ * Concrete sinks re-expose these with schema-typed `tables`/`height`. */
161
+ interface CreateSinkOptions {
162
+ /** Error-message prefix naming the concrete sink (e.g. `"kyselySink"`),
163
+ * so a thrown guard points at the thing the user actually constructed. */
164
+ label: string;
165
+ /** Checkpoint identity AND concurrency key. */
166
+ id: string;
167
+ /** Rollback scope: on a reorg, rows at/above the fork point are deleted
168
+ * from exactly these tables. */
169
+ tables: readonly string[];
170
+ /** The block-height stamp column, present on every declared table. */
171
+ height: string;
172
+ /** Checkpoint table name. Default {@link DEFAULT_CHECKPOINT_TABLE}. */
173
+ checkpointTable?: string;
174
+ /** Forwarded to {@link ConsumerSink.capabilities}. */
175
+ capabilities?: ConsumerSink["capabilities"];
176
+ }
177
+ /** Throw unless `name` is a bare SQL identifier (letters, digits,
178
+ * underscores; no leading digit) — the guard that makes interpolating
179
+ * table/column names into DDL and DELETE statements safe. */
180
+ declare function assertSqlIdentifier(name: string, label: string): void;
181
+ /** Double-quote a (pre-validated) identifier for interpolation. */
182
+ declare function quoteIdent(name: string): string;
183
+ /**
184
+ * Build a {@link ConsumerSink} from a {@link SinkDriver}: the portable ~90
185
+ * lines every SQL sink otherwise re-implements — and re-risks. Owns the two
186
+ * transaction sequences (begin → lock → write rows → write cursor;
187
+ * begin → lock → delete `>=` fork → write rewound cursor), the empty-`tables`
188
+ * guard, identifier validation, and the first-use height-column check.
189
+ *
190
+ * A driver implemented against this base cannot violate contract invariants
191
+ * #4, #5, #8, #9, or #11 (see {@link ConsumerSink}) without breaking
192
+ * `transact` itself — which is exactly what the conformance kit probes.
193
+ */
194
+ declare function createSink<Tx>(driver: SinkDriver<Tx>, options: CreateSinkOptions): ConsumerSink<Tx>;
195
+ export { quoteIdent, createSink, assertSqlIdentifier, SinkDriver, DEFAULT_CHECKPOINT_TABLE, CreateSinkOptions };