@quark-fw/clisma 0.1.13 → 0.1.14

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.
Files changed (42) hide show
  1. package/README.md +126 -19
  2. package/dist/assert-replicated-schema.d.ts +2 -0
  3. package/dist/assert-replicated-schema.js +16 -0
  4. package/dist/build-count-query.js +2 -1
  5. package/dist/build-create-dictionary-query.d.ts +2 -0
  6. package/dist/build-create-dictionary-query.js +51 -0
  7. package/dist/build-find-many-query.js +1 -1
  8. package/dist/build-index-sql.d.ts +2 -0
  9. package/dist/build-index-sql.js +11 -0
  10. package/dist/client.d.ts +17 -3
  11. package/dist/client.js +45 -18
  12. package/dist/column.d.ts +30 -1
  13. package/dist/column.js +55 -0
  14. package/dist/ddl-shared.d.ts +13 -0
  15. package/dist/ddl-shared.js +54 -0
  16. package/dist/ddl.d.ts +2 -0
  17. package/dist/ddl.js +41 -7
  18. package/dist/dictionary.d.ts +26 -0
  19. package/dist/dictionary.js +7 -0
  20. package/dist/engine.d.ts +16 -0
  21. package/dist/engine.js +26 -0
  22. package/dist/ensure-dictionaries-on-nodes.d.ts +3 -0
  23. package/dist/ensure-dictionaries-on-nodes.js +15 -0
  24. package/dist/ensure-tables-on-nodes.d.ts +11 -0
  25. package/dist/ensure-tables-on-nodes.js +39 -0
  26. package/dist/escape-id.d.ts +1 -0
  27. package/dist/escape-id.js +4 -0
  28. package/dist/expr.d.ts +5 -0
  29. package/dist/expr.js +4 -0
  30. package/dist/index.d.ts +7 -0
  31. package/dist/index.js +5 -0
  32. package/dist/migrate.js +15 -5
  33. package/dist/query.d.ts +26 -0
  34. package/dist/query.js +60 -0
  35. package/dist/rows.js +9 -0
  36. package/dist/schema.d.ts +4 -2
  37. package/dist/sql-shared.d.ts +5 -2
  38. package/dist/sql-shared.js +16 -7
  39. package/dist/table-client.js +9 -2
  40. package/dist/table.d.ts +26 -4
  41. package/dist/table.js +24 -3
  42. package/package.json +2 -2
package/README.md CHANGED
@@ -119,16 +119,84 @@ const user = await db.users.create({
119
119
  | Фабрика | ClickHouse | TypeScript |
120
120
  |--------------------|-----------------------|------------|
121
121
  | `t.string()` | `String` | `string` |
122
- | `t.uint8()` | `UInt8` | `number` |
123
- | `t.int32()` | `Int32` | `number` |
124
- | `t.uint64()` | `UInt64` | `bigint` |
122
+ | `t.uint8()` / `t.uint16()` / `t.uint32()` | `UInt8/16/32` | `number` |
123
+ | `t.int8()` / `t.int16()` / `t.int32()` | `Int8/16/32` | `number` |
124
+ | `t.uint64()` / `t.int64()` | `UInt64` / `Int64` | `bigint` |
125
+ | `t.float32()` / `t.float64()` | `Float32/64` | `number` |
126
+ | `t.decimal(p, s)` | `Decimal(p, s)` | `string` (без потери точности) |
125
127
  | `t.boolean()` | `Bool` | `boolean` |
128
+ | `t.date()` / `t.date32()` | `Date` / `Date32` | `Date` |
126
129
  | `t.datetime()` | `DateTime` | `Date` |
130
+ | `t.datetime64(p, tz?)` | `DateTime64(p[, 'tz'])` | `Date` |
131
+ | `t.enum8({ a: 1, b: 2 })` | `Enum8('a' = 1, 'b' = 2)` | `"a" \| "b"` |
132
+ | `t.lowCardinality(col)` | `LowCardinality(...)` | тот же `T` |
133
+ | `t.ipv4()` / `t.ipv6()` | `IPv4` / `IPv6` | `string` |
134
+ | `t.map(k, v)` | `Map(K, V)` | `Record<string, V>` |
127
135
  | `t.nullable(col)` | `Nullable(...)` | `T \| null`|
128
136
  | `t.array(col)` | `Array(...)` | `T[]` |
129
137
 
130
- Пока нет: `DateTime64`, `Decimal`, `Enum`, `LowCardinality`, `DEFAULT`,
131
- `PARTITION BY`, `TTL`.
138
+ Модификаторы (возвращают копию колонки): `t.default(col, expr)`,
139
+ `t.materialized(col, expr)`, `t.alias(col, expr)`, `t.codec(col, "Delta, ZSTD(1)")`,
140
+ `t.comment(col, "…")`.
141
+
142
+ ### Движок и ключи таблицы
143
+
144
+ ```ts
145
+ import { engine, expr, table, t } from "@quark-fw/clisma"
146
+
147
+ const clicks = table({
148
+ engine: engine.versionedCollapsingMergeTree("sign", "version"),
149
+ // путь в Keeper и макрос реплики → ReplicatedVersionedCollapsingMergeTree(...)
150
+ replication: { path: "/clickhouse/tables/{shard}/analytics/clicks", replica: "{replica}" },
151
+ partitionBy: expr("toYYYYMM(datetime)"),
152
+ primaryKey: [expr("toStartOfHour(datetime)"), "sub_id_15"],
153
+ orderBy: [expr("toStartOfHour(datetime)"), "sub_id_15", "click_id"],
154
+ indexes: [{ name: "idx_sub", expr: "sub_id_16", type: "bloom_filter", args: [0.01], granularity: 4 }],
155
+ settings: { index_granularity: 8192 },
156
+ columns: { /* … */ sign: t.int8(), version: t.uint64() }
157
+ })
158
+ ```
159
+
160
+ - `engine` — строка как раньше **или** спека из `engine.*`: `mergeTree()`,
161
+ `replacingMergeTree(ver?)`, `collapsingMergeTree(sign)`,
162
+ `versionedCollapsingMergeTree(sign, version)`, `summingMergeTree(cols?)`,
163
+ `aggregatingMergeTree()`. Со спекой clisma знает семейство: проверяет колонки
164
+ знака/версии при объявлении, считает `count()` как `sum(sign)` и требует
165
+ `final: true` в `findMany` у коллапсирующих таблиц.
166
+ - `expr("…")` — сырое выражение там, где иначе ждут имя колонки (`orderBy`,
167
+ `primaryKey`, `partitionBy`, `sampleBy`, `indexes[].expr`); имена колонок
168
+ по-прежнему экранируются.
169
+ - Опции: `primaryKey`, `partitionBy`, `sampleBy`, `indexes`, `ttl`, `settings`,
170
+ `comment`.
171
+
172
+ ### Словари
173
+
174
+ ```ts
175
+ import { dictionary } from "@quark-fw/clisma"
176
+
177
+ createClisma({
178
+ database: "analytics",
179
+ tables: { /* … */ },
180
+ dictionaries: {
181
+ user_subids_dict: dictionary({
182
+ key: { subid_value: t.uint32() },
183
+ attributes: { user_id: t.uint32(), team_ids: t.array(t.uint32()) },
184
+ source: { query: "SELECT subid_value, user_id, team_ids FROM analytics.user_subids FINAL" },
185
+ // или { table: "user_subids", where: "kind = 'BUYER'" }
186
+ layout: "hashed",
187
+ lifetime: { min: 60, max: 300 }
188
+ })
189
+ }
190
+ })
191
+ ```
192
+
193
+ Словарь — замена JOIN на измерение: `dictGet` ищет по хешу за O(1) на строку.
194
+ Создаётся на каждой DDL-ноде; `$init` — `CREATE DICTIONARY IF NOT EXISTS`,
195
+ `$migrate` — `CREATE OR REPLACE DICTIONARY` (иначе изменение источника или
196
+ атрибутов никогда бы не доехало). Учётка источника по умолчанию — учётка
197
+ клиента (`USER … PASSWORD …`): без неё ClickHouse читает локальные таблицы
198
+ как `default`, у которого обычно стоит пароль; переопределяется полями
199
+ `user`/`password` в `source`.
132
200
 
133
201
  ### Колонка `id`
134
202
 
@@ -142,16 +210,43 @@ clisma генерирует уникальный `bigint` (timestamp в стар
142
210
 
143
211
  ```ts
144
212
  const db = createClient(schema, {
145
- url, // http://host:8123
213
+ url, // http://host:8123 — сюда идут все запросы
146
214
  username, password,
147
215
  database, // приоритетнее schema.database
148
- autoCreateDatabase: true // $init/$migrate создадут БД
216
+ autoCreateDatabase: true, // $init/$migrate создадут БД
217
+ ddlNodes: [url, replicaUrl] // необязательно: ноды для DDL, первая — источник истины
218
+ requestTimeoutMs: 300_000, // необязательно: таймаут HTTP-запроса, по умолчанию 30 с драйвера
149
219
  })
150
220
  ```
151
221
 
152
222
  - `db.<table>` — клиент таблицы (см. API ниже)
153
- - `db.$init()` — создать БД и таблицы (`CREATE ... IF NOT EXISTS`), без диффа
223
+ - `db.$init()` — создать БД, таблицы и словари (`CREATE ... IF NOT EXISTS`), без диффа
154
224
  - `db.$migrate()` — привести БД к схеме (см. «Миграции»)
225
+ - `db.$query<Row>(sql, { params, settings, columns })` — сырой SELECT
226
+ (`JSONEachRow`); `columns` — карта колонок результата, чтобы `UInt64`/`DateTime`
227
+ вернулись как `bigint`/`Date`
228
+ - `db.$queryJson<Row, Totals>(sql, opts)` — тот же запрос в `FORMAT JSON`:
229
+ `{ data, totals?, rows, rows_before_limit_at_least?, statistics }` — для
230
+ `WITH TOTALS`, точного числа строк до `LIMIT` (`exact_rows_before_limit = 1`)
231
+ и статистики чтения
232
+ - `db.$command(sql, { params, settings })` — `INSERT … SELECT`, `ALTER`, `DROP PARTITION`
233
+ - `db.$close()` — закрыть все соединения
234
+
235
+ Параметры — серверные `{name:Type}` (`Date` уходит unix-секундами), настройки
236
+ запроса сливаются с проводными (`date_time_output_format = iso`,
237
+ `output_format_json_quote_64bit_integers = 1`).
238
+
239
+ ### DDL на несколько нод (`ddlNodes`)
240
+
241
+ Когда БД `Atomic` и в `remote_servers` нет кластера, `CREATE TABLE` не
242
+ разъезжается по репликам сам: без таблицы на реплике чтение через неё падает с
243
+ `UNKNOWN_TABLE`. С `ddlNodes` clisma создаёт таблицы на первой ноде из схемы,
244
+ а на остальные копирует её `SHOW CREATE TABLE` (Keeper сверяет метаданные реплик
245
+ побайтово, повторный рендер мог бы разойтись); `ALTER` идёт только на первую —
246
+ реплицируемая мутация разъезжается сама; словари создаются на каждой ноде.
247
+ При двух и более нодах принимаются только таблицы с `replication` (или строкой
248
+ движка `Replicated*`) — обычный `MergeTree` на двух нодах был бы двумя
249
+ независимыми таблицами.
155
250
 
156
251
  ## Миграции: `db.$migrate()`
157
252
 
@@ -161,15 +256,21 @@ const db = createClient(schema, {
161
256
  - колонки нет → `ALTER TABLE ... ADD COLUMN`
162
257
  - тип отличается → `ALTER TABLE ... MODIFY COLUMN`
163
258
 
164
- Возвращает `{ statements, warnings }` — применённые SQL и предупреждения.
165
- Повторный запуск на совпадающей схеме — пустой план (идемпотентно).
259
+ Возвращает `{ statements, warnings, executed }` — план, предупреждения и
260
+ фактически выполненные SQL с нодой (`executed[].node`). Повторный запуск на
261
+ совпадающей схеме — пустой план (идемпотентно); словари при этом всё равно
262
+ пересоздаются (`CREATE OR REPLACE`, дёшево).
166
263
 
167
264
  **Ограничения (осознанные):**
168
265
 
169
266
  - лишние колонки в БД **никогда не удаляются** — только warning;
170
- - `ENGINE`, `ORDER BY`, `PARTITION BY` не диффятся — их изменение требует
171
- пересоздания таблицы вручную;
172
- - `MODIFY COLUMN` ключевой колонки (из `ORDER BY`) отклонит сам ClickHouse;
267
+ - сравнивается только тип колонки: кодеки, `DEFAULT`, комментарии в диффе не
268
+ участвуют (в `ADD COLUMN` они рендерятся);
269
+ - `ENGINE`, `ORDER BY`, `PARTITION BY`, индексы, TTL не диффятся — их
270
+ изменение требует пересоздания таблицы вручную;
271
+ - смена типа ключевой колонки (из `ORDER BY`/`PRIMARY KEY`) — ошибка до
272
+ выполнения плана: сервер её всё равно отклонит, лучше упасть целиком, чем
273
+ посреди миграции;
173
274
  - нет файлов/истории миграций как у `prisma migrate` — сравнение всегда
174
275
  с живой схемой.
175
276
 
@@ -179,9 +280,13 @@ const db = createClient(schema, {
179
280
 
180
281
  ### Чтение
181
282
 
182
- - `findMany({ where?, select?, orderBy?, take?, skip? })` → `Row[]`
283
+ - `findMany({ where?, select?, orderBy?, take?, skip?, final?, settings? })` → `Row[]`
183
284
  - `findFirst({ where?, select?, orderBy?, skip? })` → `Row | null`
184
- - `count({ where? })` → `number`
285
+ - `count({ where? })` → `number`; у коллапсирующих таблиц это `sum(sign)`
286
+
287
+ `final: true` добавляет `FINAL` таблице без стратегии `replacing`;
288
+ у коллапсирующих таблиц он обязателен для `findMany` (сырые строки содержат
289
+ погашенные версии). `settings` — настройки ClickHouse на этот запрос.
185
290
 
186
291
  ### Запись
187
292
 
@@ -285,9 +390,11 @@ npx tsc --noEmit # проверка типов — обязательный
285
390
 
286
391
  ## Ограничения / roadmap
287
392
 
288
- - нет `aggregate`, `groupBy`, `distinct` (план: `LIMIT 1 BY`), `findUnique`, `upsert`;
393
+ - нет `aggregate`, `groupBy`, `distinct` (план: `LIMIT 1 BY`), `findUnique`, `upsert` —
394
+ агрегаты пишутся сырым SQL через `$query`/`$queryJson`;
289
395
  - нет вложенного `not`, `mode: "insensitive"`, операторов для Array-колонок;
290
- - нет файлов миграций/истории; дифф только add/modify колонок;
291
- - нет `DateTime64`/`Decimal`/`Enum`/`LowCardinality`, `DEFAULT`, `PARTITION BY`, `TTL`;
396
+ - нет файлов миграций/истории; дифф только add/modify колонок по типу;
397
+ - нет `Nested`-сахара (объявляйте `"players.deposits": t.array(t.float64())`),
398
+ `Tuple`, `UUID`, материализованных представлений и `Distributed`;
292
399
  - `create` без серверного round-trip (см. выше);
293
- - нет relations/include — ClickHouse-философия: денормализуйте.
400
+ - нет relations/include — ClickHouse-философия: денормализуйте (или словари).
@@ -0,0 +1,2 @@
1
+ import type { AnyTable } from "./table.js";
2
+ export declare function assertReplicatedSchema(nodeCount: number, tables: Record<string, AnyTable>): void;
@@ -0,0 +1,16 @@
1
+ // src/assert-replicated-schema.ts
2
+ import { isReplicatedEngine } from "./ddl-shared.js";
3
+ // Без кластера в remote_servers DDL не разъезжается сам: таблицу создают на
4
+ // каждой ноде руками. Обычный MergeTree на нескольких нодах — это две
5
+ // независимые таблицы, которые тихо разойдутся по данным, поэтому при
6
+ // нескольких DDL-нодах принимаем только Replicated-движки.
7
+ export function assertReplicatedSchema(nodeCount, tables) {
8
+ if (nodeCount < 2)
9
+ return;
10
+ for (const name in tables) {
11
+ if (!isReplicatedEngine(tables[name])) {
12
+ throw new Error(`Table "${name}" must use a Replicated* engine (set \`replication\`) ` +
13
+ `when DDL targets ${nodeCount} nodes`);
14
+ }
15
+ }
16
+ }
@@ -1,4 +1,5 @@
1
1
  // src/build-count-query.ts
2
+ import { countExpr } from "./sql-shared.js";
2
3
  import { buildFindManyQuery } from "./build-find-many-query.js";
3
4
  export function buildCountQuery(database, tableName, table, where) {
4
5
  const inner = buildFindManyQuery(database, tableName, table, { where });
@@ -6,7 +7,7 @@ export function buildCountQuery(database, tableName, table, where) {
6
7
  // swap the projection for count()
7
8
  const from = inner.sql.indexOf(" FROM ");
8
9
  return {
9
- sql: "SELECT count() AS count" + inner.sql.slice(from),
10
+ sql: `SELECT ${countExpr(table)} AS count` + inner.sql.slice(from),
10
11
  params: inner.params,
11
12
  };
12
13
  }
@@ -0,0 +1,2 @@
1
+ import type { AnyDictionary, DictionaryCredentials } from "./dictionary.js";
2
+ export declare function buildCreateDictionarySQL(database: string, name: string, dict: AnyDictionary, credentials?: DictionaryCredentials, mode?: "create" | "replace"): string;
@@ -0,0 +1,51 @@
1
+ // src/build-create-dictionary-query.ts
2
+ import { escapeId } from "./escape-id.js";
3
+ import { quoteString } from "./ddl-shared.js";
4
+ export function buildCreateDictionarySQL(database, name, dict, credentials,
5
+ // $migrate пересоздаёт словарь: IF NOT EXISTS оставил бы старое
6
+ // определение (источник, учётку, атрибуты) навсегда
7
+ mode = "create") {
8
+ const columns = [
9
+ ...Object.entries(dict.key),
10
+ ...Object.entries(dict.attributes),
11
+ ]
12
+ .map(([colName, col]) => `${escapeId(colName)} ${col.clickhouseType}`)
13
+ .join(",\n");
14
+ const keys = Object.keys(dict.key).map(escapeId).join(", ");
15
+ const auth = dict.source.user
16
+ ? { user: dict.source.user, password: dict.source.password }
17
+ : credentials;
18
+ const authSQL = auth
19
+ ? `USER ${quoteString(auth.user)} PASSWORD ${quoteString(auth.password ?? "")} `
20
+ : "";
21
+ // источник — тот же ClickHouse: запрос целиком, либо таблица с фильтром
22
+ const source = "query" in dict.source
23
+ ? `CLICKHOUSE(${authSQL}QUERY ${quoteString(dict.source.query)})`
24
+ : `CLICKHOUSE(${authSQL}DB ${quoteString(dict.source.database ?? database)} ` +
25
+ `TABLE ${quoteString(dict.source.table)}` +
26
+ (dict.source.where
27
+ ? ` WHERE ${quoteString(dict.source.where)}`
28
+ : "") +
29
+ ")";
30
+ const lifetime = typeof dict.lifetime === "number"
31
+ ? `LIFETIME(${dict.lifetime})`
32
+ : `LIFETIME(MIN ${dict.lifetime.min} MAX ${dict.lifetime.max})`;
33
+ const clauses = [
34
+ `PRIMARY KEY ${keys}`,
35
+ `SOURCE(${source})`,
36
+ `LAYOUT(${dict.layout.toUpperCase()}())`,
37
+ lifetime,
38
+ ];
39
+ if (dict.comment)
40
+ clauses.push(`COMMENT ${quoteString(dict.comment)}`);
41
+ const verb = mode === "replace"
42
+ ? "CREATE OR REPLACE DICTIONARY"
43
+ : "CREATE DICTIONARY IF NOT EXISTS";
44
+ return `
45
+ ${verb} ${escapeId(database)}.${escapeId(name)}
46
+ (
47
+ ${columns}
48
+ )
49
+ ${clauses.join("\n ")}
50
+ `.trim();
51
+ }
@@ -12,7 +12,7 @@ export function buildFindManyQuery(database, tableName, table, args = {}) {
12
12
  }
13
13
  }
14
14
  const clauses = [
15
- `SELECT ${selectSQL} FROM ${tableRef(database, tableName, table)}`,
15
+ `SELECT ${selectSQL} FROM ${tableRef(database, tableName, table, args.final)}`,
16
16
  ];
17
17
  // WHERE
18
18
  if (args.where) {
@@ -0,0 +1,2 @@
1
+ import type { IndexDefinition } from "./table.js";
2
+ export declare function buildIndexSQL(index: IndexDefinition): string;
@@ -0,0 +1,11 @@
1
+ // src/build-index-sql.ts
2
+ import { escapeId } from "./escape-id.js";
3
+ import { renderKeyPart } from "./ddl-shared.js";
4
+ export function buildIndexSQL(index) {
5
+ const args = index.args?.length ? `(${index.args.join(", ")})` : "";
6
+ const granularity = index.granularity !== undefined
7
+ ? ` GRANULARITY ${index.granularity}`
8
+ : "";
9
+ return (`INDEX ${escapeId(index.name)} ${renderKeyPart(index.expr)} ` +
10
+ `TYPE ${index.type}${args}${granularity}`);
11
+ }
package/dist/client.d.ts CHANGED
@@ -1,18 +1,32 @@
1
+ import { type ClickHouseClient } from "@clickhouse/client";
1
2
  import { TableClient } from "./table-client.js";
2
3
  import { AnyTable, InferRow } from "./table.js";
4
+ import type { AnyDictionary } from "./dictionary.js";
3
5
  import { MigrationPlan } from "./migrate.js";
6
+ import { type DdlStatement } from "./ensure-tables-on-nodes.js";
7
+ import { type QueryJsonResult, type QueryOptions } from "./query.js";
4
8
  type ClientOptions = {
5
9
  url: string;
6
10
  username?: string;
7
11
  password?: string;
8
12
  database?: string;
9
13
  autoCreateDatabase?: boolean;
14
+ ddlNodes?: string[];
15
+ requestTimeoutMs?: number;
16
+ };
17
+ export type DdlPlan = MigrationPlan & {
18
+ executed: DdlStatement[];
10
19
  };
11
20
  export declare function createClient<TSchema extends {
12
21
  database?: string;
13
22
  tables: Record<string, AnyTable>;
23
+ dictionaries?: Record<string, AnyDictionary>;
14
24
  }>(schema: TSchema, options: ClientOptions): { [K in keyof TSchema["tables"]]: TableClient<InferRow<TSchema["tables"][K]>>; } & {
15
- $init(): Promise<void>;
16
- $migrate(): Promise<MigrationPlan>;
25
+ $init(): Promise<DdlStatement[]>;
26
+ $migrate(): Promise<DdlPlan>;
27
+ $query<TRow>(sql: string, opts?: QueryOptions): Promise<TRow[]>;
28
+ $queryJson<TRow, TTotals = TRow>(sql: string, opts?: QueryOptions): Promise<QueryJsonResult<TRow, TTotals>>;
29
+ $command(sql: string, opts?: Omit<QueryOptions, "columns">): Promise<void>;
30
+ $close(): Promise<void>;
17
31
  };
18
- export {};
32
+ export type { ClickHouseClient };
package/dist/client.js CHANGED
@@ -1,27 +1,37 @@
1
- import { createClient as createCHClient } from "@clickhouse/client";
1
+ import { createClient as createCHClient, } from "@clickhouse/client";
2
2
  import { TableClient } from "./table-client.js";
3
- import { buildCreateTableSQL } from "./ddl.js";
4
- import { escapeId } from "./sql.js";
3
+ import { escapeId } from "./escape-id.js";
5
4
  import { buildMigrationPlan } from "./migrate.js";
5
+ import { assertReplicatedSchema } from "./assert-replicated-schema.js";
6
+ import { ensureTablesOnNodes, } from "./ensure-tables-on-nodes.js";
7
+ import { ensureDictionariesOnNodes } from "./ensure-dictionaries-on-nodes.js";
8
+ import { runCommand, runQuery, runQueryJson, } from "./query.js";
6
9
  export function createClient(schema, options) {
7
10
  const database = options.database ?? schema.database ?? "default";
8
- const ch = createCHClient({
9
- url: options.url,
11
+ const dictionaries = schema.dictionaries ?? {};
12
+ const connect = (url, withDatabase) => createCHClient({
13
+ url,
10
14
  username: options.username,
11
15
  password: options.password,
12
- database,
16
+ ...(withDatabase ? { database } : {}),
17
+ ...(options.requestTimeoutMs ? { request_timeout: options.requestTimeoutMs } : {}),
13
18
  });
19
+ const ch = connect(options.url, true);
20
+ // клиенты DDL-нод создаются один раз; основная нода переиспользует `ch`
21
+ const ddlUrls = options.ddlNodes?.length ? options.ddlNodes : [options.url];
22
+ const nodes = ddlUrls.map((url) => ({
23
+ url,
24
+ client: url === options.url ? ch : connect(url, true),
25
+ }));
14
26
  const db = {};
15
27
  for (const tableName in schema.tables) {
16
28
  db[tableName] = new TableClient(database, tableName, schema.tables[tableName], ch);
17
29
  }
18
30
  const ensureDatabase = async () => {
19
- if (options.autoCreateDatabase) {
20
- const rootClient = createCHClient({
21
- url: options.url,
22
- username: options.username,
23
- password: options.password,
24
- });
31
+ if (!options.autoCreateDatabase)
32
+ return;
33
+ for (const node of nodes) {
34
+ const rootClient = connect(node.url, false);
25
35
  try {
26
36
  await rootClient.command({
27
37
  query: `CREATE DATABASE IF NOT EXISTS ${escapeId(database)}`,
@@ -32,15 +42,19 @@ export function createClient(schema, options) {
32
42
  }
33
43
  }
34
44
  };
45
+ const ensureEverywhere = async (dictionaryMode) => [
46
+ ...(await ensureTablesOnNodes(nodes, database, schema.tables)),
47
+ ...(await ensureDictionariesOnNodes(nodes, database, dictionaries, options.username
48
+ ? { user: options.username, password: options.password }
49
+ : undefined, dictionaryMode)),
50
+ ];
35
51
  db.$init = async () => {
52
+ assertReplicatedSchema(nodes.length, schema.tables);
36
53
  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
- }
54
+ return ensureEverywhere("create");
42
55
  };
43
56
  db.$migrate = async () => {
57
+ assertReplicatedSchema(nodes.length, schema.tables);
44
58
  await ensureDatabase();
45
59
  const result = await ch.query({
46
60
  query: `
@@ -54,10 +68,23 @@ export function createClient(schema, options) {
54
68
  });
55
69
  const existing = await result.json();
56
70
  const plan = buildMigrationPlan(database, schema.tables, existing);
71
+ // план (CREATE недостающих, ALTER ADD/MODIFY) выполняется на первой
72
+ // ноде: реплицируемый ALTER сам разъезжается по репликам, а недостающие
73
+ // на репликах таблицы докладывает ensureTablesOnNodes копией SHOW CREATE
74
+ const executed = [];
57
75
  for (const statement of plan.statements) {
58
76
  await ch.command({ query: statement });
77
+ executed.push({ node: options.url, sql: statement });
59
78
  }
60
- return plan;
79
+ executed.push(...(await ensureEverywhere("replace")));
80
+ return { ...plan, executed };
81
+ };
82
+ db.$query = (sql, opts) => runQuery(ch, sql, opts);
83
+ db.$queryJson = (sql, opts) => runQueryJson(ch, sql, opts);
84
+ db.$command = (sql, opts) => runCommand(ch, sql, opts);
85
+ db.$close = async () => {
86
+ for (const node of nodes)
87
+ await node.client.close();
61
88
  };
62
89
  return db;
63
90
  }
package/dist/column.d.ts CHANGED
@@ -1,17 +1,46 @@
1
+ export type ColumnDefault = {
2
+ kind: "DEFAULT" | "MATERIALIZED" | "ALIAS";
3
+ expr: string;
4
+ };
1
5
  export type Column<T> = {
2
6
  clickhouseType: string;
3
7
  nullable: boolean;
8
+ default?: ColumnDefault;
9
+ codec?: string;
10
+ comment?: string;
4
11
  __type?: T;
5
12
  };
6
13
  type NullableColumn<C extends Column<unknown>> = C extends Column<infer T> ? Column<T | null> : never;
14
+ type ColumnType<C extends Column<unknown>> = C extends Column<infer T> ? T : never;
7
15
  export declare const t: {
8
16
  string: () => Column<string>;
9
17
  uint8: () => Column<number>;
18
+ uint16: () => Column<number>;
19
+ uint32: () => Column<number>;
10
20
  uint64: () => Column<bigint>;
21
+ int8: () => Column<number>;
22
+ int16: () => Column<number>;
11
23
  int32: () => Column<number>;
24
+ int64: () => Column<bigint>;
25
+ float32: () => Column<number>;
26
+ float64: () => Column<number>;
27
+ decimal: (precision: number, scale: number) => Column<string>;
12
28
  boolean: () => Column<boolean>;
29
+ date: () => Column<Date>;
30
+ date32: () => Column<Date>;
13
31
  datetime: () => Column<Date>;
32
+ datetime64: (precision?: number, timezone?: string) => Column<Date>;
33
+ ipv4: () => Column<string>;
34
+ ipv6: () => Column<string>;
35
+ enum8<TValues extends Record<string, number>>(values: TValues): Column<keyof TValues & string>;
36
+ lowCardinality<C extends Column<unknown>>(col: C): C;
37
+ map<K extends Column<unknown>, V extends Column<unknown>>(key: K, value: V): Column<Record<string, ColumnType<V>>>;
14
38
  nullable<C extends Column<unknown>>(col: C): NullableColumn<C>;
15
- array<C extends Column<unknown>>(col: C): Column<C extends Column<infer T> ? T[] : never>;
39
+ array<C extends Column<unknown>>(col: C): Column<ColumnType<C>[]>;
40
+ default<C extends Column<unknown>>(col: C, expr: string): C;
41
+ materialized<C extends Column<unknown>>(col: C, expr: string): C;
42
+ alias<C extends Column<unknown>>(col: C, expr: string): C;
43
+ codec<C extends Column<unknown>>(col: C, codec: string): C;
44
+ comment<C extends Column<unknown>>(col: C, comment: string): C;
16
45
  };
17
46
  export {};
package/dist/column.js CHANGED
@@ -8,12 +8,50 @@ function makeColumn(clickhouseType, nullable = false) {
8
8
  export const t = {
9
9
  string: () => makeColumn("String"),
10
10
  uint8: () => makeColumn("UInt8"),
11
+ uint16: () => makeColumn("UInt16"),
12
+ uint32: () => makeColumn("UInt32"),
13
+ // 64-битные целые не помещаются в number без потерь — только bigint
11
14
  uint64: () => makeColumn("UInt64"),
15
+ int8: () => makeColumn("Int8"),
16
+ int16: () => makeColumn("Int16"),
12
17
  int32: () => makeColumn("Int32"),
18
+ int64: () => makeColumn("Int64"),
19
+ float32: () => makeColumn("Float32"),
20
+ float64: () => makeColumn("Float64"),
21
+ // Decimal отдаётся строкой: number потерял бы точность, а bigint не знает
22
+ // про дробную часть — арифметику ведёт вызывающий код
23
+ decimal: (precision, scale) => makeColumn(`Decimal(${precision}, ${scale})`),
13
24
  boolean: () => makeColumn("Bool"),
25
+ date: () => makeColumn("Date"),
26
+ date32: () => makeColumn("Date32"),
14
27
  datetime: () => makeColumn("DateTime"),
28
+ datetime64: (precision = 3, timezone) => makeColumn(timezone
29
+ ? `DateTime64(${precision}, '${timezone}')`
30
+ : `DateTime64(${precision})`),
31
+ ipv4: () => makeColumn("IPv4"),
32
+ ipv6: () => makeColumn("IPv6"),
33
+ // Enum8: TS-тип — объединение имён значений, на проводе те же строки
34
+ enum8(values) {
35
+ const items = Object.entries(values)
36
+ .map(([name, code]) => `'${name.replace(/'/g, "''")}' = ${code}`)
37
+ .join(", ");
38
+ return makeColumn(`Enum8(${items})`);
39
+ },
40
+ // LowCardinality не меняет TS-тип и признак nullable: это только способ
41
+ // хранения словарём, который для строк с малым числом значений даёт
42
+ // и сжатие, и быстрый GROUP BY
43
+ lowCardinality(col) {
44
+ return {
45
+ ...col,
46
+ clickhouseType: `LowCardinality(${col.clickhouseType})`,
47
+ };
48
+ },
49
+ map(key, value) {
50
+ return makeColumn(`Map(${key.clickhouseType}, ${value.clickhouseType})`);
51
+ },
15
52
  nullable(col) {
16
53
  return {
54
+ ...col,
17
55
  clickhouseType: `Nullable(${col.clickhouseType})`,
18
56
  nullable: true,
19
57
  };
@@ -21,4 +59,21 @@ export const t = {
21
59
  array(col) {
22
60
  return makeColumn(`Array(${col.clickhouseType})`);
23
61
  },
62
+ // модификаторы возвращают копию: одна фабрика может описывать несколько
63
+ // колонок разных таблиц, общий объект нельзя мутировать
64
+ default(col, expr) {
65
+ return { ...col, default: { kind: "DEFAULT", expr } };
66
+ },
67
+ materialized(col, expr) {
68
+ return { ...col, default: { kind: "MATERIALIZED", expr } };
69
+ },
70
+ alias(col, expr) {
71
+ return { ...col, default: { kind: "ALIAS", expr } };
72
+ },
73
+ codec(col, codec) {
74
+ return { ...col, codec };
75
+ },
76
+ comment(col, comment) {
77
+ return { ...col, comment };
78
+ },
24
79
  };
@@ -0,0 +1,13 @@
1
+ import type { EngineSpec } from "./engine.js";
2
+ import type { Expr, KeyPart } from "./expr.js";
3
+ import type { AnyTable } from "./table.js";
4
+ export declare function isExpr(part: KeyPart): part is Expr;
5
+ export declare function renderKeyPart(part: KeyPart): string;
6
+ export declare function renderKeyList(parts: KeyPart[]): string;
7
+ export declare function keyColumns(parts: KeyPart[]): string[];
8
+ export declare function quoteString(value: string): string;
9
+ export declare function engineFamily(engine: string | EngineSpec): string;
10
+ export declare function isReplicatedEngine(table: AnyTable): boolean;
11
+ export declare function isReplacingEngine(engine: string | EngineSpec): boolean;
12
+ export declare function collapsingOf(engine: string | EngineSpec): EngineSpec["collapsing"] | undefined;
13
+ export declare function renderEngine(table: AnyTable): string;
@@ -0,0 +1,54 @@
1
+ // src/ddl-shared.ts
2
+ // Общие хелперы рендера DDL: разбор движка и ключей, экранирование.
3
+ import { escapeId } from "./escape-id.js";
4
+ export function isExpr(part) {
5
+ return typeof part === "object" && part !== null && "__expr" in part;
6
+ }
7
+ export function renderKeyPart(part) {
8
+ return isExpr(part) ? part.__expr : escapeId(part);
9
+ }
10
+ export function renderKeyList(parts) {
11
+ return `(${parts.map(renderKeyPart).join(", ")})`;
12
+ }
13
+ // имена колонок ключа без выражений — для проверок «ключ нельзя менять»
14
+ export function keyColumns(parts) {
15
+ return parts.filter((p) => typeof p === "string");
16
+ }
17
+ export function quoteString(value) {
18
+ return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
19
+ }
20
+ export function engineFamily(engine) {
21
+ if (typeof engine !== "string")
22
+ return engine.family;
23
+ // у сырой строки семейство — имя до скобки, без префикса Replicated
24
+ return engine
25
+ .replace(/^Replicated/, "")
26
+ .replace(/\(.*$/s, "")
27
+ .trim();
28
+ }
29
+ export function isReplicatedEngine(table) {
30
+ if (typeof table.engine === "string") {
31
+ return table.engine.startsWith("Replicated");
32
+ }
33
+ return table.replication !== undefined;
34
+ }
35
+ export function isReplacingEngine(engine) {
36
+ return engineFamily(engine).includes("ReplacingMergeTree");
37
+ }
38
+ export function collapsingOf(engine) {
39
+ return typeof engine === "string" ? undefined : engine.collapsing;
40
+ }
41
+ export function renderEngine(table) {
42
+ if (typeof table.engine === "string")
43
+ return table.engine;
44
+ const { family, args } = table.engine;
45
+ if (!table.replication) {
46
+ return args.length ? `${family}(${args.join(", ")})` : family;
47
+ }
48
+ const replicated = [
49
+ quoteString(table.replication.path),
50
+ quoteString(table.replication.replica),
51
+ ...args,
52
+ ];
53
+ return `Replicated${family}(${replicated.join(", ")})`;
54
+ }
package/dist/ddl.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  import { AnyTable } from "./table.js";
2
+ import type { Column } from "./column.js";
3
+ export declare function buildColumnSQL(name: string, col: Column<unknown>): string;
2
4
  export declare function buildCreateTableSQL(database: string, tableName: string, table: AnyTable): string;