@quark-fw/clisma 0.1.14 → 0.1.16

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/README.md CHANGED
@@ -255,6 +255,15 @@ const db = createClient(schema, {
255
255
  - таблицы нет → `CREATE TABLE`
256
256
  - колонки нет → `ALTER TABLE ... ADD COLUMN`
257
257
  - тип отличается → `ALTER TABLE ... MODIFY COLUMN`
258
+ - объявленная в `settings` настройка отличается от `system.tables.engine_full` →
259
+ `ALTER TABLE ... MODIFY SETTING` (правка метаданных, данные не переписываются);
260
+ настройки, которых нет в схеме, не трогаются. Настройки — локальные метаданные
261
+ ноды и **не реплицируются**, поэтому сравниваются и меняются на каждой ноде из
262
+ `ddlNodes` отдельно (`plan.settings`: `{ node, sql }`)
263
+
264
+ `db.$migrate({ dryRun: true })` только строит план: ничего не выполняет, не
265
+ создаёт БД и таблицы на нодах (`executed` пустой) — проверка перед прогоном на
266
+ живой базе.
258
267
 
259
268
  Возвращает `{ statements, warnings, executed }` — план, предупреждения и
260
269
  фактически выполненные SQL с нодой (`executed[].node`). Повторный запуск на
@@ -266,7 +275,7 @@ const db = createClient(schema, {
266
275
  - лишние колонки в БД **никогда не удаляются** — только warning;
267
276
  - сравнивается только тип колонки: кодеки, `DEFAULT`, комментарии в диффе не
268
277
  участвуют (в `ADD COLUMN` они рендерятся);
269
- - `ENGINE`, `ORDER BY`, `PARTITION BY`, индексы, TTL не диффятся — их
278
+ - `ENGINE`, `ORDER BY`, `PARTITION BY`, индексы, TTL не диффятся (настройки — диффятся) — их
270
279
  изменение требует пересоздания таблицы вручную;
271
280
  - смена типа ключевой колонки (из `ORDER BY`/`PRIMARY KEY`) — ошибка до
272
281
  выполнения плана: сервер её всё равно отклонит, лучше упасть целиком, чем
package/dist/client.d.ts CHANGED
@@ -15,15 +15,19 @@ type ClientOptions = {
15
15
  requestTimeoutMs?: number;
16
16
  };
17
17
  export type DdlPlan = MigrationPlan & {
18
+ settings: DdlStatement[];
18
19
  executed: DdlStatement[];
19
20
  };
21
+ export type MigrateOptions = {
22
+ dryRun?: boolean;
23
+ };
20
24
  export declare function createClient<TSchema extends {
21
25
  database?: string;
22
26
  tables: Record<string, AnyTable>;
23
27
  dictionaries?: Record<string, AnyDictionary>;
24
28
  }>(schema: TSchema, options: ClientOptions): { [K in keyof TSchema["tables"]]: TableClient<InferRow<TSchema["tables"][K]>>; } & {
25
29
  $init(): Promise<DdlStatement[]>;
26
- $migrate(): Promise<DdlPlan>;
30
+ $migrate(opts?: MigrateOptions): Promise<DdlPlan>;
27
31
  $query<TRow>(sql: string, opts?: QueryOptions): Promise<TRow[]>;
28
32
  $queryJson<TRow, TTotals = TRow>(sql: string, opts?: QueryOptions): Promise<QueryJsonResult<TRow, TTotals>>;
29
33
  $command(sql: string, opts?: Omit<QueryOptions, "columns">): Promise<void>;
package/dist/client.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { createClient as createCHClient, } from "@clickhouse/client";
2
2
  import { TableClient } from "./table-client.js";
3
3
  import { escapeId } from "./escape-id.js";
4
- import { buildMigrationPlan } from "./migrate.js";
4
+ import { buildMigrationPlan, buildSettingsPlan, parseEngineSettings, } from "./migrate.js";
5
5
  import { assertReplicatedSchema } from "./assert-replicated-schema.js";
6
6
  import { ensureTablesOnNodes, } from "./ensure-tables-on-nodes.js";
7
7
  import { ensureDictionariesOnNodes } from "./ensure-dictionaries-on-nodes.js";
8
+ import { MigrationError } from "./migration-error.js";
8
9
  import { runCommand, runQuery, runQueryJson, } from "./query.js";
9
10
  export function createClient(schema, options) {
10
11
  const database = options.database ?? schema.database ?? "default";
@@ -14,7 +15,9 @@ export function createClient(schema, options) {
14
15
  username: options.username,
15
16
  password: options.password,
16
17
  ...(withDatabase ? { database } : {}),
17
- ...(options.requestTimeoutMs ? { request_timeout: options.requestTimeoutMs } : {}),
18
+ ...(options.requestTimeoutMs
19
+ ? { request_timeout: options.requestTimeoutMs }
20
+ : {}),
18
21
  });
19
22
  const ch = connect(options.url, true);
20
23
  // клиенты DDL-нод создаются один раз; основная нода переиспользует `ch`
@@ -23,6 +26,9 @@ export function createClient(schema, options) {
23
26
  url,
24
27
  client: url === options.url ? ch : connect(url, true),
25
28
  }));
29
+ // источник истины схемы: `url` может быть балансировщиком или нодой вне
30
+ // ddlNodes, и план с него разошёлся бы с тем, куда пишется DDL
31
+ const primary = nodes[0];
26
32
  const db = {};
27
33
  for (const tableName in schema.tables) {
28
34
  db[tableName] = new TableClient(database, tableName, schema.tables[tableName], ch);
@@ -48,15 +54,28 @@ export function createClient(schema, options) {
48
54
  ? { user: options.username, password: options.password }
49
55
  : undefined, dictionaryMode)),
50
56
  ];
57
+ const readTableSettings = async (client) => {
58
+ const result = await client.query({
59
+ query: `SELECT name, engine_full FROM system.tables WHERE database = {db:String}`,
60
+ query_params: { db: database },
61
+ format: "JSONEachRow",
62
+ });
63
+ const out = {};
64
+ for (const row of await result.json()) {
65
+ out[row.name] = parseEngineSettings(row.engine_full);
66
+ }
67
+ return out;
68
+ };
51
69
  db.$init = async () => {
52
70
  assertReplicatedSchema(nodes.length, schema.tables);
53
71
  await ensureDatabase();
54
72
  return ensureEverywhere("create");
55
73
  };
56
- db.$migrate = async () => {
74
+ db.$migrate = async (opts) => {
57
75
  assertReplicatedSchema(nodes.length, schema.tables);
58
- await ensureDatabase();
59
- const result = await ch.query({
76
+ if (!opts?.dryRun)
77
+ await ensureDatabase();
78
+ const result = await primary.client.query({
60
79
  query: `
61
80
  SELECT table, name, type
62
81
  FROM system.columns
@@ -68,16 +87,46 @@ export function createClient(schema, options) {
68
87
  });
69
88
  const existing = await result.json();
70
89
  const plan = buildMigrationPlan(database, schema.tables, existing);
90
+ const settings = [];
91
+ for (const node of nodes) {
92
+ for (const sql of buildSettingsPlan(database, schema.tables, await readTableSettings(node.client))) {
93
+ settings.push({ node: node.url, sql });
94
+ }
95
+ }
96
+ // dryRun: только план — ни CREATE DATABASE, ни DDL на нодах
97
+ if (opts?.dryRun)
98
+ return { ...plan, settings, executed: [] };
71
99
  // план (CREATE недостающих, ALTER ADD/MODIFY) выполняется на первой
72
100
  // ноде: реплицируемый ALTER сам разъезжается по репликам, а недостающие
73
- // на репликах таблицы докладывает ensureTablesOnNodes копией SHOW CREATE
101
+ // на репликах таблицы докладывает ensureTablesOnNodes копией SHOW CREATE.
102
+ // Сбой посередине — MigrationError со списком уже выполненного
74
103
  const executed = [];
75
- for (const statement of plan.statements) {
76
- await ch.command({ query: statement });
77
- executed.push({ node: options.url, sql: statement });
104
+ const run = async (node, sql) => {
105
+ try {
106
+ await node.client.command({ query: sql });
107
+ }
108
+ catch (cause) {
109
+ throw new MigrationError({
110
+ executed,
111
+ failed: { node: node.url, sql },
112
+ cause,
113
+ });
114
+ }
115
+ executed.push({ node: node.url, sql });
116
+ };
117
+ for (const statement of plan.statements)
118
+ await run(primary, statement);
119
+ try {
120
+ executed.push(...(await ensureEverywhere("replace")));
121
+ }
122
+ catch (cause) {
123
+ throw new MigrationError({ executed, cause });
124
+ }
125
+ for (const statement of settings) {
126
+ const node = nodes.find((n) => n.url === statement.node);
127
+ await run(node, statement.sql);
78
128
  }
79
- executed.push(...(await ensureEverywhere("replace")));
80
- return { ...plan, executed };
129
+ return { ...plan, settings, executed };
81
130
  };
82
131
  db.$query = (sql, opts) => runQuery(ch, sql, opts);
83
132
  db.$queryJson = (sql, opts) => runQueryJson(ch, sql, opts);
package/dist/ddl.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { AnyTable } from "./table.js";
2
2
  import type { Column } from "./column.js";
3
+ export declare function renderSettings(settings: Record<string, string | number>): string;
3
4
  export declare function buildColumnSQL(name: string, col: Column<unknown>): string;
4
5
  export declare function buildCreateTableSQL(database: string, tableName: string, table: AnyTable): string;
package/dist/ddl.js CHANGED
@@ -2,6 +2,14 @@
2
2
  import { escapeId } from "./escape-id.js";
3
3
  import { buildIndexSQL } from "./build-index-sql.js";
4
4
  import { quoteString, renderEngine, renderKeyList, renderKeyPart, } from "./ddl-shared.js";
5
+ // список для `SETTINGS` / `MODIFY SETTING`: числа как есть, строки в кавычках
6
+ export function renderSettings(settings) {
7
+ return Object.entries(settings)
8
+ .map(([key, value]) => typeof value === "number"
9
+ ? `${key} = ${value}`
10
+ : `${key} = ${quoteString(value)}`)
11
+ .join(", ");
12
+ }
5
13
  export function buildColumnSQL(name, col) {
6
14
  const parts = [escapeId(name), col.clickhouseType];
7
15
  if (col.default)
@@ -30,12 +38,7 @@ export function buildCreateTableSQL(database, tableName, table) {
30
38
  if (table.ttl)
31
39
  clauses.push(`TTL ${table.ttl}`);
32
40
  if (table.settings && Object.keys(table.settings).length) {
33
- const settings = Object.entries(table.settings)
34
- .map(([key, value]) => typeof value === "number"
35
- ? `${key} = ${value}`
36
- : `${key} = ${quoteString(value)}`)
37
- .join(", ");
38
- clauses.push(`SETTINGS ${settings}`);
41
+ clauses.push(`SETTINGS ${renderSettings(table.settings)}`);
39
42
  }
40
43
  if (table.comment)
41
44
  clauses.push(`COMMENT ${quoteString(table.comment)}`);
package/dist/index.d.ts CHANGED
@@ -11,6 +11,7 @@ export * from "./table-client.js";
11
11
  export * from "./rows.js";
12
12
  export * from "./migrate.js";
13
13
  export * from "./ddl.js";
14
+ export * from "./migration-error.js";
14
15
  export * from "./build-create-dictionary-query.js";
15
16
  export type { QueryOptions, QueryJsonResult, QuerySettings } from "./query.js";
16
17
  export type { DdlNode, DdlStatement } from "./ensure-tables-on-nodes.js";
package/dist/index.js CHANGED
@@ -11,4 +11,5 @@ export * from "./table-client.js";
11
11
  export * from "./rows.js";
12
12
  export * from "./migrate.js";
13
13
  export * from "./ddl.js";
14
+ export * from "./migration-error.js";
14
15
  export * from "./build-create-dictionary-query.js";
package/dist/migrate.d.ts CHANGED
@@ -4,8 +4,11 @@ export type DbColumn = {
4
4
  name: string;
5
5
  type: string;
6
6
  };
7
+ export type DbTableSettings = Record<string, Record<string, string>>;
7
8
  export type MigrationPlan = {
8
9
  statements: string[];
9
10
  warnings: string[];
10
11
  };
12
+ export declare function parseEngineSettings(engineFull: string): Record<string, string>;
11
13
  export declare function buildMigrationPlan(database: string, tables: Record<string, AnyTable>, existing: DbColumn[]): MigrationPlan;
14
+ export declare function buildSettingsPlan(database: string, tables: Record<string, AnyTable>, existingSettings: DbTableSettings): string[];
package/dist/migrate.js CHANGED
@@ -1,7 +1,22 @@
1
1
  // src/migrate.ts
2
- import { buildColumnSQL, buildCreateTableSQL } from "./ddl.js";
2
+ import { buildColumnSQL, buildCreateTableSQL, renderSettings } from "./ddl.js";
3
3
  import { escapeId } from "./escape-id.js";
4
4
  import { keyColumns } from "./ddl-shared.js";
5
+ // `SETTINGS a = 1, b = 'x'` из хвоста `system.tables.engine_full`
6
+ export function parseEngineSettings(engineFull) {
7
+ const out = {};
8
+ const at = engineFull.lastIndexOf(" SETTINGS ");
9
+ if (at === -1)
10
+ return out;
11
+ const re = /(\w+)\s*=\s*('(?:[^'\\]|\\.)*'|[^,]+)/g;
12
+ for (const m of engineFull.slice(at + " SETTINGS ".length).matchAll(re)) {
13
+ const raw = m[2].trim();
14
+ out[m[1]] = raw.startsWith("'")
15
+ ? raw.slice(1, -1).replace(/\\(.)/g, "$1")
16
+ : raw;
17
+ }
18
+ return out;
19
+ }
5
20
  export function buildMigrationPlan(database, tables, existing) {
6
21
  const statements = [];
7
22
  const warnings = [];
@@ -50,3 +65,27 @@ export function buildMigrationPlan(database, tables, existing) {
50
65
  }
51
66
  return { statements, warnings };
52
67
  }
68
+ // Настройки таблицы — локальные метаданные каждой ноды: MODIFY SETTING не
69
+ // реплицируется (в отличие от ADD/MODIFY COLUMN), поэтому план настроек
70
+ // строится и выполняется на каждой ноде по её `system.tables`. Только
71
+ // объявленные в схеме настройки и только существующие на ноде таблицы —
72
+ // новые получат их из CREATE (или из SHOW CREATE копии). Правка метаданных,
73
+ // данные не переписываются.
74
+ export function buildSettingsPlan(database, tables, existingSettings) {
75
+ const statements = [];
76
+ for (const tableName in tables) {
77
+ const declared = tables[tableName].settings;
78
+ const actual = existingSettings[tableName];
79
+ if (!declared || !actual)
80
+ continue;
81
+ const changed = {};
82
+ for (const [key, value] of Object.entries(declared)) {
83
+ if (actual[key] !== String(value))
84
+ changed[key] = value;
85
+ }
86
+ if (Object.keys(changed).length) {
87
+ statements.push(`ALTER TABLE ${escapeId(database)}.${escapeId(tableName)} MODIFY SETTING ${renderSettings(changed)}`);
88
+ }
89
+ }
90
+ return statements;
91
+ }
@@ -0,0 +1,10 @@
1
+ import type { DdlStatement } from "./ensure-tables-on-nodes.js";
2
+ export declare class MigrationError extends Error {
3
+ readonly executed: DdlStatement[];
4
+ readonly failed?: DdlStatement;
5
+ constructor(args: {
6
+ executed: DdlStatement[];
7
+ failed?: DdlStatement;
8
+ cause: unknown;
9
+ });
10
+ }
@@ -0,0 +1,24 @@
1
+ // src/migration-error.ts
2
+ // DDL в ClickHouse не транзакционен: упавший посередине $migrate оставляет
3
+ // выполненную часть на нодах. Без списка выполненного вызывающий не знает,
4
+ // что уже применено, поэтому ошибка несёт его вместе с упавшим оператором.
5
+ export class MigrationError extends Error {
6
+ // успешно выполненные до сбоя операторы, в порядке выполнения
7
+ executed;
8
+ // упавший оператор; нет, если сбой внутри докладки таблиц/словарей на
9
+ // ноды — тогда их выполненная часть видна только по самим нодам
10
+ failed;
11
+ constructor(args) {
12
+ const reason = args.cause instanceof Error
13
+ ? args.cause.message
14
+ : String(args.cause);
15
+ super(`$migrate failed after ${args.executed.length} statement(s)` +
16
+ (args.failed
17
+ ? ` on ${args.failed.node}: ${args.failed.sql}`
18
+ : "") +
19
+ `\n${reason}`, { cause: args.cause });
20
+ this.name = "MigrationError";
21
+ this.executed = args.executed;
22
+ this.failed = args.failed;
23
+ }
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quark-fw/clisma",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Prisma-like typed client and schema migrator for ClickHouse",
5
5
  "keywords": [
6
6
  "clickhouse",
@@ -31,9 +31,9 @@
31
31
  "scripts": {
32
32
  "build": "tsc -p tsconfig.build.json",
33
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 src/test/ddl.test.ts",
36
- "prepublishOnly": "npm run typecheck && npm run test:unit && npm run build"
34
+ "test:integration": "tsx --env-file=./src/test/.env ./src/test/index.ts",
35
+ "test": "tsx --test src/test/unit.test.ts src/test/ddl.test.ts src/test/migrate-nodes.test.ts",
36
+ "prepublishOnly": "npm run typecheck && npm test && npm run build"
37
37
  },
38
38
  "dependencies": {
39
39
  "@clickhouse/client": "^1.17.0"