Sinks
A sink is the destination your consume loop writes through. It owns the checkpoint, the transaction boundary, and the reorg rollback, so your handler only inserts rows.
That is the whole indexer. Write only through ctx.tx, the sink's transaction,
and rows plus checkpoint commit together. onBatch's return value is ignored:
the sink owns the cursor.
A sink without reorg handling would be a no-op
A sink makes rollback unconditional: omitting onReorg no longer skips reorgs.
onReorgstill runs after the sink transaction commits.- Fold current-state (balances) in the sink's
onRollback, not there.
| Sink | Import | Store |
|---|---|---|
kyselySink | @secondlayer/sdk/sinks/kysely | Postgres via Kysely |
drizzleSink | @secondlayer/sdk/sinks/drizzle | Postgres or SQLite via drizzle |
bunSqliteSink | @secondlayer/sdk/sinks/bun-sqlite | bun:sqlite (one file, zero dependencies) |
Any other store: write your own and prove it with the conformance kit.
The problem it removes
Hand-rolled, a correct consumer is about ninety lines, most of them not about your data:
and you double-count on restart. and one block of orphaned rows survives the fork.
Skip onReorg and nothing errors; you keep rows from a chain that no longer exists.
Keyed on the query builder, not the driver
kyselySink(db, …) takes a Kysely instance, not a driver.
drizzleSink does the same with your schema objects.
A bare pg.Pool carries no schema type, which would mean:
tablesgoes unchecked.ctx.txisany, exactly where correctness matters.
kysely and drizzle-orm are optional peer dependencies; the root entry stays dependency-free.
Rollback is height-stamped
v1 deletes height >= fork_point_height from every declared table. That is
correct for one-row-per-event tables, which is why height is required.
A running balance is a fold of those rows, not a row to delete. Keep it off
tables and invert (or recompute) it in onRollback.
- It runs in the same transaction, while the doomed facts are still visible.
- Remaining facts are
height < fork. - Throwing aborts the rewind.
A subgraph still owns this if you do not want to keep the events.
Resuming
The sink loads its committed cursor on start, so a restart continues with no
arguments. An explicit fromCursor overrides the position for replaying a
range; the sink still initializes.
Health and shutdown
consumerHealth returns two members; the SDK never starts a server for you.
| Member | Use |
|---|---|
record | Feed it from onProgress |
handler | Mount it yourself |
Write your own sink
createSink(driver, options) from @secondlayer/sdk/sinks/core owns the transaction sequences and guards; your driver only speaks your database's dialect.
The contract
Three methods, thirteen invariants. Each one is silent in production when violated, so the conformance kit tests them mechanically.
| # | Invariant |
|---|---|
| 1 | loadCursor runs once, before the first page; it is the init hook |
| 2 | Replay is deterministic: same cursor, same rows, so cursor is an idempotency key |
| 3 | Delivery is at-least-once: a lost commit outcome is re-committed with the same cursor |
| 4 | Rows and cursor commit in ONE transaction |
| 5 | A throw from write aborts the whole transaction |
| 6 | The lent tx is the real transaction; every handler write goes through it |
| 7 | Committing the same cursor twice must not corrupt state |
| 8 | Rollback's delete and cursor rewind commit in ONE transaction |
| 9 | Delete AT OR ABOVE forkPointHeight (>=), since the new chain re-supplies the fork block |
| 10 | Rollback may be re-applied after a crash; the second pass is a no-op |
| 11 | Scope the undo by forkPointHeight, never by rewindCursor |
| 12 | Throw, don't swallow; no internal retry |
| 13 | One writer per checkpoint id; a lock must fail loudly, never block |
The driver
A complete postgres.js sink:
createSink also takes onRollback: same transaction, before the delete. Fold pattern: Rollback is height-stamped.
- Identifiers arrive pre-validated (
[A-Za-z_][A-Za-z0-9_]*), safe to interpolate afterquoteIdent. - Values stay bound parameters.
acquireLockis the optional seventh method: throw when another writer holds your id.
Prove it
@secondlayer/sdk/sinks/testing probes every invariant against your real store: torn batches, cursor replay, inclusive >=, mismatched rollback pairs, lock contention.
- One probe per test; the
testargument accepts bun:test, vitest, or jest. - Every shipped sink passes this exact kit in CI.
insertRowmust be replay-safe, so upsert keyed onkey.
Append-only stores
A store that can't delete (ClickHouse, parquet, an append log) can't implement rollback. Declare it:
The loop refuses to run without finalizedOnly: true, at startup, before data flows. Finalized rows never reorg; deterministic replay makes cursor a dedup token for exactly-once reads.
Store caveats
| Store | Caveat |
|---|---|
| Prisma | $transaction defaults to a 5s interactive timeout, so raise it; writes through the GLOBAL client escape the lent tx silently |
| libsql / Turso remote | 5s write lock; remote statement-per-request can't hold the commit transaction open, so local files only |
| ClickHouse / parquet | No deletes: capabilities: { finalizedOnly: true }, dedup on cursor |