chkit-py 0.1.0__py3-none-any.whl

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.
chkit/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ """chkit — ClickHouse schema and migration toolkit."""
2
+
3
+ from chkit.core import (
4
+ ChxResolvedClickHouseConfig,
5
+ ChxResolvedConfig,
6
+ ChxUserClickHouseConfig,
7
+ ChxUserConfig,
8
+ ChxValidationError,
9
+ ColumnDefinition,
10
+ MaterializedViewDefinition,
11
+ MaterializedViewRefresh,
12
+ MigrationOperation,
13
+ MigrationPlan,
14
+ ProjectionDefinition,
15
+ SchemaDefinition,
16
+ TableDefinition,
17
+ TableRef,
18
+ ValidationIssue,
19
+ ViewDefinition,
20
+ canonicalize_definitions,
21
+ define_config,
22
+ materialized_view,
23
+ plan_diff,
24
+ resolve_config,
25
+ schema,
26
+ table,
27
+ to_create_sql,
28
+ validate_definitions,
29
+ view,
30
+ )
31
+
32
+ __version__ = "0.1.0"
33
+
34
+ __all__ = [
35
+ "ChxResolvedClickHouseConfig",
36
+ "ChxResolvedConfig",
37
+ "ChxUserClickHouseConfig",
38
+ "ChxUserConfig",
39
+ "ChxValidationError",
40
+ "ColumnDefinition",
41
+ "MaterializedViewDefinition",
42
+ "MaterializedViewRefresh",
43
+ "MigrationOperation",
44
+ "MigrationPlan",
45
+ "ProjectionDefinition",
46
+ "SchemaDefinition",
47
+ "TableDefinition",
48
+ "TableRef",
49
+ "ValidationIssue",
50
+ "ViewDefinition",
51
+ "__version__",
52
+ "canonicalize_definitions",
53
+ "define_config",
54
+ "materialized_view",
55
+ "plan_diff",
56
+ "resolve_config",
57
+ "schema",
58
+ "table",
59
+ "to_create_sql",
60
+ "validate_definitions",
61
+ "view",
62
+ ]
chkit/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """chkit CLI."""
@@ -0,0 +1,5 @@
1
+ """chkit CLI commands."""
2
+
3
+ from chkit.cli.commands import check, drift, generate, init, migrate, status
4
+
5
+ __all__ = ["check", "drift", "generate", "init", "migrate", "status"]
@@ -0,0 +1,59 @@
1
+ """`chkit check` — pre-flight gate for CI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from chkit.cli.config_loader import load_config
12
+ from chkit.cli.migration_store import list_migrations, read_snapshot
13
+ from chkit.cli.schema_loader import load_schema
14
+ from chkit.core.canonical import canonicalize_definitions
15
+ from chkit.core.planner import plan_diff
16
+ from chkit.core.validate import validate_definitions
17
+
18
+
19
+ def run(
20
+ config_path: Annotated[
21
+ Path | None, typer.Option("--config", "-c", help="Path to chkit.config.py.")
22
+ ] = None,
23
+ output_json: Annotated[
24
+ bool, typer.Option("--json", help="Emit a JSON-formatted summary.")
25
+ ] = False,
26
+ ) -> None:
27
+ config = load_config(config_path)
28
+ schema_defs = canonicalize_definitions(load_schema(config.schema_))
29
+
30
+ issues = [issue.model_dump(mode="json") for issue in validate_definitions(schema_defs)]
31
+ pending = [m.stem for m in list_migrations(Path(config.migrations_dir))]
32
+ snapshot = read_snapshot(Path(config.meta_dir))
33
+ drift_ops: list[str] = []
34
+ if snapshot is not None:
35
+ plan = plan_diff(list(snapshot.definitions), schema_defs)
36
+ drift_ops = [op.key for op in plan.operations]
37
+
38
+ failed = False
39
+ if config.check.fail_on_pending and pending:
40
+ failed = True
41
+ if config.check.fail_on_drift and drift_ops:
42
+ failed = True
43
+ if issues:
44
+ failed = True
45
+
46
+ summary = {
47
+ "ok": not failed,
48
+ "issues": issues,
49
+ "pending_migrations": pending,
50
+ "drift_operations": drift_ops,
51
+ }
52
+ if output_json:
53
+ typer.echo(json.dumps(summary, indent=2))
54
+ else:
55
+ typer.echo(f"Validation issues: {len(issues)}")
56
+ typer.echo(f"Pending migrations: {len(pending)}")
57
+ typer.echo(f"Drift operations: {len(drift_ops)}")
58
+ if failed:
59
+ raise typer.Exit(code=1)
@@ -0,0 +1,63 @@
1
+ """`chkit drift` — compare last snapshot vs. current schema."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from chkit.cli.config_loader import load_config
12
+ from chkit.cli.migration_store import read_snapshot
13
+ from chkit.cli.schema_loader import load_schema
14
+ from chkit.core.canonical import canonicalize_definitions
15
+ from chkit.core.planner import plan_diff
16
+
17
+
18
+ def run(
19
+ config_path: Annotated[
20
+ Path | None, typer.Option("--config", "-c", help="Path to chkit.config.py.")
21
+ ] = None,
22
+ output_json: Annotated[
23
+ bool, typer.Option("--json", help="Emit a JSON-formatted summary.")
24
+ ] = False,
25
+ ) -> None:
26
+ config = load_config(config_path)
27
+ schema_defs = canonicalize_definitions(load_schema(config.schema_))
28
+ snapshot = read_snapshot(Path(config.meta_dir))
29
+ if snapshot is None:
30
+ typer.echo(
31
+ "No snapshot found — run `chkit generate` first."
32
+ if not output_json
33
+ else json.dumps({"status": "no-snapshot"})
34
+ )
35
+ return
36
+
37
+ plan = plan_diff(list(snapshot.definitions), schema_defs)
38
+ if not plan.operations:
39
+ typer.echo(
40
+ "No drift detected."
41
+ if not output_json
42
+ else json.dumps({"status": "no-drift"})
43
+ )
44
+ return
45
+
46
+ if output_json:
47
+ typer.echo(
48
+ json.dumps(
49
+ {
50
+ "status": "drift",
51
+ "operations": [op.model_dump(by_alias=True) for op in plan.operations],
52
+ "rename_suggestions": [
53
+ s.model_dump(by_alias=True) for s in plan.rename_suggestions
54
+ ],
55
+ },
56
+ indent=2,
57
+ )
58
+ )
59
+ return
60
+
61
+ typer.secho(f"Drift detected: {len(plan.operations)} operation(s)", fg=typer.colors.YELLOW)
62
+ for op in plan.operations:
63
+ typer.echo(f" [{op.risk}] {op.type} {op.key}")
@@ -0,0 +1,79 @@
1
+ """`chkit generate` — diff current schema vs. last snapshot and emit a migration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from chkit.cli.config_loader import load_config
12
+ from chkit.cli.migration_store import read_snapshot, write_migration, write_snapshot
13
+ from chkit.cli.schema_loader import load_schema
14
+ from chkit.core.canonical import canonicalize_definitions
15
+ from chkit.core.model import ChxValidationError
16
+ from chkit.core.planner import plan_diff
17
+ from chkit.core.snapshot import create_snapshot
18
+ from chkit.core.validate import validate_definitions
19
+
20
+
21
+ def run(
22
+ config_path: Annotated[
23
+ Path | None,
24
+ typer.Option("--config", "-c", help="Path to chkit.config.py."),
25
+ ] = None,
26
+ label: Annotated[
27
+ str,
28
+ typer.Option("--label", "-l", help="Label suffix appended to the migration id."),
29
+ ] = "auto",
30
+ output_json: Annotated[
31
+ bool,
32
+ typer.Option("--json", help="Emit a JSON-formatted summary."),
33
+ ] = False,
34
+ ) -> None:
35
+ config = load_config(config_path)
36
+ schema_globs = config.schema_
37
+ definitions = load_schema(schema_globs)
38
+ canonical = canonicalize_definitions(definitions)
39
+
40
+ issues = validate_definitions(canonical)
41
+ if issues:
42
+ raise ChxValidationError(issues)
43
+
44
+ meta_dir = Path(config.meta_dir)
45
+ migrations_dir = Path(config.migrations_dir)
46
+
47
+ previous = read_snapshot(meta_dir)
48
+ old_defs = list(previous.definitions) if previous is not None else []
49
+
50
+ plan = plan_diff(old_defs, canonical)
51
+ if not plan.operations:
52
+ message = {"status": "no-changes"}
53
+ typer.echo(json.dumps(message) if output_json else "No schema changes detected.")
54
+ return
55
+
56
+ artifact = write_migration(migrations_dir, plan, label=label)
57
+ snapshot = create_snapshot(canonical)
58
+ snapshot_path = write_snapshot(meta_dir, snapshot)
59
+
60
+ summary = {
61
+ "status": "generated",
62
+ "migration_id": artifact.id,
63
+ "sql_path": str(artifact.sql_path),
64
+ "snapshot_path": str(snapshot_path),
65
+ "operations": len(plan.operations),
66
+ "risk_summary": plan.risk_summary.model_dump(),
67
+ }
68
+ if output_json:
69
+ typer.echo(json.dumps(summary, indent=2))
70
+ return
71
+
72
+ typer.secho(f"Generated migration {artifact.id}", fg=typer.colors.GREEN)
73
+ typer.echo(f" SQL: {artifact.sql_path}")
74
+ typer.echo(f" Snapshot: {snapshot_path}")
75
+ typer.echo(f" Operations: {len(plan.operations)}")
76
+ risks = plan.risk_summary
77
+ typer.echo(
78
+ f" Risk: safe={risks.safe} caution={risks.caution} danger={risks.danger}"
79
+ )
@@ -0,0 +1,68 @@
1
+ """`chkit init` — scaffold a starter project."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ _CONFIG_TEMPLATE = '''"""chkit configuration. Edit and re-run `chkit generate`."""
11
+
12
+ from chkit import ChxUserConfig
13
+
14
+ config = ChxUserConfig.model_validate(
15
+ {
16
+ "schema": ["./schema/**/*.py"],
17
+ "outDir": "./chkit",
18
+ "clickhouse": {
19
+ "url": "http://localhost:8123",
20
+ "username": "default",
21
+ "password": "",
22
+ "database": "default",
23
+ },
24
+ }
25
+ )
26
+ '''
27
+
28
+ _EXAMPLE_TEMPLATE = '''"""Example chkit schema."""
29
+
30
+ from chkit import ColumnDefinition, table
31
+
32
+ events = table(
33
+ database="default",
34
+ name="events",
35
+ engine="MergeTree",
36
+ columns=[
37
+ ColumnDefinition(name="ts", type="DateTime"),
38
+ ColumnDefinition(name="user_id", type="UInt64"),
39
+ ColumnDefinition(name="event", type="String"),
40
+ ],
41
+ primary_key=["ts"],
42
+ order_by=["ts", "user_id"],
43
+ )
44
+ '''
45
+
46
+
47
+ def run(
48
+ out_dir: Annotated[
49
+ Path,
50
+ typer.Option("--out", "-o", help="Project root to scaffold into."),
51
+ ] = Path("."),
52
+ ) -> None:
53
+ out_dir.mkdir(parents=True, exist_ok=True)
54
+ config_path = out_dir / "chkit.config.py"
55
+ example_path = out_dir / "schema" / "events.py"
56
+
57
+ if config_path.exists():
58
+ typer.secho(f"Skipping: {config_path} already exists", fg=typer.colors.YELLOW)
59
+ else:
60
+ config_path.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
61
+ typer.secho(f"Created {config_path}", fg=typer.colors.GREEN)
62
+
63
+ example_path.parent.mkdir(parents=True, exist_ok=True)
64
+ if example_path.exists():
65
+ typer.secho(f"Skipping: {example_path} already exists", fg=typer.colors.YELLOW)
66
+ else:
67
+ example_path.write_text(_EXAMPLE_TEMPLATE, encoding="utf-8")
68
+ typer.secho(f"Created {example_path}", fg=typer.colors.GREEN)
@@ -0,0 +1,83 @@
1
+ """`chkit migrate` — apply pending migrations to the target database."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from chkit.cli.config_loader import load_config
12
+ from chkit.cli.migration_store import list_migrations
13
+ from chkit.clickhouse.client import ClickHouseClient
14
+ from chkit.core.sql_splitter import split_sql_statements
15
+
16
+
17
+ def _read_applied(meta_dir: Path) -> set[str]:
18
+ applied_file = meta_dir / "applied.json"
19
+ if not applied_file.exists():
20
+ return set()
21
+ payload = json.loads(applied_file.read_text(encoding="utf-8"))
22
+ return {str(item) for item in payload.get("ids", [])}
23
+
24
+
25
+ def _write_applied(meta_dir: Path, ids: set[str]) -> None:
26
+ meta_dir.mkdir(parents=True, exist_ok=True)
27
+ applied_file = meta_dir / "applied.json"
28
+ applied_file.write_text(
29
+ json.dumps({"ids": sorted(ids)}, indent=2), encoding="utf-8"
30
+ )
31
+
32
+
33
+ def run(
34
+ config_path: Annotated[
35
+ Path | None, typer.Option("--config", "-c", help="Path to chkit.config.py.")
36
+ ] = None,
37
+ dry_run: Annotated[
38
+ bool, typer.Option("--dry-run", help="Print statements without executing.")
39
+ ] = False,
40
+ output_json: Annotated[
41
+ bool, typer.Option("--json", help="Emit a JSON-formatted summary.")
42
+ ] = False,
43
+ ) -> None:
44
+ config = load_config(config_path)
45
+ if config.clickhouse is None:
46
+ msg = "chkit.config.py must include a `clickhouse` block to migrate."
47
+ raise typer.BadParameter(msg)
48
+
49
+ migrations_dir = Path(config.migrations_dir)
50
+ meta_dir = Path(config.meta_dir)
51
+
52
+ all_migrations = list_migrations(migrations_dir)
53
+ applied = _read_applied(meta_dir)
54
+ pending = [m for m in all_migrations if m.stem not in applied]
55
+
56
+ if not pending:
57
+ result = {"status": "up-to-date", "applied": sorted(applied)}
58
+ typer.echo(json.dumps(result) if output_json else "No pending migrations.")
59
+ return
60
+
61
+ executed: list[str] = []
62
+ if dry_run:
63
+ for migration in pending:
64
+ typer.echo(f"-- {migration.name}")
65
+ typer.echo(migration.read_text(encoding="utf-8"))
66
+ return
67
+
68
+ with ClickHouseClient.connect(config.clickhouse) as client:
69
+ for migration in pending:
70
+ sql_text = migration.read_text(encoding="utf-8")
71
+ for statement in split_sql_statements(sql_text):
72
+ client.execute(statement)
73
+ executed.append(migration.stem)
74
+ applied.add(migration.stem)
75
+ _write_applied(meta_dir, applied)
76
+
77
+ summary = {"status": "applied", "executed": executed}
78
+ if output_json:
79
+ typer.echo(json.dumps(summary, indent=2))
80
+ return
81
+ typer.secho(f"Applied {len(executed)} migration(s).", fg=typer.colors.GREEN)
82
+ for migration_id in executed:
83
+ typer.echo(f" - {migration_id}")
@@ -0,0 +1,51 @@
1
+ """`chkit status` — show applied vs. pending migrations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from chkit.cli.config_loader import load_config
12
+ from chkit.cli.migration_store import list_migrations
13
+
14
+
15
+ def _read_applied(meta_dir: Path) -> set[str]:
16
+ applied_file = meta_dir / "applied.json"
17
+ if not applied_file.exists():
18
+ return set()
19
+ payload = json.loads(applied_file.read_text(encoding="utf-8"))
20
+ return {str(item) for item in payload.get("ids", [])}
21
+
22
+
23
+ def run(
24
+ config_path: Annotated[
25
+ Path | None, typer.Option("--config", "-c", help="Path to chkit.config.py.")
26
+ ] = None,
27
+ output_json: Annotated[
28
+ bool, typer.Option("--json", help="Emit a JSON-formatted summary.")
29
+ ] = False,
30
+ ) -> None:
31
+ config = load_config(config_path)
32
+ migrations_dir = Path(config.migrations_dir)
33
+ meta_dir = Path(config.meta_dir)
34
+ all_migrations = list_migrations(migrations_dir)
35
+ applied = _read_applied(meta_dir)
36
+ pending = [m.stem for m in all_migrations if m.stem not in applied]
37
+
38
+ summary = {
39
+ "applied": sorted(applied),
40
+ "pending": pending,
41
+ "total": len(all_migrations),
42
+ }
43
+ if output_json:
44
+ typer.echo(json.dumps(summary, indent=2))
45
+ return
46
+
47
+ typer.echo(f"Total migrations: {len(all_migrations)}")
48
+ typer.echo(f"Applied: {len(applied)}")
49
+ typer.echo(f"Pending: {len(pending)}")
50
+ for migration_id in pending:
51
+ typer.echo(f" - {migration_id}")
@@ -0,0 +1,47 @@
1
+ """Load and validate a user's ``chkit.config.py`` config file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from chkit.core.model import ChxResolvedConfig, ChxUserConfig, resolve_config
11
+
12
+
13
+ def _load_module(config_path: Path) -> Any:
14
+ spec = importlib.util.spec_from_file_location("chkit_user_config", config_path)
15
+ if spec is None or spec.loader is None:
16
+ msg = f"Unable to load config module from {config_path}"
17
+ raise RuntimeError(msg)
18
+ module = importlib.util.module_from_spec(spec)
19
+ sys.modules["chkit_user_config"] = module
20
+ spec.loader.exec_module(module)
21
+ return module
22
+
23
+
24
+ def load_config(config_path: Path | None = None) -> ChxResolvedConfig:
25
+ """Resolve a ``ChxResolvedConfig`` from a config file.
26
+
27
+ The default path is ``./chkit.config.py``. The module must export a
28
+ ``config`` attribute of type ``ChxUserConfig`` (or a dict that validates
29
+ against ``ChxUserConfig``).
30
+ """
31
+ path = config_path if config_path is not None else Path("chkit.config.py")
32
+ if not path.exists():
33
+ msg = f"Config file not found: {path}"
34
+ raise FileNotFoundError(msg)
35
+
36
+ module = _load_module(path)
37
+ raw_config = getattr(module, "config", None)
38
+ if raw_config is None:
39
+ msg = f"Config file {path} must export a `config` attribute"
40
+ raise AttributeError(msg)
41
+
42
+ user_config = (
43
+ raw_config
44
+ if isinstance(raw_config, ChxUserConfig)
45
+ else ChxUserConfig.model_validate(raw_config)
46
+ )
47
+ return resolve_config(user_config)
chkit/cli/main.py ADDED
@@ -0,0 +1,47 @@
1
+ """Top-level Typer app and command wiring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from chkit import __version__
8
+ from chkit.cli.commands import check, drift, generate, init, migrate, status
9
+
10
+ app = typer.Typer(
11
+ name="chkit",
12
+ help="ClickHouse schema and migration toolkit",
13
+ add_completion=False,
14
+ )
15
+
16
+ app.command("init", help="Create a starter chkit.config.py and example schema.")(init.run)
17
+ app.command("generate", help="Generate a new migration from the current schema.")(generate.run)
18
+ app.command("migrate", help="Apply pending migrations to the target database.")(migrate.run)
19
+ app.command("status", help="Show migration status and pending operations.")(status.run)
20
+ app.command("check", help="Run pre-flight checks (drift, checksums, pending).")(check.run)
21
+ app.command("drift", help="Compare the live database against the schema snapshot.")(drift.run)
22
+
23
+
24
+ @app.callback(invoke_without_command=True)
25
+ def _root(
26
+ ctx: typer.Context,
27
+ version: bool = typer.Option(
28
+ False, # noqa: FBT003 - typer positional default is part of its API
29
+ "--version",
30
+ "-V",
31
+ help="Show the chkit version and exit.",
32
+ ),
33
+ ) -> None:
34
+ if version:
35
+ typer.echo(__version__)
36
+ raise typer.Exit(code=0)
37
+ if ctx.invoked_subcommand is None:
38
+ typer.echo(ctx.get_help())
39
+ raise typer.Exit(code=0)
40
+
41
+
42
+ def main() -> None:
43
+ app()
44
+
45
+
46
+ if __name__ == "__main__":
47
+ main()
@@ -0,0 +1,84 @@
1
+ """Filesystem layout for migrations and snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from datetime import UTC, datetime
8
+ from pathlib import Path
9
+
10
+ from pydantic import BaseModel, ConfigDict
11
+
12
+ from chkit.core.model import MigrationPlan, Snapshot
13
+
14
+
15
+ class MigrationArtifact(BaseModel):
16
+ model_config = ConfigDict(frozen=True)
17
+
18
+ id: str
19
+ sql_path: Path
20
+ meta_path: Path
21
+ plan: MigrationPlan
22
+ checksum: str
23
+
24
+
25
+ def _timestamp_id() -> str:
26
+ return datetime.now(tz=UTC).strftime("%Y%m%d%H%M%S")
27
+
28
+
29
+ def _hash_sql(sql_text: str) -> str:
30
+ return hashlib.sha256(sql_text.encode("utf-8")).hexdigest()
31
+
32
+
33
+ def write_snapshot(meta_dir: Path, snapshot: Snapshot) -> Path:
34
+ meta_dir.mkdir(parents=True, exist_ok=True)
35
+ snapshot_path = meta_dir / "snapshot.json"
36
+ snapshot_path.write_text(
37
+ json.dumps(snapshot.model_dump(mode="json", by_alias=True), indent=2),
38
+ encoding="utf-8",
39
+ )
40
+ return snapshot_path
41
+
42
+
43
+ def read_snapshot(meta_dir: Path) -> Snapshot | None:
44
+ snapshot_path = meta_dir / "snapshot.json"
45
+ if not snapshot_path.exists():
46
+ return None
47
+ raw = json.loads(snapshot_path.read_text(encoding="utf-8"))
48
+ return Snapshot.model_validate(raw)
49
+
50
+
51
+ def write_migration(
52
+ migrations_dir: Path, plan: MigrationPlan, label: str = "auto"
53
+ ) -> MigrationArtifact:
54
+ migrations_dir.mkdir(parents=True, exist_ok=True)
55
+ migration_id = f"{_timestamp_id()}_{label}"
56
+ sql_text = "\n\n".join(op.sql for op in plan.operations) + "\n"
57
+ sql_path = migrations_dir / f"{migration_id}.sql"
58
+ meta_path = migrations_dir / f"{migration_id}.json"
59
+ sql_path.write_text(sql_text, encoding="utf-8")
60
+ checksum = _hash_sql(sql_text)
61
+ meta_path.write_text(
62
+ json.dumps(
63
+ {
64
+ "id": migration_id,
65
+ "checksum": checksum,
66
+ "plan": plan.model_dump(mode="json", by_alias=True),
67
+ },
68
+ indent=2,
69
+ ),
70
+ encoding="utf-8",
71
+ )
72
+ return MigrationArtifact(
73
+ id=migration_id,
74
+ sql_path=sql_path,
75
+ meta_path=meta_path,
76
+ plan=plan,
77
+ checksum=checksum,
78
+ )
79
+
80
+
81
+ def list_migrations(migrations_dir: Path) -> list[Path]:
82
+ if not migrations_dir.exists():
83
+ return []
84
+ return sorted(migrations_dir.glob("*.sql"))