Build / Write your own sink

Write your own sink

Implement a 7-method driver, run it through the conformance kit, and any store becomes a first-class sink: for consume loops.

What you'll build

A sink for a store we don't ship

A ConsumerSink is three methods — loadCursor, commitBatch, rollback. You won't write them: the createSink base owns the transaction sequences, and your driver only speaks your database's dialect.

Three methods, thirteen invariants — each one silent in production when violated, which is why the kit tests them mechanically.

interface ConsumerSink<Tx> {
  loadCursor(): Promise<string | null>;
  commitBatch(cursor: string, write: (tx: Tx) => Promise<void> | void): Promise<void>;
  rollback(forkPointHeight: number, rewindCursor: string): Promise<void>;
}
#Invariant
1loadCursor runs once, before the first page — it is the init hook
2Replay is deterministic: same cursor, same rows — cursor is an idempotency key
3Delivery is at-least-once: a lost commit outcome is re-committed with the same cursor
4Rows and cursor commit in ONE transaction
5A throw from write aborts the whole transaction
6The lent tx is the real transaction — every handler write goes through it
7Committing the same cursor twice must not corrupt state
8Rollback's delete and cursor rewind commit in ONE transaction
9Delete AT OR ABOVE forkPointHeight>=, the new chain re-supplies the fork block
10Rollback may be re-applied after a crash; the second pass is a no-op
11Scope the undo by forkPointHeight, never by rewindCursor
12Throw, don't swallow; no internal retry
13One writer per checkpoint id; a lock must fail loudly, never block

createSink(driver, options) from @secondlayer/sdk/sinks/core owns the sequences and guards; you implement the dialect. A complete postgres.js sink:

import postgres from "postgres";
import { createSink, quoteIdent, type SinkDriver } from "@secondlayer/sdk/sinks/core";

type Tx = postgres.TransactionSql;

export function postgresJsSink(
  sql: postgres.Sql,
  options: { id: string; tables: readonly string[]; height: string },
) {
  const cp = quoteIdent("sl_consumer_checkpoints");
  const driver: SinkDriver<Tx> = {
    transact: (fn) => sql.begin((tx) => fn(tx as Tx)) as Promise<never>,
    async ensureCheckpointStore() {
      await sql.unsafe(`CREATE TABLE IF NOT EXISTS ${cp} (id text PRIMARY KEY, cursor text NOT NULL)`);
    },
    async readCursor() {
      const rows = await sql.unsafe(`SELECT cursor FROM ${cp} WHERE id = $1`, [options.id]);
      return rows[0]?.cursor ?? null;
    },
    async writeCursor(tx, cursor) {
      await tx.unsafe(
        `INSERT INTO ${cp} (id, cursor) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET cursor = excluded.cursor`,
        [options.id, cursor],
      );
    },
    async deleteAtOrAbove(tx, table, height) {
      await tx.unsafe(
        `DELETE FROM ${quoteIdent(table)} WHERE ${quoteIdent(options.height)} >= $1`,
        [height],
      );
    },
    async hasColumn(table, column) {
      const rows = await sql`SELECT EXISTS (SELECT 1 FROM information_schema.columns
        WHERE table_name = ${table} AND column_name = ${column}) AS ok`;
      return rows[0]?.ok === true;
    },
  };
  return createSink(driver, { label: "postgresJsSink", ...options });
}

Identifiers arrive pre-validated ([A-Za-z_][A-Za-z0-9_]*), safe to interpolate after quoteIdent. Values stay bound parameters. acquireLock is the optional eighth method — throw when another writer holds your id.

@secondlayer/sdk/sinks/testing probes every invariant against your real store — torn batches, cursor replay, inclusive >=, mismatched rollback pairs, lock contention.

import { test } from "bun:test";
import { attachSinkConformance } from "@secondlayer/sdk/sinks/testing";

attachSinkConformance(test, {
  makeSink: () => postgresJsSink(sql, { id: "conformance", tables: ["rows"], height: "height" }),
  reset: () => sql`TRUNCATE rows, sl_consumer_checkpoints`,
  insertRow: (tx, height, key) =>
    tx`INSERT INTO rows (key, height) VALUES (${key}, ${height}) ON CONFLICT DO NOTHING`,
  readRows: () => sql`SELECT height, key FROM rows`,
  readCursor: async () =>
    (await sql`SELECT cursor FROM sl_consumer_checkpoints WHERE id = 'conformance'`)[0]?.cursor ?? null,
});

One probe becomes one test; the test argument accepts bun:test, vitest, or jest. Every shipped sink passes this exact kit in CI.

insertRow must be replay-safe

Upsert or insert-on-conflict-do-nothing, keyed on key — the same requirement at-least-once delivery puts on real onBatch handlers.

A store that can't delete (ClickHouse, parquet, an append log) can't implement rollback. Declare it:

const sink: ConsumerSink<MyWriter> = {
  capabilities: { finalizedOnly: true },
  // rollback is never called — throw if reached
  ...
};

The loop then refuses to run without finalizedOnly: true — loudly, at startup, before any data flows. Finalized rows never reorg, and deterministic replay makes cursor a dedup token for exactly-once semantics on the read side.

StorePathCaveats
Postgres via Kysely@secondlayer/sdk/sinks/kyselyships; advisory lock, typed ctx.tx
Postgres or SQLite via drizzle@secondlayer/sdk/sinks/drizzleships; schema objects, key→column resolution
bun:sqlite@secondlayer/sdk/sinks/bun-sqliteships; zero-dep, no docker, file lock
Prismawrite your own$transaction defaults to a 5s interactive timeout — raise it; writes through the GLOBAL client escape the lent tx silently
libsql / Turso remotewrite your own5s write lock; remote statement-per-request can't hold the commit transaction open — local files only
ClickHouse / parquetwrite your ownno deletes: capabilities: { finalizedOnly: true }, dedup on cursor