Skip to content

SQLite example ​

The SQLite example uses the same structural query primitives as PostgreSQL and MySQL. It needs no container or third-party driver because the explicit adapter targets Node's built-in node:sqlite module.

AreaDemonstrated behavior
Read and writeDynamic queries, CTEs, cardinality helpers, inserts, updates, deletes, transactions
Reuse and batchingPrepared queries and a small transactional insert batch
Large resultsAsync iteration over the adapter's serialized statement queue
Validation and capability discoveryStandard Schema result validation and explicit unsupported capability flags

Define the database ​

The setup script recreates a local database from this strict SQLite schema:

sql
PRAGMA foreign_keys = ON;

CREATE TABLE account (
  id INTEGER PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  status TEXT NOT NULL CHECK (status IN ('active', 'suspended'))
) STRICT;

CREATE TABLE project (
  id INTEGER PRIMARY KEY,
  owner_id INTEGER NOT NULL REFERENCES account(id),
  name TEXT NOT NULL,
  budget REAL
) STRICT;

INSERT INTO account (id, email, status) VALUES
  (1, 'alice@example.com', 'active'),
  (2, 'bob@example.com', 'suspended');

INSERT INTO project (id, owner_id, name, budget) VALUES
  (1, 1, 'Compiler', 12500.50);

The config selects the SQLite grammar, local schema provider, generated output, and matching runtime type policy:

ts
import { defineConfig } from "@typed-sql/core";
import { sqlite, typePolicy } from "@typed-sql/sqlite";
import { nodeSqlite } from "@typed-sql/sqlite/node-sqlite";

export const databasePath = new URL("./example.sqlite", import.meta.url);

export default defineConfig({
  dialect: sqlite({ typePolicy }),
  schema: {
    file: "generated/db/schema.json",
    provider: nodeSqlite({ path: databasePath, typePolicy }),
  },
  outDir: "generated/db",
  projects: ["tsconfig.json"],
  typePolicy,
});

Compose native SQL ​

SQLite's grammar analyzes the complete query after all conditional fragments are composed:

ts
import type { QueryParameters, QueryRow } from "@typed-sql/core";
import { sql } from "@typed-sql/sqlite";

export interface AccountFilters {
  readonly status?: string | null;
  readonly minimumId?: bigint | null;
}

export interface AccountSelect {
  readonly status: boolean;
  readonly projectBudget: boolean;
}

export function accounts<const Select extends AccountSelect>(filters: AccountFilters, select: Select) {
  return sql`
    SELECT
      account.id,
      account.email
      ${select.status ? sql.fragment`, account.status` : sql.empty}
      ${select.projectBudget ? sql.fragment`, project.budget` : sql.empty}
    FROM account
      ${select.projectBudget ? sql.fragment`LEFT JOIN project ON project.owner_id = account.id` : sql.empty}
    WHERE 1 = 1
      ${filters.status == null ? sql.empty : sql.fragment`AND account.status = ${filters.status}`}
      ${filters.minimumId == null ? sql.empty : sql.fragment`AND account.id >= ${filters.minimumId}`}
    ORDER BY account.id
  `;
}

export const activeAccounts = accounts({ status: "active", minimumId: 1n }, { status: true, projectBudget: true });

export const accountById = (accountId: bigint) => sql`
  SELECT account.id, account.email, account.status
  FROM account
  WHERE account.id = ${accountId}
`;

export const projectsByOwner = (ownerId: bigint) => sql`
  SELECT project.id, project.owner_id, project.name, project.budget
  FROM project
  WHERE project.owner_id = ${ownerId}
  ORDER BY project.id
`;

export const accountProjectSummary = sql`
  WITH project_totals AS (
    SELECT
      project.owner_id,
      COUNT(*) AS project_count,
      SUM(project.budget) AS total_budget
    FROM project
    GROUP BY project.owner_id
  )
  SELECT
    account.id,
    account.email,
    account.status,
    project_totals.project_count,
    project_totals.total_budget
  FROM account
  LEFT JOIN project_totals ON project_totals.owner_id = account.id
  ORDER BY account.id
`;

export type ActiveAccount = QueryRow<typeof activeAccounts>;
export type ActiveAccountParameters = QueryParameters<typeof activeAccounts>;

SQLite cannot derive an enum from a CHECK constraint, so status remains string. The selected budget column is number | null under this type policy.

Mutations and cardinality ​

ts
import { sql } from "@typed-sql/sqlite";

export interface NewAccount {
  readonly id: bigint;
  readonly email: string;
  readonly status: string;
}

export interface NewProject {
  readonly id: bigint;
  readonly ownerId: bigint;
  readonly name: string;
  readonly budget: number | null;
}

export const insertAccount = (account: NewAccount) => sql`
  INSERT INTO account (id, email, status)
  VALUES (${account.id}, ${account.email}, ${account.status})
  RETURNING id, email, status
`;

export const updateAccountStatus = (accountId: bigint, status: string) => sql`
  UPDATE account
  SET status = ${status}
  WHERE id = ${accountId}
  RETURNING id, email, status
`;

export const deleteAccount = (accountId: bigint) => sql`
  DELETE FROM account
  WHERE id = ${accountId}
  RETURNING id, email, status
`;

export const insertProject = (project: NewProject) => sql`
  INSERT INTO project (id, owner_id, name, budget)
  VALUES (${project.id}, ${project.ownerId}, ${project.name}, ${project.budget})
  RETURNING id, owner_id, name, budget
`;

export const deleteProjectsByOwner = (ownerId: bigint) => sql`
  DELETE FROM project
  WHERE owner_id = ${ownerId}
`;
ts
import type { SqliteDatabase } from "@typed-sql/sqlite/runtime";
import { accountById, activeAccounts } from "./queries.js";

export const listActiveAccounts = (database: SqliteDatabase) => database.all(activeAccounts);

export const requireAccount = (database: SqliteDatabase, accountId: bigint) => database.one(accountById(accountId));

export const findAccount = (database: SqliteDatabase, accountId: bigint) => database.maybeOne(accountById(accountId));
ts
import type { SqliteDatabase } from "@typed-sql/sqlite/runtime";
import { insertAccount, insertProject, type NewAccount, type NewProject } from "./mutations.js";

export function createAccountWithProject(
  database: SqliteDatabase,
  account: NewAccount,
  project: Omit<NewProject, "ownerId">,
) {
  return database.transaction(async (transaction) => {
    const insertedAccount = await transaction.one(insertAccount(account));
    const insertedProject = await transaction.one(insertProject({ ...project, ownerId: account.id }));
    return { account: insertedAccount, project: insertedProject };
  });
}

Prepared, batched, and streamed work ​

ts
import type { SqliteDatabase } from "@typed-sql/sqlite/runtime";
import { accountById, projectsByOwner } from "./queries.js";

export function prepareAccountQueries(database: SqliteDatabase) {
  return Object.freeze({
    accountById: database.prepare("example-account-by-id", accountById),
    projectsByOwner: database.prepare("example-projects-by-owner", projectsByOwner),
  });
}

SQLite does not pretend to offer a server bulk protocol. Small input sets can still use a transaction batch:

ts
import type { SqliteDatabase } from "@typed-sql/sqlite/runtime";
import { insertAccount, type NewAccount } from "./mutations.js";
import { accountById, projectsByOwner } from "./queries.js";

export async function loadAccountWorkspace(database: SqliteDatabase, accountId: bigint) {
  const [accounts, projects] = await database.batch([accountById(accountId), projectsByOwner(accountId)]);
  return { account: accounts[0], projects };
}

/**
 * SQLite has no typed-sql native bulk protocol. A small batch can still share one explicit
 * transaction; large ingestion should use an application-specific native strategy.
 */
export function insertSmallAccountBatch(database: SqliteDatabase, accounts: readonly NewAccount[]) {
  return database.transaction((transaction) => transaction.batch(accounts.map(insertAccount)));
}
ts
import type { QueryRow } from "@typed-sql/core";
import type { SqliteDatabase } from "@typed-sql/sqlite/runtime";
import { activeAccounts } from "./queries.js";

export function streamActiveAccounts(database: SqliteDatabase, batchSize = 100) {
  return database.stream(activeAccounts, { batchSize });
}

export async function collectActiveAccounts(database: SqliteDatabase) {
  const accounts: QueryRow<typeof activeAccounts>[] = [];
  for await (const account of streamActiveAccounts(database, 25)) accounts.push(account);
  return accounts;
}

export async function firstActiveAccount(database: SqliteDatabase) {
  for await (const account of streamActiveAccounts(database, 1)) return account;
  return undefined;
}

Validation and capability discovery ​

ts
import { sql } from "@typed-sql/sqlite";
import * as v from "valibot";

export const accountResult = v.object({
  id: v.bigint(),
  email: v.pipe(v.string(), v.email()),
  status: v.string(),
});

interface ValidatedAccount {
  readonly id: bigint;
  readonly email: string;
  readonly status: string;
}

export const validatedAccountById = (accountId: bigint) =>
  sql.validateResult(
    sql<ValidatedAccount>`
      SELECT account.id, account.email, account.status
      FROM account
      WHERE account.id = ${accountId}
    `,
    accountResult,
  );

The application can inspect the adapter contract instead of assuming that cancellation or deadlines exist:

ts
import type { SqliteDatabase } from "@typed-sql/sqlite/runtime";

export function sqliteExecutionCapabilities(database: SqliteDatabase) {
  return database.executionCapabilities;
}

Execute through node:sqlite ​

The adapter presents the same promise-shaped database contract even though the underlying built-in driver is synchronous:

ts
import { typePolicy } from "@typed-sql/sqlite";
import { createNodeSqliteDatabase } from "@typed-sql/sqlite/node-sqlite";
import { databasePath } from "../typed-sql.config.js";
import { loadAccountWorkspace } from "./batches.js";
import { sqliteExecutionCapabilities } from "./capabilities.js";
import { findAccount } from "./cardinality.js";
import { prepareAccountQueries } from "./prepared.js";
import { accountProjectSummary } from "./queries.js";
import { collectActiveAccounts } from "./streams.js";

export async function runSqliteExample() {
  const database = await createNodeSqliteDatabase({ path: databasePath, typePolicy });

  try {
    const prepared = prepareAccountQueries(database);
    const account = await findAccount(database, 1n);
    const preparedAccount = await database.maybeOne(prepared.accountById(2n));
    const workspace = await loadAccountWorkspace(database, 1n);
    const summary = await database.all(accountProjectSummary);
    const streamedAccounts = await collectActiveAccounts(database);
    return {
      account,
      preparedAccount,
      workspace,
      summary,
      streamedAccounts,
      executionCapabilities: sqliteExecutionCapabilities(database),
    };
  } finally {
    await database.close();
  }
}

The real Poku suite recreates the database, executes every documented path, checks driver-specific decoded values, and removes its mutation fixtures:

ts
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
  CONFORMANCE_VERSION,
  type ConformanceLiveAdapter,
  type ConformanceServerErrorClass,
  type ConformanceTypeNormalizer,
  createConformanceReport,
  createConformanceReproductionBundle,
  defineConformanceProbe,
  runLiveConformanceProbe,
  runStaticConformanceProbe,
  selectExpectedOutcome,
  serializeConformanceReport,
  serializeConformanceReproductionBundle,
} from "@typed-sql/conformance/v2";
import { sql, sqlite, typePolicy } from "@typed-sql/sqlite";
import { createNodeSqliteDatabase } from "@typed-sql/sqlite/node-sqlite";
import { describe, it, strict } from "poku";
import { insertSmallAccountBatch, loadAccountWorkspace } from "../src/batches.js";
import { sqliteExecutionCapabilities } from "../src/capabilities.js";
import { findAccount, listActiveAccounts, requireAccount } from "../src/cardinality.js";
import { deleteAccount, deleteProjectsByOwner, updateAccountStatus } from "../src/mutations.js";
import { prepareAccountQueries } from "../src/prepared.js";
import { accountProjectSummary } from "../src/queries.js";
import { collectActiveAccounts, firstActiveAccount } from "../src/streams.js";
import { createAccountWithProject } from "../src/transactions.js";
import { validatedAccountById } from "../src/validation.js";
import { databasePath } from "../typed-sql.config.js";

const database = await createNodeSqliteDatabase({ path: databasePath, typePolicy });
const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const workspaceDirectory = resolve(packageDirectory, "../..");
const generatedSnapshotPath = join(packageDirectory, "generated", "db", "schema.json");

function rowValue(row: unknown): Readonly<Record<string, unknown>> {
  if (row === null || typeof row !== "object") throw new TypeError("Expected a SQLite row object");
  return { ...row };
}

async function removeAccounts(ids: readonly bigint[]): Promise<void> {
  for (const id of ids) {
    await database.execute(deleteProjectsByOwner(id));
    await database.execute(deleteAccount(id));
  }
}

try {
  await describe("SQLite example against node:sqlite", async () => {
    await it("executes queries, cardinality, CTEs, prepared statements, batches, and streams", async () => {
      strict.strictEqual((await listActiveAccounts(database)).length, 1);
      strict.deepStrictEqual(rowValue(await requireAccount(database, 1n)), {
        id: 1n,
        email: "alice@example.com",
        status: "active",
      });
      strict.strictEqual(await findAccount(database, -1n), undefined);
      strict.strictEqual((await database.all(accountProjectSummary)).length, 2);

      const prepared = prepareAccountQueries(database);
      strict.strictEqual(prepared.accountById.statementName, "example-account-by-id");
      strict.deepStrictEqual(rowValue(await database.one(prepared.accountById(2n))), {
        id: 2n,
        email: "bob@example.com",
        status: "suspended",
      });
      strict.strictEqual((await loadAccountWorkspace(database, 1n)).projects.length, 1);
      strict.strictEqual((await collectActiveAccounts(database)).length, 1);
      strict.deepStrictEqual(rowValue((await firstActiveAccount(database))!), {
        id: 1n,
        email: "alice@example.com",
        status: "active",
        budget: 12500.5,
      });
      strict.deepStrictEqual(rowValue(await database.one(validatedAccountById(1n))), {
        id: 1n,
        email: "alice@example.com",
        status: "active",
      });
      strict.deepStrictEqual(sqliteExecutionCapabilities(database), { cancellation: false, deadlines: false });
    });

    await it("commits typed mutations atomically", async () => {
      await removeAccounts([9_001n]);
      const created = await createAccountWithProject(
        database,
        { id: 9_001n, email: "transaction.sqlite@example.com", status: "active" },
        { id: 9_001n, name: "SQLite transaction", budget: 42.5 },
      );
      strict.deepStrictEqual(rowValue(created.account), {
        id: 9_001n,
        email: "transaction.sqlite@example.com",
        status: "active",
      });
      strict.deepStrictEqual(rowValue(await database.one(updateAccountStatus(9_001n, "suspended"))), {
        id: 9_001n,
        email: "transaction.sqlite@example.com",
        status: "suspended",
      });
      await removeAccounts([9_001n]);
      strict.strictEqual(await findAccount(database, 9_001n), undefined);
    });

    await it("uses a transaction batch for small inserts without claiming a native bulk protocol", async () => {
      const ids = [9_101n, 9_102n] as const;
      await removeAccounts(ids);
      const results = await insertSmallAccountBatch(database, [
        { id: ids[0], email: "batch-one.sqlite@example.com", status: "active" },
        { id: ids[1], email: "batch-two.sqlite@example.com", status: "suspended" },
      ]);
      strict.strictEqual(results.length, 2);
      strict.deepStrictEqual(rowValue(await requireAccount(database, ids[1])), {
        id: ids[1],
        email: "batch-two.sqlite@example.com",
        status: "suspended",
      });
      await removeAccounts(ids);
    });

    await it("executes SQLite recursive compounds with positional members", async () => {
      const rows = await database.execute(sql`
        WITH RECURSIVE cnt(value) AS (
          SELECT ${1n}
          UNION ALL
          SELECT value + 1 FROM cnt WHERE value < ${3n}
        )
        SELECT value FROM cnt
      `);
      strict.deepStrictEqual(
        rows.map((row) => rowValue(row).value),
        [1n, 2n, 3n],
      );
    });

    await it("executes chained SQLite windows and explicit frames", async () => {
      const rows = await database.execute(sql`
        SELECT
          id,
          ROW_NUMBER() OVER ordered AS position,
          LAG(email, 1, 'missing') OVER ordered AS previous,
          SUM(id) OVER (ordered ROWS BETWEEN 1 PRECEDING AND CURRENT ROW EXCLUDE TIES) AS running_id
        FROM account
        WINDOW ordered AS (ORDER BY id)
        ORDER BY id
      `);
      strict.deepStrictEqual(rows.map(rowValue), [
        { id: 1n, position: 1n, previous: "missing", running_id: 1n },
        { id: 2n, position: 2n, previous: "alice@example.com", running_id: 3n },
      ]);
    });

    await it("executes SQLite UPSERT, RETURNING, and UPDATE FROM", async () => {
      await removeAccounts([9_201n]);
      const inserted = await database.execute(sql`
        INSERT INTO account (id, email, status)
        VALUES (${9_201n}, ${"upsert.sqlite@example.com"}, ${"active"})
        ON CONFLICT (id) DO UPDATE SET
          email = excluded.email,
          status = excluded.status
        RETURNING id, email, status
      `);
      strict.deepStrictEqual(rowValue(inserted[0]), {
        id: 9_201n,
        email: "upsert.sqlite@example.com",
        status: "active",
      });
      const updated = await database.execute(sql`
        UPDATE account
        SET status = source.status
        FROM account AS source
        WHERE account.id = ${9_201n} AND source.id = ${2n}
        RETURNING id, status
      `);
      strict.deepStrictEqual(rowValue(updated[0]), { id: 9_201n, status: "suspended" });
      await removeAccounts([9_201n]);
    });

    await it("records a redacted conformance v2 differential report", async () => {
      const dialect = sqlite();
      const snapshot = dialect.validateSnapshot(JSON.parse(await readFile(generatedSnapshotPath, "utf8")));
      if (snapshot.formatVersion !== 2) throw new TypeError("The SQLite conformance run requires snapshot v2");
      if (snapshot.metadata === undefined) throw new TypeError("The generated SQLite snapshot requires metadata");
      const query = sql`SELECT id FROM account WHERE id = ${1n}`;
      const versionRows = await database.execute(sql`SELECT sqlite_version() AS version`);
      const version = String(rowValue(versionRows[0]).version);
      const target = {
        grammar: "sqlite",
        grammarVersion: dialect.grammarVersion,
        databaseVersion: version,
      } as const;
      const probe = defineConformanceProbe({
        version: CONFORMANCE_VERSION,
        id: "sqlite.statement.select.live-bigint",
        featureId: "statement.select",
        grammar: "sqlite",
        targets: [target],
        source: "SELECT id FROM account WHERE id = ?",
        schemaFixture: "examples/sqlite/schema/catalog.snapshot.json",
        query,
        compilerSource:
          'import { sql } from "@typed-sql/sqlite";\nexport const query = sql`SELECT id FROM account WHERE id = ${1n}`;',
        live: { execute: true, maximumRows: 1 },
        expected: [
          {
            target: { grammarVersion: dialect.grammarVersion, databaseVersion: version },
            support: "conservative",
            rows: [
              {
                name: "id",
                tsType: "bigint",
                nullable: false,
                databaseType: "INTEGER",
                range: { start: 7, end: 9, line: 1, column: 8 },
              },
            ],
            parameters: [{ index: 1, tsType: "bigint", nullable: false, databaseType: "INTEGER" }],
            diagnostics: [],
            rendered: { text: "SELECT id FROM account WHERE id = ?", values: [1n] },
            compiled: { rowType: '{ "id": bigint; }', parameterType: "readonly [bigint]" },
            decodedRows: [{ id: 1n }],
            skips: {
              "lex-parse": "grammar-parser-private",
              prepare: "no-server-metadata",
              plan: "plan-format-unstable",
            },
          },
        ],
      });
      const requestedProbe = process.env.TYPED_SQL_CONFORMANCE_PROBE;
      if (requestedProbe !== undefined && requestedProbe !== probe.id) {
        throw new TypeError(`SQLite live suite does not contain requested probe ${requestedProbe}`);
      }
      const requestedDatabaseVersion = process.env.TYPED_SQL_CONFORMANCE_DATABASE_VERSION;
      if (requestedDatabaseVersion !== undefined && requestedDatabaseVersion !== version) {
        throw new TypeError(`Requested SQLite ${requestedDatabaseVersion}, connected to ${version}`);
      }
      const requestedFixtureGroup = process.env.TYPED_SQL_CONFORMANCE_FIXTURE_GROUP;
      if (requestedFixtureGroup !== undefined && requestedFixtureGroup !== "statement.select") {
        throw new TypeError(`SQLite live suite does not contain fixture group ${requestedFixtureGroup}`);
      }
      const adapter: ConformanceLiveAdapter = {
        grammar: "sqlite",
        driver: "node:sqlite",
        driverVersion: process.versions.node,
        async server() {
          return { version, capabilities: {} };
        },
        async prepare() {
          return { columns: [], parameters: [], unavailable: ["columns", "parameters", "nullability"] };
        },
        execute: async () => (await database.execute(query)).map(rowValue),
        classify(error): ConformanceServerErrorClass {
          const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : "";
          if (code.includes("ERROR")) return "semantic";
          if (code.includes("BUSY") || code.includes("LOCKED")) return "environment";
          return "semantic";
        },
        async cleanup() {},
        async close() {},
      };
      const normalizer: ConformanceTypeNormalizer = {
        column: (field) => ({
          name: field.name ?? "id",
          tsType: "bigint",
          nullable: field.nullable ?? false,
          databaseType: field.nativeType ?? "INTEGER",
        }),
        parameter: (field) => ({
          index: field.index,
          tsType: "bigint",
          nullable: field.nullable ?? false,
          databaseType: field.nativeType ?? "INTEGER",
        }),
      };
      const staticResult = runStaticConformanceProbe(probe, target, {
        dialect,
        snapshot,
        renderer: {
          placeholder: (index) => dialect.placeholder(index),
          quoteIdentifier: (identifier) => dialect.quoteIdentifier(identifier),
        },
      });
      const result = await runLiveConformanceProbe(probe, target, adapter, normalizer, staticResult);
      const report = createConformanceReport(
        "sqlite-live",
        {
          grammar: "sqlite",
          grammarVersion: dialect.grammarVersion,
          databaseVersion: version,
          driver: "node:sqlite",
          driverVersion: process.versions.node,
          runtime: "node",
          runtimeVersion: process.version,
          typescriptVersion: "7.0.2",
          schemaFingerprint: `sha256:${snapshot.metadata.schemaHash}`,
          capabilities: {},
        },
        [result],
      );
      const artifactDirectory = join(workspaceDirectory, "artifacts", "conformance");
      await mkdir(artifactDirectory, { recursive: true });
      const serialized = serializeConformanceReport(report);
      strict.ok(!serialized.includes("alice@example.com"));
      strict.ok(!serialized.includes("1n"));
      await writeFile(join(artifactDirectory, "sqlite.json"), serialized);
      if (result.status !== "pass") {
        const reproduction = createConformanceReproductionBundle(
          probe,
          target,
          report.environment,
          selectExpectedOutcome(probe, target),
          result,
        );
        await writeFile(
          join(artifactDirectory, "sqlite-reproduction.json"),
          serializeConformanceReproductionBundle(reproduction),
        );
      }
      strict.strictEqual(result.status, "pass", JSON.stringify(result, null, 2));
    });
  });
} finally {
  await removeAccounts([9_001n, 9_101n, 9_102n, 9_201n]).catch(() => undefined);
  await database.close();
}

Run it ​

From the repository root:

sh
pnpm --filter @typed-sql/example-sqlite generate
pnpm --filter @typed-sql/example-sqlite check
pnpm --filter @typed-sql/example-sqlite test
pnpm --filter @typed-sql/example-sqlite start
pnpm --filter @typed-sql/example-sqlite test:database

Both generate and start recreate the scoped examples/sqlite/example.sqlite file before use. The SQLite dialect guide describes its dynamic-typing, threading, and execution constraints.

Released under the MIT License.