Skip to content

Schema DSL Reference

Schema files are TypeScript files that export definitions using functions from @chkit/core. All exported definitions are collected when chkit loads schema files matched by the schema glob in your configuration.

import { schema, table, view, materializedView, dictionary } from '@chkit/core'

Groups definitions into a single array for export.

schema(...definitions: SchemaDefinition[]): SchemaDefinition[]
export default schema(users, events)

You can also export definitions individually — any exported value with a valid kind is discovered automatically.

Creates a table definition.

table(input: Omit<TableDefinition, 'kind'>): TableDefinition

Minimal example:

import { schema, table } from '@chkit/core'
const users = table({
database: 'app',
name: 'users',
columns: [
{ name: 'id', type: 'UInt64' },
{ name: 'email', type: 'String' },
],
engine: 'MergeTree',
primaryKey: ['id'],
orderBy: ['id'],
})
export default schema(users)

Comprehensive example (all features):

const events = table({
database: 'analytics',
name: 'events',
columns: [
{ name: 'id', type: 'UInt64' },
{ name: 'org_id', type: 'String' },
{ name: 'source', type: 'LowCardinality(String)' },
{ name: 'payload', type: 'String', nullable: true },
{ name: 'received_at', type: 'DateTime64(3)', default: 'fn:now64(3)' },
{ name: 'status', type: 'String', default: 'pending', comment: 'Event processing status' },
],
engine: 'MergeTree',
primaryKey: ['id'],
orderBy: ['org_id', 'received_at', 'id'],
partitionBy: 'toYYYYMM(received_at)',
ttl: 'received_at + INTERVAL 90 DAY',
settings: { index_granularity: 8192 },
indexes: [
{ name: 'idx_source', expression: 'source', type: 'set', maxRows: 0, granularity: 1 },
],
projections: [
{ name: 'p_recent', query: 'SELECT id ORDER BY received_at DESC LIMIT 10' },
],
comment: 'Raw ingested events',
})
FieldTypeDescription
databasestringClickHouse database name
namestringTable name
columnsColumnDefinition[]Column definitions (see Columns)
enginestringEngine clause, e.g. 'MergeTree', 'ReplacingMergeTree(ver)'
primaryKeystring[]Primary key columns or expressions, e.g. ['toDate(ts)', 'id']
orderBystring[]ORDER BY columns or expressions, e.g. ['toStartOfHour(ts)', 'id']
FieldTypeDescription
partitionBystringPartition expression, e.g. 'toYYYYMM(created_at)'
uniqueKeystring[]Unique key columns
ttlstringTTL expression, e.g. 'created_at + INTERVAL 90 DAY'
settingsRecord<string, string | number | boolean>Table-level settings
indexesSkipIndexDefinition[]Skip indexes (see Skip indexes)
projectionsProjectionDefinition[]Projections (see Projections)
commentstringTable comment
renamedFrom{ database?: string; name: string }Previous identity for rename tracking (see Rename support)
pluginsTablePluginsPer-table plugin configuration (see Plugin configuration)

Each entry in the columns array is a ColumnDefinition.

Column name.

Any ClickHouse type string. Parameterized types like DateTime64(3), Decimal(18, 4), Enum8('a' = 1, 'b' = 2), and FixedString(32) are supported.

Primitive types recognized by the DSL type system: String, UInt8, UInt16, UInt32, UInt64, UInt128, UInt256, Int8, Int16, Int32, Int64, Int128, Int256, Float32, Float64, Bool, Boolean, Date, DateTime, DateTime64.

chkit passes the type string through to ClickHouse verbatim — it does not rewrite it. ClickHouse itself accepts standard SQL type aliases and stores them as its native types, so a table declared with aliases like BIGINT or TEXT is created successfully:

SQL aliasClickHouse native type
TINYINTInt8
SMALLINTInt16
INTEGER / INTInt32
BIGINTInt64
FLOAT / REALFloat32
DOUBLEFloat64
TEXT / VARCHAR / CHARString
TIMESTAMPDateTime

See the ClickHouse data types reference for the complete alias list.

When true, the column type is wrapped in Nullable(...) in the generated SQL.

{ name: 'payload', type: 'String', nullable: true }
// SQL: `payload` Nullable(String)

default (string | number | boolean, optional)

Section titled “default (string | number | boolean, optional)”

Default value for the column.

  • String values are single-quoted in SQL: default: 'pending' produces DEFAULT 'pending'
  • Number/boolean values are rendered literally: default: 0 produces DEFAULT 0
  • fn: prefix — for function-call defaults, prefix the string with fn: to emit a raw SQL expression:
{ name: 'received_at', type: 'DateTime64(3)', default: 'fn:now64(3)' }
// SQL: `received_at` DateTime64(3) DEFAULT now64(3)

Column-level comment rendered in SQL.

Previous column name for rename tracking. See Rename support.

Sets the column compression codec, rendered as a CODEC(...) clause. A codec is an object with a kind, or an array forming a chain (zero or more preprocessors followed by exactly one general codec).

columns: [
{ name: 'ts', type: 'DateTime64(3)', codec: { kind: 'Delta', size: 4 } },
{ name: 'amount', type: 'Float64', codec: { kind: 'ZSTD', level: 3 } },
// chain: preprocessor then general codec
{ name: 'seq', type: 'UInt64', codec: [{ kind: 'DoubleDelta' }, { kind: 'LZ4HC', level: 9 }] },
]

General codecs (the compressor; at most one, and it must come last in a chain):

kindArgsRenders
NONE, LZ4, T64, GCD, ALPCODEC(LZ4)
LZ4HClevel?: numberCODEC(LZ4HC(9))
ZSTDlevel?: numberCODEC(ZSTD(3))

Preprocessing codecs (placed before the general codec):

kindArgsRenders
Delta, DoubleDelta, Gorillasize?: 1 | 2 | 4 | 8 (bytes, defaults to 1)CODEC(Delta(4))
FPClevel: number, floatSize: 4 | 8CODEC(FPC(...))

Raw escape hatch — for codecs not yet typed (new ClickHouse versions, unusual arg shapes), pass the inner expression through verbatim:

{ name: 'blob', type: 'String', codec: { kind: 'raw', expression: 'T64, LZ4' } }
// → CODEC(T64, LZ4)

Codec chains are validated (see Validation rules): a chain must be non-empty, contain at most one general codec, and end with the general codec.

Each entry in the indexes array is a SkipIndexDefinition. The shared base fields are:

FieldTypeDescription
namestringIndex name
expressionstringIndexed expression
type'minmax' | 'set' | 'bloom_filter' | 'tokenbf_v1' | 'ngrambf_v1'Index type
granularitynumberIndex granularity

Type-specific fields:

TypeRequired fieldsOptional fieldsNotes
minmaxNo arguments
setmaxRows: numbermaxRows: 0 stores all unique values (ClickHouse 26+ requires set(0) rather than bare set)
bloom_filterfalsePositiveRate: numberDefaults to 0.025 when omitted
tokenbf_v1sizeBytes, hashFunctions, randomSeed (all number)Maps to tokenbf_v1(size_bytes, n_hash, seed)
ngrambf_v1ngramSize, sizeBytes, hashFunctions, randomSeed (all number)Maps to ngrambf_v1(n, size_bytes, n_hash, seed)
indexes: [
{ name: 'idx_source', expression: 'source', type: 'set', maxRows: 0, granularity: 1 },
{ name: 'idx_ts', expression: 'received_at', type: 'minmax', granularity: 3 },
{
name: 'idx_body',
expression: 'body',
type: 'tokenbf_v1',
sizeBytes: 256,
hashFunctions: 2,
randomSeed: 0,
granularity: 1,
},
]

Each entry in the projections array is a ProjectionDefinition, which takes one of two forms.

A SELECT projection stores a rewritten copy of the data.

FieldTypeDescription
namestringProjection name
querystringProjection SELECT query

An index-only projection stores no SELECT body. It reorders parts by a secondary key so lookups on that key prune instead of scanning.

FieldTypeDescription
namestringProjection name
indexstringExpression list to order by, e.g. receiver, sender
typestringProjection index type. ClickHouse currently accepts basic
projections: [
{ name: 'p_recent', query: 'SELECT id ORDER BY received_at DESC LIMIT 10' },
{ name: 'by_receiver', index: 'receiver, sender', type: 'basic' },
]

The index expression is rendered the way ClickHouse normalizes it: a single expression is emitted bare (INDEX receiver), several are emitted as a tuple (INDEX (receiver, sender)), redundant parentheses are dropped, and a space follows every argument separator. Writing '(receiver)' and 'receiver' therefore produce the same table, and neither reads as drift.

A projection must be exactly one of the two kinds. Setting both query and index on the same entry is a projection_ambiguous_kind validation error, and an empty index is a projection_empty_index error.

Creates a view definition.

view(input: Omit<ViewDefinition, 'kind'>): ViewDefinition
FieldTypeRequiredDescription
databasestringyesDatabase name
namestringyesView name
asstringyesSELECT query
commentstringnoView comment
import { view } from '@chkit/core'
const activeUsers = view({
database: 'app',
name: 'active_users',
as: 'SELECT id, email FROM app.users WHERE active = 1',
})

Creates a materialized view definition.

materializedView(input: Omit<MaterializedViewDefinition, 'kind'>): MaterializedViewDefinition
FieldTypeRequiredDescription
databasestringyesDatabase name
namestringyesMaterialized view name
to{ database: string; name: string }yesTarget table for the view
refreshMaterializedViewRefreshnoRefresh schedule — see Refreshable materialized views
asstringyesSELECT query
commentstringnoView comment
import { materializedView } from '@chkit/core'
const eventCounts = materializedView({
database: 'analytics',
name: 'event_counts_mv',
to: { database: 'analytics', name: 'event_counts' },
as: 'SELECT org_id, count() AS total FROM analytics.events GROUP BY org_id',
})

For a refreshable (scheduled) materialized view, add the refresh field:

const dailyReport = materializedView({
database: 'analytics',
name: 'daily_report_mv',
to: { database: 'analytics', name: 'daily_report' },
refresh: { every: '1 DAY', offset: '2 HOUR' },
as: 'SELECT toDate(ts) AS day, count() AS total FROM analytics.events GROUP BY day',
})

See Refreshable materialized views for the full refresh field reference, including APPEND mode, DEPENDS ON, and the ClickHouse rules that chkit validates.

Creates a ClickHouse dictionary definition — a key-value lookup structure backed by an external or in-database source, queried with dictGet().

dictionary(input: Omit<DictionaryDefinition, 'kind'>): DictionaryDefinition
import { dictionary } from '@chkit/core'
const usersDict = dictionary({
database: 'default',
name: 'users_dict',
attributes: [
{ name: 'id', type: 'UInt64' },
{ name: 'name', type: 'String' },
{ name: 'email', type: 'String', default: '' },
],
primaryKey: ['id'],
source: `MYSQL(host 'db' port 3306 user 'reader' password '${process.env.MYSQL_PASSWORD}' db 'app' table 'users')`,
layout: `HASHED()`,
lifetime: `300`,
comment: 'User lookup dictionary',
})
FieldTypeDescription
databasestringClickHouse database name
namestringDictionary name
attributesDictionaryAttribute[]Attribute definitions (see Dictionary attributes)
primaryKeystring[]Key attribute name(s) — every entry must name a declared attribute
sourcestringRaw SOURCE(...) body, e.g. `MYSQL(host '...' password '...' ...)`
layoutstringRaw LAYOUT(...) body, e.g. `HASHED()` or `COMPLEX_KEY_HASHED()`
lifetimestringRaw LIFETIME(...) body, e.g. `300` or `MIN 300 MAX 360`
FieldTypeDescription
range{ min: string; max: string }RANGE(MIN ... MAX ...) — required by RANGE_HASHED / COMPLEX_KEY_RANGE_HASHED layouts. Both min and max must name declared attributes
settingsRecord<string, string | number>Raw SETTINGS(...) key/value pairs, e.g. { dictionary_use_async_executor: 1 }
commentstringDictionary comment
renamedFrom{ database?: string; name: string }Previous identity for rename tracking

Each entry in the attributes array is a DictionaryAttribute.

FieldTypeDescription
namestringAttribute name
typestringClickHouse type
defaultstring | number | booleanDEFAULT value for missing keys. Mutually exclusive with expression
expressionstringEXPRESSION computed from source columns. Mutually exclusive with default
hierarchicalbooleanMarks the attribute HIERARCHICAL
bidirectionalbooleanMarks the attribute BIDIRECTIONAL — enables parent/child lookups in both directions. Only valid alongside hierarchical
injectivebooleanMarks the attribute INJECTIVE
isObjectIdbooleanMarks the attribute IS_OBJECT_ID (MongoDB sources)

Inline credentials in source (e.g. a MySQL/PostgreSQL password '...') should be interpolated from environment variables at schema-authoring time, the same way you’d handle any other secret in a TypeScript config file:

source: `MYSQL(host 'db' password '${process.env.MYSQL_PASSWORD}' ...)`,

ClickHouse redacts inline passwords back to [HIDDEN] on introspection (SHOW CREATE DICTIONARY, system.dictionaries). A real password change diffs and migrates like any other field change. The one exception is a source that still carries the literal [HIDDEN] placeholder written by chkit pull — chkit never knows the real value in that case, so it excludes source from the diff entirely rather than risk rendering [HIDDEN] into DDL — see Pull: credential handling.

ClickHouse has no ALTER DICTIONARY — every structural change to a dictionary is rendered as a single CREATE OR REPLACE DICTIONARY statement (atomic, dependency-safe). See Structural vs. alterable properties. A pure rename (renamedFrom with no other change) is the one exception — it renders as RENAME DICTIONARY, not a replace; see Dictionary rename.

The codegen plugin maps ClickHouse types to TypeScript types using these rules:

CategoryClickHouse TypesTypeScript Type
String-likeString, FixedString, Date, Date32, DateTime, DateTime64, UUID, IPv4, IPv6, Enum8, Enum16, Decimal*string
NumberInt8, Int16, Int32, UInt8, UInt16, UInt32, Float32, Float64, BFloat16number
Large integersInt64, Int128, Int256, UInt64, UInt128, UInt256string (default) or bigint
BooleanBool, Booleanboolean
WrappersNullable(T)T | null
WrappersLowCardinality(T)same as T
CompositeArray(T)T[]
CompositeMap(K, V)Record<K, V>
CompositeTuple(T1, T2, ...)[T1, T2, ...]
AggregateSimpleAggregateFunction(fn, T)same as T
JSONJSONRecord<string, unknown>

Parameterized types like DateTime('UTC'), Decimal(18, 4), and Enum8('a' = 1) are supported. The bigintMode option in the codegen plugin controls whether large integers map to string or bigint.

chkit tracks renames to avoid destructive drop-and-recreate operations.

Set renamedFrom on a table definition to rename a table:

const users = table({
database: 'app',
name: 'accounts', // new name
renamedFrom: { name: 'users' }, // old name
// ...
})

The database field in renamedFrom is optional and defaults to the table’s current database.

Set renamedFrom on a column definition to rename a column:

columns: [
{ name: 'user_email', type: 'String', renamedFrom: 'email' },
]

Set renamedFrom on a dictionary definition to rename a dictionary. This emits a single RENAME DICTIONARY IF EXISTS ... TO ... statement instead of a drop_dictionary + create_dictionary pair:

const lookupDict = dictionary({
database: 'app',
name: 'lookup_dict', // new name
renamedFrom: { name: 'users_dict' }, // old name
// ...
})

The database field in renamedFrom is optional and defaults to the dictionary’s current database.

Table, column, and dictionary renames can all be overridden by CLI flags: --rename-table, --rename-column, and --rename-dictionary.

The plugins field on a table definition provides per-table configuration for plugins. Each plugin that supports table-level config augments the TablePlugins interface via TypeScript declaration merging, so the available keys and their types depend on which plugin packages are imported.

import { table } from '@chkit/core'
const events = table({
database: 'app',
name: 'events',
columns: [
{ name: 'event_time', type: 'DateTime' },
{ name: 'id', type: 'UInt64' },
],
engine: 'MergeTree',
orderBy: ['event_time', 'id'],
primaryKey: ['event_time', 'id'],
plugins: {
backfill: { timeColumn: 'event_time' },
},
})

Currently supported plugin keys:

KeyPluginFieldsDescription
backfill@chkit/plugin-backfilltimeColumn?: stringTime column for backfill WHERE clauses

The plugins field is ignored by the diff engine — it does not affect migration planning or SQL generation.

chkit validates schema definitions and throws a ChxValidationError if any issues are found:

  • Duplicate object names — two definitions with the same kind, database, and name
  • Duplicate column names — repeated column name within a table
  • Duplicate index names — repeated index name within a table
  • Duplicate projection names — repeated projection name within a table
  • Ambiguous projection kind (projection_ambiguous_kind) — a projection sets both query and index; use one or the other (see Projections)
  • Empty projection index (projection_empty_index) — an index-only projection whose index expression is empty
  • Primary key references missing columnprimaryKey includes a bare column name not in columns (function expressions like toDate(ts) are passed through to ClickHouse unchecked)
  • Order by references missing columnorderBy includes a bare column name not in columns (function expressions like toStartOfHour(ts) are passed through to ClickHouse unchecked)
  • Empty codec chain (codec_chain_empty) — a codec array with no steps; provide at least one codec or omit the field
  • Multiple general codecs (codec_chain_multiple_general) — more than one general codec in a chain; only one is allowed
  • Codec chain must end with a general codec (codec_chain_must_end_with_general) — preprocessors must precede the single general codec (NONE, LZ4, LZ4HC, ZSTD, T64, GCD, ALP)
  • Dictionary missing primary key (dictionary_missing_primary_key) — a dictionary’s primaryKey is empty
  • Dictionary primary key references missing attribute (dictionary_primary_key_missing_attribute) — a primaryKey entry doesn’t name a declared attribute
  • Dictionary missing source/layout/lifetime (dictionary_missing_source, dictionary_missing_layout, dictionary_missing_lifetime) — one of these raw-string fields is empty
  • Dictionary attribute default/expression exclusive (dictionary_attribute_default_expression_exclusive) — an attribute sets both default and expression
  • Dictionary range references missing attribute (dictionary_range_missing_attribute) — range.min/range.max doesn’t name a declared attribute
  • Dictionary bidirectional requires hierarchical (dictionary_bidirectional_requires_hierarchical) — an attribute sets bidirectional without hierarchical

When a property changes, chkit determines whether the table can be altered in place or must be dropped and recreated.

Structural (drop + recreate): engine, primaryKey, orderBy, partitionBy, uniqueKey

Alterable (ALTER in place): columns, indexes, projections, settings, TTL, comment

Views and materialized views always use drop + recreate.

Dictionaries have no ALTER at all: any change to attributes, primaryKey, layout, lifetime, source (including a password change), or comment renders as a single CREATE OR REPLACE DICTIONARY (risk=caution) — except a source still carrying the [HIDDEN] introspection placeholder, which is excluded from the diff entirely (see Credentials in source). Removing a dictionary from schema emits DROP DICTIONARY (risk=danger, requires --allow-destructive).