ch-migrate-cli 0.6.0__tar.gz → 0.8.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 (104) hide show
  1. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/.gitignore +2 -0
  2. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/PKG-INFO +226 -16
  3. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/README.md +224 -15
  4. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/__init__.py +1 -1
  5. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/alembic_env.py +84 -19
  6. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/bootstrap.py +5 -1
  7. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/cli.py +240 -151
  8. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/deps.py +28 -20
  9. ch_migrate_cli-0.8.0/ch_migrate/diff.py +277 -0
  10. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/display.py +3 -1
  11. ch_migrate_cli-0.8.0/ch_migrate/gate.py +59 -0
  12. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/introspect.py +7 -20
  13. ch_migrate_cli-0.8.0/ch_migrate/json_output.py +152 -0
  14. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/lint.py +252 -94
  15. ch_migrate_cli-0.8.0/ch_migrate/migration_runner.py +250 -0
  16. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/mv_validate.py +17 -46
  17. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/scaffold.py +2 -6
  18. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/secrets.py +1 -2
  19. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/skills/ch-migrate/SKILL.md +13 -5
  20. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/sql.py +10 -2
  21. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/statements.py +46 -7
  22. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/templates/bootstrap/init_users.sql +2 -0
  23. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/ui.py +4 -4
  24. ch_migrate_cli-0.8.0/ch_migrate/waiting/__init__.py +13 -0
  25. ch_migrate_cli-0.8.0/ch_migrate/waiting/budget.py +47 -0
  26. ch_migrate_cli-0.8.0/ch_migrate/waiting/display.py +265 -0
  27. ch_migrate_cli-0.8.0/ch_migrate/waiting/errors.py +37 -0
  28. ch_migrate_cli-0.8.0/ch_migrate/waiting/mutations.py +264 -0
  29. ch_migrate_cli-0.8.0/ch_migrate/waiting/reruns.py +182 -0
  30. ch_migrate_cli-0.8.0/ch_migrate/waiting/sql.py +36 -0
  31. ch_migrate_cli-0.8.0/ch_migrate/waiting/waiter.py +68 -0
  32. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/pyproject.toml +2 -0
  33. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/integration/conftest.py +35 -2
  34. ch_migrate_cli-0.8.0/tests/integration/test_cloud.py +174 -0
  35. ch_migrate_cli-0.8.0/tests/integration/test_drift_workflow.py +94 -0
  36. ch_migrate_cli-0.8.0/tests/integration/test_json_commands.py +105 -0
  37. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/integration/test_lint_pending.py +1 -3
  38. ch_migrate_cli-0.8.0/tests/integration/test_mutation_waiting.py +550 -0
  39. ch_migrate_cli-0.8.0/tests/integration/test_on_cluster.py +199 -0
  40. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_cli_output.py +21 -8
  41. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_deps.py +11 -3
  42. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_diff.py +56 -26
  43. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_hooks.py +50 -32
  44. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_introspect.py +25 -25
  45. ch_migrate_cli-0.8.0/tests/test_json_output.py +198 -0
  46. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_lint.py +49 -47
  47. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_mv_validate.py +57 -57
  48. ch_migrate_cli-0.8.0/tests/test_reruns.py +123 -0
  49. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_snapshot.py +10 -7
  50. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_statements.py +53 -0
  51. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_status_command.py +2 -2
  52. ch_migrate_cli-0.8.0/tests/test_waiting.py +77 -0
  53. ch_migrate_cli-0.8.0/tests/test_waiting_display.py +174 -0
  54. ch_migrate_cli-0.6.0/ch_migrate/diff.py +0 -232
  55. ch_migrate_cli-0.6.0/ch_migrate/gate.py +0 -26
  56. ch_migrate_cli-0.6.0/tests/integration/test_cloud.py +0 -63
  57. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/LICENSE +0 -0
  58. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/authoring.py +0 -0
  59. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/baseline.py +0 -0
  60. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/config.py +0 -0
  61. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/connection.py +0 -0
  62. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/downgrade.py +0 -0
  63. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/env.py +0 -0
  64. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/helpers.py +0 -0
  65. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/hooks.py +0 -0
  66. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/idempotency.py +0 -0
  67. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/py.typed +0 -0
  68. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/rebase.py +0 -0
  69. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/runner.py +0 -0
  70. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/templates/project/alembic.ini.template +0 -0
  71. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/templates/project/config.yaml.template +0 -0
  72. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/templates/project/env.local.example.template +0 -0
  73. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/templates/project/script.py.mako.template +0 -0
  74. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/ch_migrate/version_table.py +0 -0
  75. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/clickhouse_alembic/__init__.py +0 -0
  76. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/corpus/idempotency.yaml +0 -0
  77. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/fixtures/env_v0_4_1.py +0 -0
  78. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/integration/dialect_spike_probe.py +0 -0
  79. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/integration/test_cluster_harness.py +0 -0
  80. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/integration/test_gate.py +0 -0
  81. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/integration/test_idempotency_advice.py +0 -0
  82. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/integration/test_irreversible.py +0 -0
  83. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/integration/test_readme_quickstart.py +0 -0
  84. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/integration/test_run_sql.py +0 -0
  85. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/integration/test_session_env.py +0 -0
  86. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/integration/test_smoke.py +0 -0
  87. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/integration/test_sql_first.py +0 -0
  88. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/integration/test_standalone_set.py +0 -0
  89. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/integration/test_version_table.py +0 -0
  90. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_bootstrap.py +0 -0
  91. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_compat_import.py +0 -0
  92. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_config.py +0 -0
  93. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_display.py +0 -0
  94. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_downgrade.py +0 -0
  95. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_helpers.py +0 -0
  96. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_idempotency.py +0 -0
  97. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_new.py +0 -0
  98. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_package.py +0 -0
  99. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_readme.py +0 -0
  100. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_rebase.py +0 -0
  101. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_scaffold.py +0 -0
  102. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_secrets.py +0 -0
  103. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_sql.py +0 -0
  104. {ch_migrate_cli-0.6.0 → ch_migrate_cli-0.8.0}/tests/test_version_table_policy.py +0 -0
@@ -66,4 +66,6 @@ tmp/
66
66
  temp/
67
67
 
68
68
  examples/
69
+ # Documentation examples are reviewed source, not local sample projects.
70
+ !docs/examples/
69
71
  lambda-exporter/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ch-migrate-cli
3
- Version: 0.6.0
3
+ Version: 0.8.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
@@ -32,6 +32,7 @@ Requires-Dist: ruamel-yaml<0.20,>=0.19.1
32
32
  Provides-Extra: dev
33
33
  Requires-Dist: black>=23.0.0; extra == 'dev'
34
34
  Requires-Dist: isort>=5.12.0; extra == 'dev'
35
+ Requires-Dist: jsonschema>=4.18; extra == 'dev'
35
36
  Requires-Dist: mypy>=1.5.0; extra == 'dev'
36
37
  Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
37
38
  Requires-Dist: pytest>=7.4.0; extra == 'dev'
@@ -65,8 +66,19 @@ This README describes the source checkout, which may be ahead of PyPI. To try an
65
66
 
66
67
  ## Upgrade from 0.x
67
68
 
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
69
+ **From 0.7:** 0.8 needs no `upgrade-env`, and nothing changes unless you pass
70
+ `--json` (see [Drift detection in CI](#drift-detection-in-ci)). A loose requirement
71
+ such as `ch-migrate-cli` upgrades to 0.8; pin `ch-migrate-cli==0.7.0` to stay on 0.7.
72
+
73
+ **From 0.6:** 0.7 needs no `upgrade-env`. `up` now waits for the mutations and
74
+ `ON CLUSTER` DDL its statements start (see [Migrations that wait](#migrations-that-wait)),
75
+ so give CI jobs time to finish or pass `--timeout`. Where `bootstrap` couldn't grant
76
+ `SELECT ON system.*`, such as on ClickHouse Cloud, add the grants listed in the
77
+ [CHANGELOG](CHANGELOG.md). A loose requirement such as `ch-migrate-cli` upgrades to
78
+ 0.7; pin `ch-migrate-cli==0.6.0` to stay on 0.6.
79
+
80
+ **From 0.5:** 0.6 and later require Python 3.10+; 0.5.x is the last line supporting Python 3.9.
81
+ A loose requirement such as `ch-migrate-cli` or `~=0.5` upgrades past 0.5; pin
70
82
  `ch-migrate-cli==0.5.1` until each project has run the command below.
71
83
  After upgrading the package, run this in each migration project:
72
84
 
@@ -269,13 +281,111 @@ information or the single-server/Cloud default for version-table DDL. Review
269
281
  that DDL before using it for a self-hosted Replicated database. Cloud qualification
270
282
  is separate from the local suite; the local replicated harness has one shard.
271
283
 
284
+ ### Migrations that wait
285
+
286
+ `up` doesn't record a migration as applied until the work its statements started
287
+ has finished. After each statement that can write, it polls `system.mutations` on
288
+ the same checked ClickHouse session, so earlier `SET` statements stay in effect.
289
+ `op.execute`, raw `op.get_bind()` execution and `run_sql` all pass through that
290
+ check. Upgrades run in the CLI process, so killing it can't leave a child Alembic
291
+ runner advancing revisions. `down` waits the same way.
292
+
293
+ ```bash
294
+ ch-migrate up dev # No default waiting timeout
295
+ ch-migrate up dev --timeout 120 # One waiting budget across the invocation
296
+ ```
297
+
298
+ What `up` waits for:
299
+
300
+ - **Every unfinished mutation in the project database,** including one another
301
+ writer started. Before the first migration runs, `up` waits for any already
302
+ queued, and a failed one blocks the run. The version table's own mutations are
303
+ left to [its bookkeeping](#the-version-table).
304
+ - **Each replica that has to apply it.** Zero parts left isn't completion on its
305
+ own: the mutation must report `is_done`. A ReplicatedMergeTree mutation must be
306
+ finished on every replica, and a replica that hasn't received it yet is still
307
+ pending. Configure `cluster` for self-hosted replicated tables, or use a
308
+ Replicated database's automatic cluster; without either, `up` refuses a table
309
+ with more than one replica. SharedMergeTree on ClickHouse Cloud reports one
310
+ shared mutation.
311
+ - **`ON CLUSTER` DDL on every host.** `up` sends these statements with
312
+ `distributed_ddl_output_mode = 'throw'`, so ClickHouse answers once every host
313
+ has run the statement, and a host that fails or doesn't finish in time fails the
314
+ migration with ClickHouse's message. The limit is 270 seconds, below the
315
+ client's read timeout, or what's left of `--timeout` if that's less. Mutations
316
+ the statement starts are then waited for as above.
317
+
318
+ Statements ClickHouse already holds until they finish, such as a type-changing
319
+ `MODIFY COLUMN`, a `MODIFY TTL` that materializes, or a lightweight `DELETE`,
320
+ still hold their request as they did in 0.6.
321
+
322
+ While it waits, `up` shows progress on stderr. In a terminal, each mutation gets a
323
+ live bar weighted by bytes, with its parts and an estimate of the time left:
324
+
325
+ ```text
326
+ → Applying 3f9c2a1b backfill_digest
327
+ ━━━━━━━━━━━━━━━━━━━━╺━━━━━━━━━━━━━━━━━ 18.2 / 41.0 GiB · 23/41 parts · 1:12 · ~1:30 left
328
+ ✓ 3f9c2a1b backfill_digest mutation_7.txt · 41 parts · 41.0 GiB · 2:03
329
+ ```
330
+
331
+ Self-hosted replicated tables get one row per replica. When output isn't a
332
+ terminal (CI logs, pipes, coding agents), `up` prints plain lines instead: one
333
+ when a wait starts, one at each 10% of the parts or every 30 seconds, and one
334
+ when it finishes:
335
+
336
+ ```text
337
+ → Applying 3f9c2a1b backfill_digest
338
+ waiting on mutation_7.txt (demo_dev.events): 0/41 parts
339
+ waiting on mutation_7.txt (demo_dev.events): 5/41 parts · 0:14
340
+ ✓ 3f9c2a1b backfill_digest mutation_7.txt · 41 parts · 2:03
341
+ ```
342
+
343
+ Part sizes come from `system.parts`. If the migration role can't read it, the bar
344
+ counts parts only and says so once; progress never changes whether `up` succeeds.
345
+
346
+ **When `up` stops short.** After a timeout, Ctrl-C, dropped connection, killed
347
+ process or failed statement, ClickHouse keeps working and the migration isn't
348
+ recorded. `up` and `down` note each migration in `_ch_migrate_in_progress` when it
349
+ starts and clear the note once its version is recorded, so the next run knows the
350
+ migration was interrupted. That run waits for unfinished work, then:
351
+
352
+ - **Runs the migration again on its own** when every statement in it passes the
353
+ idempotency gate without a waiver, so a repeat leaves the same result.
354
+ - **Stops before sending anything** when the migration has a waived statement or
355
+ SQL the gate can't read, because that statement may already have applied. It
356
+ names each one. Check what the earlier run changed, fix the SQL if needed, then
357
+ name the revision: `ch-migrate up ENV --rerun-interrupted REV` (or `down`), or
358
+ `-x rerun_interrupted=REV` with `alembic`. Naming it accepts the repeat.
359
+
360
+ Every `UPDATE`, `DELETE` and `INSERT ... SELECT` needs a waiver, so an interrupted
361
+ backfill always takes that second path.
362
+
363
+ **Failed mutation.** ClickHouse's `latest_fail_reason` stops the run, and `up`
364
+ prints the scoped `KILL MUTATION` statement for an operator; ch-migrate never
365
+ runs it. ClickHouse keeps retrying a failed mutation, and later mutations on that
366
+ table queue behind it. If the cause is temporary, such as memory or disk, fix it
367
+ and run `up` again. If the statement itself is wrong, kill the mutation, fix the
368
+ SQL and run `up` again, naming the revision with `--rerun-interrupted` if it's
369
+ waived. Killing a mutation doesn't undo the parts it already rewrote.
370
+
371
+ Run one migration runner per database, using a CI concurrency key or an external
372
+ lock; the in-progress notes aren't a lock. Re-run `bootstrap` when upgrading a
373
+ restricted migration user to obtain the `system.replicas` and `system.parts` grants.
374
+
272
375
  ### Re-runnable migrations
273
376
 
274
377
  `up` statically checks pending upgrade statements before Alembic executes any of
275
378
  them. Idempotency and standalone-SET errors refuse the whole run and print the source file, line,
276
379
  statement, and suggested fix. There is no command-line bypass; `--skip-mv-check`
277
380
  does not skip this gate. Other findings are warnings in `up`; standalone `lint`
278
- retains their configured severities.
381
+ retains their configured severities. A direct `alembic upgrade` runs the same gate
382
+ from `env.py` before its first migration.
383
+
384
+ The gate reads SQL in `run_sql` files and in literal strings, f-strings and
385
+ `text("...")` passed to `op.execute`, or to `execute` and `exec_driver_sql` on a
386
+ connection such as `op.get_bind()`. SQL that only exists at run time, such as a
387
+ variable, a built string or `run_sql(name)`, can't be checked, so it needs a
388
+ reasoned waiver like any other statement the gate can't vouch for.
279
389
 
280
390
  Static lint rejects a standalone `SET` when `migrations/env.py` lacks the
281
391
  `CH_MIGRATE_ENV_VERSION = 2` marker: that statement would be ignored on the old
@@ -309,7 +419,7 @@ INSERT INTO {db}.target SELECT id FROM {db}.source;
309
419
  ```
310
420
 
311
421
  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
422
+ the call that sends it. Empty reasons remain errors. Waivers print as INFO with their
313
423
  reasons in `lint` and `up`; they do not make the statement idempotent.
314
424
  `MODIFY`, `MATERIALIZE`, `CLEAR COLUMN`, `TRUNCATE`, `OPTIMIZE`, `SYSTEM`, grants,
315
425
  revokes, `REPLACE PARTITION`, attaching or detaching a whole partition, and
@@ -318,11 +428,12 @@ The reviewable classification corpus is `tests/corpus/idempotency.yaml`.
318
428
 
319
429
  **Recovery:** fix the cause of a partial failure, then run `ch-migrate up ENV`
320
430
  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.
431
+ when the revision completes. A dropped or renamed column stays locked until that
432
+ change's mutation finishes, and `up` waits for it before running the next
433
+ statement, so a retry after a later failure doesn't hit "affected by mutation ...
434
+ which is not finished yet". If `up` was stopped while it waited, run it again: it
435
+ waits for that mutation, then runs the revision again, unless the revision has a
436
+ statement the gate can't vouch for (see below).
326
437
 
327
438
  **Existing history:** `upgrade-env` records the current local script head(s) in
328
439
  `config.yaml` as `lint.gate_baseline`, preserving YAML comments. It records
@@ -409,12 +520,68 @@ def upgrade():
409
520
  create_dictionary("history/dictionaries/dict_users/001_create.sql")
410
521
  ```
411
522
 
523
+ ## Drift detection in CI
524
+
525
+ After applying and verifying migrations in an environment, capture its expected
526
+ schema:
527
+
528
+ ```bash
529
+ ch-migrate snapshot dev
530
+ mv migrations/sql/snapshots/20261002_120000 migrations/sql/snapshots/dev
531
+ ch-migrate diff dev --snapshot-dir migrations/sql/snapshots/dev --json
532
+ ```
533
+
534
+ `snapshot` writes a new timestamped directory every time; replace the example
535
+ timestamp with the one it reports. Give each environment's snapshot a fixed name,
536
+ such as `snapshots/dev`, so `CH_MIGRATE_SNAPSHOT_DIR` never changes and the next
537
+ snapshot shows up in review as a diff against this one. Review and commit that
538
+ directory alongside the migration head. Do not regenerate the snapshot
539
+ automatically in the drift job: doing so would accept an out-of-band change as the
540
+ new expected schema. Re-snapshot after an intentional migration or an explicitly
541
+ reviewed reconciliation, not merely to turn a failed check green. To re-snapshot,
542
+ run `snapshot` again, delete the old `snapshots/dev`, and move the new directory
543
+ into its place, so the commit also shows objects that were dropped.
544
+
545
+ Copy [the GitHub Actions example](docs/examples/github-actions-drift.yml) into the
546
+ consumer repository's `.github/workflows/`. It runs on a daily schedule and
547
+ same-repository pull requests; fork PRs are excluded because they have no database
548
+ secret. It does not use `pull_request_target`. All actions are pinned by commit.
549
+ The checkout must contain `config.yaml` and the committed snapshot, and the runner
550
+ must be able to reach the selected server.
551
+
552
+ Configure these repository variables:
553
+
554
+ | Variable | Value |
555
+ |---|---|
556
+ | `CH_MIGRATE_PACKAGE` | A pinned `ch-migrate-cli` requirement, such as `ch-migrate-cli==0.8.0`. 0.8.0 is the first release with `diff --json`; there is no fallback to an older one. |
557
+ | `CH_MIGRATE_ENV` | An environment name in `config.yaml`, such as `dev`. |
558
+ | `CH_MIGRATE_SNAPSHOT_DIR` | That environment's committed snapshot directory. |
559
+
560
+ Set the repository secret `DRIFT_MIGRATION_PASSWORD` to the password for that
561
+ environment's configured user. Prefer a dedicated schema-inspection account,
562
+ not an admin account. The job writes a private `.env.local` without printing the
563
+ value and removes the file even when comparison fails.
564
+
565
+ `diff --json` exits **0** for matching schemas, **1** for drift, and **2** for a
566
+ configuration/connection error. The workflow saves `drift.json`, uploads it even
567
+ on comparison failure, then fails the job for either nonzero result. The report
568
+ names changed tables and fields. ch-migrate's own test suite runs the example's
569
+ shell steps against a real ClickHouse server, installing from the source checkout,
570
+ for both a matching and a drifted schema.
571
+
572
+ Without `--snapshot-dir`, `diff` sorts entries in `migrations/sql/snapshots/` by
573
+ name and chooses the last one. It does not select a snapshot by environment or
574
+ recorded migration revision. Use an explicit path in CI, especially when one
575
+ repository holds snapshots for several environments.
576
+
412
577
  ## Command reference
413
578
 
414
579
  Every command accepts `--help`. Top-level `ch-migrate --version` reports the installed package version. `ENV` below names an entry in `config.yaml`.
415
580
 
416
581
  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.
417
582
 
583
+ `status`, `history`, `lint`, and `diff` accept `--json`. JSON mode writes one document to stdout; diagnostics go to stderr. Every document includes `"schema_version": 1` and `"command"`. The format can still change before 1.0; `schema_version` says which one a document follows. Errors include an `error` string rather than inventing a successful empty result. The schemas linked below use JSON Schema draft 2020-12.
584
+
418
585
  ### `init`
419
586
 
420
587
  `ch-migrate init [PATH] [-n NAME]` initializes the current directory by default. `-n/--name` sets the project name; otherwise it uses the directory name.
@@ -435,7 +602,7 @@ Example: `ch-migrate new dev add_status --table logs`
435
602
 
436
603
  ### `up`
437
604
 
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.
605
+ `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.
439
606
 
440
607
  Example: `ch-migrate up dev --revision abc123`
441
608
 
@@ -447,22 +614,53 @@ Example: `ch-migrate down dev --revision base`
447
614
 
448
615
  ### `status`
449
616
 
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.
617
+ `ch-migrate status ENV [--json]` shows connection information, applied/pending counts, and head status, and names the `up` command when migrations are pending. Status is a report: human output 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. JSON mode uses nonzero exits for unreachable or pending state.
451
618
 
452
619
  Example: `ch-migrate status dev`
453
620
 
621
+ With `--json`, [the status schema](docs/schemas/status.schema.json) includes
622
+ `database`, `current_heads`, `script_heads`, `pending`, `applied`, and `at_head`.
623
+ `applied` includes ancestors resolved through the revision graph, not just stored
624
+ heads. JSON exits 0 when the head sets match exactly, 1 when pending or diverged,
625
+ and 2 when configuration or database state cannot be read. Human-mode exits are
626
+ unchanged.
627
+
628
+ ```bash
629
+ ch-migrate status dev --json | jq -e .current_heads
630
+ # Check the actual head set, not the last element of applied:
631
+ ch-migrate status dev --json | jq -e '.current_heads == ["PINNED_REVISION"]'
632
+ ```
633
+
634
+ Replace `PINNED_REVISION` with the required revision. In a shell pipeline, enable
635
+ `set -o pipefail` if the caller must also preserve ch-migrate's exit code.
636
+
454
637
  ### `history`
455
638
 
456
- `ch-migrate history ENV` displays the revision graph and applied state. No command-specific options.
639
+ `ch-migrate history ENV [--json]` displays the revision graph and applied state.
457
640
 
458
641
  Example: `ch-migrate history dev`
459
642
 
643
+ `ch-migrate history dev --json` follows [the history schema](docs/schemas/history.schema.json).
644
+ Each entry in `revisions` includes its revision, all `down_revisions` (including
645
+ merge parents), description, create date, path, applied status, and irreversible
646
+ reason. A reversible revision has `irreversible: null`. If database state is
647
+ unavailable, `applied` is null, the document includes `error`, and JSON exits 2.
648
+ An unknown database head also makes applied status unknown instead of claiming
649
+ that every local revision is unapplied.
650
+
460
651
  ### `lint`
461
652
 
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.
653
+ `ch-migrate lint [ENV] [--json]` 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. Errors exit nonzero; warnings and waiver INFO lines alone do not.
463
654
 
464
655
  Example: `ch-migrate lint`
465
656
 
657
+ `ch-migrate lint --json` follows [the lint schema](docs/schemas/lint.schema.json).
658
+ `findings` include rule, severity, source file/line, message, `blocking`, and
659
+ `waived`. `blocking` means the finding currently blocks `up`; a waived finding
660
+ has its written reason and does not block. `counts` reports total, error, warning,
661
+ info, blocking, and waived findings. JSON and human output use the same exit
662
+ codes: 1 for lint/configuration errors, otherwise 0.
663
+
466
664
  Findings name the project-relative SQL file and statement line. Inline Python SQL
467
665
  points to its `op.execute` call. Extraction reads `run_sql`/`read_sql` file
468
666
  references and literal or f-string `op.execute` arguments without importing
@@ -479,10 +677,16 @@ Example: `ch-migrate deps dev --validate migrations/sql/history/tables/logs/chan
479
677
 
480
678
  ### `diff`
481
679
 
482
- `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.
680
+ `ch-migrate diff ENV [-s PATH] [--json]` compares the live schema with the latest snapshot. `-s/--snapshot-dir PATH` chooses another snapshot. Exit code 0 means no drift; 1 means drift. JSON errors exit 2; human-mode execution errors retain exit 1.
483
681
 
484
682
  Example: `ch-migrate diff dev --snapshot-dir migrations/sql/snapshots/20261002_120000`
485
683
 
684
+ `ch-migrate diff dev --json` follows [the diff schema](docs/schemas/diff.schema.json).
685
+ `objects` identifies each object's type, name, status, and structural `details`
686
+ (field, local value, remote value, and message). `in_sync` is true only when every
687
+ object matches. Use the exit code as the drift gate; an error document does not
688
+ contain a fabricated `in_sync: true`.
689
+
486
690
  ### `snapshot`
487
691
 
488
692
  `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.
@@ -549,7 +753,13 @@ The legacy `CH_<ENV>_PASSWORD` remains supported. Never commit credentials or pa
549
753
 
550
754
  ### Hooks
551
755
 
552
- Top-level hooks run SQL on the migration connection. `pre_migrate` runs before the migration batch; `post_migrate` runs after each revision. `{db}` is substituted. Hooks execute as SQLAlchemy text, not through the SQL-file splitter; supply one statement per entry. Hook SQL is logged, so do not put secrets in it.
756
+ Top-level hooks run SQL on the migration connection. `pre_migrate` runs before the
757
+ migration batch; `post_migrate` runs after each revision body **before its version
758
+ is completed**, so hook mutations cannot escape the waiting barrier. A resumed
759
+ upgrade reuses its unfinished batch's receipts instead of repeating completed
760
+ hook writes. `{db}` is substituted. Hooks execute as SQLAlchemy text, not through
761
+ the SQL-file splitter; supply one statement per entry. Hook SQL is logged, so do
762
+ not put secrets in it.
553
763
 
554
764
  ```yaml
555
765
  hooks: