@chkit/clickhouse 0.1.2-beta.7 → 0.2.0-beta.12

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 (45) hide show
  1. package/dist/alter-column-statement.d.ts +32 -0
  2. package/dist/alter-column-statement.d.ts.map +1 -0
  3. package/dist/alter-column-statement.js +137 -0
  4. package/dist/alter-column-statement.js.map +1 -0
  5. package/dist/async-insert-guard.d.ts +31 -0
  6. package/dist/async-insert-guard.d.ts.map +1 -0
  7. package/dist/async-insert-guard.js +55 -0
  8. package/dist/async-insert-guard.js.map +1 -0
  9. package/dist/canonicalize.d.ts +21 -0
  10. package/dist/canonicalize.d.ts.map +1 -0
  11. package/dist/canonicalize.js +70 -0
  12. package/dist/canonicalize.js.map +1 -0
  13. package/dist/create-table-parser.d.ts +3 -0
  14. package/dist/create-table-parser.d.ts.map +1 -1
  15. package/dist/create-table-parser.js +54 -43
  16. package/dist/create-table-parser.js.map +1 -1
  17. package/dist/ddl-propagation.d.ts +62 -6
  18. package/dist/ddl-propagation.d.ts.map +1 -1
  19. package/dist/ddl-propagation.js +459 -49
  20. package/dist/ddl-propagation.js.map +1 -1
  21. package/dist/e2e-testkit.d.ts +26 -4
  22. package/dist/e2e-testkit.d.ts.map +1 -1
  23. package/dist/e2e-testkit.js +56 -14
  24. package/dist/e2e-testkit.js.map +1 -1
  25. package/dist/exact-json.d.ts +27 -0
  26. package/dist/exact-json.d.ts.map +1 -0
  27. package/dist/exact-json.js +128 -0
  28. package/dist/exact-json.js.map +1 -0
  29. package/dist/index.d.ts +52 -9
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +220 -64
  32. package/dist/index.js.map +1 -1
  33. package/dist/insert-barrier.d.ts +32 -0
  34. package/dist/insert-barrier.d.ts.map +1 -0
  35. package/dist/insert-barrier.js +57 -0
  36. package/dist/insert-barrier.js.map +1 -0
  37. package/dist/replicas.d.ts +21 -0
  38. package/dist/replicas.d.ts.map +1 -0
  39. package/dist/replicas.js +39 -0
  40. package/dist/replicas.js.map +1 -0
  41. package/dist/view-details.d.ts +30 -0
  42. package/dist/view-details.d.ts.map +1 -0
  43. package/dist/view-details.js +73 -0
  44. package/dist/view-details.js.map +1 -0
  45. package/package.json +5 -2
@@ -1,18 +1,74 @@
1
1
  import type { ClickHouseExecutor } from './index.js';
2
- export declare function waitForTable(executor: ClickHouseExecutor, database: string, tableName: string): Promise<void>;
3
- export declare function waitForView(executor: ClickHouseExecutor, database: string, viewName: string): Promise<void>;
4
- export declare function waitForDictionary(executor: ClickHouseExecutor, database: string, dictionaryName: string): Promise<void>;
5
- export declare function waitForColumn(executor: ClickHouseExecutor, database: string, tableName: string, columnName: string): Promise<void>;
2
+ /**
3
+ * Where a propagation wait looks. Pass the configured `clickhouse.cluster`;
4
+ * without one the `default` cluster is probed (ObsessionDB). On a target with
5
+ * several replicas the wait holds until every replica shows the change: the
6
+ * next statement may land on any of them, and an ALTER that runs on a replica
7
+ * that has not applied the previous one can write back its stale schema.
8
+ */
9
+ export interface PropagationOptions {
10
+ cluster?: string;
11
+ /**
12
+ * The statement that ran. A MODIFY COLUMN wait reads what the statement sets
13
+ * from it, and a RENAME COLUMN wait reads the old and new names.
14
+ */
15
+ statement?: string;
16
+ }
17
+ export declare function waitForTable(executor: ClickHouseExecutor, database: string, tableName: string, options?: PropagationOptions): Promise<void>;
18
+ export declare function waitForView(executor: ClickHouseExecutor, database: string, viewName: string, options?: PropagationOptions): Promise<void>;
19
+ /**
20
+ * Waits until the dictionary's catalog row is on every replica.
21
+ *
22
+ * `system.dictionaries` is the wrong signal. It lists each replica's
23
+ * in-memory loader (`ExternalLoader` load results), not the replicated
24
+ * catalog. `CREATE OR REPLACE DICTIONARY` drops that loader entry while the
25
+ * replica reloads, so a replica can omit the dictionary for the whole retry
26
+ * budget after the DDL is already in `system.tables`. A following statement
27
+ * only needs the catalog object.
28
+ *
29
+ * On several replicas the wait holds until every replica stores the same
30
+ * non-nil `uuid`. A replace does not remove the row, and ClickHouse redacts
31
+ * a SOURCE password to `[HIDDEN]` in `create_table_query`, so a password-only
32
+ * replace compares equal to the previous definition. `CREATE OR REPLACE`
33
+ * still assigns one new UUID in the replicated DDL (an `ON CLUSTER` statement
34
+ * and a Replicated database), and every replica that has applied it reports
35
+ * that UUID. `metadata_modification_time` is each replica's metadata-file
36
+ * mtime at second precision: it can stay unchanged across a replace in the
37
+ * same second, and two replicas that have both applied the DDL can disagree.
38
+ */
39
+ export declare function waitForDictionary(executor: ClickHouseExecutor, database: string, dictionaryName: string, options?: PropagationOptions): Promise<void>;
40
+ export declare function waitForColumn(executor: ClickHouseExecutor, database: string, tableName: string, columnName: string, options?: PropagationOptions): Promise<void>;
41
+ export declare function waitForColumnAbsent(executor: ClickHouseExecutor, database: string, tableName: string, columnName: string, options?: PropagationOptions): Promise<void>;
42
+ /**
43
+ * Waits until every replica lists both tables of `EXCHANGE TABLES a AND b`
44
+ * with one uuid each, and the two uuids differ. The replica that ran the
45
+ * statement has swapped them, so once all replicas agree, all have. A
46
+ * statement that runs on a replica that has not applied the swap yet would
47
+ * see the old names: a following DROP of the pre-cutover table would drop the
48
+ * new one.
49
+ *
50
+ * With `clickhouse.cluster` the statement ran `ON CLUSTER`, which returns once
51
+ * every host executed it, and each host may hold its own uuids, so there is
52
+ * nothing to wait for. A single replica has nothing to wait for either.
53
+ */
54
+ export declare function waitForExchange(executor: ClickHouseExecutor, statement: string, options?: PropagationOptions): Promise<void>;
55
+ interface QualifiedName {
56
+ database: string;
57
+ name: string;
58
+ }
59
+ /** The two tables of `EXCHANGE TABLES db.a AND db.b`, or undefined. */
60
+ export declare function parseExchangeTables(statement: string): [QualifiedName, QualifiedName] | undefined;
6
61
  /**
7
62
  * Polls a query until its rows satisfy `predicate`, then returns them. Use for
8
63
  * reads that race DDL/DML propagation on managed ClickHouse (e.g. journal rows
9
64
  * written by a just-finished migration that aren't yet visible via FINAL).
10
65
  */
11
66
  export declare function waitForRows<T>(executor: ClickHouseExecutor, sql: string, predicate: (rows: T[]) => boolean, label?: string): Promise<T[]>;
12
- export declare function waitForTableAbsent(executor: ClickHouseExecutor, database: string, tableName: string): Promise<void>;
67
+ export declare function waitForTableAbsent(executor: ClickHouseExecutor, database: string, tableName: string, options?: PropagationOptions): Promise<void>;
13
68
  /**
14
69
  * Waits for DDL propagation based on the operation type and key.
15
70
  * Called after each DDL statement in the migration execution loop.
16
71
  */
17
- export declare function waitForDDLPropagation(executor: ClickHouseExecutor, operationType: string, operationKey: string): Promise<void>;
72
+ export declare function waitForDDLPropagation(executor: ClickHouseExecutor, operationType: string, operationKey: string, options?: PropagationOptions): Promise<void>;
73
+ export {};
18
74
  //# sourceMappingURL=ddl-propagation.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ddl-propagation.d.ts","sourceRoot":"","sources":["../src/ddl-propagation.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AAIpD,wBAAsB,YAAY,CAChC,QAAQ,EAAE,kBAAkB,EAC5B,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CASf;AAED,wBAAsB,WAAW,CAC/B,QAAQ,EAAE,kBAAkB,EAC5B,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,IAAI,CAAC,CASf;AAED,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,kBAAkB,EAC5B,QAAQ,EAAE,MAAM,EAChB,cAAc,EAAE,MAAM,GACrB,OAAO,CAAC,IAAI,CAAC,CASf;AAED,wBAAsB,aAAa,CACjC,QAAQ,EAAE,kBAAkB,EAC5B,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,IAAI,CAAC,CAWf;AAED;;;;GAIG;AACH,wBAAsB,WAAW,CAAC,CAAC,EACjC,QAAQ,EAAE,kBAAkB,EAC5B,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,OAAO,EACjC,KAAK,SAAgB,GACpB,OAAO,CAAC,CAAC,EAAE,CAAC,CAQd;AAED,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,kBAAkB,EAC5B,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,IAAI,CAAC,CAWf;AAwBD;;;GAGG;AACH,wBAAsB,qBAAqB,CACzC,QAAQ,EAAE,kBAAkB,EAC5B,aAAa,EAAE,MAAM,EACrB,YAAY,EAAE,MAAM,GACnB,OAAO,CAAC,IAAI,CAAC,CA0Cf"}
1
+ {"version":3,"file":"ddl-propagation.d.ts","sourceRoot":"","sources":["../src/ddl-propagation.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAA;AAwCpD;;;;;;GAMG;AACH,MAAM,WAAW,kBAAkB;IACjC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAsB,YAAY,CAChC,QAAQ,EAAE,kBAAkB,EAC5B,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,EACjB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAOf;AAED,wBAAsB,WAAW,CAC/B,QAAQ,EAAE,kBAAkB,EAC5B,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAOf;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,iBAAiB,CACrC,QAAQ,EAAE,kBAAkB,EAC5B,QAAQ,EAAE,MAAM,EAChB,cAAc,EAAE,MAAM,EACtB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAOf;AAED,wBAAsB,aAAa,CACjC,QAAQ,EAAE,kBAAkB,EAC5B,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAOf;AAED,wBAAsB,mBAAmB,CACvC,QAAQ,EAAE,kBAAkB,EAC5B,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAOf;AAgHD;;;;;;;;;;;GAWG;AACH,wBAAsB,eAAe,CACnC,QAAQ,EAAE,kBAAkB,EAC5B,SAAS,EAAE,MAAM,EACjB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAmBf;AAED,UAAU,aAAa;IACrB,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;CACb;AAQD,uEAAuE;AACvE,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,CAAC,aAAa,EAAE,aAAa,CAAC,GAAG,SAAS,CASjG;AAOD;;;;GAIG;AACH,wBAAsB,WAAW,CAAC,CAAC,EACjC,QAAQ,EAAE,kBAAkB,EAC5B,GAAG,EAAE,MAAM,EACX,SAAS,EAAE,CAAC,IAAI,EAAE,CAAC,EAAE,KAAK,OAAO,EACjC,KAAK,SAAgB,GACpB,OAAO,CAAC,CAAC,EAAE,CAAC,CAQd;AAED,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,kBAAkB,EAC5B,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,EACjB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAOf;AA4DD;;;GAGG;AACH,wBAAsB,qBAAqB,CACzC,QAAQ,EAAE,kBAAkB,EAC5B,aAAa,EAAE,MAAM,EACrB,YAAY,EAAE,MAAM,EACpB,OAAO,CAAC,EAAE,kBAAkB,GAC3B,OAAO,CAAC,IAAI,CAAC,CAyFf"}
@@ -1,36 +1,223 @@
1
1
  import pRetry from 'p-retry';
2
+ import { parseModifyColumnIntent, parseRenameColumn } from './alter-column-statement.js';
3
+ import { allReplicas, resolveReplicaFanout, stringLiteral } from './replicas.js';
2
4
  const RETRY_OPTIONS = { retries: 20, minTimeout: 500, factor: 1 };
3
- export async function waitForTable(executor, database, tableName) {
4
- await pRetry(async () => {
5
- const rows = await executor.query(`SELECT 1 AS x FROM system.tables WHERE database = '${database}' AND name = '${tableName}'`);
6
- if (rows.length === 0) {
7
- throw new Error(`waitForTable: ${database}.${tableName} not yet visible`);
8
- }
9
- }, RETRY_OPTIONS);
5
+ // Upper bound on a column definition or rename wait, including slow queries.
6
+ const DEFINITION_WAIT_DEADLINE_MS = 30_000;
7
+ // The `system.columns` fields a column definition wait compares.
8
+ const COLUMN_FIELDS = ['type', 'default_kind', 'default_expression', 'comment', 'compression_codec'];
9
+ // What each ALTER or rename appends to the `<kind>:<database>.<name>` key of the
10
+ // object it changes. Creates and drops use the bare key.
11
+ const KEY_SUFFIXES = new Map([
12
+ ['alter_materialized_view_modify_refresh', ':refresh'],
13
+ ['alter_table_add_column', ':column:'],
14
+ ['alter_table_modify_column', ':column:'],
15
+ ['alter_table_drop_column', ':column:'],
16
+ ['alter_table_rename_column', ':column_rename:'],
17
+ ['alter_table_add_index', ':index:'],
18
+ ['alter_table_drop_index', ':index:'],
19
+ ['alter_table_add_projection', ':projection:'],
20
+ ['alter_table_drop_projection', ':projection:'],
21
+ ['alter_table_add_constraint', ':constraint:'],
22
+ ['alter_table_drop_constraint', ':constraint:'],
23
+ ['alter_table_modify_setting', ':setting:'],
24
+ ['alter_table_reset_setting', ':setting:'],
25
+ ['alter_table_modify_ttl', ':ttl'],
26
+ ['alter_table_rename_table', ':rename_table'],
27
+ ['rename_dictionary', ':rename_dictionary'],
28
+ ]);
29
+ export async function waitForTable(executor, database, tableName, options) {
30
+ await waitForSystemRows(executor, {
31
+ source: 'system.tables',
32
+ where: `database = ${stringLiteral(database)} AND name = ${stringLiteral(tableName)}`,
33
+ want: 'present',
34
+ label: `waitForTable: ${database}.${tableName} not yet visible`,
35
+ }, options);
10
36
  }
11
- export async function waitForView(executor, database, viewName) {
12
- await pRetry(async () => {
13
- const rows = await executor.query(`SELECT 1 AS x FROM system.tables WHERE database = '${database}' AND name = '${viewName}' AND engine LIKE '%View%'`);
14
- if (rows.length === 0) {
15
- throw new Error(`waitForView: ${database}.${viewName} not yet visible`);
16
- }
17
- }, RETRY_OPTIONS);
37
+ export async function waitForView(executor, database, viewName, options) {
38
+ await waitForSystemRows(executor, {
39
+ source: 'system.tables',
40
+ where: `database = ${stringLiteral(database)} AND name = ${stringLiteral(viewName)} AND engine LIKE '%View%'`,
41
+ want: 'present',
42
+ label: `waitForView: ${database}.${viewName} not yet visible`,
43
+ }, options);
18
44
  }
19
- export async function waitForDictionary(executor, database, dictionaryName) {
45
+ /**
46
+ * Waits until the dictionary's catalog row is on every replica.
47
+ *
48
+ * `system.dictionaries` is the wrong signal. It lists each replica's
49
+ * in-memory loader (`ExternalLoader` load results), not the replicated
50
+ * catalog. `CREATE OR REPLACE DICTIONARY` drops that loader entry while the
51
+ * replica reloads, so a replica can omit the dictionary for the whole retry
52
+ * budget after the DDL is already in `system.tables`. A following statement
53
+ * only needs the catalog object.
54
+ *
55
+ * On several replicas the wait holds until every replica stores the same
56
+ * non-nil `uuid`. A replace does not remove the row, and ClickHouse redacts
57
+ * a SOURCE password to `[HIDDEN]` in `create_table_query`, so a password-only
58
+ * replace compares equal to the previous definition. `CREATE OR REPLACE`
59
+ * still assigns one new UUID in the replicated DDL (an `ON CLUSTER` statement
60
+ * and a Replicated database), and every replica that has applied it reports
61
+ * that UUID. `metadata_modification_time` is each replica's metadata-file
62
+ * mtime at second precision: it can stay unchanged across a replace in the
63
+ * same second, and two replicas that have both applied the DDL can disagree.
64
+ */
65
+ export async function waitForDictionary(executor, database, dictionaryName, options) {
66
+ const where = `database = ${stringLiteral(database)} AND name = ${stringLiteral(dictionaryName)} AND engine = 'Dictionary'`;
67
+ const label = `waitForDictionary: ${database}.${dictionaryName} not yet visible`;
68
+ const fanout = await resolveReplicaFanout(executor, options?.cluster);
20
69
  await pRetry(async () => {
21
- const rows = await executor.query(`SELECT 1 AS x FROM system.dictionaries WHERE database = '${database}' AND name = '${dictionaryName}'`);
22
- if (rows.length === 0) {
23
- throw new Error(`waitForDictionary: ${database}.${dictionaryName} not yet visible`);
24
- }
70
+ if (!(await dictionaryCatalogVisible(executor, where, fanout)))
71
+ throw new Error(label);
25
72
  }, RETRY_OPTIONS);
26
73
  }
27
- export async function waitForColumn(executor, database, tableName, columnName) {
28
- await pRetry(async () => {
29
- const rows = await executor.query(`SELECT 1 AS x FROM system.columns WHERE database = '${database}' AND table = '${tableName}' AND name = '${columnName}'`);
30
- if (rows.length === 0) {
31
- throw new Error(`waitForColumn: ${database}.${tableName}.${columnName} not yet visible`);
74
+ export async function waitForColumn(executor, database, tableName, columnName, options) {
75
+ await waitForSystemRows(executor, {
76
+ source: 'system.columns',
77
+ where: columnWhere(database, tableName, columnName),
78
+ want: 'present',
79
+ label: `waitForColumn: ${database}.${tableName}.${columnName} not yet visible`,
80
+ }, options);
81
+ }
82
+ export async function waitForColumnAbsent(executor, database, tableName, columnName, options) {
83
+ await waitForSystemRows(executor, {
84
+ source: 'system.columns',
85
+ where: columnWhere(database, tableName, columnName),
86
+ want: 'absent',
87
+ label: `waitForColumnAbsent: ${database}.${tableName}.${columnName} still present`,
88
+ }, options);
89
+ }
90
+ /**
91
+ * Waits until every replica reports the column with one definition, and with
92
+ * what `intent` says the statement set.
93
+ *
94
+ * Agreement: the replica that ran an ALTER has applied it when the statement
95
+ * returns, so once every replica reports the same `type`, `default_kind`,
96
+ * `default_expression`, `comment` and `compression_codec`, all of them have.
97
+ * Each replica formats these values itself, so the comparison is between
98
+ * server-formatted text and never trips over how chkit wrote an expression.
99
+ *
100
+ * Intent: agreement alone passes when every replica kept the old definition,
101
+ * e.g. ObsessionDB ignoring `MODIFY COLUMN ... EPHEMERAL <default>` (#285). So
102
+ * the fields the statement sets are compared too: the default kind and comment
103
+ * verbatim, a removed codec as empty, and the default expression after the
104
+ * server formats it with `formatQuerySingleLine`, the formatter that writes
105
+ * `system.columns.default_expression`. Types and codecs the statement sets are
106
+ * only compared between replicas: ClickHouse rewrites them (`Decimal(10,2)`
107
+ * becomes `Decimal(10, 2)`, `ZSTD` becomes `ZSTD(1)`).
108
+ */
109
+ async function waitForColumnDefinition(executor, database, tableName, columnName, intent, options) {
110
+ const fanout = await resolveReplicaFanout(executor, options?.cluster);
111
+ const expected = await expectedColumnValues(executor, intent);
112
+ const source = fanout ? allReplicas(fanout, 'system.columns') : 'system.columns';
113
+ const sql = `SELECT hostName() AS replica, ${COLUMN_FIELDS.join(', ')} FROM ${source} WHERE ${columnWhere(database, tableName, columnName)}`;
114
+ await waitUntilApplied(`${database}.${tableName}.${columnName} definition`, async () => columnDefinitionMismatch(await executor.query(sql), fanout?.replicas ?? 1, expected));
115
+ }
116
+ /** Waits until no replica lists the old name and every replica lists the new one. */
117
+ async function waitForColumnRename(executor, database, tableName, rename, options) {
118
+ const fanout = await resolveReplicaFanout(executor, options?.cluster);
119
+ const replicas = fanout?.replicas ?? 1;
120
+ const source = fanout ? allReplicas(fanout, 'system.columns') : 'system.columns';
121
+ const sql = `SELECT hostName() AS replica, name FROM ${source} WHERE database = ${stringLiteral(database)} AND table = ${stringLiteral(tableName)} AND name IN (${stringLiteral(rename.from)}, ${stringLiteral(rename.to)})`;
122
+ await waitUntilApplied(`${database}.${tableName} rename of column ${rename.from} to ${rename.to}`, async () => {
123
+ const rows = await executor.query(sql);
124
+ const stale = rows.filter((row) => row.name === rename.from).map((row) => row.replica);
125
+ if (stale.length > 0)
126
+ return `replica ${stale.join(', ')} still lists column ${rename.from}`;
127
+ const renamed = new Set(rows.filter((row) => row.name === rename.to).map((row) => row.replica));
128
+ if (renamed.size < replicas)
129
+ return `column ${rename.to} is listed on ${renamed.size} of ${replicas} replicas`;
130
+ return undefined;
131
+ });
132
+ }
133
+ async function waitForIndex(executor, database, tableName, indexName, want, options) {
134
+ await waitForSystemRows(executor, {
135
+ source: 'system.data_skipping_indices',
136
+ where: `database = ${stringLiteral(database)} AND table = ${stringLiteral(tableName)} AND name = ${stringLiteral(indexName)}`,
137
+ want,
138
+ label: `waitForIndex: ${database}.${tableName}.${indexName} ${want === 'present' ? 'not yet visible' : 'still present'}`,
139
+ }, options);
140
+ }
141
+ /**
142
+ * Waits until every replica's `create_table_query` lists the constraint. There
143
+ * is no system table of constraints. A replica that has not applied the ADD
144
+ * yet would let a following `DROP CONSTRAINT IF EXISTS` (a changed constraint)
145
+ * or `ADD CONSTRAINT IF NOT EXISTS` silently do nothing.
146
+ */
147
+ async function waitForConstraint(executor, database, tableName, constraintName, options) {
148
+ await waitForSystemRows(executor, {
149
+ source: 'system.tables',
150
+ where: constraintWhere(database, tableName, constraintName),
151
+ want: 'present',
152
+ label: `waitForConstraint: ${database}.${tableName}.${constraintName} not yet visible`,
153
+ }, options);
154
+ }
155
+ async function waitForConstraintAbsent(executor, database, tableName, constraintName, options) {
156
+ await waitForSystemRows(executor, {
157
+ source: 'system.tables',
158
+ where: constraintWhere(database, tableName, constraintName),
159
+ want: 'absent',
160
+ label: `waitForConstraintAbsent: ${database}.${tableName}.${constraintName} still present`,
161
+ }, options);
162
+ }
163
+ /**
164
+ * Waits until every replica lists both tables of `EXCHANGE TABLES a AND b`
165
+ * with one uuid each, and the two uuids differ. The replica that ran the
166
+ * statement has swapped them, so once all replicas agree, all have. A
167
+ * statement that runs on a replica that has not applied the swap yet would
168
+ * see the old names: a following DROP of the pre-cutover table would drop the
169
+ * new one.
170
+ *
171
+ * With `clickhouse.cluster` the statement ran `ON CLUSTER`, which returns once
172
+ * every host executed it, and each host may hold its own uuids, so there is
173
+ * nothing to wait for. A single replica has nothing to wait for either.
174
+ */
175
+ export async function waitForExchange(executor, statement, options) {
176
+ if (options?.cluster)
177
+ return;
178
+ const tables = parseExchangeTables(statement);
179
+ if (!tables)
180
+ return;
181
+ const fanout = await resolveReplicaFanout(executor);
182
+ if (!fanout)
183
+ return;
184
+ const [first, second] = tables;
185
+ const where = `(database = ${stringLiteral(first.database)} AND name = ${stringLiteral(first.name)}) OR (database = ${stringLiteral(second.database)} AND name = ${stringLiteral(second.name)})`;
186
+ const sql = `SELECT database, name, count(DISTINCT hostName()) AS replicas, uniqExact(uuid) AS uuids, any(toString(uuid)) AS sample_uuid FROM ${allReplicas(fanout, 'system.tables')} WHERE ${where} GROUP BY database, name`;
187
+ await waitUntilApplied(`EXCHANGE TABLES ${first.database}.${first.name} AND ${second.database}.${second.name}`, async () => {
188
+ const rows = await executor.query(sql);
189
+ if (rows.length < 2)
190
+ return `only ${rows.length} of the 2 tables are listed`;
191
+ for (const row of rows) {
192
+ if (Number(row.replicas) < fanout.replicas)
193
+ return `${row.database}.${row.name} is listed on ${row.replicas} of ${fanout.replicas} replicas`;
194
+ if (Number(row.uuids) !== 1)
195
+ return `replicas disagree on the uuid of ${row.database}.${row.name}`;
32
196
  }
33
- }, RETRY_OPTIONS);
197
+ if (rows[0]?.sample_uuid === rows[1]?.sample_uuid)
198
+ return 'both names report the same uuid';
199
+ return undefined;
200
+ });
201
+ }
202
+ const QUOTED_OR_BARE = String.raw `(?:\`(?:[^\`\\]|\\.)*\`|[^\s.\`;]+)`;
203
+ const EXCHANGE_PATTERN = new RegExp(String.raw `^\s*EXCHANGE\s+TABLES\s+(${QUOTED_OR_BARE})\.(${QUOTED_OR_BARE})\s+AND\s+(${QUOTED_OR_BARE})\.(${QUOTED_OR_BARE})`, 'i');
204
+ /** The two tables of `EXCHANGE TABLES db.a AND db.b`, or undefined. */
205
+ export function parseExchangeTables(statement) {
206
+ const match = EXCHANGE_PATTERN.exec(statement);
207
+ if (!match)
208
+ return undefined;
209
+ const [, db1, t1, db2, t2] = match;
210
+ if (!db1 || !t1 || !db2 || !t2)
211
+ return undefined;
212
+ return [
213
+ { database: unquote(db1), name: unquote(t1) },
214
+ { database: unquote(db2), name: unquote(t2) },
215
+ ];
216
+ }
217
+ function unquote(identifier) {
218
+ if (!identifier.startsWith('`'))
219
+ return identifier;
220
+ return identifier.slice(1, -1).replace(/\\(.)/g, '$1');
34
221
  }
35
222
  /**
36
223
  * Polls a query until its rows satisfy `predicate`, then returns them. Use for
@@ -46,60 +233,283 @@ export async function waitForRows(executor, sql, predicate, label = 'waitForRows
46
233
  return rows;
47
234
  }, RETRY_OPTIONS);
48
235
  }
49
- export async function waitForTableAbsent(executor, database, tableName) {
50
- await pRetry(async () => {
51
- const rows = await executor.query(`SELECT 1 AS x FROM system.tables WHERE database = '${database}' AND name = '${tableName}'`);
52
- if (rows.length > 0) {
53
- throw new Error(`waitForTableAbsent: ${database}.${tableName} still present`);
54
- }
55
- }, RETRY_OPTIONS);
236
+ export async function waitForTableAbsent(executor, database, tableName, options) {
237
+ await waitForSystemRows(executor, {
238
+ source: 'system.tables',
239
+ where: `database = ${stringLiteral(database)} AND name = ${stringLiteral(tableName)}`,
240
+ want: 'absent',
241
+ label: `waitForTableAbsent: ${database}.${tableName} still present`,
242
+ }, options);
56
243
  }
57
244
  /**
58
245
  * Parses an operation key like "table:app.users", "table:app.users:column:name",
59
- * or "dictionary:app.users_dict" into its components.
246
+ * "dictionary:app.users_dict", "view:app.active_users" or
247
+ * "materialized_view:app.events_mv:refresh" into its components. `table` is the
248
+ * object's name in system.tables, which lists views and dictionaries too.
249
+ *
250
+ * Names may contain ':', so the object's name runs to the last occurrence of the
251
+ * suffix its operation type appends; without a known suffix the rest of the key
252
+ * is the name. The database runs to the first '.': the key cannot tell a '.'
253
+ * inside a database name from the separator, so objects in such a database are
254
+ * not found.
60
255
  */
61
- function parseOperationKey(key) {
62
- const tableMatch = key.match(/^(?:table|dictionary):([^.]+)\.([^:]+)/);
63
- if (!tableMatch)
256
+ function parseOperationKey(operationType, key) {
257
+ const keyMatch = key.match(/^(?:table|dictionary|view|materialized_view):([^.]+)\.(.+)$/);
258
+ if (!keyMatch)
64
259
  return undefined;
65
260
  // biome-ignore lint/style/noNonNullAssertion: capture groups guaranteed by regex match
66
- const database = tableMatch[1];
261
+ const database = keyMatch[1];
67
262
  // biome-ignore lint/style/noNonNullAssertion: capture groups guaranteed by regex match
68
- const table = tableMatch[2];
69
- const columnMatch = key.match(/:column:([^:]+)/);
70
- return { database, table, column: columnMatch?.[1] };
263
+ const rest = keyMatch[2];
264
+ const suffix = KEY_SUFFIXES.get(operationType);
265
+ const at = suffix === undefined ? -1 : rest.lastIndexOf(suffix);
266
+ if (suffix === undefined || at < 1)
267
+ return { database, table: rest, column: undefined };
268
+ const member = rest.slice(at + suffix.length);
269
+ return {
270
+ database,
271
+ table: rest.slice(0, at),
272
+ column: suffix === ':column:' ? member : undefined,
273
+ constraint: suffix === ':constraint:' ? member : undefined,
274
+ index: suffix === ':index:' ? member : undefined,
275
+ rename: suffix === ':column_rename:' ? member : undefined,
276
+ };
277
+ }
278
+ /**
279
+ * The old and new names of a column rename. The statement names them; without
280
+ * it the key's `<from>:<to>` is split only when it holds exactly one ':'.
281
+ */
282
+ function renamedColumn(member, statement) {
283
+ const parsed = statement === undefined ? undefined : parseRenameColumn(statement);
284
+ if (parsed && `${parsed.from}:${parsed.to}` === member)
285
+ return parsed;
286
+ const parts = member.split(':');
287
+ return parts.length === 2 && parts[0] && parts[1] ? { from: parts[0], to: parts[1] } : undefined;
71
288
  }
72
289
  /**
73
290
  * Waits for DDL propagation based on the operation type and key.
74
291
  * Called after each DDL statement in the migration execution loop.
75
292
  */
76
- export async function waitForDDLPropagation(executor, operationType, operationKey) {
77
- const parsed = parseOperationKey(operationKey);
293
+ export async function waitForDDLPropagation(executor, operationType, operationKey, options) {
294
+ // Backfill assertions execute SELECT guards. Their keys identify the guard,
295
+ // not an object changed by DDL, so there is no schema propagation to await.
296
+ if (operationType === 'assert_backfill')
297
+ return;
298
+ // Cutover guards, barriers, copies and the snapshot ALTER change no object
299
+ // in the catalog; the objects a cutover creates and drops use the regular
300
+ // create/drop operation types.
301
+ if (operationType.startsWith('cutover_'))
302
+ return;
303
+ if (operationType === 'exchange_tables') {
304
+ if (options?.statement === undefined)
305
+ return;
306
+ return waitForExchange(executor, options.statement, options);
307
+ }
308
+ const parsed = parseOperationKey(operationType, operationKey);
78
309
  if (!parsed)
79
310
  return; // database-level ops or unrecognized keys — no wait needed
80
311
  switch (operationType) {
81
312
  case 'create_table':
82
- return waitForTable(executor, parsed.database, parsed.table);
313
+ return waitForTable(executor, parsed.database, parsed.table, options);
83
314
  case 'create_view':
84
315
  case 'create_materialized_view':
85
- return waitForView(executor, parsed.database, parsed.table);
316
+ return waitForView(executor, parsed.database, parsed.table, options);
86
317
  case 'create_dictionary':
87
- return waitForDictionary(executor, parsed.database, parsed.table);
318
+ return waitForDictionary(executor, parsed.database, parsed.table, options);
319
+ // ADD COLUMN IF NOT EXISTS leaves an existing column as it is, so only
320
+ // agreement between replicas is checked; MODIFY also checks what it sets.
88
321
  case 'alter_table_add_column':
89
322
  case 'alter_table_modify_column':
90
323
  if (parsed.column) {
91
- return waitForColumn(executor, parsed.database, parsed.table, parsed.column);
324
+ const intent = operationType === 'alter_table_modify_column' && options?.statement !== undefined
325
+ ? parseModifyColumnIntent(options.statement, parsed.column)
326
+ : {};
327
+ return waitForColumnDefinition(executor, parsed.database, parsed.table, parsed.column, intent, options);
92
328
  }
93
329
  return;
330
+ case 'alter_table_rename_column': {
331
+ const rename = parsed.rename === undefined ? undefined : renamedColumn(parsed.rename, options?.statement);
332
+ if (rename)
333
+ return waitForColumnRename(executor, parsed.database, parsed.table, rename, options);
334
+ return waitForTable(executor, parsed.database, parsed.table, options);
335
+ }
336
+ case 'alter_table_add_index':
337
+ case 'alter_table_drop_index':
338
+ if (parsed.index) {
339
+ const want = operationType === 'alter_table_add_index' ? 'present' : 'absent';
340
+ return waitForIndex(executor, parsed.database, parsed.table, parsed.index, want, options);
341
+ }
342
+ return waitForTable(executor, parsed.database, parsed.table, options);
343
+ case 'alter_table_drop_column':
344
+ if (parsed.column) {
345
+ return waitForColumnAbsent(executor, parsed.database, parsed.table, parsed.column, options);
346
+ }
347
+ return waitForTable(executor, parsed.database, parsed.table, options);
348
+ case 'alter_table_add_constraint':
349
+ if (parsed.constraint) {
350
+ return waitForConstraint(executor, parsed.database, parsed.table, parsed.constraint, options);
351
+ }
352
+ return waitForTable(executor, parsed.database, parsed.table, options);
353
+ case 'alter_table_drop_constraint':
354
+ if (parsed.constraint) {
355
+ return waitForConstraintAbsent(executor, parsed.database, parsed.table, parsed.constraint, options);
356
+ }
357
+ return waitForTable(executor, parsed.database, parsed.table, options);
94
358
  case 'drop_table':
95
359
  case 'drop_view':
96
360
  case 'drop_materialized_view':
97
361
  case 'drop_dictionary':
98
- return waitForTableAbsent(executor, parsed.database, parsed.table);
362
+ return waitForTableAbsent(executor, parsed.database, parsed.table, options);
99
363
  default:
100
- // alter_table_add_index, alter_table_modify_setting, etc.
364
+ // alter_table_add_projection, alter_table_modify_setting,
365
+ // alter_materialized_view_modify_refresh, etc.
101
366
  // Wait for the table to exist as a basic sanity check.
102
- return waitForTable(executor, parsed.database, parsed.table);
367
+ return waitForTable(executor, parsed.database, parsed.table, options);
368
+ }
369
+ }
370
+ async function waitForSystemRows(executor, wait, options) {
371
+ const fanout = await resolveReplicaFanout(executor, options?.cluster);
372
+ await pRetry(async () => {
373
+ if (!(await systemRowsMatch(executor, wait, fanout)))
374
+ throw new Error(wait.label);
375
+ }, RETRY_OPTIONS);
376
+ }
377
+ async function dictionaryCatalogVisible(executor, where, fanout) {
378
+ if (!fanout) {
379
+ const rows = await executor.query(`SELECT 1 AS x FROM system.tables WHERE ${where}`);
380
+ return rows.length > 0;
381
+ }
382
+ const rows = await executor.query(`SELECT count(DISTINCT hostName()) AS replicas, (min(uuid) = max(uuid) AND min(uuid) != toUUID('00000000-0000-0000-0000-000000000000') AND count(uuid) = count()) AS agreed FROM ${allReplicas(fanout, 'system.tables')} WHERE ${where}`);
383
+ const replicas = Number(rows[0]?.replicas ?? 0);
384
+ return replicas >= fanout.replicas && isAgreed(rows[0]?.agreed);
385
+ }
386
+ function isAgreed(value) {
387
+ return value === true || value === 1 || value === '1';
388
+ }
389
+ async function systemRowsMatch(executor, wait, fanout) {
390
+ if (!fanout) {
391
+ const rows = await executor.query(`SELECT 1 AS x FROM ${wait.source} WHERE ${wait.where}`);
392
+ return wait.want === 'present' ? rows.length > 0 : rows.length === 0;
393
+ }
394
+ const rows = await executor.query(`SELECT count(DISTINCT hostName()) AS replicas FROM ${allReplicas(fanout, wait.source)} WHERE ${wait.where}`);
395
+ const replicas = Number(rows[0]?.replicas ?? 0);
396
+ return wait.want === 'present' ? replicas >= fanout.replicas : replicas === 0;
397
+ }
398
+ function columnWhere(database, tableName, columnName) {
399
+ return `database = ${stringLiteral(database)} AND table = ${stringLiteral(tableName)} AND name = ${stringLiteral(columnName)}`;
400
+ }
401
+ // ClickHouse writes a constraint name bare when it is a plain identifier and
402
+ // backquoted otherwise (`backQuoteIfNeed`), so match either spelling.
403
+ function constraintWhere(database, tableName, constraintName) {
404
+ const quoted = `\`${constraintName.replace(/\\/g, '\\\\').replace(/`/g, '\\`')}\``;
405
+ const spellings = [`CONSTRAINT ${constraintName} `, `CONSTRAINT ${quoted} `];
406
+ const listed = spellings.map((spelling) => `position(create_table_query, ${stringLiteral(spelling)}) > 0`).join(' OR ');
407
+ return `database = ${stringLiteral(database)} AND name = ${stringLiteral(tableName)} AND (${listed})`;
408
+ }
409
+ /**
410
+ * Polls `check` until it reports no mismatch, with the retry budget of the
411
+ * other waits (21 checks, 500 ms apart) and a hard deadline: chkit's queries
412
+ * have no request timeout, so a check that does not return ends the wait at
413
+ * the deadline instead of holding the migration. On timeout the error names
414
+ * what was still different on the last check.
415
+ */
416
+ async function waitUntilApplied(subject, check) {
417
+ const deadline = Date.now() + DEFINITION_WAIT_DEADLINE_MS;
418
+ let checks = 0;
419
+ let detail = '';
420
+ for (;;) {
421
+ checks += 1;
422
+ const outcome = await checkBefore(check, deadline);
423
+ if (outcome.mismatch === undefined && outcome.failure === undefined)
424
+ return;
425
+ detail = outcome.failure ?? outcome.mismatch ?? '';
426
+ if (checks > RETRY_OPTIONS.retries || Date.now() + RETRY_OPTIONS.minTimeout >= deadline)
427
+ break;
428
+ await new Promise((resolve) => setTimeout(resolve, RETRY_OPTIONS.minTimeout));
103
429
  }
430
+ throw new Error(`waitForDDLPropagation: ${subject} not applied on every replica after ${checks} checks: ${detail}`);
431
+ }
432
+ async function checkBefore(check, deadline) {
433
+ let timer;
434
+ const expired = new Promise((resolve) => {
435
+ timer = setTimeout(() => resolve({ failure: `a check did not return within the ${DEFINITION_WAIT_DEADLINE_MS / 1000}s deadline` }), Math.max(deadline - Date.now(), 0));
436
+ });
437
+ try {
438
+ return await Promise.race([
439
+ check().then((mismatch) => ({ mismatch }), (error) => ({ failure: error instanceof Error ? error.message : String(error) })),
440
+ expired,
441
+ ]);
442
+ }
443
+ finally {
444
+ clearTimeout(timer);
445
+ }
446
+ }
447
+ /**
448
+ * The `system.columns` values `intent` pins down. The default expression is
449
+ * formatted by the server; when it cannot be formatted (a server without
450
+ * `formatQuerySingleLine`), the expression is left to the agreement check.
451
+ */
452
+ async function expectedColumnValues(executor, intent) {
453
+ const expected = [];
454
+ if (intent.defaultKind !== undefined)
455
+ expected.push({ field: 'default_kind', value: intent.defaultKind });
456
+ if (intent.defaultKind === '')
457
+ expected.push({ field: 'default_expression', value: '' });
458
+ if (intent.typeDefault)
459
+ expected.push({ field: 'default_expression', value: 'defaultValueOfTypeName(', prefix: true });
460
+ if (intent.comment !== undefined)
461
+ expected.push({ field: 'comment', value: intent.comment });
462
+ if (intent.codecRemoved)
463
+ expected.push({ field: 'compression_codec', value: '' });
464
+ if (intent.defaultExpression !== undefined) {
465
+ const formatted = await formatExpression(executor, intent.defaultExpression);
466
+ if (formatted !== undefined)
467
+ expected.push({ field: 'default_expression', value: formatted });
468
+ }
469
+ return expected;
470
+ }
471
+ async function formatExpression(executor, expression) {
472
+ const select = 'SELECT ';
473
+ try {
474
+ const rows = await executor.query(`SELECT formatQuerySingleLine(${stringLiteral(`${select}${expression}`)}) AS formatted`);
475
+ const formatted = rows[0]?.formatted;
476
+ return formatted?.startsWith(select) ? formatted.slice(select.length) : undefined;
477
+ }
478
+ catch {
479
+ return undefined;
480
+ }
481
+ }
482
+ /**
483
+ * Why the replicas' rows do not show the intended definition yet, or
484
+ * undefined when every replica reports it.
485
+ */
486
+ function columnDefinitionMismatch(rows, replicas, expected) {
487
+ const listed = new Set(rows.map((row) => row.replica));
488
+ if (listed.size < replicas) {
489
+ const where = listed.size > 0 ? ` (${[...listed].join(', ')})` : '';
490
+ return `the column is listed on ${listed.size} of ${replicas} replicas${where}`;
491
+ }
492
+ for (const row of rows) {
493
+ const off = expected.find((want) => !matchesExpected(row[want.field], want));
494
+ if (!off)
495
+ continue;
496
+ const sets = off.prefix ? `${JSON.stringify(off.value)}...` : JSON.stringify(off.value);
497
+ const unanimous = rows.length > 1 && rows.every((other) => other[off.field] === row[off.field]);
498
+ return (`replica ${row.replica} reports ${off.field} ${JSON.stringify(row[off.field])}, the statement sets ${sets}` +
499
+ (unanimous ? '; every replica reports the same, so this is not replication lag: the server accepted the statement without applying it' : ''));
500
+ }
501
+ const [first, ...others] = rows;
502
+ if (!first)
503
+ return undefined;
504
+ for (const row of others) {
505
+ const field = COLUMN_FIELDS.find((name) => row[name] !== first[name]);
506
+ if (field) {
507
+ return `replica ${row.replica} reports ${field} ${JSON.stringify(row[field])}, replica ${first.replica} reports ${JSON.stringify(first[field])}`;
508
+ }
509
+ }
510
+ return undefined;
511
+ }
512
+ function matchesExpected(actual, want) {
513
+ return want.prefix ? actual.startsWith(want.value) : actual === want.value;
104
514
  }
105
515
  //# sourceMappingURL=ddl-propagation.js.map