@quark-fw/clisma 0.1.13 → 0.1.15

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 (43) hide show
  1. package/README.md +135 -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 +21 -3
  11. package/dist/client.js +75 -21
  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 +3 -0
  17. package/dist/ddl.js +42 -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.d.ts +3 -0
  33. package/dist/migrate.js +52 -5
  34. package/dist/query.d.ts +26 -0
  35. package/dist/query.js +60 -0
  36. package/dist/rows.js +9 -0
  37. package/dist/schema.d.ts +4 -2
  38. package/dist/sql-shared.d.ts +5 -2
  39. package/dist/sql-shared.js +16 -7
  40. package/dist/table-client.js +9 -2
  41. package/dist/table.d.ts +26 -4
  42. package/dist/table.js +24 -3
  43. 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
 
@@ -160,16 +255,31 @@ const db = createClient(schema, {
160
255
  - таблицы нет → `CREATE TABLE`
161
256
  - колонки нет → `ALTER TABLE ... ADD COLUMN`
162
257
  - тип отличается → `ALTER TABLE ... MODIFY COLUMN`
258
+ - объявленная в `settings` настройка отличается от `system.tables.engine_full` →
259
+ `ALTER TABLE ... MODIFY SETTING` (правка метаданных, данные не переписываются);
260
+ настройки, которых нет в схеме, не трогаются. Настройки — локальные метаданные
261
+ ноды и **не реплицируются**, поэтому сравниваются и меняются на каждой ноде из
262
+ `ddlNodes` отдельно (`plan.settings`: `{ node, sql }`)
163
263
 
164
- Возвращает `{ statements, warnings }` — применённые SQL и предупреждения.
165
- Повторный запуск на совпадающей схеме — пустой план (идемпотентно).
264
+ `db.$migrate({ dryRun: true })` только строит план: ничего не выполняет, не
265
+ создаёт БД и таблицы на нодах (`executed` пустой) — проверка перед прогоном на
266
+ живой базе.
267
+
268
+ Возвращает `{ statements, warnings, executed }` — план, предупреждения и
269
+ фактически выполненные SQL с нодой (`executed[].node`). Повторный запуск на
270
+ совпадающей схеме — пустой план (идемпотентно); словари при этом всё равно
271
+ пересоздаются (`CREATE OR REPLACE`, дёшево).
166
272
 
167
273
  **Ограничения (осознанные):**
168
274
 
169
275
  - лишние колонки в БД **никогда не удаляются** — только warning;
170
- - `ENGINE`, `ORDER BY`, `PARTITION BY` не диффятся — их изменение требует
171
- пересоздания таблицы вручную;
172
- - `MODIFY COLUMN` ключевой колонки (из `ORDER BY`) отклонит сам ClickHouse;
276
+ - сравнивается только тип колонки: кодеки, `DEFAULT`, комментарии в диффе не
277
+ участвуют (в `ADD COLUMN` они рендерятся);
278
+ - `ENGINE`, `ORDER BY`, `PARTITION BY`, индексы, TTL не диффятся (настройки — диффятся) — их
279
+ изменение требует пересоздания таблицы вручную;
280
+ - смена типа ключевой колонки (из `ORDER BY`/`PRIMARY KEY`) — ошибка до
281
+ выполнения плана: сервер её всё равно отклонит, лучше упасть целиком, чем
282
+ посреди миграции;
173
283
  - нет файлов/истории миграций как у `prisma migrate` — сравнение всегда
174
284
  с живой схемой.
175
285
 
@@ -179,9 +289,13 @@ const db = createClient(schema, {
179
289
 
180
290
  ### Чтение
181
291
 
182
- - `findMany({ where?, select?, orderBy?, take?, skip? })` → `Row[]`
292
+ - `findMany({ where?, select?, orderBy?, take?, skip?, final?, settings? })` → `Row[]`
183
293
  - `findFirst({ where?, select?, orderBy?, skip? })` → `Row | null`
184
- - `count({ where? })` → `number`
294
+ - `count({ where? })` → `number`; у коллапсирующих таблиц это `sum(sign)`
295
+
296
+ `final: true` добавляет `FINAL` таблице без стратегии `replacing`;
297
+ у коллапсирующих таблиц он обязателен для `findMany` (сырые строки содержат
298
+ погашенные версии). `settings` — настройки ClickHouse на этот запрос.
185
299
 
186
300
  ### Запись
187
301
 
@@ -285,9 +399,11 @@ npx tsc --noEmit # проверка типов — обязательный
285
399
 
286
400
  ## Ограничения / roadmap
287
401
 
288
- - нет `aggregate`, `groupBy`, `distinct` (план: `LIMIT 1 BY`), `findUnique`, `upsert`;
402
+ - нет `aggregate`, `groupBy`, `distinct` (план: `LIMIT 1 BY`), `findUnique`, `upsert` —
403
+ агрегаты пишутся сырым SQL через `$query`/`$queryJson`;
289
404
  - нет вложенного `not`, `mode: "insensitive"`, операторов для Array-колонок;
290
- - нет файлов миграций/истории; дифф только add/modify колонок;
291
- - нет `DateTime64`/`Decimal`/`Enum`/`LowCardinality`, `DEFAULT`, `PARTITION BY`, `TTL`;
405
+ - нет файлов миграций/истории; дифф только add/modify колонок по типу;
406
+ - нет `Nested`-сахара (объявляйте `"players.deposits": t.array(t.float64())`),
407
+ `Tuple`, `UUID`, материализованных представлений и `Distributed`;
292
408
  - `create` без серверного round-trip (см. выше);
293
- - нет relations/include — ClickHouse-философия: денормализуйте.
409
+ - нет 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,36 @@
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
+ settings: DdlStatement[];
19
+ executed: DdlStatement[];
20
+ };
21
+ export type MigrateOptions = {
22
+ dryRun?: boolean;
10
23
  };
11
24
  export declare function createClient<TSchema extends {
12
25
  database?: string;
13
26
  tables: Record<string, AnyTable>;
27
+ dictionaries?: Record<string, AnyDictionary>;
14
28
  }>(schema: TSchema, options: ClientOptions): { [K in keyof TSchema["tables"]]: TableClient<InferRow<TSchema["tables"][K]>>; } & {
15
- $init(): Promise<void>;
16
- $migrate(): Promise<MigrationPlan>;
29
+ $init(): Promise<DdlStatement[]>;
30
+ $migrate(opts?: MigrateOptions): Promise<DdlPlan>;
31
+ $query<TRow>(sql: string, opts?: QueryOptions): Promise<TRow[]>;
32
+ $queryJson<TRow, TTotals = TRow>(sql: string, opts?: QueryOptions): Promise<QueryJsonResult<TRow, TTotals>>;
33
+ $command(sql: string, opts?: Omit<QueryOptions, "columns">): Promise<void>;
34
+ $close(): Promise<void>;
17
35
  };
18
- export {};
36
+ 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";
5
- import { buildMigrationPlan } from "./migrate.js";
3
+ import { escapeId } from "./escape-id.js";
4
+ import { buildMigrationPlan, buildSettingsPlan, parseEngineSettings, } 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,16 +42,33 @@ export function createClient(schema, options) {
32
42
  }
33
43
  }
34
44
  };
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 });
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
+ ];
51
+ const readTableSettings = async (client) => {
52
+ const result = await client.query({
53
+ query: `SELECT name, engine_full FROM system.tables WHERE database = {db:String}`,
54
+ query_params: { db: database },
55
+ format: "JSONEachRow",
56
+ });
57
+ const out = {};
58
+ for (const row of await result.json()) {
59
+ out[row.name] = parseEngineSettings(row.engine_full);
41
60
  }
61
+ return out;
42
62
  };
43
- db.$migrate = async () => {
63
+ db.$init = async () => {
64
+ assertReplicatedSchema(nodes.length, schema.tables);
44
65
  await ensureDatabase();
66
+ return ensureEverywhere("create");
67
+ };
68
+ db.$migrate = async (opts) => {
69
+ assertReplicatedSchema(nodes.length, schema.tables);
70
+ if (!opts?.dryRun)
71
+ await ensureDatabase();
45
72
  const result = await ch.query({
46
73
  query: `
47
74
  SELECT table, name, type
@@ -54,10 +81,37 @@ export function createClient(schema, options) {
54
81
  });
55
82
  const existing = await result.json();
56
83
  const plan = buildMigrationPlan(database, schema.tables, existing);
84
+ const settings = [];
85
+ for (const node of nodes) {
86
+ for (const sql of buildSettingsPlan(database, schema.tables, await readTableSettings(node.client))) {
87
+ settings.push({ node: node.url, sql });
88
+ }
89
+ }
90
+ // dryRun: только план — ни CREATE DATABASE, ни DDL на нодах
91
+ if (opts?.dryRun)
92
+ return { ...plan, settings, executed: [] };
93
+ // план (CREATE недостающих, ALTER ADD/MODIFY) выполняется на первой
94
+ // ноде: реплицируемый ALTER сам разъезжается по репликам, а недостающие
95
+ // на репликах таблицы докладывает ensureTablesOnNodes копией SHOW CREATE
96
+ const executed = [];
57
97
  for (const statement of plan.statements) {
58
98
  await ch.command({ query: statement });
99
+ executed.push({ node: options.url, sql: statement });
59
100
  }
60
- return plan;
101
+ executed.push(...(await ensureEverywhere("replace")));
102
+ for (const statement of settings) {
103
+ const node = nodes.find((n) => n.url === statement.node);
104
+ await node.client.command({ query: statement.sql });
105
+ executed.push(statement);
106
+ }
107
+ return { ...plan, settings, executed };
108
+ };
109
+ db.$query = (sql, opts) => runQuery(ch, sql, opts);
110
+ db.$queryJson = (sql, opts) => runQueryJson(ch, sql, opts);
111
+ db.$command = (sql, opts) => runCommand(ch, sql, opts);
112
+ db.$close = async () => {
113
+ for (const node of nodes)
114
+ await node.client.close();
61
115
  };
62
116
  return db;
63
117
  }
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;