ch-migrate-cli 0.5.0__tar.gz → 0.6.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 (85) hide show
  1. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/PKG-INFO +231 -17
  2. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/README.md +226 -11
  3. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/__init__.py +1 -1
  4. ch_migrate_cli-0.6.0/ch_migrate/alembic_env.py +212 -0
  5. ch_migrate_cli-0.6.0/ch_migrate/baseline.py +55 -0
  6. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/bootstrap.py +7 -0
  7. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/cli.py +94 -19
  8. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/connection.py +24 -23
  9. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/display.py +18 -8
  10. ch_migrate_cli-0.6.0/ch_migrate/env.py +8 -0
  11. ch_migrate_cli-0.6.0/ch_migrate/gate.py +26 -0
  12. ch_migrate_cli-0.6.0/ch_migrate/idempotency.py +339 -0
  13. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/lint.py +86 -46
  14. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/skills/ch-migrate/SKILL.md +43 -4
  15. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/templates/bootstrap/init_users.sql +7 -0
  16. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/ui.py +22 -1
  17. ch_migrate_cli-0.6.0/ch_migrate/version_table.py +124 -0
  18. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/pyproject.toml +11 -9
  19. ch_migrate_cli-0.6.0/tests/corpus/idempotency.yaml +283 -0
  20. ch_migrate_cli-0.5.0/ch_migrate/env.py → ch_migrate_cli-0.6.0/tests/fixtures/env_v0_4_1.py +3 -24
  21. ch_migrate_cli-0.6.0/tests/integration/conftest.py +463 -0
  22. ch_migrate_cli-0.6.0/tests/integration/dialect_spike_probe.py +65 -0
  23. ch_migrate_cli-0.6.0/tests/integration/test_cloud.py +63 -0
  24. ch_migrate_cli-0.6.0/tests/integration/test_cluster_harness.py +74 -0
  25. ch_migrate_cli-0.6.0/tests/integration/test_gate.py +148 -0
  26. ch_migrate_cli-0.6.0/tests/integration/test_idempotency_advice.py +125 -0
  27. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/integration/test_irreversible.py +8 -21
  28. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/integration/test_run_sql.py +13 -5
  29. ch_migrate_cli-0.6.0/tests/integration/test_session_env.py +195 -0
  30. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/integration/test_sql_first.py +18 -0
  31. ch_migrate_cli-0.6.0/tests/integration/test_standalone_set.py +35 -0
  32. ch_migrate_cli-0.6.0/tests/integration/test_version_table.py +341 -0
  33. ch_migrate_cli-0.6.0/tests/test_cli_output.py +95 -0
  34. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/test_hooks.py +1 -8
  35. ch_migrate_cli-0.6.0/tests/test_idempotency.py +224 -0
  36. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/test_lint.py +46 -0
  37. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/test_mv_validate.py +14 -4
  38. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/test_new.py +1 -1
  39. ch_migrate_cli-0.6.0/tests/test_package.py +21 -0
  40. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/test_statements.py +4 -1
  41. ch_migrate_cli-0.6.0/tests/test_status_command.py +36 -0
  42. ch_migrate_cli-0.6.0/tests/test_version_table_policy.py +38 -0
  43. ch_migrate_cli-0.5.0/tests/integration/conftest.py +0 -206
  44. ch_migrate_cli-0.5.0/tests/test_package.py +0 -11
  45. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/.gitignore +0 -0
  46. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/LICENSE +0 -0
  47. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/authoring.py +0 -0
  48. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/config.py +0 -0
  49. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/deps.py +0 -0
  50. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/diff.py +0 -0
  51. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/downgrade.py +0 -0
  52. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/helpers.py +0 -0
  53. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/hooks.py +0 -0
  54. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/introspect.py +0 -0
  55. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/mv_validate.py +0 -0
  56. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/py.typed +0 -0
  57. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/rebase.py +0 -0
  58. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/runner.py +0 -0
  59. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/scaffold.py +0 -0
  60. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/secrets.py +0 -0
  61. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/sql.py +0 -0
  62. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/statements.py +0 -0
  63. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/templates/project/alembic.ini.template +0 -0
  64. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/templates/project/config.yaml.template +0 -0
  65. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/templates/project/env.local.example.template +0 -0
  66. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/ch_migrate/templates/project/script.py.mako.template +0 -0
  67. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/clickhouse_alembic/__init__.py +0 -0
  68. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/integration/test_lint_pending.py +0 -0
  69. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/integration/test_readme_quickstart.py +0 -0
  70. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/integration/test_smoke.py +0 -0
  71. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/test_bootstrap.py +0 -0
  72. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/test_compat_import.py +0 -0
  73. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/test_config.py +0 -0
  74. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/test_deps.py +0 -0
  75. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/test_diff.py +0 -0
  76. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/test_display.py +0 -0
  77. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/test_downgrade.py +0 -0
  78. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/test_helpers.py +0 -0
  79. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/test_introspect.py +0 -0
  80. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/test_readme.py +0 -0
  81. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/test_rebase.py +0 -0
  82. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/test_scaffold.py +0 -0
  83. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/test_secrets.py +0 -0
  84. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/test_snapshot.py +0 -0
  85. {ch_migrate_cli-0.5.0 → ch_migrate_cli-0.6.0}/tests/test_sql.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ch-migrate-cli
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: SQL-first ClickHouse schema migrations across environments: the ch-migrate command
5
5
  Project-URL: Homepage, https://github.com/DRYCodeWorks/ch-migrate
6
6
  Project-URL: Repository, https://github.com/DRYCodeWorks/ch-migrate
@@ -14,22 +14,21 @@ Classifier: Development Status :: 4 - Beta
14
14
  Classifier: Intended Audience :: Developers
15
15
  Classifier: License :: OSI Approved :: MIT License
16
16
  Classifier: Programming Language :: Python :: 3
17
- Classifier: Programming Language :: Python :: 3.9
18
17
  Classifier: Programming Language :: Python :: 3.10
19
18
  Classifier: Programming Language :: Python :: 3.11
20
19
  Classifier: Programming Language :: Python :: 3.12
21
20
  Classifier: Programming Language :: Python :: 3.13
22
21
  Classifier: Programming Language :: Python :: 3.14
23
22
  Classifier: Topic :: Database
24
- Requires-Python: >=3.9
25
- Requires-Dist: alembic>=1.14.0
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: alembic>=1.18.0
26
25
  Requires-Dist: boto3>=1.42.24
27
26
  Requires-Dist: click>=8.1.0
28
- Requires-Dist: clickhouse-connect>=0.7.0
29
- Requires-Dist: clickhouse-sqlalchemy>=0.3.0
27
+ Requires-Dist: clickhouse-connect[alembic]>=1.9
30
28
  Requires-Dist: python-dotenv>=1.0.0
31
29
  Requires-Dist: pyyaml>=6.0
32
30
  Requires-Dist: rich>=13.0.0
31
+ Requires-Dist: ruamel-yaml<0.20,>=0.19.1
33
32
  Provides-Extra: dev
34
33
  Requires-Dist: black>=23.0.0; extra == 'dev'
35
34
  Requires-Dist: isort>=5.12.0; extra == 'dev'
@@ -45,7 +44,7 @@ Description-Content-Type: text/markdown
45
44
 
46
45
  ## What it is
47
46
 
48
- `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; the database dialect owns DDL compilation. This operational layer complements ClickHouse's official Alembic integration rather than replacing it. This development line still uses `clickhouse-sqlalchemy` for Alembic connections; it does not imply an endorsement from ClickHouse.
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.
49
48
 
50
49
  Background: [ClickHouse migrations with Alembic](https://www.drycodeworks.com/articles/dev-guides/clickhouse-migrations-with-alembic).
51
50
 
@@ -58,12 +57,38 @@ pip install ch-migrate-cli
58
57
  ch-migrate --version
59
58
  ```
60
59
 
61
- 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, and run `ch-migrate upgrade-env` to refresh `migrations/env.py`.
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.
62
61
 
63
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`.
64
63
 
65
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`.
66
65
 
66
+ ## Upgrade from 0.x
67
+
68
+ 0.6 requires Python 3.10+; 0.5.x is the last line supporting Python 3.9.
69
+ A loose requirement such as `ch-migrate-cli` or `~=0.5` upgrades to 0.6; pin
70
+ `ch-migrate-cli==0.5.1` until each project has run the command below.
71
+ After upgrading the package, run this in each migration project:
72
+
73
+ ```bash
74
+ ch-migrate upgrade-env
75
+ ```
76
+
77
+ `init` and `upgrade-env` write a thin version-2 environment that delegates to the
78
+ package. The old file is saved as `migrations/env.py.bak`; repeating the command
79
+ on the current shim leaves that backup intact. Review any old customizations.
80
+ Existing Python migrations and version tables continue to work without edits.
81
+ `up`, `down`, and `history` refuse old environments before connecting and tell
82
+ you to run `upgrade-env`; `status` still reports, so CI jobs that use it as a
83
+ non-blocking reporter keep passing.
84
+
85
+ One HTTP session spans a migration run, including later revisions in that run.
86
+ A standalone `SET` therefore carries into following revisions. Prefer a
87
+ statement's `SETTINGS` clause when the change should apply only to that query.
88
+ `session_timeout` in an environment or `defaults` sets the idle timeout in
89
+ seconds (default `1800`). If that session expires, the next request fails rather
90
+ than silently recreating a session with default settings.
91
+
67
92
  ## Quick start
68
93
 
69
94
  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.
@@ -176,7 +201,141 @@ project/
176
201
 
177
202
  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`.
178
203
 
179
- To render without executing, set `CH_ENVIRONMENT` and run `alembic upgrade head --sql`. Existing projects need `ch-migrate upgrade-env` for the offline version-table and literal-rendering fixes. The package requires Alembic 1.14 or later for that extension point.
204
+ 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.
205
+
206
+ ### The version table
207
+
208
+ New `alembic_version` tables use `ORDER BY version_num`. The runtime inspects
209
+ `system.databases.engine` and the environment's `cluster` setting:
210
+
211
+ | Deployment | New version table |
212
+ |---|---|
213
+ | Shared database on ClickHouse Cloud | `MergeTree`; Cloud supplies its shared implementation |
214
+ | Self-hosted `Replicated` database | `ReplicatedMergeTree()` with server-default arguments |
215
+ | Atomic database with `cluster` configured | `ReplicatedMergeTree` created `ON CLUSTER`, with a Keeper path containing the database name and shard macro |
216
+ | Single server | `MergeTree` |
217
+
218
+ The official Alembic implementation advances a version by **inserting the new
219
+ row, then deleting the old row with `mutations_sync = 2`**. It does not issue an
220
+ asynchronous version UPDATE. If the process stops while the delete is pending,
221
+ both rows remain and Alembic refuses their overlapping history rather than
222
+ repeating the migration. Let the version mutation finish, then retry. A
223
+ server-reported failed version mutation stops the run with its reason; ch-migrate
224
+ never kills that mutation.
225
+
226
+ Before reading replicated heads, the tool catches up replicated database
227
+ metadata and inserted version parts. The table barrier uses
228
+ [`SYSTEM SYNC REPLICA ... LIGHTWEIGHT`](https://clickhouse.com/docs/reference/statements/system#sync-replica),
229
+ which does not wait for held mutation tasks. Failure checks cover reachable
230
+ hosts in the configured cluster, or the
231
+ [Replicated database's automatically named cluster](https://clickhouse.com/docs/reference/engines/database-engines/replicated).
232
+ An unavailable host is not evidence that its state is healthy.
233
+
234
+ Self-hosted replicas need Keeper and suitable `{shard}`/`{replica}` macros.
235
+ Configure authentication for a Replicated database's automatic cluster through
236
+ its `collection_name` setting; the named collection uses `cluster_username`,
237
+ `cluster_password`, and optionally `cluster_secret`/`cluster_secure_connection`.
238
+ Keep those values in server-side secret configuration, not migration files.
239
+ Provision the migration user on each node. Re-run bootstrap when upgrading to
240
+ grant the system-table reads and available `CLUSTER`, remote-read, and replica
241
+ synchronization privileges used by these checks.
242
+
243
+ Existing 0.4.1 `ReplacingMergeTree ORDER BY updated` tables are **never converted
244
+ automatically**. `upgrade-env` remains offline: it prints a conditional advisory
245
+ instead of probing every configured environment. `status ENV` performs the live
246
+ check and warns when existing state is not replicated but the deployment is.
247
+ Do not route migrations across nodes until that state is reconciled.
248
+
249
+ For a manual conversion:
250
+
251
+ 1. Stop every migration runner. Back up the old table's DDL and rows on each
252
+ node, and inspect unfinished version mutations.
253
+ 2. Reconcile one authoritative set of completed revision heads with the
254
+ revision graph. Do not infer it from the newest timestamp or blindly use
255
+ `FINAL`: the old timestamp key can collapse heads written in the same second.
256
+ 3. Keep the old tables as backups. On an Atomic cluster, rename each existing
257
+ old table on its own node, then create the replacement on the cluster.
258
+ In a Replicated database, issue schema changes once and let its DDL log
259
+ propagate them.
260
+ 4. Use `MergeTree ORDER BY version_num` on a single server, or the replicated
261
+ engine from the table above. For an Atomic cluster, use a fresh shared Keeper
262
+ path containing the database and the appropriate replica macros.
263
+ 5. Copy the reconciled, distinct heads **once** into the empty replacement,
264
+ synchronize replicas, and compare `status` through every routing endpoint.
265
+ Resume runners only after they agree. Keep backups until the result is verified.
266
+
267
+ Offline SQL cannot inspect a live database engine: it uses configured `cluster`
268
+ information or the single-server/Cloud default for version-table DDL. Review
269
+ that DDL before using it for a self-hosted Replicated database. Cloud qualification
270
+ is separate from the local suite; the local replicated harness has one shard.
271
+
272
+ ### Re-runnable migrations
273
+
274
+ `up` statically checks pending upgrade statements before Alembic executes any of
275
+ them. Idempotency and standalone-SET errors refuse the whole run and print the source file, line,
276
+ statement, and suggested fix. There is no command-line bypass; `--skip-mv-check`
277
+ does not skip this gate. Other findings are warnings in `up`; standalone `lint`
278
+ retains their configured severities.
279
+
280
+ Static lint rejects a standalone `SET` when `migrations/env.py` lacks the
281
+ `CH_MIGRATE_ENV_VERSION = 2` marker: that statement would be ignored on the old
282
+ connection. Run `ch-migrate upgrade-env`, or put the setting in the relevant
283
+ statement's `SETTINGS` clause. Version-2 projects allow `SET`; `SETTINGS` clauses
284
+ and the word `SET` inside string literals are not flagged. `standalone_set` is
285
+ a gate rule whose severity cannot be lowered.
286
+
287
+ Use `IF NOT EXISTS` for `CREATE` and `ALTER ... ADD`, and `IF EXISTS` for `DROP`,
288
+ `ALTER ... DROP`, `RENAME COLUMN`, and table, dictionary, and database renames.
289
+ This includes tables, views, materialized views, dictionaries, databases, users,
290
+ roles, row policies, settings profiles, quotas, functions, and named collections.
291
+ Column, index, projection, and constraint changes are checked separately.
292
+ `CREATE OR REPLACE` is accepted. `ATTACH` and `DETACH` of an object need their
293
+ matching `IF` form.
294
+
295
+ Each finding says how to make its statement safe to run twice, using the
296
+ statement's own names: the exact `IF` form, or what a retry would do and the
297
+ usual fix. For example, a backfill into a table that only its migration writes
298
+ can start with `TRUNCATE`, and `ATTACH PARTITION ... FROM` can usually be
299
+ `REPLACE PARTITION ... FROM`.
300
+
301
+ Inserts, exchanges, updates/deletes, `ATTACH PARTITION ... FROM`,
302
+ `MOVE PARTITION ... TO TABLE`, and attaching or detaching a single `PART` need a
303
+ reasoned waiver. Put it directly above the statement, without a separating blank
304
+ line:
305
+
306
+ ```sql
307
+ -- ch-migrate: allow-non-idempotent This backfill targets a reviewed deduplicating table
308
+ INSERT INTO {db}.target SELECT id FROM {db}.source;
309
+ ```
310
+
311
+ For inline Python SQL, use the same directive in a `#` comment directly above
312
+ `op.execute`. Empty reasons remain errors. Waivers print as INFO with their
313
+ reasons in `lint` and `up`; they do not make the statement idempotent.
314
+ `MODIFY`, `MATERIALIZE`, `CLEAR COLUMN`, `TRUNCATE`, `OPTIMIZE`, `SYSTEM`, grants,
315
+ revokes, `REPLACE PARTITION`, attaching or detaching a whole partition, and
316
+ comments are not flagged by this rule.
317
+ The reviewable classification corpus is `tests/corpus/idempotency.yaml`.
318
+
319
+ **Recovery:** fix the cause of a partial failure, then run `ch-migrate up ENV`
320
+ again. Earlier idempotent statements can run again; the version advances only
321
+ when the revision completes. On ClickHouse Cloud, a column that the failed attempt
322
+ dropped or renamed stays locked until that change's mutation finishes: a retry
323
+ before then fails with "affected by mutation ... which is not finished yet", even
324
+ with `IF EXISTS`. Wait for it in `system.mutations`, then run `up` again. Before retrying waived operations, review what the
325
+ earlier attempt actually changed.
326
+
327
+ **Existing history:** `upgrade-env` records the current local script head(s) in
328
+ `config.yaml` as `lint.gate_baseline`, preserving YAML comments. It records
329
+ script history, not the deployed database head. The baseline and its ancestors
330
+ are exempt in every environment, including where they have not yet run.
331
+ Multiple heads are stored as a list. `init` writes no baseline, so new projects
332
+ gate every revision.
333
+
334
+ Review and commit the baseline diff. Running `upgrade-env` again updates it to
335
+ the current script heads; do not use that operation to hide new findings.
336
+ Static `lint` checks revisions after the baseline, and live `lint ENV` intersects
337
+ that scope with pending revisions. Lowering a gate rule through `lint.rules`
338
+ is an error: use a reviewed baseline or an explicit statement waiver instead.
180
339
 
181
340
  ### Irreversible migrations
182
341
 
@@ -226,6 +385,9 @@ def downgrade():
226
385
 
227
386
  `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.
228
387
 
388
+ The generated copy and exchange statements require explicit, reasoned waivers
389
+ before `up` accepts them. The generator does not waive them automatically.
390
+
229
391
  For a controlled change, the underlying pattern is:
230
392
 
231
393
  ```sql
@@ -273,7 +435,7 @@ Example: `ch-migrate new dev add_status --table logs`
273
435
 
274
436
  ### `up`
275
437
 
276
- `ch-migrate up ENV [-r REV] [--skip-mv-check] [--verbose]` applies migrations to `head` by default, printing one line per migration. `-r/--revision` selects a target. `--skip-mv-check` bypasses materialized-view declaration validation; 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.
438
+ `ch-migrate up ENV [-r REV] [--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. `--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.
277
439
 
278
440
  Example: `ch-migrate up dev --revision abc123`
279
441
 
@@ -285,7 +447,7 @@ Example: `ch-migrate down dev --revision base`
285
447
 
286
448
  ### `status`
287
449
 
288
- `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. Exits 1 if it cannot reach the database.
450
+ `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.
289
451
 
290
452
  Example: `ch-migrate status dev`
291
453
 
@@ -297,7 +459,7 @@ Example: `ch-migrate history dev`
297
459
 
298
460
  ### `lint`
299
461
 
300
- `ch-migrate lint [ENV]` analyzes upgrade statements, not downgrade SQL. Without `ENV`, it checks every revision statically without credentials or a connection. With an environment, it checks only pending revisions 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 alone do not.
462
+ `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.
301
463
 
302
464
  Example: `ch-migrate lint`
303
465
 
@@ -335,7 +497,7 @@ Example: `ch-migrate rebase dev --onto abc123 --dry-run`
335
497
 
336
498
  ### `upgrade-env`
337
499
 
338
- `ch-migrate upgrade-env` replaces `migrations/env.py` with the installed version and backs up the old file as `env.py.bak`. No command-specific options. Review and reapply local customizations from the backup.
500
+ `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.
339
501
 
340
502
  Example: `ch-migrate upgrade-env`
341
503
 
@@ -401,7 +563,7 @@ Only configure the dictionary hook when that dictionary exists at every revision
401
563
 
402
564
  ### Lint configuration
403
565
 
404
- Set rule severities to `error`, `warn`, or `off`. `mv_validation_cutoff` can exclude older revisions from materialized-view declaration checks.
566
+ 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.
405
567
 
406
568
  ```yaml
407
569
  lint:
@@ -409,7 +571,7 @@ lint:
409
571
  mv_validation_cutoff: "2026-01-01"
410
572
  rules:
411
573
  destructive_changes: warn
412
- idempotency: warn
574
+ idempotency: error
413
575
  reserved_words: warn
414
576
  ```
415
577
 
@@ -417,7 +579,7 @@ Review findings rather than treating a successful command as a guarantee that a
417
579
 
418
580
  ### Bootstrap roles and Cloud notes
419
581
 
420
- Bootstrap creates `{project}_migration_role` for schema/data operations and introspection, including explicit `system.grants` access. 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.
582
+ 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.
421
583
 
422
584
  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`.
423
585
 
@@ -435,10 +597,62 @@ Run the opt-in real-server suite:
435
597
  uv run --locked pytest -q -m integration
436
598
  ```
437
599
 
438
- The 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.
600
+ 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.
439
601
 
440
602
  `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.
441
603
 
604
+ For the single-server suite only:
605
+
606
+ ```bash
607
+ uv run --locked pytest -q -m "integration and not cluster"
608
+ ```
609
+
610
+ For the replicated harness:
611
+
612
+ ```bash
613
+ uv run --locked --python 3.12 pytest -q -m "integration and cluster" -k harness
614
+ ```
615
+
616
+ `clickhouse_cluster` owns a Docker network and two nodes, with Keeper embedded
617
+ in node 1. `it_cluster` has one shard and two replicas. `cluster_project` creates
618
+ an **Atomic database on both nodes with `ON CLUSTER`**; replicated tables use
619
+ `ReplicatedMergeTree` with a shared Keeper path and per-node replica macros.
620
+ The fixture exposes `clients[1]` and `clients[2]`, plus `stop_node(2)` and
621
+ `start_node(2)`. Read `clients[2]` again after a restart; its connection is renewed.
622
+
623
+ Tests prove row replication and both hosts' distributed-DDL completion. The
624
+ node-down scenario uses a five-second distributed-DDL timeout, inspects the
625
+ unfinished host, then proves catch-up after restart. Its HTTP response is
626
+ buffered with `wait_end_of_query=1` so the timeout is not obscured by a truncated
627
+ chunked response.
628
+
629
+ Cluster fixtures use the same image-tag override, remove their own containers,
630
+ anonymous volumes, and network, and never operate on external servers. Cluster
631
+ tests skip when `CH_MIGRATE_IT_URL` is set. The integration CI job runs cluster
632
+ tests on Python 3.12 only, and single-server tests on 3.10 and 3.14.
633
+
634
+ ### Against a ClickHouse Cloud test service
635
+
636
+ Point `CH_MIGRATE_IT_URL` at a dedicated Cloud test service
637
+ (`https://<user>:<url-encoded password>@<service>:8443`), loading the password
638
+ from your secret store at run time. Use a user that can create and drop `it_*`
639
+ databases, users and roles. The fixture waits up to 300 seconds for an idle
640
+ service to wake.
641
+
642
+ ```bash
643
+ uv run --locked pytest -q -m "integration and not cluster" # the full single-server suite
644
+ uv run --locked pytest -q -m "integration and cloud" # Cloud-only checks
645
+ ```
646
+
647
+ Tests marked `cloud` run only when the URL is a Cloud service (Shared
648
+ databases); they check that a migration's `SET` reaches every later statement
649
+ whichever replica serves it, and version-table round trips on SharedMergeTree.
650
+ Cluster tests skip. Cloud grants no user `SYSTEM STOP MERGES`, so tests that
651
+ hold a mutation pending skip with that reason; the self-hosted suite still
652
+ covers them. Each test drops its own database. After an interrupted run,
653
+ `scripts/cloud_sweep.py [--dry-run]` drops `it_*` databases no query has
654
+ touched for 24 hours, with their test users and roles.
655
+
442
656
  ## License
443
657
 
444
658
  MIT License — see [LICENSE](LICENSE).