ch-migrate-cli 0.5.1__tar.gz → 0.7.0__tar.gz

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.

Potentially problematic release.


This version of ch-migrate-cli might be problematic. Click here for more details.

Files changed (102) hide show
  1. ch_migrate_cli-0.7.0/PKG-INFO +774 -0
  2. ch_migrate_cli-0.7.0/README.md +732 -0
  3. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/__init__.py +1 -1
  4. ch_migrate_cli-0.7.0/ch_migrate/alembic_env.py +277 -0
  5. ch_migrate_cli-0.7.0/ch_migrate/baseline.py +55 -0
  6. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/bootstrap.py +12 -1
  7. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/cli.py +117 -26
  8. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/connection.py +24 -23
  9. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/deps.py +28 -20
  10. ch_migrate_cli-0.7.0/ch_migrate/diff.py +277 -0
  11. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/display.py +21 -9
  12. ch_migrate_cli-0.7.0/ch_migrate/env.py +8 -0
  13. ch_migrate_cli-0.7.0/ch_migrate/gate.py +59 -0
  14. ch_migrate_cli-0.7.0/ch_migrate/idempotency.py +339 -0
  15. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/introspect.py +7 -20
  16. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/lint.py +335 -139
  17. ch_migrate_cli-0.7.0/ch_migrate/migration_runner.py +250 -0
  18. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/mv_validate.py +14 -43
  19. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/scaffold.py +2 -6
  20. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/secrets.py +1 -2
  21. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/skills/ch-migrate/SKILL.md +43 -4
  22. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/sql.py +10 -2
  23. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/statements.py +46 -7
  24. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/templates/bootstrap/init_users.sql +9 -0
  25. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/ui.py +26 -5
  26. ch_migrate_cli-0.7.0/ch_migrate/version_table.py +124 -0
  27. ch_migrate_cli-0.7.0/ch_migrate/waiting/__init__.py +13 -0
  28. ch_migrate_cli-0.7.0/ch_migrate/waiting/budget.py +47 -0
  29. ch_migrate_cli-0.7.0/ch_migrate/waiting/display.py +265 -0
  30. ch_migrate_cli-0.7.0/ch_migrate/waiting/errors.py +37 -0
  31. ch_migrate_cli-0.7.0/ch_migrate/waiting/mutations.py +264 -0
  32. ch_migrate_cli-0.7.0/ch_migrate/waiting/reruns.py +182 -0
  33. ch_migrate_cli-0.7.0/ch_migrate/waiting/sql.py +36 -0
  34. ch_migrate_cli-0.7.0/ch_migrate/waiting/waiter.py +68 -0
  35. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/pyproject.toml +11 -9
  36. ch_migrate_cli-0.7.0/tests/corpus/idempotency.yaml +283 -0
  37. ch_migrate_cli-0.5.1/ch_migrate/env.py → ch_migrate_cli-0.7.0/tests/fixtures/env_v0_4_1.py +3 -24
  38. ch_migrate_cli-0.7.0/tests/integration/conftest.py +496 -0
  39. ch_migrate_cli-0.7.0/tests/integration/dialect_spike_probe.py +65 -0
  40. ch_migrate_cli-0.7.0/tests/integration/test_cloud.py +174 -0
  41. ch_migrate_cli-0.7.0/tests/integration/test_cluster_harness.py +74 -0
  42. ch_migrate_cli-0.7.0/tests/integration/test_gate.py +148 -0
  43. ch_migrate_cli-0.7.0/tests/integration/test_idempotency_advice.py +125 -0
  44. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/integration/test_irreversible.py +8 -21
  45. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/integration/test_lint_pending.py +1 -3
  46. ch_migrate_cli-0.7.0/tests/integration/test_mutation_waiting.py +550 -0
  47. ch_migrate_cli-0.7.0/tests/integration/test_on_cluster.py +199 -0
  48. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/integration/test_run_sql.py +13 -5
  49. ch_migrate_cli-0.7.0/tests/integration/test_session_env.py +195 -0
  50. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/integration/test_sql_first.py +18 -0
  51. ch_migrate_cli-0.7.0/tests/integration/test_standalone_set.py +35 -0
  52. ch_migrate_cli-0.7.0/tests/integration/test_version_table.py +341 -0
  53. ch_migrate_cli-0.7.0/tests/test_cli_output.py +93 -0
  54. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_deps.py +11 -3
  55. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_diff.py +56 -26
  56. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_hooks.py +50 -39
  57. ch_migrate_cli-0.7.0/tests/test_idempotency.py +224 -0
  58. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_lint.py +95 -47
  59. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_mv_validate.py +57 -47
  60. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_new.py +1 -1
  61. ch_migrate_cli-0.7.0/tests/test_package.py +21 -0
  62. ch_migrate_cli-0.7.0/tests/test_reruns.py +123 -0
  63. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_snapshot.py +5 -2
  64. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_statements.py +57 -1
  65. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_status_command.py +3 -3
  66. ch_migrate_cli-0.7.0/tests/test_version_table_policy.py +38 -0
  67. ch_migrate_cli-0.7.0/tests/test_waiting.py +77 -0
  68. ch_migrate_cli-0.7.0/tests/test_waiting_display.py +174 -0
  69. ch_migrate_cli-0.5.1/PKG-INFO +0 -448
  70. ch_migrate_cli-0.5.1/README.md +0 -405
  71. ch_migrate_cli-0.5.1/ch_migrate/diff.py +0 -232
  72. ch_migrate_cli-0.5.1/tests/integration/conftest.py +0 -206
  73. ch_migrate_cli-0.5.1/tests/test_package.py +0 -11
  74. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/.gitignore +0 -0
  75. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/LICENSE +0 -0
  76. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/authoring.py +0 -0
  77. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/config.py +0 -0
  78. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/downgrade.py +0 -0
  79. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/helpers.py +0 -0
  80. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/hooks.py +0 -0
  81. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/py.typed +0 -0
  82. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/rebase.py +0 -0
  83. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/runner.py +0 -0
  84. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/templates/project/alembic.ini.template +0 -0
  85. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/templates/project/config.yaml.template +0 -0
  86. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/templates/project/env.local.example.template +0 -0
  87. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/ch_migrate/templates/project/script.py.mako.template +0 -0
  88. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/clickhouse_alembic/__init__.py +0 -0
  89. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/integration/test_readme_quickstart.py +0 -0
  90. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/integration/test_smoke.py +0 -0
  91. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_bootstrap.py +0 -0
  92. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_compat_import.py +0 -0
  93. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_config.py +0 -0
  94. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_display.py +0 -0
  95. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_downgrade.py +0 -0
  96. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_helpers.py +0 -0
  97. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_introspect.py +0 -0
  98. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_readme.py +0 -0
  99. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_rebase.py +0 -0
  100. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_scaffold.py +0 -0
  101. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_secrets.py +0 -0
  102. {ch_migrate_cli-0.5.1 → ch_migrate_cli-0.7.0}/tests/test_sql.py +0 -0
@@ -0,0 +1,774 @@
1
+ Metadata-Version: 2.5
2
+ Name: ch-migrate-cli
3
+ Version: 0.7.0
4
+ Summary: SQL-first ClickHouse schema migrations across environments: the ch-migrate command
5
+ Project-URL: Homepage, https://github.com/DRYCodeWorks/ch-migrate
6
+ Project-URL: Repository, https://github.com/DRYCodeWorks/ch-migrate
7
+ Project-URL: Issues, https://github.com/DRYCodeWorks/ch-migrate/issues
8
+ Project-URL: Guide, https://www.drycodeworks.com/articles/dev-guides/clickhouse-migrations-with-alembic
9
+ Author: Dan Young
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: alembic,ch-migrate,clickhouse,clickhouse-cloud,database,migrations,schema,schema-migrations
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Database
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: alembic>=1.18.0
25
+ Requires-Dist: boto3>=1.42.24
26
+ Requires-Dist: click>=8.1.0
27
+ Requires-Dist: clickhouse-connect[alembic]>=1.9
28
+ Requires-Dist: python-dotenv>=1.0.0
29
+ Requires-Dist: pyyaml>=6.0
30
+ Requires-Dist: rich>=13.0.0
31
+ Requires-Dist: ruamel-yaml<0.20,>=0.19.1
32
+ Provides-Extra: dev
33
+ Requires-Dist: black>=23.0.0; extra == 'dev'
34
+ Requires-Dist: isort>=5.12.0; extra == 'dev'
35
+ Requires-Dist: mypy>=1.5.0; extra == 'dev'
36
+ Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
37
+ Requires-Dist: pytest>=7.4.0; extra == 'dev'
38
+ Requires-Dist: types-pyyaml; extra == 'dev'
39
+ Provides-Extra: postgres
40
+ Requires-Dist: psycopg2-binary>=2.9.0; extra == 'postgres'
41
+ Description-Content-Type: text/markdown
42
+
43
+ # ch-migrate
44
+
45
+ ## What it is
46
+
47
+ `ch-migrate` manages SQL-first ClickHouse migrations across environments: author SQL files, bootstrap databases and roles, inspect migrations and dependencies, and compare schema snapshots. Alembic owns revision history; ClickHouse's official `clickhouse-connect[alembic]` integration owns the dialect and DDL compilation. ch-migrate supplies the operational layer above them, without implying an endorsement from ClickHouse.
48
+
49
+ Background: [ClickHouse migrations with Alembic](https://www.drycodeworks.com/articles/dev-guides/clickhouse-migrations-with-alembic).
50
+
51
+ ## Install
52
+
53
+ ```bash
54
+ uv tool install ch-migrate-cli
55
+ # Or:
56
+ pip install ch-migrate-cli
57
+ ch-migrate --version
58
+ ```
59
+
60
+ The command is `ch-migrate`, the PyPI package is `ch-migrate-cli` (PyPI treats `ch-migrate` as the same name as the existing, unrelated `chmigrate`), and migrations import from `ch_migrate`. Versions up to 0.4.1 were published as `clickhouse-alembic` with the import package `clickhouse_alembic`; that import still works with a deprecation warning until 1.0, so existing migration files keep running. Replace `clickhouse_alembic` with `ch_migrate` in them when convenient. For `migrations/env.py`, run `ch-migrate upgrade-env` only if you never edited it; if you did (for example to add connection settings), change its `clickhouse_alembic` imports to `ch_migrate` by hand instead, because `upgrade-env` replaces the whole file.
61
+
62
+ To switch an existing install, remove the old package first, because both install the `ch-migrate` command and the `clickhouse_alembic` folder: `uv tool uninstall clickhouse-alembic && uv tool install ch-migrate-cli`, or `pip uninstall clickhouse-alembic && pip install ch-migrate-cli`. In a project that lists `clickhouse-alembic` as a dependency, replace it with `ch-migrate-cli`.
63
+
64
+ This README describes the source checkout, which may be ahead of PyPI. To try an unreleased checkout locally, run `uv tool install .` in the repository. For development without installing a global tool, use `uv run --locked ch-migrate`.
65
+
66
+ ## Upgrade from 0.x
67
+
68
+ **From 0.6:** 0.7 needs no `upgrade-env`. `up` now waits for the mutations and
69
+ `ON CLUSTER` DDL its statements start (see [Migrations that wait](#migrations-that-wait)),
70
+ so give CI jobs time to finish or pass `--timeout`. Where `bootstrap` couldn't grant
71
+ `SELECT ON system.*`, such as on ClickHouse Cloud, add the grants listed in the
72
+ [CHANGELOG](CHANGELOG.md). A loose requirement such as `ch-migrate-cli` upgrades to
73
+ 0.7; pin `ch-migrate-cli==0.6.0` to stay on 0.6.
74
+
75
+ **From 0.5:** 0.6 and later require Python 3.10+; 0.5.x is the last line supporting Python 3.9.
76
+ A loose requirement such as `ch-migrate-cli` or `~=0.5` upgrades past 0.5; pin
77
+ `ch-migrate-cli==0.5.1` until each project has run the command below.
78
+ After upgrading the package, run this in each migration project:
79
+
80
+ ```bash
81
+ ch-migrate upgrade-env
82
+ ```
83
+
84
+ `init` and `upgrade-env` write a thin version-2 environment that delegates to the
85
+ package. The old file is saved as `migrations/env.py.bak`; repeating the command
86
+ on the current shim leaves that backup intact. Review any old customizations.
87
+ Existing Python migrations and version tables continue to work without edits.
88
+ `up`, `down`, and `history` refuse old environments before connecting and tell
89
+ you to run `upgrade-env`; `status` still reports, so CI jobs that use it as a
90
+ non-blocking reporter keep passing.
91
+
92
+ One HTTP session spans a migration run, including later revisions in that run.
93
+ A standalone `SET` therefore carries into following revisions. Prefer a
94
+ statement's `SETTINGS` clause when the change should apply only to that query.
95
+ `session_timeout` in an environment or `defaults` sets the idle timeout in
96
+ seconds (default `1800`). If that session expires, the next request fails rather
97
+ than silently recreating a session with default settings.
98
+
99
+ ## Quick start
100
+
101
+ Use a dedicated ClickHouse test server. The example uses local HTTP; replace the host and port with your server's address. For HTTPS, set `secure: true` and the HTTPS port (usually `8443`). Never run a trial migration against a shared or production database.
102
+
103
+ ### 1. Initialize
104
+
105
+ ```bash
106
+ mkdir my-clickhouse-project
107
+ cd my-clickhouse-project
108
+ ch-migrate init --name my_project
109
+ ```
110
+
111
+ ### 2. Configure the server
112
+
113
+ Replace `config.yaml` with:
114
+
115
+ ```yaml
116
+ project:
117
+ name: my_project
118
+
119
+ defaults:
120
+ port: 8123
121
+ secure: false
122
+ admin_user: default
123
+
124
+ environments:
125
+ dev:
126
+ host: 127.0.0.1
127
+ database: my_project_dev
128
+ migration_user: migration_dev
129
+ ```
130
+
131
+ Create `.env.local` in this project directory. Replace both values: the admin password is your server's existing password; the migration password is the password to give the new migration user.
132
+
133
+ ```dotenv
134
+ CH_DEV_ADMIN_PASSWORD=your-admin-password
135
+ CH_DEV_MIGRATION_PASSWORD=your-migration-password
136
+ ```
137
+
138
+ Keep `.env.local` out of git. `init` creates an ignore entry for it.
139
+
140
+ ### 3. Bootstrap and create a migration
141
+
142
+ ```bash
143
+ ch-migrate bootstrap dev --dry-run
144
+ ch-migrate bootstrap dev
145
+ ch-migrate new dev add_status --table logs
146
+ ```
147
+
148
+ The last command creates a revision plus two files under `migrations/sql/history/tables/logs/`. Their names include a timestamp and revision ID. Replace the contents of the generated `.up.sql` with:
149
+
150
+ ```sql
151
+ CREATE TABLE IF NOT EXISTS {db}.logs (id UInt64)
152
+ ENGINE = MergeTree ORDER BY id;
153
+ ALTER TABLE {db}.logs ADD COLUMN IF NOT EXISTS status String;
154
+ ```
155
+
156
+ Replace the contents of its `.down.sql` with:
157
+
158
+ ```sql
159
+ DROP TABLE IF EXISTS {db}.logs;
160
+ ```
161
+
162
+ Do not edit the generated revision file. The example downgrade drops the table and its data; only use it in this empty test project.
163
+
164
+ ### 4. Apply, inspect, and revert
165
+
166
+ ```bash
167
+ ch-migrate up dev
168
+ ch-migrate status dev
169
+ ch-migrate history dev
170
+ ch-migrate down dev
171
+ ```
172
+
173
+ After `up`, the `logs` table has `id` and `status` columns and status reports one applied revision. After `down`, the example table is gone.
174
+
175
+ ## Concepts
176
+
177
+ ### Project layout
178
+
179
+ ```text
180
+ project/
181
+ ├── config.yaml
182
+ ├── .env.local # Secrets, ignored by git
183
+ ├── alembic.ini
184
+ └── migrations/
185
+ ├── env.py # Generated Alembic environment
186
+ ├── script.py.mako
187
+ ├── versions/ # Revision graph; generated Python adapters
188
+ └── sql/
189
+ ├── bootstrap/ # Optional bootstrap SQL
190
+ └── history/
191
+ ├── tables/<name>/
192
+ ├── views/<name>/
193
+ ├── dictionaries/<name>/
194
+ └── other/ # No named object
195
+ ```
196
+
197
+ `new` creates `<YYYY_MM_DD_HHMM>_<revision>_<slug>.up.sql` and `.down.sql`. The message slug is at most 40 characters. Object directories are created when needed. Only one of `--table`, `--view`, and `--dict` may be supplied.
198
+
199
+ ### SQL files and placeholders
200
+
201
+ `run_sql` runs one statement per request. Semicolons inside strings, quoted identifiers, comments, or heredocs do not split statements. Empty or comment-only files fail instead of recording an unfilled migration as applied. Execution stops at the first failed statement; ClickHouse DDL is not transactional, so earlier changes remain.
202
+
203
+ | Placeholder | Value |
204
+ |---|---|
205
+ | `{db}` | Environment database |
206
+ | `{cluster}` | Configured cluster, or an empty string |
207
+ | `{on_cluster}` | `ON CLUSTER <cluster>`, or an empty string |
208
+
209
+ Keyword arguments to `run_sql` add or override substitutions. All other braces remain literal, including JSON and ClickHouse parameters such as `{id:UInt64}`. Doubled braces are not format escapes. Write statements that are safe to repeat where possible, such as `CREATE ... IF NOT EXISTS` and `DROP ... IF EXISTS`.
210
+
211
+ To render without executing, set `CH_ENVIRONMENT` and run `alembic upgrade head --sql`. Existing projects need `ch-migrate upgrade-env` to use the packaged environment. The package requires Alembic 1.18 or later and the official `clickhouse-connect[alembic]` integration.
212
+
213
+ ### The version table
214
+
215
+ New `alembic_version` tables use `ORDER BY version_num`. The runtime inspects
216
+ `system.databases.engine` and the environment's `cluster` setting:
217
+
218
+ | Deployment | New version table |
219
+ |---|---|
220
+ | Shared database on ClickHouse Cloud | `MergeTree`; Cloud supplies its shared implementation |
221
+ | Self-hosted `Replicated` database | `ReplicatedMergeTree()` with server-default arguments |
222
+ | Atomic database with `cluster` configured | `ReplicatedMergeTree` created `ON CLUSTER`, with a Keeper path containing the database name and shard macro |
223
+ | Single server | `MergeTree` |
224
+
225
+ The official Alembic implementation advances a version by **inserting the new
226
+ row, then deleting the old row with `mutations_sync = 2`**. It does not issue an
227
+ asynchronous version UPDATE. If the process stops while the delete is pending,
228
+ both rows remain and Alembic refuses their overlapping history rather than
229
+ repeating the migration. Let the version mutation finish, then retry. A
230
+ server-reported failed version mutation stops the run with its reason; ch-migrate
231
+ never kills that mutation.
232
+
233
+ Before reading replicated heads, the tool catches up replicated database
234
+ metadata and inserted version parts. The table barrier uses
235
+ [`SYSTEM SYNC REPLICA ... LIGHTWEIGHT`](https://clickhouse.com/docs/reference/statements/system#sync-replica),
236
+ which does not wait for held mutation tasks. Failure checks cover reachable
237
+ hosts in the configured cluster, or the
238
+ [Replicated database's automatically named cluster](https://clickhouse.com/docs/reference/engines/database-engines/replicated).
239
+ An unavailable host is not evidence that its state is healthy.
240
+
241
+ Self-hosted replicas need Keeper and suitable `{shard}`/`{replica}` macros.
242
+ Configure authentication for a Replicated database's automatic cluster through
243
+ its `collection_name` setting; the named collection uses `cluster_username`,
244
+ `cluster_password`, and optionally `cluster_secret`/`cluster_secure_connection`.
245
+ Keep those values in server-side secret configuration, not migration files.
246
+ Provision the migration user on each node. Re-run bootstrap when upgrading to
247
+ grant the system-table reads and available `CLUSTER`, remote-read, and replica
248
+ synchronization privileges used by these checks.
249
+
250
+ Existing 0.4.1 `ReplacingMergeTree ORDER BY updated` tables are **never converted
251
+ automatically**. `upgrade-env` remains offline: it prints a conditional advisory
252
+ instead of probing every configured environment. `status ENV` performs the live
253
+ check and warns when existing state is not replicated but the deployment is.
254
+ Do not route migrations across nodes until that state is reconciled.
255
+
256
+ For a manual conversion:
257
+
258
+ 1. Stop every migration runner. Back up the old table's DDL and rows on each
259
+ node, and inspect unfinished version mutations.
260
+ 2. Reconcile one authoritative set of completed revision heads with the
261
+ revision graph. Do not infer it from the newest timestamp or blindly use
262
+ `FINAL`: the old timestamp key can collapse heads written in the same second.
263
+ 3. Keep the old tables as backups. On an Atomic cluster, rename each existing
264
+ old table on its own node, then create the replacement on the cluster.
265
+ In a Replicated database, issue schema changes once and let its DDL log
266
+ propagate them.
267
+ 4. Use `MergeTree ORDER BY version_num` on a single server, or the replicated
268
+ engine from the table above. For an Atomic cluster, use a fresh shared Keeper
269
+ path containing the database and the appropriate replica macros.
270
+ 5. Copy the reconciled, distinct heads **once** into the empty replacement,
271
+ synchronize replicas, and compare `status` through every routing endpoint.
272
+ Resume runners only after they agree. Keep backups until the result is verified.
273
+
274
+ Offline SQL cannot inspect a live database engine: it uses configured `cluster`
275
+ information or the single-server/Cloud default for version-table DDL. Review
276
+ that DDL before using it for a self-hosted Replicated database. Cloud qualification
277
+ is separate from the local suite; the local replicated harness has one shard.
278
+
279
+ ### Migrations that wait
280
+
281
+ `up` doesn't record a migration as applied until the work its statements started
282
+ has finished. After each statement that can write, it polls `system.mutations` on
283
+ the same checked ClickHouse session, so earlier `SET` statements stay in effect.
284
+ `op.execute`, raw `op.get_bind()` execution and `run_sql` all pass through that
285
+ check. Upgrades run in the CLI process, so killing it can't leave a child Alembic
286
+ runner advancing revisions. `down` waits the same way.
287
+
288
+ ```bash
289
+ ch-migrate up dev # No default waiting timeout
290
+ ch-migrate up dev --timeout 120 # One waiting budget across the invocation
291
+ ```
292
+
293
+ What `up` waits for:
294
+
295
+ - **Every unfinished mutation in the project database,** including one another
296
+ writer started. Before the first migration runs, `up` waits for any already
297
+ queued, and a failed one blocks the run. The version table's own mutations are
298
+ left to [its bookkeeping](#the-version-table).
299
+ - **Each replica that has to apply it.** Zero parts left isn't completion on its
300
+ own: the mutation must report `is_done`. A ReplicatedMergeTree mutation must be
301
+ finished on every replica, and a replica that hasn't received it yet is still
302
+ pending. Configure `cluster` for self-hosted replicated tables, or use a
303
+ Replicated database's automatic cluster; without either, `up` refuses a table
304
+ with more than one replica. SharedMergeTree on ClickHouse Cloud reports one
305
+ shared mutation.
306
+ - **`ON CLUSTER` DDL on every host.** `up` sends these statements with
307
+ `distributed_ddl_output_mode = 'throw'`, so ClickHouse answers once every host
308
+ has run the statement, and a host that fails or doesn't finish in time fails the
309
+ migration with ClickHouse's message. The limit is 270 seconds, below the
310
+ client's read timeout, or what's left of `--timeout` if that's less. Mutations
311
+ the statement starts are then waited for as above.
312
+
313
+ Statements ClickHouse already holds until they finish, such as a type-changing
314
+ `MODIFY COLUMN`, a `MODIFY TTL` that materializes, or a lightweight `DELETE`,
315
+ still hold their request as they did in 0.6.
316
+
317
+ While it waits, `up` shows progress on stderr. In a terminal, each mutation gets a
318
+ live bar weighted by bytes, with its parts and an estimate of the time left:
319
+
320
+ ```text
321
+ → Applying 3f9c2a1b backfill_digest
322
+ ━━━━━━━━━━━━━━━━━━━━╺━━━━━━━━━━━━━━━━━ 18.2 / 41.0 GiB · 23/41 parts · 1:12 · ~1:30 left
323
+ ✓ 3f9c2a1b backfill_digest mutation_7.txt · 41 parts · 41.0 GiB · 2:03
324
+ ```
325
+
326
+ Self-hosted replicated tables get one row per replica. When output isn't a
327
+ terminal (CI logs, pipes, coding agents), `up` prints plain lines instead: one
328
+ when a wait starts, one at each 10% of the parts or every 30 seconds, and one
329
+ when it finishes:
330
+
331
+ ```text
332
+ → Applying 3f9c2a1b backfill_digest
333
+ waiting on mutation_7.txt (demo_dev.events): 0/41 parts
334
+ waiting on mutation_7.txt (demo_dev.events): 5/41 parts · 0:14
335
+ ✓ 3f9c2a1b backfill_digest mutation_7.txt · 41 parts · 2:03
336
+ ```
337
+
338
+ Part sizes come from `system.parts`. If the migration role can't read it, the bar
339
+ counts parts only and says so once; progress never changes whether `up` succeeds.
340
+
341
+ **When `up` stops short.** After a timeout, Ctrl-C, dropped connection, killed
342
+ process or failed statement, ClickHouse keeps working and the migration isn't
343
+ recorded. `up` and `down` note each migration in `_ch_migrate_in_progress` when it
344
+ starts and clear the note once its version is recorded, so the next run knows the
345
+ migration was interrupted. That run waits for unfinished work, then:
346
+
347
+ - **Runs the migration again on its own** when every statement in it passes the
348
+ idempotency gate without a waiver, so a repeat leaves the same result.
349
+ - **Stops before sending anything** when the migration has a waived statement or
350
+ SQL the gate can't read, because that statement may already have applied. It
351
+ names each one. Check what the earlier run changed, fix the SQL if needed, then
352
+ name the revision: `ch-migrate up ENV --rerun-interrupted REV` (or `down`), or
353
+ `-x rerun_interrupted=REV` with `alembic`. Naming it accepts the repeat.
354
+
355
+ Every `UPDATE`, `DELETE` and `INSERT ... SELECT` needs a waiver, so an interrupted
356
+ backfill always takes that second path.
357
+
358
+ **Failed mutation.** ClickHouse's `latest_fail_reason` stops the run, and `up`
359
+ prints the scoped `KILL MUTATION` statement for an operator; ch-migrate never
360
+ runs it. ClickHouse keeps retrying a failed mutation, and later mutations on that
361
+ table queue behind it. If the cause is temporary, such as memory or disk, fix it
362
+ and run `up` again. If the statement itself is wrong, kill the mutation, fix the
363
+ SQL and run `up` again, naming the revision with `--rerun-interrupted` if it's
364
+ waived. Killing a mutation doesn't undo the parts it already rewrote.
365
+
366
+ Run one migration runner per database, using a CI concurrency key or an external
367
+ lock; the in-progress notes aren't a lock. Re-run `bootstrap` when upgrading a
368
+ restricted migration user to obtain the `system.replicas` and `system.parts` grants.
369
+
370
+ ### Re-runnable migrations
371
+
372
+ `up` statically checks pending upgrade statements before Alembic executes any of
373
+ them. Idempotency and standalone-SET errors refuse the whole run and print the source file, line,
374
+ statement, and suggested fix. There is no command-line bypass; `--skip-mv-check`
375
+ does not skip this gate. Other findings are warnings in `up`; standalone `lint`
376
+ retains their configured severities. A direct `alembic upgrade` runs the same gate
377
+ from `env.py` before its first migration.
378
+
379
+ The gate reads SQL in `run_sql` files and in literal strings, f-strings and
380
+ `text("...")` passed to `op.execute`, or to `execute` and `exec_driver_sql` on a
381
+ connection such as `op.get_bind()`. SQL that only exists at run time, such as a
382
+ variable, a built string or `run_sql(name)`, can't be checked, so it needs a
383
+ reasoned waiver like any other statement the gate can't vouch for.
384
+
385
+ Static lint rejects a standalone `SET` when `migrations/env.py` lacks the
386
+ `CH_MIGRATE_ENV_VERSION = 2` marker: that statement would be ignored on the old
387
+ connection. Run `ch-migrate upgrade-env`, or put the setting in the relevant
388
+ statement's `SETTINGS` clause. Version-2 projects allow `SET`; `SETTINGS` clauses
389
+ and the word `SET` inside string literals are not flagged. `standalone_set` is
390
+ a gate rule whose severity cannot be lowered.
391
+
392
+ Use `IF NOT EXISTS` for `CREATE` and `ALTER ... ADD`, and `IF EXISTS` for `DROP`,
393
+ `ALTER ... DROP`, `RENAME COLUMN`, and table, dictionary, and database renames.
394
+ This includes tables, views, materialized views, dictionaries, databases, users,
395
+ roles, row policies, settings profiles, quotas, functions, and named collections.
396
+ Column, index, projection, and constraint changes are checked separately.
397
+ `CREATE OR REPLACE` is accepted. `ATTACH` and `DETACH` of an object need their
398
+ matching `IF` form.
399
+
400
+ Each finding says how to make its statement safe to run twice, using the
401
+ statement's own names: the exact `IF` form, or what a retry would do and the
402
+ usual fix. For example, a backfill into a table that only its migration writes
403
+ can start with `TRUNCATE`, and `ATTACH PARTITION ... FROM` can usually be
404
+ `REPLACE PARTITION ... FROM`.
405
+
406
+ Inserts, exchanges, updates/deletes, `ATTACH PARTITION ... FROM`,
407
+ `MOVE PARTITION ... TO TABLE`, and attaching or detaching a single `PART` need a
408
+ reasoned waiver. Put it directly above the statement, without a separating blank
409
+ line:
410
+
411
+ ```sql
412
+ -- ch-migrate: allow-non-idempotent This backfill targets a reviewed deduplicating table
413
+ INSERT INTO {db}.target SELECT id FROM {db}.source;
414
+ ```
415
+
416
+ For inline Python SQL, use the same directive in a `#` comment directly above
417
+ the call that sends it. Empty reasons remain errors. Waivers print as INFO with their
418
+ reasons in `lint` and `up`; they do not make the statement idempotent.
419
+ `MODIFY`, `MATERIALIZE`, `CLEAR COLUMN`, `TRUNCATE`, `OPTIMIZE`, `SYSTEM`, grants,
420
+ revokes, `REPLACE PARTITION`, attaching or detaching a whole partition, and
421
+ comments are not flagged by this rule.
422
+ The reviewable classification corpus is `tests/corpus/idempotency.yaml`.
423
+
424
+ **Recovery:** fix the cause of a partial failure, then run `ch-migrate up ENV`
425
+ again. Earlier idempotent statements can run again; the version advances only
426
+ when the revision completes. A dropped or renamed column stays locked until that
427
+ change's mutation finishes, and `up` waits for it before running the next
428
+ statement, so a retry after a later failure doesn't hit "affected by mutation ...
429
+ which is not finished yet". If `up` was stopped while it waited, run it again: it
430
+ waits for that mutation, then runs the revision again, unless the revision has a
431
+ statement the gate can't vouch for (see below).
432
+
433
+ **Existing history:** `upgrade-env` records the current local script head(s) in
434
+ `config.yaml` as `lint.gate_baseline`, preserving YAML comments. It records
435
+ script history, not the deployed database head. The baseline and its ancestors
436
+ are exempt in every environment, including where they have not yet run.
437
+ Multiple heads are stored as a list. `init` writes no baseline, so new projects
438
+ gate every revision.
439
+
440
+ Review and commit the baseline diff. Running `upgrade-env` again updates it to
441
+ the current script heads; do not use that operation to hide new findings.
442
+ Static `lint` checks revisions after the baseline, and live `lint ENV` intersects
443
+ that scope with pending revisions. Lowering a gate rule through `lint.rules`
444
+ is an error: use a reviewed baseline or an explicit statement waiver instead.
445
+
446
+ ### Irreversible migrations
447
+
448
+ ```bash
449
+ ch-migrate new dev drop_legacy --table logs --irreversible "Drops legacy data"
450
+ ```
451
+
452
+ This creates only an upgrade file. Its revision has an `irreversible` reason and raises `IrreversibleMigration` in its downgrade. An empty reason is rejected.
453
+
454
+ `down` reads markers statically, without importing migration files. It refuses an entire known range if any revision is irreversible: no preceding reversible downgrade runs first. It understands `-N` on linear history, full or unique-prefix IDs, and `base`. If a range is unknown, including a relative target across a merge point, it prints a note and relies on the migration's exception. Direct Alembic calls rely on the same backstop.
455
+
456
+ There is no override flag. To revert past a marked revision, implement its downgrade and remove the marker in a reviewed change. `irreversible = True` is accepted as "(no reason given)".
457
+
458
+ ### Python migrations
459
+
460
+ Use `new --python` for logic that cannot be expressed as SQL files. It retains the Python template and, with an object option, a single SQL history file. Existing Python migrations continue to work.
461
+
462
+ ```python
463
+ from ch_migrate import get_db, run_sql
464
+
465
+ def upgrade():
466
+ run_sql("history/tables/logs/001_add_status.up.sql", db=get_db())
467
+ ```
468
+
469
+ `get_db()` returns the environment database. `read_sql(path, **values)` still returns a string using Python `str.format`; unlike `run_sql`, callers must escape literal braces and execute the returned SQL themselves. For a single-statement file, the existing pattern remains valid:
470
+
471
+ ```python
472
+ from alembic import op
473
+ from ch_migrate import get_db, read_sql
474
+
475
+ def upgrade():
476
+ op.execute(read_sql("history/tables/users/001_create.sql", db=get_db()))
477
+ ```
478
+
479
+ A hand-written irreversible Python revision uses both the marker and backstop:
480
+
481
+ ```python
482
+ from ch_migrate import IrreversibleMigration
483
+
484
+ irreversible = "Drops legacy data"
485
+
486
+ def downgrade():
487
+ raise IrreversibleMigration(revision, irreversible)
488
+ ```
489
+
490
+ ### Exchange and dictionary patterns
491
+
492
+ `new --exchange --table NAME` generates the existing shadow-table, copy, exchange, and drop scaffold. Coordinate or pause writers: this copy-and-swap pattern alone does not preserve inserts arriving during the copy. It is not an online-rebuild guarantee. Review the generated SQL and column mapping before applying it. The scaffold is marked irreversible because it drops the old table.
493
+
494
+ The generated copy and exchange statements require explicit, reasoned waivers
495
+ before `up` accepts them. The generator does not waive them automatically.
496
+
497
+ For a controlled change, the underlying pattern is:
498
+
499
+ ```sql
500
+ CREATE TABLE IF NOT EXISTS {db}.users_shadow
501
+ (id UInt64, email String, phone String) ENGINE = MergeTree ORDER BY id;
502
+ INSERT INTO {db}.users_shadow SELECT id, email, '' FROM {db}.users;
503
+ EXCHANGE TABLES {db}.users AND {db}.users_shadow;
504
+ DROP TABLE IF EXISTS {db}.users_shadow;
505
+ ```
506
+
507
+ `EXCHANGE TABLES` requires a supporting database engine. Neither the copy nor the exchange is automatically idempotent.
508
+
509
+ The dictionary helper retains automatic SELECT grants for a configured dictionary reader:
510
+
511
+ ```python
512
+ from ch_migrate import create_dictionary
513
+
514
+ def upgrade():
515
+ create_dictionary("history/dictionaries/dict_users/001_create.sql")
516
+ ```
517
+
518
+ ## Command reference
519
+
520
+ Every command accepts `--help`. Top-level `ch-migrate --version` reports the installed package version. `ENV` below names an entry in `config.yaml`.
521
+
522
+ Output lines start with `→` for a step, `✓` for a result, `!` for a warning and `✗` for an error; warnings and errors go to stderr. Colour is dropped when output is not a terminal or `NO_COLOR` is set, and lines are never wrapped, so paths and SQL can be copied or grepped.
523
+
524
+ ### `init`
525
+
526
+ `ch-migrate init [PATH] [-n NAME]` initializes the current directory by default. `-n/--name` sets the project name; otherwise it uses the directory name.
527
+
528
+ Example: `ch-migrate init analytics --name analytics`
529
+
530
+ ### `bootstrap`
531
+
532
+ `ch-migrate bootstrap ENV [--dry-run] [-v]` creates the database, roles, and configured users. `--dry-run` prints SQL without executing it; `-v/--verbose` prints statements during execution. Requires admin and migration credentials.
533
+
534
+ Example: `ch-migrate bootstrap dev --dry-run`
535
+
536
+ ### `new`
537
+
538
+ `ch-migrate new ENV NAME [--table T | --view V | --dict D] [--irreversible REASON | --python | --exchange]` creates SQL-first migrations by default. Object-option aliases are `-t`, `-v`, and `-d`. `--python` keeps the Python template. `--exchange` requires `--table`. The three authoring-mode options are mutually exclusive, and conflicts fail before a revision is written.
539
+
540
+ Example: `ch-migrate new dev add_status --table logs`
541
+
542
+ ### `up`
543
+
544
+ `ch-migrate up ENV [-r REV] [--timeout SECONDS] [--skip-mv-check] [--verbose]` applies migrations to `head` by default after the idempotency gate passes, printing one line per migration. `-r/--revision` selects a target. `--timeout` is a finite positive waiting limit; omitting it waits without a deadline. `--skip-mv-check` skips nonblocking materialized-view declaration checks, not the idempotency gate; use it only after reviewing those findings. If a migration fails, `up` names it, the SQL file, the statement and its line, and ClickHouse's error; `--verbose` adds the Python traceback.
545
+
546
+ Example: `ch-migrate up dev --revision abc123`
547
+
548
+ ### `down`
549
+
550
+ `ch-migrate down ENV [-r REV] [--verbose]` reverts one revision by default (`-1`). `-r/--revision` accepts another target. Known ranges containing irreversible revisions are refused. Failures are reported as for `up`.
551
+
552
+ Example: `ch-migrate down dev --revision base`
553
+
554
+ ### `status`
555
+
556
+ `ch-migrate status ENV` shows connection information, applied/pending counts, and head status, and names the `up` command when migrations are pending. No command-specific options. Status is a report: it exits 0 when the database is unreachable (with a warning) or migrations are pending, and exits 1 only when the configuration or `migrations/versions/` is missing, so CI can run it as a non-blocking check.
557
+
558
+ Example: `ch-migrate status dev`
559
+
560
+ ### `history`
561
+
562
+ `ch-migrate history ENV` displays the revision graph and applied state. No command-specific options.
563
+
564
+ Example: `ch-migrate history dev`
565
+
566
+ ### `lint`
567
+
568
+ `ch-migrate lint [ENV]` analyzes upgrade statements, not downgrade SQL. Without `ENV`, it checks revisions after the gate baseline statically without credentials or a connection. With an environment, it checks only pending revisions in that scope and adds live size and dependency checks. If it cannot determine the pending set, it fails rather than silently checking a different scope. No command-specific options. Errors exit nonzero; warnings and waiver INFO lines alone do not.
569
+
570
+ Example: `ch-migrate lint`
571
+
572
+ Findings name the project-relative SQL file and statement line. Inline Python SQL
573
+ points to its `op.execute` call. Extraction reads `run_sql`/`read_sql` file
574
+ references and literal or f-string `op.execute` arguments without importing
575
+ revisions. It preserves placeholders and adjacent comments; arbitrary Python
576
+ expressions are not evaluated. Materialized-view declaration and companion-grant
577
+ validation still uses the complete migration batch, with lint findings limited
578
+ to selected upgrade statements.
579
+
580
+ ### `deps`
581
+
582
+ `ch-migrate deps ENV [-v PATH]` reads the live materialized-view and dictionary dependency graph. `-v/--validate PATH` checks a SQL file against it.
583
+
584
+ Example: `ch-migrate deps dev --validate migrations/sql/history/tables/logs/change.up.sql`
585
+
586
+ ### `diff`
587
+
588
+ `ch-migrate diff ENV [-s PATH]` compares the live schema with the latest snapshot. `-s/--snapshot-dir PATH` chooses another snapshot. Exit code 0 means no drift; 1 means drift or an execution error.
589
+
590
+ Example: `ch-migrate diff dev --snapshot-dir migrations/sql/snapshots/20261002_120000`
591
+
592
+ ### `snapshot`
593
+
594
+ `ch-migrate snapshot ENV [-e GLOB] [-f GLOB]` writes CREATE statements to a timestamped snapshot directory. `-e/--exclude` and `-f/--filter` accept repeated glob patterns for excluded and included objects.
595
+
596
+ Example: `ch-migrate snapshot dev --exclude 'temp_*' --filter 'logs*'`
597
+
598
+ ### `rebase`
599
+
600
+ `ch-migrate rebase ENV [--onto REV] [--dry-run]` rewrites dangling local revision branches onto the deployed head. `--onto` selects an explicit target; `--dry-run` previews changes. Review the preview before rewriting migration history; do not rewrite deployed revisions.
601
+
602
+ Example: `ch-migrate rebase dev --onto abc123 --dry-run`
603
+
604
+ ### `upgrade-env`
605
+
606
+ `ch-migrate upgrade-env` replaces `migrations/env.py` with the installed version, backs up the old file as `env.py.bak`, and records current script heads as `lint.gate_baseline` while preserving YAML comments. No command-specific options. Review the baseline diff. It does not merge: any local customizations (connection settings, session pins, hooks) are dropped from the new file. Reapply them from the backup, or skip `upgrade-env` and edit a customized `env.py` by hand. An unchanged shim is not backed up again.
607
+
608
+ Example: `ch-migrate upgrade-env`
609
+
610
+ ### `skill`
611
+
612
+ `ch-migrate skill [--user | --project]` installs the bundled Claude skill. `--user` is the default (`~/.claude/skills/ch-migrate/`); `--project` writes `./.claude/skills/ch-migrate/`.
613
+
614
+ Example: `ch-migrate skill --project`
615
+
616
+ ## Configuration
617
+
618
+ `defaults` are merged with each environment. Environment fields override defaults. The project name controls role names. A Cloud/HTTPS example:
619
+
620
+ ```yaml
621
+ project:
622
+ name: analytics
623
+
624
+ defaults:
625
+ port: 8443
626
+ secure: true
627
+ admin_user: default
628
+ # cluster: my_cluster
629
+ # dict_reader_name: dict_reader
630
+ # mcp_user_name: mcp_reader
631
+
632
+ environments:
633
+ dev:
634
+ host: your-service.clickhouse.cloud
635
+ database: analytics_dev
636
+ migration_user: migration_dev
637
+ aws_region: us-east-1
638
+ ssm:
639
+ admin_password: /analytics/dev/admin_password
640
+ migration_password: /analytics/credentials#password
641
+ ```
642
+
643
+ ### Secrets
644
+
645
+ Choose `.env.local` (or exported environment variables) or per-environment SSM paths. When an SSM path is configured, it is used for that secret. A `#key` suffix extracts a JSON key from the parameter. SSM access requires AWS credentials and permission to read the specified parameters; `aws_region` is optional.
646
+
647
+ | Variable | Purpose |
648
+ |---|---|
649
+ | `CH_<ENV>_MIGRATION_PASSWORD` | Required migration password |
650
+ | `CH_<ENV>_ADMIN_PASSWORD` | Admin password for bootstrap |
651
+ | `CH_<ENV>_DICT_READER_PASSWORD` | Password when a dictionary reader is configured |
652
+ | `CH_<ENV>_MCP_PASSWORD` | Password when a read-only MCP user is configured |
653
+
654
+ The legacy `CH_<ENV>_PASSWORD` remains supported. Never commit credentials or pass them in migration SQL that will be logged.
655
+
656
+ ### Hooks
657
+
658
+ Top-level hooks run SQL on the migration connection. `pre_migrate` runs before the
659
+ migration batch; `post_migrate` runs after each revision body **before its version
660
+ is completed**, so hook mutations cannot escape the waiting barrier. A resumed
661
+ upgrade reuses its unfinished batch's receipts instead of repeating completed
662
+ hook writes. `{db}` is substituted. Hooks execute as SQLAlchemy text, not through
663
+ the SQL-file splitter; supply one statement per entry. Hook SQL is logged, so do
664
+ not put secrets in it.
665
+
666
+ ```yaml
667
+ hooks:
668
+ pre_migrate:
669
+ - "SELECT 1"
670
+ post_migrate:
671
+ - "SYSTEM RELOAD DICTIONARY {db}.dict_regions"
672
+ ```
673
+
674
+ Only configure the dictionary hook when that dictionary exists at every revision where the hook runs.
675
+
676
+ ### Lint configuration
677
+
678
+ Non-gate rule severities can be `error`, `warn`, or `off`. Gate rules must remain `error`. `mv_validation_cutoff` can exclude older revisions from materialized-view declaration checks.
679
+
680
+ ```yaml
681
+ lint:
682
+ large_table_threshold: 100000000
683
+ mv_validation_cutoff: "2026-01-01"
684
+ rules:
685
+ destructive_changes: warn
686
+ idempotency: error
687
+ reserved_words: warn
688
+ ```
689
+
690
+ Review findings rather than treating a successful command as a guarantee that a migration is safe. DDL and mutations are not transactional.
691
+
692
+ ### Bootstrap roles and Cloud notes
693
+
694
+ Bootstrap creates `{project}_migration_role` for schema/data operations and introspection, including explicit `system.grants`, `system.databases`, `system.tables`, and `system.mutations` access. It grants available cluster, remote-read, and synchronization rights through `CURRENT GRANTS`. Optional users add `{project}_readonly_role` (SELECT/SHOW) and `{project}_dict_role` (dictionary sources). Bootstrap uses explicit grants rather than `GRANT ALL` for Cloud compatibility.
695
+
696
+ Use standard table engine names such as `MergeTree` and `ReplacingMergeTree`; ClickHouse Cloud supplies its shared variants. Cloud usually uses HTTPS port `8443`; local HTTP usually uses `8123`.
697
+
698
+ ## Development
699
+
700
+ Run unit tests without starting Docker:
701
+
702
+ ```bash
703
+ uv run --locked pytest -q
704
+ ```
705
+
706
+ Run the opt-in real-server suite:
707
+
708
+ ```bash
709
+ uv run --locked pytest -q -m integration
710
+ ```
711
+
712
+ The single-server fixture starts `clickhouse/clickhouse-server:26.3` in its own `chm-it-*` container on a random loopback port. Each test uses a separate database. Finalizers clean up on success, failure, and handled interrupts; a forced process kill cannot run finalizers. Docker-unavailable runs skip with a reason.
713
+
714
+ `CH_MIGRATE_IT_IMAGE` overrides the image tag. `CH_MIGRATE_IT_URL` selects a dedicated test server instead of starting Docker. It is an HTTP(S) URL with credentials supplied only through the environment. Tests create and drop databases there: never select a shared or production server, and never commit the URL.
715
+
716
+ For the single-server suite only:
717
+
718
+ ```bash
719
+ uv run --locked pytest -q -m "integration and not cluster"
720
+ ```
721
+
722
+ For the replicated harness:
723
+
724
+ ```bash
725
+ uv run --locked --python 3.12 pytest -q -m "integration and cluster" -k harness
726
+ ```
727
+
728
+ `clickhouse_cluster` owns a Docker network and two nodes, with Keeper embedded
729
+ in node 1. `it_cluster` has one shard and two replicas. `cluster_project` creates
730
+ an **Atomic database on both nodes with `ON CLUSTER`**; replicated tables use
731
+ `ReplicatedMergeTree` with a shared Keeper path and per-node replica macros.
732
+ The fixture exposes `clients[1]` and `clients[2]`, plus `stop_node(2)` and
733
+ `start_node(2)`. Read `clients[2]` again after a restart; its connection is renewed.
734
+
735
+ Tests prove row replication and both hosts' distributed-DDL completion. The
736
+ node-down scenario uses a five-second distributed-DDL timeout, inspects the
737
+ unfinished host, then proves catch-up after restart. Its HTTP response is
738
+ buffered with `wait_end_of_query=1` so the timeout is not obscured by a truncated
739
+ chunked response.
740
+
741
+ Cluster fixtures use the same image-tag override, remove their own containers,
742
+ anonymous volumes, and network, and never operate on external servers. Cluster
743
+ tests skip when `CH_MIGRATE_IT_URL` is set. The integration CI job runs cluster
744
+ tests on Python 3.12 only, and single-server tests on 3.10 and 3.14.
745
+
746
+ ### Against a ClickHouse Cloud test service
747
+
748
+ Point `CH_MIGRATE_IT_URL` at a dedicated Cloud test service
749
+ (`https://<user>:<url-encoded password>@<service>:8443`), loading the password
750
+ from your secret store at run time. Use a user that can create and drop `it_*`
751
+ databases, users and roles. The fixture waits up to 300 seconds for an idle
752
+ service to wake.
753
+
754
+ ```bash
755
+ uv run --locked pytest -q -m "integration and not cluster" # the full single-server suite
756
+ uv run --locked pytest -q -m "integration and cloud" # Cloud-only checks
757
+ ```
758
+
759
+ Tests marked `cloud` run only when the URL is a Cloud service (Shared
760
+ databases); they check that a migration's `SET` reaches every later statement
761
+ whichever replica serves it, and version-table round trips on SharedMergeTree.
762
+ Cluster tests skip. Cloud grants no user `SYSTEM STOP MERGES`, so tests that
763
+ hold a mutation pending skip with that reason; the self-hosted suite still
764
+ covers them. Each test drops its own database. After an interrupted run,
765
+ `scripts/cloud_sweep.py [--dry-run]` drops `it_*` databases no query has
766
+ touched for 24 hours, with their test users and roles.
767
+
768
+ ## License
769
+
770
+ MIT License — see [LICENSE](LICENSE).
771
+
772
+ ## Author
773
+
774
+ Dan Young