Skip to content
Flowblade
Esc
↑↓navigate↵open⌘Jpreview
On this page

@flowblade/sqlduck

Load rows into DuckDB with the appender, or query Kysely data materialized in DuckDB.

@flowblade/sqlduck creates DuckDB tables from Zod schemas and loads rows from arrays, generators, async iterables and streams. Its default loader uses the DuckDB appender for efficient chunked inserts. It also integrates Kysely queries with DuckDB through withMaterializedKyselyQuery.

Install

npm install @flowblade/sqlduck @duckdb/node-api zod

For the Kysely integration, also install @flowblade/sql-tag, kysely and the driver dependencies for your source database.

Load rows with the DuckDB appender

Create a loader with a DuckDB connection, then pass it a table, Zod schema and row iterable. The schema defines the table columns and converts input values.

import { DuckDBInstance } from "@duckdb/node-api";
import { Table } from "@flowblade/sqlduck";
import { DuckTableLoader } from "@flowblade/sqlduck/loader";
import * as z from "zod";

const instance = await DuckDBInstance.create();
const conn = await instance.connect();
const loader = new DuckTableLoader({ conn });

const userSchema = z.object({
  id: z.int32().meta({ primaryKey: true }),
  name: z.string(),
});

async function* getUsers(): AsyncGenerator<z.input<typeof userSchema>> {
  yield { id: 1, name: "Ada" };
  yield { id: 2, name: "Grace" };
}

const result = await loader.load({
  table: new Table({ name: "user" }),
  schema: userSchema,
  rows: getUsers(),
});

console.log(`Loaded ${result.totalRows} rows in ${result.timeMs}ms`);

ChunkAppenderStrategy is the default. It writes column chunks with DuckDB’s appender, which is generally the fastest strategy for bulk loads. Set it explicitly to tune chunk size or flush frequency:

import {
  ChunkAppenderStrategy,
  DuckTableLoader,
} from "@flowblade/sqlduck/loader";

const loader = new DuckTableLoader({
  conn,
  strategy: new ChunkAppenderStrategy({
    chunkSize: 2048,
    flushEveryRows: 204_800,
  }),
});

chunkSize is between 1 and 2048 rows. flushEveryRows controls when the appender flushes buffered rows into the table; it is not a database checkpoint. If the load runs in a transaction, other connections cannot see the flushed rows until the transaction commits. Frequent flushes add overhead, and the appender is flushed and closed when the load completes. Use RowAppenderStrategy to identify a failing row and column, or PreparedInsertStrategy when you need primary-key conflict handling.

See the loader guide for load modes, checkpoints, progress reporting, cancellation and strategy details.

Query Kysely data materialized in DuckDB

Experimental: this API may change in a minor release.

withMaterializedKyselyQuery streams the results of a Kysely select query into a temporary DuckDB table, runs a DuckDB query against it, then attempts to drop the table whether the query succeeds, fails or throws. Cleanup is best effort; a failed drop may leave the table behind.

import { DuckDBInstance } from "@duckdb/node-api";
import { sql } from "@flowblade/sql-tag";
import {
  KyselyQueryWithZodSchema,
  withMaterializedKyselyQuery,
} from "@flowblade/sqlduck/kysely";
import type { Kysely } from "kysely";
import * as z from "zod";

type DB = {
  user: { id: number; name: string };
};

declare const db: Kysely<DB>; // Configured source Kysely instance

const instance = await DuckDBInstance.create();
const conn = await instance.connect();

const table = new KyselyQueryWithZodSchema({
  query: db.selectFrom("user").select(["id", "name"]),
  schema: z.strictObject({ id: z.int32(), name: z.string() }),
});

const result = await withMaterializedKyselyQuery({
  duckConn: conn,
  table,
  query: ({ dsDuck, table, signal }) =>
    dsDuck.query(
      sql<{ id: number; name: string }>`
        SELECT id, name
        FROM ${sql.raw(table.getFullName())}
        WHERE id < ${1000}
      `,
      { signal }
    ),
});

if (result.error) {
  console.error(result.error.message);
} else {
  console.log(result.data);
  console.log(result.meta.getSpansByType("materialization"));
}

The Zod schema must match the selected columns. duckConn accepts either a DuckDBConnection or a DuckdbDatasource. The optional database and schema parameters choose where the temporary table is created; the schema must already exist. Both names must be valid, non-reserved DuckDB identifiers, or the function returns an error without running the query. An attached file database can keep large materializations out of the in-memory database.

Pass an AbortSignal with signal to stop the Kysely stream and forward the callback’s signal to dsDuck.query to cancel the DuckDB query. On success, the result metadata includes a materialization span with the table DDL, row count, elapsed time and table name.

See the package README and API reference for additional details.

Was this page helpful?