@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 +10 -1
- package/dist/client.d.ts +5 -1
- package/dist/client.js +60 -11
- package/dist/ddl.d.ts +1 -0
- package/dist/ddl.js +9 -6
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/migrate.d.ts +3 -0
- package/dist/migrate.js +40 -1
- package/dist/migration-error.d.ts +10 -0
- package/dist/migration-error.js +24 -0
- package/package.json +4 -4
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
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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.
|
|
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
|
|
36
|
-
"prepublishOnly": "npm run typecheck && npm
|
|
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"
|