npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@smooai/clickhouse-kit

v0.5.0

Published

TS-only schema-as-code toolkit for ClickHouse — declarative schemas, Zod schema emitters, forward-only migrations, and drift detection.

Readme


ClickHouse has two schema populations, and they have different natural owners. Tables a developer authors (observability, metrics, billing) are best written once, in code, with inferred row types — that's the TypeScript package. Tables your customers define at runtime (multi-tenant, user-driven shapes) mean turning untrusted input into SQL — that boundary belongs in the process holding the input, so the runtime engine is canonical in Rust: an allowlisted type system where SQL injection and unbounded tables are impossible by construction, not merely discouraged. Two packages, one deliberate split — and a TS→Rust bridge so the Rust side never hand-copies a TS-owned schema.

What is this?

A schema toolkit for ClickHouse, shipped as two packages from one repo:

  • @smooai/clickhouse-kit (npm, TypeScript) — schema-as-code authoring: clickhouseTable(...) → DDL + inferred row type (InferSelect) + Zod select/insert schemas, materialized views, forward-only migration generation (numbered files + journal) and a migration runner + drift gate that ride your own ClickHouse client.
  • smooai-clickhouse-kit (crates.io, Rust — imports as clickhouse_kit) — the runtime engine for user-defined / multi-tenant tables (allowlisted types, identifier validation, bounds, flexible_table, flatten + coerce, additive evolution), plus the TS→Rust bridge: live-DB introspection → generated #[derive(Row)] structs, drift checking, and a driver-agnostic migration runner. Rows stay Serde-native — the kit never reimplements row mapping.

Deliberately no WASM/npm binding of the Rust crate — each language authors in its own kit; the bridge meets at the live database.

%%{init: {'theme':'base','themeVariables':{
  'background':'#020618','primaryColor':'#0b1426','primaryTextColor':'#e6edf6','primaryBorderColor':'#2b3a52',
  'lineColor':'#7c8aa0','secondaryColor':'#0b1426','tertiaryColor':'#0b1426','fontFamily':'ui-sans-serif, system-ui, sans-serif',
  'clusterBkg':'#0b1426','clusterBorder':'#22304a'}}}%%
flowchart LR
  subgraph TS["TypeScript — static tables (source of truth)"]
    AUTHOR["clickhouseTable(...)<br/>ch.* columns · MVs"] --> GEN["generate → numbered<br/>forward-only migrations"]
  end
  subgraph RS["Rust — dynamic tables (canonical engine)"]
    SPEC["untrusted spec →<br/>allowlist · identifier · bounds"] --> DDL["to_create_table_sql ·<br/>flexible_table · additive ALTER"]
  end
  GEN -->|"runner + drift gate"| CH[("live ClickHouse<br/>system.columns")]
  DDL --> CH
  CH -->|"introspect_row_struct<br/>(TS→Rust bridge)"| ROW["generated #[derive(Row)]<br/>structs, drift-checked"]

  classDef warm fill:#f49f0a,stroke:#ff6b6c,color:#1a0f00;
  classDef teal fill:#00a6a6,stroke:#00c2c2,color:#011;
  class RS,SPEC,DDL warm
  class TS,AUTHOR,GEN,ROW teal

Which language owns what

The honest per-package capability matrix — the split is intentional, not backlog:

| Capability | TS @smooai/clickhouse-kit | Rust smooai-clickhouse-kit | | ------------------------------------------------------------------------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------- | | Static schema authoring (clickhouseTable, ch.*, materialized views, TTL / indexes / settings) | ✅ source of truth | — (runtime TableSpec carries the same DDL clauses, but authoring DX is TS) | | Inferred row types + Zod select/insert schemas | ✅ InferSelect, createSelectSchema | ✅ emits them as generated TS source (emit_ts_module) | | Migration file generation (numbered files + journal) | ✅ generateClickHouseMigrations | ❌ | | Forward-only migration runner (bring your own client) | ✅ runClickHouseMigrations | ✅ run_migrations over the ChExecutor trait | | Drift gate (live system.columns vs. declared schema) | ✅ checkClickHouseDrift | ✅ check_drift | | Safety primitives — type allowlist, identifier validation, bounds, reserved columns | ✅ | ✅ canonical | | Runtime / user-defined tables, flexibleTable, flatten + coerce, additive-only evolution | ✅ | ✅ canonical | | Live-DB introspection → Rust #[derive(Row)] structs | ❌ | ✅ introspect_row_struct | | Integration-tested against real ClickHouse (testcontainers) in CI | ❌ (unit tests only) | ✅ three integration suites |

Rule of thumb: authoring a developer table → TS. Holding untrusted customer input, or in a Rust service → Rust.


Feature tour

Every snippet is the actual API, verified against the source.

| | Capability | Package | | --- | ---------------------------------------------------------------------------- | ------- | | 📐 | Schema-as-code authoring | TS | | 🛡️ | Untrusted input → safe DDL | Rust | | 🧩 | The flexible (hybrid) table | both | | ⏩ | Forward-only migrations + drift | both | | 🌉 | TS→Rust bridge | Rust |

📐 Schema-as-code authoring (TS)

Define a table once — DDL, inferred row type, and Zod schemas all come from the same literal. Production clauses (PARTITION BY, TTL with volume moves, skip indexes, SETTINGS) are first-class options:

import { ch, clickhouseTable, createSelectSchema, type InferSelect } from "@smooai/clickhouse-kit";

export const events = clickhouseTable(
  "events",
  {
    ts: ch.dateTime64(3),
    org_id: ch.lowCardinality(ch.string()),
    event_id: ch.uuid(),
    value: ch.float64(),
    attributes: ch.mapStringString(),
    ingested_at: ch.dateTime().default("now()"),
  },
  {
    engine: "MergeTree()",
    partitionBy: "(org_id, toDate(ts))",
    orderBy: ["org_id", "ts", "event_id"],
    ttl: {
      column: "ts",
      moveToVolumeAfter: { interval: "14 DAY", volume: "cold" },
      deleteAfter: "90 DAY",
    },
    indexes: [{ name: "idx_name", expr: "name", type: "bloom_filter(0.01)", granularity: 1 }],
    settings: { storage_policy: "hot_cold", index_granularity: 8192 },
  },
);

export type EventRow = InferSelect<typeof events>; // inferred, not hand-written
export const selectEventSchema = createSelectSchema(events); // Zod validator for reads

🛡️ Untrusted input → safe DDL (Rust)

A column type can come straight from a customer config. The allowlist is an enum — disallowed types (Decimal, FixedString, Tuple, arbitrary expressions) have no representation, so they fail to deserialize at the boundary. There is no path from an arbitrary type string to DDL:

use clickhouse_kit::{to_create_table_sql, ColumnSpec, ColumnTypeSpec, ScalarType, SchemaLimits, TableSpec};

// `{"lowCardinality": "String"}` from untrusted JSON — `Decimal(...)` here would be rejected.
let org_type: ColumnTypeSpec = serde_json::from_str(r#"{"lowCardinality":"String"}"#)?;

let table = TableSpec {
    name: "events".into(),
    columns: vec![
        ColumnSpec { name: "id".into(),  type_spec: ColumnTypeSpec::Scalar(ScalarType::Uuid),       default: None },
        ColumnSpec { name: "org".into(), type_spec: org_type,                                       default: None },
        ColumnSpec { name: "ts".into(),  type_spec: ColumnTypeSpec::Scalar(ScalarType::DateTime64), default: None },
    ],
    engine: "MergeTree()".into(),
    order_by: vec!["id".into()],
    partition_by: None,
    ttl: None,
    indexes: vec![],
    settings: vec![],
};

let ddl = to_create_table_sql(&table, &SchemaLimits::default())?;

Every identifier is validated (^[A-Za-z_][A-Za-z0-9_]*$ + length bound, backtick-quoted on render), column counts are bounded, and ORDER BY entries must be real columns.

🧩 The flexible (hybrid) table (both)

The most-reused multi-tenant shape in one call — mandatory + promoted typed columns, plus an attrs Map(String, String) catch-all and a raw String — with flatten_record / coerce_to_table (TS: flattenRecord / coerceToTable) to shape arbitrary records into it, and diff_columns + alter_add_columns_sql for additive-only evolution:

use clickhouse_kit::{flexible_table, coerce_to_table, FlexibleConfig, FlattenOptions, SchemaLimits};

let table = flexible_table("customer_events", config, &SchemaLimits::default())?;
let shaped = coerce_to_table(input_json, &table, &FlattenOptions::default());
// shaped.row → ready to insert · shaped.overflow_keys → what routed into `attrs`

⏩ Forward-only migrations + drift (both)

No auto-diff engine — schema changes are explicit numbered migrations, tracked in _ch_migrations, applied forward-only. TS generates the files (generateClickHouseMigrations + a journal); both sides run them and both gate drift against live system.columns. The I/O layer is a tiny trait/interface, so neither package pins your ClickHouse driver:

use clickhouse_kit::{run_migrations, check_drift};

let applied = run_migrations(&exec, std::path::Path::new("clickhouse/migrations")).await?;
let drift = check_drift(&exec, &[table]).await?;   // live schema vs. your TableSpecs

🌉 TS→Rust bridge (Rust)

When TypeScript owns a table's schema, the Rust side never hand-copies the row struct — it introspects the live table and generates it, with check_drift in CI asserting the generated view stays ≡ the live schema:

use clickhouse_kit::introspect_row_struct;

let src = introspect_row_struct(&exec, "events", "EventRow").await?;
// → #[derive(Debug, Clone, clickhouse::Row, serde::Serialize, serde::Deserialize)]
//   pub struct EventRow { pub id: String, /* UUID */ pub org: String, /* LowCardinality(String) */ … }

Codegen runs the other direction too: emit_row_interface / emit_select_schema / emit_insert_schema / emit_ts_module turn a Rust TableSpec into a TS interface + Zod schemas.


Quickstart

Both packages are published — these install lines were verified against the live registries:

TypeScript (npm):

pnpm add @smooai/clickhouse-kit zod   # zod ^4 is a peer dependency

Rust (crates.io — the crate is smooai-clickhouse-kit, it imports as clickhouse_kit):

[dependencies]
smooai-clickhouse-kit = "0.3"
use clickhouse_kit::{to_create_table_sql, TableSpec};

Full Rust walkthrough (every runtime primitive, with the safety posture spelled out): crates/clickhouse-kit/README.md. The v0.2 reframe and the source-of-truth model: ROADMAP.md.

Design invariants

  • Safe by construction. Every runtime/user-facing primitive validates input; the happy path makes SQL injection and unbounded tables impossible, not discouraged.
  • Forward-only. No auto-diff engine for code-defined tables; the additive ALTER path for dynamic per-tenant tables is a separate, explicitly-bounded path.
  • Rows are Serde-native / Zod-native. Reads use #[derive(clickhouse::Row)] in Rust and Zod schemas in TS — the kit doesn't reinvent row mapping.
  • Bring your own client. ChExecutor (Rust) / ClickHouseClient (TS) are minimal interfaces; the kit never pins a driver.
  • Tested against real ClickHouse. The Rust migration runner, drift gate, DDL round-trip, and introspection→codegen path run against real ClickHouse via testcontainers in CI.

CI + publishing

  • TypeScriptpr-checks.yml on every PR: typecheck → lint → format check → test → version lockstep check → build. Publishing via changesets (release.yml).
  • Rustrust.yml on PRs + main: cargo fmt --checkclippy --all-targets -D warnings → unit tests → testcontainers integration against real ClickHouse. Crate publishing is a manual publish-crate.yml dispatch after a version bump.

Cross-language parity corpus

Where both languages implement the same behaviour, they are checked against one committed set of expectations rather than two hand-mirrored ones. spec/parity-corpus.json is loaded by src/__tests__/parity.test.ts and crates/clickhouse-kit/tests/parity.rs alike, covering flatten, the column-type allowlist, and identifier validation.

It exists because the alternative failed silently: the two flatteners disagreed about what maxDepth counts — TS reached {"a.b": "{\"c\":1}"} at maxDepth 1, Rust needed 2 — and both suites were green the whole time, each asserting its own answer. Add a case here and it must pass in every language or the build is red. (rust.yml's path filter includes spec/** so a corpus-only change actually runs the Rust half.)

The migration runner and drift gate also exist on both sides, but they are I/O against a live ClickHouse; their agreement is covered by the Rust testcontainers suites rather than by this corpus.

Versioning policy — lockstep

The npm package and the crate share one version number. package.json is the single source of truth; scripts/sync-versions.mjs writes it into crates/clickhouse-kit/Cargo.toml and Cargo.lock.

  • Every change — TS or Rust — gets a changeset naming the npm package (@smooai/clickhouse-kit), never the crate name (smooai-clickhouse-kit). A changeset naming the crate makes changeset version fail outright: changesets only knows the workspace's npm packages. That mistake blocked every release from 2026-06-29 to 2026-08-20.
  • pnpm run version (what release.yml hands to the changesets action) is changeset version && node scripts/sync-versions.mjs, so the synced manifests are committed with the bump. Syncing after changeset publish — the pattern this replaced elsewhere — leaves every git tag carrying stale version constants.
  • pnpm version:check (sync-versions.mjs --check) runs in pr-checks.yml and release.yml and fails, never warns, on any drift.
  • Adding a new version-bearing file? Add a row to the targets table in scripts/sync-versions.mjs and the guard covers it.

npm skipped 0.3.0: the crate had already published 0.3.0 when the two were aligned, so npm resumes at the next bump.

🧩 Part of Smoo AI

clickhouse-kit is built and open-sourced by Smoo AI — the AI-powered business platform with AI built into every product: CRM, customer support, campaigns, field service, observability, and developer tools.

🤝 Contributing

PRs welcome. TS changes need pnpm check-all green and a changeset; Rust changes need cargo fmt, clippy-clean --all-targets, and tests (the integration suite needs Docker for testcontainers). Keep the invariants above — especially safe-by-construction on anything reachable from untrusted input.

📄 License

MIT © SmooAI. See LICENSE.