@quark-fw/clisma 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Drus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,293 @@
1
+ # clisma
2
+
3
+ **Prisma-подобный клиент и мигратор для ClickHouse.**
4
+
5
+ Зачем: в проектах, где Postgres (данные кампаний, Prisma) живёт рядом с ClickHouse
6
+ (клики/события), запросы приходится писать двумя разными способами и вручную
7
+ синхронизировать схемы. clisma даёт ClickHouse тот же синтаксис запросов, что у
8
+ Prisma — `prisma.user.findMany(...)` переносится как `db.user.findMany(...)` почти
9
+ без изменений — плюс миграции схемы.
10
+
11
+ ```ts
12
+ const users = await db.users.findMany({
13
+ where: {
14
+ email: { endsWith: "@gmail.com" },
15
+ age: { gte: 18 },
16
+ OR: [{ name: null }, { name: { startsWith: "A" } }]
17
+ },
18
+ select: { id: true, email: true },
19
+ orderBy: { createdAt: "desc" },
20
+ take: 100
21
+ })
22
+ ```
23
+
24
+ ---
25
+
26
+ ## Установка
27
+
28
+ ```bash
29
+ npm i @quark-fw/clisma
30
+ ```
31
+
32
+ Требования: Node.js >= 20. Пакет ESM-only (`import`), CommonJS-сборки нет.
33
+
34
+ ## Быстрый старт
35
+
36
+ ```ts
37
+ // db.clisma.ts — схема
38
+ import { createClisma, table, t } from "@quark-fw/clisma"
39
+
40
+ export const schema = createClisma({
41
+ database: "analytics",
42
+ tables: {
43
+ users: table({
44
+ engine: "MergeTree",
45
+ orderBy: ["id"],
46
+ columns: {
47
+ id: t.uint64(),
48
+ email: t.string(),
49
+ name: t.nullable(t.string()),
50
+ age: t.nullable(t.uint8()),
51
+ createdAt: t.datetime()
52
+ }
53
+ })
54
+ }
55
+ })
56
+ ```
57
+
58
+ ```ts
59
+ // приложение
60
+ import { createClient } from "@quark-fw/clisma"
61
+ import { schema } from "./db.clisma.js"
62
+
63
+ const db = createClient(schema, {
64
+ url: "http://localhost:8123",
65
+ username: "default",
66
+ password: "",
67
+ autoCreateDatabase: true
68
+ })
69
+
70
+ await db.$migrate() // привести БД к схеме
71
+
72
+ const user = await db.users.create({
73
+ data: { email: "a@b.c", createdAt: new Date() } // id сгенерируется
74
+ })
75
+ ```
76
+
77
+ Все типы выводятся из схемы: `db.users.findMany()` возвращает
78
+ `{ id: bigint; email: string; name: string | null; age: number | null; createdAt: Date }[]`,
79
+ `select` сужает тип результата, `where` принимает только существующие колонки.
80
+
81
+ ---
82
+
83
+ ## Перенос кода с Prisma
84
+
85
+ | Prisma (Postgres) | clisma (ClickHouse) | Отличия |
86
+ |--------------------------------------|--------------------------------------|---------|
87
+ | `prisma.user.findMany(args)` | `db.user.findMany(args)` | тот же синтаксис `where/select/orderBy/take/skip` |
88
+ | `prisma.user.findFirst(args)` | `db.user.findFirst(args)` | — |
89
+ | `prisma.user.findUnique({where})` | `db.user.findFirst({where})` | в ClickHouse нет уникальных ключей — «уникальность» не гарантируется БД |
90
+ | `prisma.user.create({data})` | `db.user.create({data})` | возвращаемая строка собирается на клиенте, см. ниже |
91
+ | `prisma.user.createMany({data})` | `db.user.createMany({data})` | — |
92
+ | `prisma.user.update({where, data})` | `db.user.updateMany({where, data})` | без unique-ключей одиночного `update` нет |
93
+ | `prisma.user.updateMany(...)` | `db.user.updateMany(...)` | механика зависит от стратегии таблицы, см. «Мутации» |
94
+ | `prisma.user.delete({where})` | `db.user.deleteMany({where})` | аналогично |
95
+ | `prisma.user.deleteMany(...)` | `db.user.deleteMany(...)` | — |
96
+ | `prisma.user.count({where})` | `db.user.count({where})` | — |
97
+ | `aggregate` / `groupBy` / `distinct` | пока нет | в планах; `distinct` будет через `LIMIT 1 BY` |
98
+ | `$transaction` | нет | ClickHouse не транзакционен |
99
+
100
+ ### Операторы `where`
101
+
102
+ Поддержан Prisma-синтаксис:
103
+
104
+ - shorthand: `{ email: "a@b.c" }`, `{ name: null }` → `IS NULL`
105
+ - сравнения: `equals`, `not` (значение или `null`), `in`, `notIn`, `lt`, `lte`, `gt`, `gte`
106
+ - строки: `contains`, `startsWith`, `endsWith` (без LIKE — через `position()/startsWith()/endsWith()`, спецсимволы экранировать не нужно)
107
+ - логика: `AND` (объект или массив), `OR` (массив), `NOT` — вкладываются рекурсивно
108
+ - `in: []` не матчит ничего, `notIn: []` матчит всё (как в Prisma)
109
+
110
+ Не поддержано (пока): вложенный фильтр в `not`, `mode: "insensitive"`,
111
+ `has/hasSome/hasEvery` для Array-колонок (Array-колонки в `where` запрещены на уровне типов).
112
+
113
+ ---
114
+
115
+ ## Схема
116
+
117
+ ### Типы колонок
118
+
119
+ | Фабрика | ClickHouse | TypeScript |
120
+ |--------------------|-----------------------|------------|
121
+ | `t.string()` | `String` | `string` |
122
+ | `t.uint8()` | `UInt8` | `number` |
123
+ | `t.int32()` | `Int32` | `number` |
124
+ | `t.uint64()` | `UInt64` | `bigint` |
125
+ | `t.boolean()` | `Bool` | `boolean` |
126
+ | `t.datetime()` | `DateTime` | `Date` |
127
+ | `t.nullable(col)` | `Nullable(...)` | `T \| null`|
128
+ | `t.array(col)` | `Array(...)` | `T[]` |
129
+
130
+ Пока нет: `DateTime64`, `Decimal`, `Enum`, `LowCardinality`, `DEFAULT`,
131
+ `PARTITION BY`, `TTL`.
132
+
133
+ ### Колонка `id`
134
+
135
+ Если в таблице есть колонка `id` и при `create`/`insert` значение не передано,
136
+ clisma генерирует уникальный `bigint` (timestamp в старших битах + счётчик —
137
+ уникален в пределах процесса, монотонно растёт).
138
+
139
+ ---
140
+
141
+ ## Клиент
142
+
143
+ ```ts
144
+ const db = createClient(schema, {
145
+ url, // http://host:8123
146
+ username, password,
147
+ database, // приоритетнее schema.database
148
+ autoCreateDatabase: true // $init/$migrate создадут БД
149
+ })
150
+ ```
151
+
152
+ - `db.<table>` — клиент таблицы (см. API ниже)
153
+ - `db.$init()` — создать БД и таблицы (`CREATE ... IF NOT EXISTS`), без диффа
154
+ - `db.$migrate()` — привести БД к схеме (см. «Миграции»)
155
+
156
+ ## Миграции: `db.$migrate()`
157
+
158
+ Сравнивает живую схему (`system.columns`) с кодом и применяет разницу:
159
+
160
+ - таблицы нет → `CREATE TABLE`
161
+ - колонки нет → `ALTER TABLE ... ADD COLUMN`
162
+ - тип отличается → `ALTER TABLE ... MODIFY COLUMN`
163
+
164
+ Возвращает `{ statements, warnings }` — применённые SQL и предупреждения.
165
+ Повторный запуск на совпадающей схеме — пустой план (идемпотентно).
166
+
167
+ **Ограничения (осознанные):**
168
+
169
+ - лишние колонки в БД **никогда не удаляются** — только warning;
170
+ - `ENGINE`, `ORDER BY`, `PARTITION BY` не диффятся — их изменение требует
171
+ пересоздания таблицы вручную;
172
+ - `MODIFY COLUMN` ключевой колонки (из `ORDER BY`) отклонит сам ClickHouse;
173
+ - нет файлов/истории миграций как у `prisma migrate` — сравнение всегда
174
+ с живой схемой.
175
+
176
+ ---
177
+
178
+ ## API таблицы
179
+
180
+ ### Чтение
181
+
182
+ - `findMany({ where?, select?, orderBy?, take?, skip? })` → `Row[]`
183
+ - `findFirst({ where?, select?, orderBy?, skip? })` → `Row | null`
184
+ - `count({ where? })` → `number`
185
+
186
+ ### Запись
187
+
188
+ - `create({ data })` → созданная строка. **Строка собирается на клиенте**
189
+ (data + сгенерированный id; пропущенные nullable-поля становятся `null`,
190
+ `Date` обрезается до секунд) — без чтения с сервера. Если в таблице появятся
191
+ серверные `DEFAULT`/`MATERIALIZED` колонки, в возвращённой строке их не будет.
192
+ - `createMany({ data })` → `{ count }`; пустой массив не ходит в БД.
193
+ - `insert(row | rows)` — низкоуровневая вставка без возврата строк.
194
+
195
+ ### Мутации
196
+
197
+ - `updateMany({ where?, data })` → `{ count }`
198
+ - `deleteMany({ where? })` → `{ count }`
199
+
200
+ Без `where` операция затрагивает **все строки** таблицы.
201
+ Обновлять колонки из `ORDER BY` нельзя (ошибка до запроса к БД).
202
+
203
+ **`count` в ответе** считается отдельным запросом до мутации — при
204
+ конкурентной записи между `count` и мутацией число может отличаться от
205
+ фактически затронутых строк (ClickHouse не возвращает affected rows).
206
+
207
+ ---
208
+
209
+ ## Мутации: стратегия на уровне таблицы
210
+
211
+ ClickHouse — не OLTP: `UPDATE`/`DELETE` в нём принципиально дороже, чем в
212
+ Postgres. clisma поддерживает три механизма; выбор — поле `mutations` таблицы:
213
+
214
+ ```ts
215
+ table({
216
+ engine: "ReplacingMergeTree",
217
+ orderBy: ["id"],
218
+ mutations: "replacing", // "alter" (по умолчанию) | "lightweight" | "replacing"
219
+ columns: { ... }
220
+ })
221
+ ```
222
+
223
+ | Стратегия | updateMany | deleteMany | Чтения |
224
+ |----------------|-----------------------------------|-----------------------------|--------------|
225
+ | `"alter"` (default) | `ALTER TABLE ... UPDATE` (тяжёлая мутация, перезаписывает парты; ждём завершения — `mutations_sync=1`) | `ALTER TABLE ... DELETE` (аналогично) | обычные |
226
+ | `"lightweight"`| **фолбэк на `ALTER UPDATE`** — стабильного lightweight update в ClickHouse нет | `DELETE FROM` (быстрые «надгробия», `lightweight_deletes_sync=2`) | обычные |
227
+ | `"replacing"` | SELECT подходящих строк (FINAL) → вставка новых версий строк | `DELETE FROM` (lightweight) | автоматически с `FINAL` |
228
+
229
+ Как выбирать:
230
+
231
+ - **append-only таблицы** (клики, события) — не трогайте мутации вообще;
232
+ стратегия не важна, оставьте `"alter"` по умолчанию.
233
+ - **редкие массовые правки** (GDPR-удаление, backfill) — `"alter"`:
234
+ честная мутация, дорого, зато таблица остаётся обычным MergeTree.
235
+ - **частые удаления, редкие обновления** — `"lightweight"`: удаление дешёвое,
236
+ но помните, что update молча превращается в тяжёлый ALTER.
237
+ - **мутабельные сущности** (сессии, статусы, профили) — `"replacing"` +
238
+ `ReplacingMergeTree`: «обновление» — это дешёвая вставка новой версии.
239
+
240
+ Особенности `"replacing"`:
241
+
242
+ - требуется engine семейства `ReplacingMergeTree` (включая
243
+ `ReplicatedReplacingMergeTree`) — проверяется при объявлении таблицы;
244
+ - все чтения (`findMany`/`findFirst`/`count`) идут с `FINAL` — корректно,
245
+ но дороже обычного SELECT на больших таблицах;
246
+ - без колонки-версии `ReplacingMergeTree()` оставляет последнюю *вставленную*
247
+ строку; для детерминизма добавьте колонку версии
248
+ (`ReplacingMergeTree(ver)`) и включайте её в `data` при обновлении;
249
+ - `updateMany` пере-вставляет строки с теми же значениями `ORDER BY` —
250
+ поэтому менять ключевые колонки запрещено.
251
+
252
+ ---
253
+
254
+ ## Особенности работы с ClickHouse (важно)
255
+
256
+ Эти решения зашиты в clisma, потому что дефолты ClickHouse/клиента ломают
257
+ корректность:
258
+
259
+ 1. **Вставки ждут флаша** (`wait_for_async_insert=1` на каждом insert).
260
+ Если на сервере включён `async_insert=1, wait_for_async_insert=0`,
261
+ вставки подтверждаются *до* парсинга: ошибки молча теряются вместе с
262
+ данными, а свежие строки не видны следующему SELECT.
263
+ 2. **Мутации синхронные** (`mutations_sync=1`, `lightweight_deletes_sync=2`) —
264
+ после `updateMany`/`deleteMany` чтение сразу видит результат.
265
+ 3. **`Date` ⇄ `DateTime`**: в параметрах и вставках даты передаются
266
+ unix-секундами (тип `DateTime` секундной точности не принимает ISO-строки
267
+ с миллисекундами); в ответах включён `date_time_output_format=iso`, и
268
+ строки парсятся в `Date` без таймзонной неоднозначности.
269
+ 4. **`bigint` без потери точности**: включён
270
+ `output_format_json_quote_64bit_integers=1` — `UInt64/Int64` приходят
271
+ строками и превращаются в `bigint` (иначе `JSON.parse` терял бы точность).
272
+ 5. Все идентификаторы (БД, таблицы, колонки) экранируются бэктиками; все
273
+ значения передаются серверными query-параметрами `{p:Тип}` с типом из
274
+ схемы — SQL-инъекции через значения невозможны.
275
+
276
+ ---
277
+
278
+ ## Тесты
279
+
280
+ ```bash
281
+ npm run test:unit # юнит-тесты (node:test): SQL-генерация, стратегии, миграции
282
+ npm test # smoke на живом ClickHouse (src/test/.env), убирает за собой
283
+ npx tsc --noEmit # проверка типов — обязательный гейт (tsx типы не проверяет)
284
+ ```
285
+
286
+ ## Ограничения / roadmap
287
+
288
+ - нет `aggregate`, `groupBy`, `distinct` (план: `LIMIT 1 BY`), `findUnique`, `upsert`;
289
+ - нет вложенного `not`, `mode: "insensitive"`, операторов для Array-колонок;
290
+ - нет файлов миграций/истории; дифф только add/modify колонок;
291
+ - нет `DateTime64`/`Decimal`/`Enum`/`LowCardinality`, `DEFAULT`, `PARTITION BY`, `TTL`;
292
+ - `create` без серверного round-trip (см. выше);
293
+ - нет relations/include — ClickHouse-философия: денормализуйте.
@@ -0,0 +1,18 @@
1
+ import { TableClient } from "./table-client.js";
2
+ import { InferRow } from "./table.js";
3
+ import { MigrationPlan } from "./migrate.js";
4
+ type ClientOptions = {
5
+ url: string;
6
+ username?: string;
7
+ password?: string;
8
+ database?: string;
9
+ autoCreateDatabase?: boolean;
10
+ };
11
+ export declare function createClient<TSchema extends {
12
+ database?: string;
13
+ tables: Record<string, any>;
14
+ }>(schema: TSchema, options: ClientOptions): { [K in keyof TSchema["tables"]]: TableClient<InferRow<TSchema["tables"][K]>>; } & {
15
+ $init(): Promise<void>;
16
+ $migrate(): Promise<MigrationPlan>;
17
+ };
18
+ export {};
package/dist/client.js ADDED
@@ -0,0 +1,63 @@
1
+ import { createClient as createCHClient } from "@clickhouse/client";
2
+ import { TableClient } from "./table-client.js";
3
+ import { buildCreateTableSQL } from "./ddl.js";
4
+ import { escapeId } from "./sql.js";
5
+ import { buildMigrationPlan } from "./migrate.js";
6
+ export function createClient(schema, options) {
7
+ const database = options.database ?? schema.database ?? "default";
8
+ const ch = createCHClient({
9
+ url: options.url,
10
+ username: options.username,
11
+ password: options.password,
12
+ database
13
+ });
14
+ const db = {};
15
+ for (const tableName in schema.tables) {
16
+ db[tableName] = new TableClient(database, tableName, schema.tables[tableName], ch);
17
+ }
18
+ const ensureDatabase = async () => {
19
+ if (options.autoCreateDatabase) {
20
+ const rootClient = createCHClient({
21
+ url: options.url,
22
+ username: options.username,
23
+ password: options.password
24
+ });
25
+ try {
26
+ await rootClient.command({
27
+ query: `CREATE DATABASE IF NOT EXISTS ${escapeId(database)}`
28
+ });
29
+ }
30
+ finally {
31
+ await rootClient.close();
32
+ }
33
+ }
34
+ };
35
+ db.$init = async () => {
36
+ await ensureDatabase();
37
+ for (const tableName in schema.tables) {
38
+ const table = schema.tables[tableName];
39
+ const createSQL = buildCreateTableSQL(database, tableName, table);
40
+ await ch.command({ query: createSQL });
41
+ }
42
+ };
43
+ db.$migrate = async () => {
44
+ await ensureDatabase();
45
+ const result = await ch.query({
46
+ query: `
47
+ SELECT table, name, type
48
+ FROM system.columns
49
+ WHERE database = {db:String}
50
+ ORDER BY table, position
51
+ `,
52
+ query_params: { db: database },
53
+ format: "JSONEachRow"
54
+ });
55
+ const existing = await result.json();
56
+ const plan = buildMigrationPlan(database, schema.tables, existing);
57
+ for (const statement of plan.statements) {
58
+ await ch.command({ query: statement });
59
+ }
60
+ return plan;
61
+ };
62
+ return db;
63
+ }
@@ -0,0 +1,17 @@
1
+ export type Column<T> = {
2
+ clickhouseType: string;
3
+ nullable: boolean;
4
+ __type?: T;
5
+ };
6
+ type NullableColumn<C extends Column<any>> = C extends Column<infer T> ? Column<T | null> : never;
7
+ export declare const t: {
8
+ string: () => Column<string>;
9
+ uint8: () => Column<number>;
10
+ uint64: () => Column<bigint>;
11
+ int32: () => Column<number>;
12
+ boolean: () => Column<boolean>;
13
+ datetime: () => Column<Date>;
14
+ nullable<C extends Column<any>>(col: C): NullableColumn<C>;
15
+ array<C extends Column<any>>(col: C): Column<C extends Column<infer T> ? T[] : never>;
16
+ };
17
+ export {};
package/dist/column.js ADDED
@@ -0,0 +1,31 @@
1
+ // src/column.ts
2
+ function makeColumn(clickhouseType, nullable = false) {
3
+ return {
4
+ clickhouseType,
5
+ nullable,
6
+ };
7
+ }
8
+ function uint64Id() {
9
+ return {
10
+ clickhouseType: "UInt64",
11
+ nullable: false,
12
+ autoId: true // phantom field для TS
13
+ };
14
+ }
15
+ export const t = {
16
+ string: () => makeColumn("String"),
17
+ uint8: () => makeColumn("UInt8"),
18
+ uint64: () => makeColumn("UInt64"),
19
+ int32: () => makeColumn("Int32"),
20
+ boolean: () => makeColumn("Bool"),
21
+ datetime: () => makeColumn("DateTime"),
22
+ nullable(col) {
23
+ return {
24
+ clickhouseType: `Nullable(${col.clickhouseType})`,
25
+ nullable: true,
26
+ };
27
+ },
28
+ array(col) {
29
+ return makeColumn(`Array(${col.clickhouseType})`);
30
+ }
31
+ };
package/dist/ddl.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import { TableDefinition } from "./table.js";
2
+ export declare function buildCreateTableSQL(database: string, tableName: string, table: TableDefinition<any>): string;
package/dist/ddl.js ADDED
@@ -0,0 +1,17 @@
1
+ // src/ddl.ts
2
+ import { escapeId } from "./sql.js";
3
+ export function buildCreateTableSQL(database, tableName, table) {
4
+ const columnsSQL = Object.entries(table.columns)
5
+ .map(([name, col]) => {
6
+ return `${escapeId(name)} ${col.clickhouseType}`;
7
+ })
8
+ .join(",\n");
9
+ return `
10
+ CREATE TABLE IF NOT EXISTS ${escapeId(database)}.${escapeId(tableName)}
11
+ (
12
+ ${columnsSQL}
13
+ )
14
+ ENGINE = ${table.engine}
15
+ ORDER BY (${table.orderBy.map(escapeId).join(", ")})
16
+ `.trim();
17
+ }
@@ -0,0 +1,9 @@
1
+ export * from "./column.js";
2
+ export * from "./table.js";
3
+ export * from "./schema.js";
4
+ export * from "./client.js";
5
+ export * from "./where.js";
6
+ export * from "./sql.js";
7
+ export * from "./table-client.js";
8
+ export * from "./rows.js";
9
+ export * from "./migrate.js";
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export * from "./column.js";
2
+ export * from "./table.js";
3
+ export * from "./schema.js";
4
+ export * from "./client.js";
5
+ export * from "./where.js";
6
+ export * from "./sql.js";
7
+ export * from "./table-client.js";
8
+ export * from "./rows.js";
9
+ export * from "./migrate.js";
@@ -0,0 +1,11 @@
1
+ import { TableDefinition } from "./table.js";
2
+ export type DbColumn = {
3
+ table: string;
4
+ name: string;
5
+ type: string;
6
+ };
7
+ export type MigrationPlan = {
8
+ statements: string[];
9
+ warnings: string[];
10
+ };
11
+ export declare function buildMigrationPlan(database: string, tables: Record<string, TableDefinition<any>>, existing: DbColumn[]): MigrationPlan;
@@ -0,0 +1,42 @@
1
+ // src/migrate.ts
2
+ import { buildCreateTableSQL } from "./ddl.js";
3
+ import { escapeId } from "./sql.js";
4
+ export function buildMigrationPlan(database, tables, existing) {
5
+ const statements = [];
6
+ const warnings = [];
7
+ const dbTables = new Map();
8
+ for (const col of existing) {
9
+ let cols = dbTables.get(col.table);
10
+ if (!cols) {
11
+ cols = new Map();
12
+ dbTables.set(col.table, cols);
13
+ }
14
+ cols.set(col.name, col.type);
15
+ }
16
+ for (const tableName in tables) {
17
+ const def = tables[tableName];
18
+ const dbCols = dbTables.get(tableName);
19
+ if (!dbCols) {
20
+ statements.push(buildCreateTableSQL(database, tableName, def));
21
+ continue;
22
+ }
23
+ const tableRef = `${escapeId(database)}.${escapeId(tableName)}`;
24
+ for (const colName in def.columns) {
25
+ const wanted = def.columns[colName].clickhouseType;
26
+ const actual = dbCols.get(colName);
27
+ if (actual === undefined) {
28
+ statements.push(`ALTER TABLE ${tableRef} ADD COLUMN ${escapeId(colName)} ${wanted}`);
29
+ }
30
+ else if (actual !== wanted) {
31
+ statements.push(`ALTER TABLE ${tableRef} MODIFY COLUMN ${escapeId(colName)} ${wanted}`);
32
+ }
33
+ }
34
+ for (const dbColName of dbCols.keys()) {
35
+ if (!(dbColName in def.columns)) {
36
+ warnings.push(`Column "${dbColName}" exists in ${database}.${tableName} ` +
37
+ `but not in the schema — not dropped, remove it manually if unneeded`);
38
+ }
39
+ }
40
+ }
41
+ return { statements, warnings };
42
+ }
package/dist/rows.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import { Column } from "./column.js";
2
+ export declare function deserializeRows<T>(rows: any[], columns: Record<string, Column<any>>): T[];
package/dist/rows.js ADDED
@@ -0,0 +1,31 @@
1
+ // src/rows.ts
2
+ const BIGINT_TYPES = new Set([
3
+ "UInt64", "Int64", "UInt128", "Int128", "UInt256", "Int256"
4
+ ]);
5
+ function convertValue(value, chType) {
6
+ if (value === null || value === undefined)
7
+ return value;
8
+ const nullable = /^Nullable\((.+)\)$/.exec(chType);
9
+ if (nullable)
10
+ return convertValue(value, nullable[1]);
11
+ const array = /^Array\((.+)\)$/.exec(chType);
12
+ if (array) {
13
+ return value.map(v => convertValue(v, array[1]));
14
+ }
15
+ if (BIGINT_TYPES.has(chType))
16
+ return BigInt(value);
17
+ if (chType.startsWith("DateTime") || chType.startsWith("Date")) {
18
+ return new Date(value);
19
+ }
20
+ return value;
21
+ }
22
+ export function deserializeRows(rows, columns) {
23
+ return rows.map(row => {
24
+ const out = {};
25
+ for (const k in row) {
26
+ const col = columns[k];
27
+ out[k] = col ? convertValue(row[k], col.clickhouseType) : row[k];
28
+ }
29
+ return out;
30
+ });
31
+ }
@@ -0,0 +1,5 @@
1
+ export type ClismaSchema<TTables extends Record<string, any>> = {
2
+ database?: string;
3
+ tables: TTables;
4
+ };
5
+ export declare function createClisma<TTables extends Record<string, any>>(schema: ClismaSchema<TTables>): ClismaSchema<TTables>;
package/dist/schema.js ADDED
@@ -0,0 +1,4 @@
1
+ // src/schema.ts
2
+ export function createClisma(schema) {
3
+ return schema;
4
+ }
package/dist/sql.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { TableDefinition } from "./table.js";
2
+ import { WhereInput } from "./where.js";
3
+ export type SQLBuildResult = {
4
+ sql: string;
5
+ params: Record<string, any>;
6
+ };
7
+ export type OrderByInput<T> = {
8
+ [K in keyof T]?: "asc" | "desc";
9
+ };
10
+ export type FindManyArgs<T> = {
11
+ where?: WhereInput<T>;
12
+ select?: {
13
+ [K in keyof T]?: boolean;
14
+ };
15
+ orderBy?: OrderByInput<T> | OrderByInput<T>[];
16
+ take?: number;
17
+ skip?: number;
18
+ };
19
+ export declare function escapeId(name: string): string;
20
+ export declare function buildFindManyQuery(database: string, tableName: string, table: TableDefinition<any>, args?: FindManyArgs<any>): SQLBuildResult;
21
+ export declare function buildCountQuery(database: string, tableName: string, table: TableDefinition<any>, where?: WhereInput<any>): SQLBuildResult;
22
+ export declare function validateUpdateData(table: TableDefinition<any>, tableName: string, data: Record<string, any>): string[];
23
+ export declare function buildUpdateQuery(database: string, tableName: string, table: TableDefinition<any>, where: WhereInput<any> | undefined, data: Record<string, any>): SQLBuildResult;
24
+ export declare function buildDeleteQuery(database: string, tableName: string, table: TableDefinition<any>, where?: WhereInput<any>): SQLBuildResult;
25
+ export declare function buildLightweightDeleteQuery(database: string, tableName: string, table: TableDefinition<any>, where?: WhereInput<any>): SQLBuildResult;
package/dist/sql.js ADDED
@@ -0,0 +1,255 @@
1
+ // src/sql.ts
2
+ export function escapeId(name) {
3
+ return "`" + name.replace(/\\/g, "\\\\").replace(/`/g, "\\`") + "`";
4
+ }
5
+ // UInt64 max — used to emulate a bare OFFSET (skip without take)
6
+ const NO_LIMIT = "18446744073709551615";
7
+ // DateTime is second-precision; the client would send Date with
8
+ // a fractional part, which ClickHouse rejects — send unix seconds.
9
+ function serialize(value) {
10
+ if (value instanceof Date) {
11
+ return Math.floor(value.getTime() / 1000);
12
+ }
13
+ if (Array.isArray(value)) {
14
+ return value.map(serialize);
15
+ }
16
+ return value;
17
+ }
18
+ function createBuildContext(tableName, columns) {
19
+ const params = {};
20
+ let paramIndex = 0;
21
+ function colRef(key) {
22
+ if (!columns[key]) {
23
+ throw new Error(`Unknown column "${key}" in table "${tableName}"`);
24
+ }
25
+ return escapeId(key);
26
+ }
27
+ function baseType(key) {
28
+ const chType = columns[key].clickhouseType;
29
+ const m = /^Nullable\((.+)\)$/.exec(chType);
30
+ return m ? m[1] : chType;
31
+ }
32
+ function addParam(value, type) {
33
+ const name = `p${paramIndex++}`;
34
+ params[name] = serialize(value);
35
+ return `{${name}:${type}}`;
36
+ }
37
+ function buildFieldFilter(key, filter) {
38
+ const col = colRef(key);
39
+ const conditions = [];
40
+ for (const op in filter) {
41
+ const value = filter[op];
42
+ if (value === undefined)
43
+ continue;
44
+ switch (op) {
45
+ case "equals":
46
+ conditions.push(value === null
47
+ ? `${col} IS NULL`
48
+ : `${col} = ${addParam(value, baseType(key))}`);
49
+ break;
50
+ case "not":
51
+ conditions.push(value === null
52
+ ? `${col} IS NOT NULL`
53
+ : `${col} != ${addParam(value, baseType(key))}`);
54
+ break;
55
+ case "in":
56
+ conditions.push(value.length === 0
57
+ ? "0"
58
+ : `${col} IN ${addParam(value, `Array(${baseType(key)})`)}`);
59
+ break;
60
+ case "notIn":
61
+ if (value.length > 0) {
62
+ conditions.push(`${col} NOT IN ${addParam(value, `Array(${baseType(key)})`)}`);
63
+ }
64
+ break;
65
+ case "lt":
66
+ case "lte":
67
+ case "gt":
68
+ case "gte": {
69
+ const cmp = { lt: "<", lte: "<=", gt: ">", gte: ">=" }[op];
70
+ conditions.push(`${col} ${cmp} ${addParam(value, baseType(key))}`);
71
+ break;
72
+ }
73
+ case "contains":
74
+ conditions.push(`position(${col}, ${addParam(value, "String")}) > 0`);
75
+ break;
76
+ case "startsWith":
77
+ conditions.push(`startsWith(${col}, ${addParam(value, "String")})`);
78
+ break;
79
+ case "endsWith":
80
+ conditions.push(`endsWith(${col}, ${addParam(value, "String")})`);
81
+ break;
82
+ default:
83
+ throw new Error(`Unknown operator "${op}" for column "${key}"`);
84
+ }
85
+ }
86
+ return conditions;
87
+ }
88
+ function isFilterObject(value) {
89
+ return (value !== null &&
90
+ typeof value === "object" &&
91
+ !(value instanceof Date) &&
92
+ !Array.isArray(value));
93
+ }
94
+ function whereSQL(where) {
95
+ const parts = [];
96
+ for (const key in where) {
97
+ const value = where[key];
98
+ if (value === undefined)
99
+ continue;
100
+ if (key === "AND") {
101
+ const list = Array.isArray(value) ? value : [value];
102
+ for (const w of list) {
103
+ const c = whereSQL(w);
104
+ if (c)
105
+ parts.push(`(${c})`);
106
+ }
107
+ }
108
+ else if (key === "OR") {
109
+ const conds = value
110
+ .map((w) => whereSQL(w))
111
+ .filter(Boolean)
112
+ .map((c) => `(${c})`);
113
+ if (conds.length)
114
+ parts.push(`(${conds.join(" OR ")})`);
115
+ }
116
+ else if (key === "NOT") {
117
+ const list = Array.isArray(value) ? value : [value];
118
+ for (const w of list) {
119
+ const c = whereSQL(w);
120
+ if (c)
121
+ parts.push(`NOT (${c})`);
122
+ }
123
+ }
124
+ else if (value === null) {
125
+ parts.push(`${colRef(key)} IS NULL`);
126
+ }
127
+ else if (isFilterObject(value)) {
128
+ parts.push(...buildFieldFilter(key, value));
129
+ }
130
+ else {
131
+ parts.push(`${colRef(key)} = ${addParam(value, baseType(key))}`);
132
+ }
133
+ }
134
+ return parts.join(" AND ");
135
+ }
136
+ return { params, colRef, baseType, addParam, whereSQL };
137
+ }
138
+ function tableRef(database, tableName, table) {
139
+ const ref = `${escapeId(database)}.${escapeId(tableName)}`;
140
+ // replacing tables must read the latest row versions
141
+ return table.mutations === "replacing" ? `${ref} FINAL` : ref;
142
+ }
143
+ function checkPagination(name, value) {
144
+ if (!Number.isInteger(value) || value < 0) {
145
+ throw new Error(`"${name}" must be a non-negative integer, got: ${value}`);
146
+ }
147
+ }
148
+ export function buildFindManyQuery(database, tableName, table, args = {}) {
149
+ const ctx = createBuildContext(tableName, table.columns);
150
+ // SELECT
151
+ let selectSQL = "*";
152
+ if (args.select) {
153
+ const keys = Object.keys(args.select).filter(k => args.select[k] === true);
154
+ if (keys.length) {
155
+ selectSQL = keys.map(ctx.colRef).join(", ");
156
+ }
157
+ }
158
+ const clauses = [
159
+ `SELECT ${selectSQL} FROM ${tableRef(database, tableName, table)}`
160
+ ];
161
+ // WHERE
162
+ if (args.where) {
163
+ const where = ctx.whereSQL(args.where);
164
+ if (where)
165
+ clauses.push(`WHERE ${where}`);
166
+ }
167
+ // ORDER BY
168
+ if (args.orderBy) {
169
+ const list = Array.isArray(args.orderBy)
170
+ ? args.orderBy
171
+ : [args.orderBy];
172
+ const orders = [];
173
+ for (const entry of list) {
174
+ for (const key in entry) {
175
+ const dir = entry[key];
176
+ if (dir === undefined)
177
+ continue;
178
+ if (dir !== "asc" && dir !== "desc") {
179
+ throw new Error(`Invalid sort direction "${dir}" for column "${key}"`);
180
+ }
181
+ orders.push(`${ctx.colRef(key)} ${dir.toUpperCase()}`);
182
+ }
183
+ }
184
+ if (orders.length)
185
+ clauses.push(`ORDER BY ${orders.join(", ")}`);
186
+ }
187
+ // LIMIT / OFFSET
188
+ if (args.take !== undefined)
189
+ checkPagination("take", args.take);
190
+ if (args.skip !== undefined)
191
+ checkPagination("skip", args.skip);
192
+ if (args.take !== undefined && args.skip !== undefined) {
193
+ clauses.push(`LIMIT ${args.take} OFFSET ${args.skip}`);
194
+ }
195
+ else if (args.take !== undefined) {
196
+ clauses.push(`LIMIT ${args.take}`);
197
+ }
198
+ else if (args.skip !== undefined) {
199
+ clauses.push(`LIMIT ${NO_LIMIT} OFFSET ${args.skip}`);
200
+ }
201
+ return { sql: clauses.join(" "), params: ctx.params };
202
+ }
203
+ export function buildCountQuery(database, tableName, table, where) {
204
+ const inner = buildFindManyQuery(database, tableName, table, { where });
205
+ // with no select arg the query starts with "SELECT * FROM ..." —
206
+ // swap the projection for count()
207
+ const from = inner.sql.indexOf(" FROM ");
208
+ return {
209
+ sql: "SELECT count() AS count" + inner.sql.slice(from),
210
+ params: inner.params
211
+ };
212
+ }
213
+ // shared by buildUpdateQuery and the replacing-strategy update path
214
+ export function validateUpdateData(table, tableName, data) {
215
+ const keys = Object.keys(data).filter(k => data[k] !== undefined);
216
+ if (keys.length === 0) {
217
+ throw new Error(`updateMany requires non-empty data`);
218
+ }
219
+ for (const key of keys) {
220
+ if (table.orderBy.includes(key)) {
221
+ throw new Error(`Cannot update ORDER BY key column "${key}" ` +
222
+ `of table "${tableName}"`);
223
+ }
224
+ }
225
+ return keys;
226
+ }
227
+ export function buildUpdateQuery(database, tableName, table, where, data) {
228
+ const keys = validateUpdateData(table, tableName, data);
229
+ const ctx = createBuildContext(tableName, table.columns);
230
+ const sets = keys.map(k => `${ctx.colRef(k)} = ${ctx.addParam(data[k], ctx.baseType(k))}`);
231
+ const whereSQL = where ? ctx.whereSQL(where) : "";
232
+ return {
233
+ sql: `ALTER TABLE ${escapeId(database)}.${escapeId(tableName)} ` +
234
+ `UPDATE ${sets.join(", ")} WHERE ${whereSQL || "1"}`,
235
+ params: ctx.params
236
+ };
237
+ }
238
+ export function buildDeleteQuery(database, tableName, table, where) {
239
+ const ctx = createBuildContext(tableName, table.columns);
240
+ const whereSQL = where ? ctx.whereSQL(where) : "";
241
+ return {
242
+ sql: `ALTER TABLE ${escapeId(database)}.${escapeId(tableName)} ` +
243
+ `DELETE WHERE ${whereSQL || "1"}`,
244
+ params: ctx.params
245
+ };
246
+ }
247
+ export function buildLightweightDeleteQuery(database, tableName, table, where) {
248
+ const ctx = createBuildContext(tableName, table.columns);
249
+ const whereSQL = where ? ctx.whereSQL(where) : "";
250
+ return {
251
+ sql: `DELETE FROM ${escapeId(database)}.${escapeId(tableName)} ` +
252
+ `WHERE ${whereSQL || "1"}`,
253
+ params: ctx.params
254
+ };
255
+ }
@@ -0,0 +1,55 @@
1
+ import type { ClickHouseClient } from "@clickhouse/client";
2
+ import { TableDefinition } from "./table.js";
3
+ import { Simplify } from "./types.js";
4
+ import { WhereInput } from "./where.js";
5
+ import { FindManyArgs } from "./sql.js";
6
+ export type FindManyResult<T, A> = A extends {
7
+ select: infer S;
8
+ } ? Simplify<{
9
+ [K in keyof S & keyof T as S[K] extends true ? K : never]: T[K];
10
+ }> : T;
11
+ type NullableKeys<T> = {
12
+ [K in keyof T]-?: null extends T[K] ? K : never;
13
+ }[keyof T];
14
+ type OptionalCreateKeys<T> = NullableKeys<T> | ("id" & keyof T);
15
+ export type CreateData<T> = Simplify<Omit<T, OptionalCreateKeys<T>> & {
16
+ [K in OptionalCreateKeys<T>]?: T[K];
17
+ }>;
18
+ export declare class TableClient<T> {
19
+ private database;
20
+ private tableName;
21
+ private table;
22
+ private client;
23
+ constructor(database: string, tableName: string, table: TableDefinition<any>, client: ClickHouseClient);
24
+ private get tableRef();
25
+ private runQuery;
26
+ findMany<A extends FindManyArgs<T>>(args?: A): Promise<FindManyResult<T, A>[]>;
27
+ findFirst<A extends Omit<FindManyArgs<T>, "take">>(args?: A): Promise<FindManyResult<T, A> | null>;
28
+ count(args?: {
29
+ where?: WhereInput<T>;
30
+ }): Promise<number>;
31
+ private prepareRows;
32
+ private insertWire;
33
+ insert(rows: Partial<T> | Partial<T>[]): Promise<void>;
34
+ create(args: {
35
+ data: CreateData<T>;
36
+ }): Promise<T>;
37
+ private get strategy();
38
+ updateMany(args: {
39
+ where?: WhereInput<T>;
40
+ data: Partial<T>;
41
+ }): Promise<{
42
+ count: number;
43
+ }>;
44
+ deleteMany(args?: {
45
+ where?: WhereInput<T>;
46
+ }): Promise<{
47
+ count: number;
48
+ }>;
49
+ createMany(args: {
50
+ data: CreateData<T>[];
51
+ }): Promise<{
52
+ count: number;
53
+ }>;
54
+ }
55
+ export {};
@@ -0,0 +1,186 @@
1
+ // src/table-client.ts
2
+ import { buildFindManyQuery, buildCountQuery, buildUpdateQuery, buildDeleteQuery, buildLightweightDeleteQuery, validateUpdateData, escapeId } from "./sql.js";
3
+ import { deserializeRows } from "./rows.js";
4
+ // process-unique id: ms timestamp in the high bits + 20-bit counter
5
+ let idCounter = 0n;
6
+ function generateId() {
7
+ return (BigInt(Date.now()) << 20n) | (idCounter++ & 0xfffffn);
8
+ }
9
+ export class TableClient {
10
+ database;
11
+ tableName;
12
+ table;
13
+ client;
14
+ constructor(database, tableName, table, client) {
15
+ this.database = database;
16
+ this.tableName = tableName;
17
+ this.table = table;
18
+ this.client = client;
19
+ }
20
+ get tableRef() {
21
+ return `${escapeId(this.database)}.${escapeId(this.tableName)}`;
22
+ }
23
+ async runQuery(sql, params) {
24
+ const result = await this.client.query({
25
+ query: sql,
26
+ query_params: params,
27
+ format: "JSONEachRow",
28
+ clickhouse_settings: {
29
+ // ISO with Z: the default DateTime rendering is in server
30
+ // timezone with no offset, which is unparseable correctly
31
+ date_time_output_format: "iso",
32
+ // force 64-bit ints to arrive as strings so BigInt() is
33
+ // lossless even if the server default was changed
34
+ output_format_json_quote_64bit_integers: 1
35
+ }
36
+ });
37
+ return result.json();
38
+ }
39
+ async findMany(args) {
40
+ const { sql, params } = buildFindManyQuery(this.database, this.tableName, this.table, args);
41
+ const rows = await this.runQuery(sql, params);
42
+ return deserializeRows(rows, this.table.columns);
43
+ }
44
+ async findFirst(args) {
45
+ const rows = await this.findMany({ ...args, take: 1 });
46
+ return rows[0] ?? null;
47
+ }
48
+ async count(args) {
49
+ const { sql, params } = buildCountQuery(this.database, this.tableName, this.table, args?.where);
50
+ const rows = await this.runQuery(sql, params);
51
+ return Number(rows[0].count);
52
+ }
53
+ // wire: values serialized for JSONEachRow; completed: JS values
54
+ // including the generated id (what create() returns)
55
+ prepareRows(arr) {
56
+ const hasIdColumn = "id" in this.table.columns;
57
+ const wire = [];
58
+ const completed = [];
59
+ for (const r of arr) {
60
+ const full = { ...r };
61
+ if (hasIdColumn && full.id === undefined) {
62
+ full.id = generateId();
63
+ }
64
+ // the returned row must match both its type and what a
65
+ // subsequent read returns: omitted nullable columns are null,
66
+ // DateTime values are stored with second precision
67
+ for (const colName in this.table.columns) {
68
+ if (full[colName] === undefined && this.table.columns[colName].nullable) {
69
+ full[colName] = null;
70
+ }
71
+ else if (full[colName] instanceof Date) {
72
+ full[colName] = new Date(Math.floor(full[colName].getTime() / 1000) * 1000);
73
+ }
74
+ }
75
+ const obj = {};
76
+ for (const k in full) {
77
+ const v = full[k];
78
+ if (v === undefined)
79
+ continue;
80
+ if (typeof v === "bigint") {
81
+ obj[k] = v.toString();
82
+ }
83
+ else if (v instanceof Date) {
84
+ // unix seconds: ISO strings are rejected by DateTime
85
+ // columns in the default (basic) input format
86
+ obj[k] = Math.floor(v.getTime() / 1000);
87
+ }
88
+ else {
89
+ obj[k] = v;
90
+ }
91
+ }
92
+ wire.push(obj);
93
+ completed.push(full);
94
+ }
95
+ return { wire, completed };
96
+ }
97
+ async insertWire(wire) {
98
+ await this.client.insert({
99
+ table: this.tableRef,
100
+ values: wire,
101
+ format: "JSONEachRow",
102
+ clickhouse_settings: {
103
+ // with wait_for_async_insert=0 on the server, inserts are
104
+ // acked before flush: errors are swallowed and rows are not
105
+ // immediately readable — force read-your-writes semantics
106
+ wait_for_async_insert: 1
107
+ }
108
+ });
109
+ }
110
+ async insert(rows) {
111
+ const arr = Array.isArray(rows) ? rows : [rows];
112
+ if (arr.length === 0)
113
+ return;
114
+ const { wire } = this.prepareRows(arr);
115
+ await this.insertWire(wire);
116
+ }
117
+ async create(args) {
118
+ const { wire, completed } = this.prepareRows([args.data]);
119
+ await this.insertWire(wire);
120
+ return completed[0];
121
+ }
122
+ get strategy() {
123
+ return this.table.mutations ?? "alter";
124
+ }
125
+ async updateMany(args) {
126
+ if (this.strategy === "replacing") {
127
+ const keys = validateUpdateData(this.table, this.tableName, args.data);
128
+ // re-insert new versions of the matching rows;
129
+ // FINAL on the read keeps this idempotent
130
+ const rows = await this.findMany({ where: args.where });
131
+ if (rows.length === 0)
132
+ return { count: 0 };
133
+ const patch = {};
134
+ for (const k of keys)
135
+ patch[k] = args.data[k];
136
+ const { wire } = this.prepareRows(rows.map(r => ({ ...r, ...patch })));
137
+ await this.insertWire(wire);
138
+ return { count: rows.length };
139
+ }
140
+ // "alter" and "lightweight" (ClickHouse has no stable lightweight
141
+ // UPDATE — lightweight tables fall back to a heavy ALTER mutation)
142
+ const { sql, params } = buildUpdateQuery(this.database, this.tableName, this.table, args.where, args.data);
143
+ const count = await this.count({ where: args.where });
144
+ if (count === 0)
145
+ return { count: 0 };
146
+ await this.client.command({
147
+ query: sql,
148
+ query_params: params,
149
+ clickhouse_settings: {
150
+ // wait for the mutation so subsequent reads see the change
151
+ mutations_sync: "1"
152
+ }
153
+ });
154
+ return { count };
155
+ }
156
+ async deleteMany(args) {
157
+ const count = await this.count({ where: args?.where });
158
+ if (count === 0)
159
+ return { count: 0 };
160
+ if (this.strategy === "alter") {
161
+ const { sql, params } = buildDeleteQuery(this.database, this.tableName, this.table, args?.where);
162
+ await this.client.command({
163
+ query: sql,
164
+ query_params: params,
165
+ clickhouse_settings: { mutations_sync: "1" }
166
+ });
167
+ }
168
+ else {
169
+ // "lightweight" and "replacing": fast tombstone-based delete
170
+ const { sql, params } = buildLightweightDeleteQuery(this.database, this.tableName, this.table, args?.where);
171
+ await this.client.command({
172
+ query: sql,
173
+ query_params: params,
174
+ clickhouse_settings: { lightweight_deletes_sync: "2" }
175
+ });
176
+ }
177
+ return { count };
178
+ }
179
+ async createMany(args) {
180
+ if (args.data.length === 0)
181
+ return { count: 0 };
182
+ const { wire } = this.prepareRows(args.data);
183
+ await this.insertWire(wire);
184
+ return { count: args.data.length };
185
+ }
186
+ }
@@ -0,0 +1,13 @@
1
+ import { Column } from "./column.js";
2
+ import { Simplify } from "./types.js";
3
+ export type MutationStrategy = "alter" | "lightweight" | "replacing";
4
+ export type TableDefinition<TColumns extends Record<string, Column<any>>> = {
5
+ engine: string;
6
+ orderBy: string[];
7
+ mutations?: MutationStrategy;
8
+ columns: TColumns;
9
+ };
10
+ export declare function table<TColumns extends Record<string, Column<any>>>(def: TableDefinition<TColumns>): TableDefinition<TColumns>;
11
+ export type InferRow<TTable> = TTable extends TableDefinition<infer TColumns> ? Simplify<{
12
+ [K in keyof TColumns]: TColumns[K] extends Column<infer T> ? T : never;
13
+ }> : never;
package/dist/table.js ADDED
@@ -0,0 +1,9 @@
1
+ // src/table.ts
2
+ export function table(def) {
3
+ if (def.mutations === "replacing" &&
4
+ !def.engine.includes("ReplacingMergeTree")) {
5
+ throw new Error(`mutations: "replacing" requires a ReplacingMergeTree engine ` +
6
+ `(got "${def.engine}")`);
7
+ }
8
+ return def;
9
+ }
@@ -0,0 +1,4 @@
1
+ export type Primitive = string | number | bigint | boolean | Date | null;
2
+ export type Simplify<T> = {
3
+ [K in keyof T]: T[K];
4
+ } & {};
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ // src/types.ts
2
+ export {};
@@ -0,0 +1,24 @@
1
+ export type ComparableFilter<T> = {
2
+ lt?: T;
3
+ lte?: T;
4
+ gt?: T;
5
+ gte?: T;
6
+ };
7
+ export type StringFilter = {
8
+ contains?: string;
9
+ startsWith?: string;
10
+ endsWith?: string;
11
+ };
12
+ export type ScalarFilter<T> = {
13
+ equals?: T | null;
14
+ not?: T | null;
15
+ in?: T[];
16
+ notIn?: T[];
17
+ } & (T extends number | bigint | Date | string ? ComparableFilter<T> : {}) & (T extends string ? StringFilter : {});
18
+ export type WhereInput<T> = {
19
+ AND?: WhereInput<T> | WhereInput<T>[];
20
+ OR?: WhereInput<T>[];
21
+ NOT?: WhereInput<T> | WhereInput<T>[];
22
+ } & {
23
+ [K in keyof T]?: T[K] extends readonly any[] ? never : T[K] | ScalarFilter<NonNullable<T[K]>> | null;
24
+ };
package/dist/where.js ADDED
@@ -0,0 +1,2 @@
1
+ // src/where.ts
2
+ export {};
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@quark-fw/clisma",
3
+ "version": "0.1.0",
4
+ "description": "Prisma-like typed client and schema migrator for ClickHouse",
5
+ "keywords": [
6
+ "clickhouse",
7
+ "orm",
8
+ "prisma",
9
+ "query-builder",
10
+ "migrations",
11
+ "typescript"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Drus",
15
+ "type": "module",
16
+ "main": "dist/index.js",
17
+ "types": "dist/index.d.ts",
18
+ "exports": {
19
+ ".": {
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js"
22
+ }
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "sideEffects": false,
28
+ "engines": {
29
+ "node": ">=20"
30
+ },
31
+ "scripts": {
32
+ "build": "tsc -p tsconfig.build.json",
33
+ "typecheck": "tsc --noEmit",
34
+ "test": "tsx --env-file=./src/test/.env ./src/test/index.ts",
35
+ "test:unit": "tsx --test src/test/unit.test.ts",
36
+ "prepublishOnly": "npm run typecheck && npm run test:unit && npm run build"
37
+ },
38
+ "dependencies": {
39
+ "@clickhouse/client": "^1.17.0"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^25.3.3",
43
+ "tsx": "^4.21.0",
44
+ "typescript": "^5.5.3"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ }
49
+ }