Navegador

feat: schema versioning and migrations Adds a migration framework: schema version stored on a :Meta node, migration functions registered by decorator, auto-applied sequentially. CLI `navegador migrate` command with --check flag. Closes #22

lmata 2026-03-23 04:51 trunk
Commit 08384953db7709a459ecca577cd871fc4c70d4e802ea38ba9394eaae0c512155
--- navegador/cli/commands.py
+++ navegador/cli/commands.py
@@ -619,10 +619,48 @@
619619
table.add_column("Count", justify="right", style="green")
620620
for k, v in stats.items():
621621
table.add_row(k.capitalize(), str(v))
622622
console.print(table)
623623
624
+
625
+# ── Schema migrations ────────────────────────────────────────────────────────
626
+
627
+
628
+@main.command()
629
+@DB_OPTION
630
+@click.option("--check", is_flag=True, help="Check if migration is needed without applying.")
631
+def migrate(db: str, check: bool):
632
+ """Apply pending schema migrations to the graph."""
633
+ from navegador.graph.migrations import (
634
+ CURRENT_SCHEMA_VERSION,
635
+ get_schema_version,
636
+ migrate as do_migrate,
637
+ needs_migration,
638
+ )
639
+
640
+ store = _get_store(db)
641
+
642
+ if check:
643
+ current = get_schema_version(store)
644
+ if needs_migration(store):
645
+ console.print(
646
+ f"[yellow]Migration needed:[/yellow] v{current} → v{CURRENT_SCHEMA_VERSION}"
647
+ )
648
+ else:
649
+ console.print(f"[green]Schema is up to date[/green] (v{current})")
650
+ return
651
+
652
+ current = get_schema_version(store)
653
+ applied = do_migrate(store)
654
+ if applied:
655
+ console.print(
656
+ f"[green]Migrated[/green] v{current} → v{CURRENT_SCHEMA_VERSION} "
657
+ f"({len(applied)} migration{'s' if len(applied) != 1 else ''})"
658
+ )
659
+ else:
660
+ console.print(f"[green]Schema is up to date[/green] (v{current})")
661
+
624662
625663
# ── MCP ───────────────────────────────────────────────────────────────────────
626664
627665
628666
@main.command()
629667
630668
ADDED navegador/graph/migrations.py
--- navegador/cli/commands.py
+++ navegador/cli/commands.py
@@ -619,10 +619,48 @@
619 table.add_column("Count", justify="right", style="green")
620 for k, v in stats.items():
621 table.add_row(k.capitalize(), str(v))
622 console.print(table)
623
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
624
625 # ── MCP ───────────────────────────────────────────────────────────────────────
626
627
628 @main.command()
629
630 DDED navegador/graph/migrations.py
--- navegador/cli/commands.py
+++ navegador/cli/commands.py
@@ -619,10 +619,48 @@
619 table.add_column("Count", justify="right", style="green")
620 for k, v in stats.items():
621 table.add_row(k.capitalize(), str(v))
622 console.print(table)
623
624
625 # ── Schema migrations ────────────────────────────────────────────────────────
626
627
628 @main.command()
629 @DB_OPTION
630 @click.option("--check", is_flag=True, help="Check if migration is needed without applying.")
631 def migrate(db: str, check: bool):
632 """Apply pending schema migrations to the graph."""
633 from navegador.graph.migrations import (
634 CURRENT_SCHEMA_VERSION,
635 get_schema_version,
636 migrate as do_migrate,
637 needs_migration,
638 )
639
640 store = _get_store(db)
641
642 if check:
643 current = get_schema_version(store)
644 if needs_migration(store):
645 console.print(
646 f"[yellow]Migration needed:[/yellow] v{current} → v{CURRENT_SCHEMA_VERSION}"
647 )
648 else:
649 console.print(f"[green]Schema is up to date[/green] (v{current})")
650 return
651
652 current = get_schema_version(store)
653 applied = do_migrate(store)
654 if applied:
655 console.print(
656 f"[green]Migrated[/green] v{current} → v{CURRENT_SCHEMA_VERSION} "
657 f"({len(applied)} migration{'s' if len(applied) != 1 else ''})"
658 )
659 else:
660 console.print(f"[green]Schema is up to date[/green] (v{current})")
661
662
663 # ── MCP ───────────────────────────────────────────────────────────────────────
664
665
666 @main.command()
667
668 DDED navegador/graph/migrations.py
--- a/navegador/graph/migrations.py
+++ b/navegador/graph/migrations.py
@@ -0,0 +1,49 @@
1
+"""
2
+Schema versioning and migrations for the navegador graph.
3
+
4
+The schema version is stored as a property on a singleton :Meta node.
5
+On store open, the current version is checked and any pending migrations
6
+are applied sequentially.
7
+
8
+Migration functions take a GraphStore and upgrade from version N to N+1.
9
+"""
10
+
11
+import logging
12
+
13
+from navegador.graph.store import GraphStore
14
+
15
+logger = logging.getLogger(__name__)
16
+
17
+CURRENT_SCHEMA_VERSION = 2
18
+
19
+# ── Migration registry ───────────────────────────────────────────────────────
20
+
21
+_migrations: dict[int, callable] = {}
22
+
23
+
24
+def migration(from_version: int):
25
+ """Decorator to register a migration from `from_version` to `from_version + 1`."""
26
+
27
+ def decorator(fn):
28
+ _migrations[from_version] = fn
29
+ return fn
30
+
31
+ return decorator
32
+
33
+
34
+# ── Core API ─────────────────────────────────────────────────────────────────
35
+
36
+
37
+def get_schema_version(store: GraphStore) -> int:
38
+ """Read the current schema version from the graph (0 if unset)."""
39
+ result = store.query("MATCH (m:Meta {name: 'schema'}) RETURN m.version")
40
+ rows = result.result_set or []
41
+ if not rows or rows[0][0] is None:
42
+ return 0
43
+ return int(rows[0][0])
44
+
45
+
46
+def set_schema_version(store: GraphStore, version: int) -> None:
47
+ """Write the schema version to the graph."""
48
+ store.query(
49
+ "MERGE (m:Meta {name: 'schema'}) SET m
--- a/navegador/graph/migrations.py
+++ b/navegador/graph/migrations.py
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/navegador/graph/migrations.py
+++ b/navegador/graph/migrations.py
@@ -0,0 +1,49 @@
1 """
2 Schema versioning and migrations for the navegador graph.
3
4 The schema version is stored as a property on a singleton :Meta node.
5 On store open, the current version is checked and any pending migrations
6 are applied sequentially.
7
8 Migration functions take a GraphStore and upgrade from version N to N+1.
9 """
10
11 import logging
12
13 from navegador.graph.store import GraphStore
14
15 logger = logging.getLogger(__name__)
16
17 CURRENT_SCHEMA_VERSION = 2
18
19 # ── Migration registry ───────────────────────────────────────────────────────
20
21 _migrations: dict[int, callable] = {}
22
23
24 def migration(from_version: int):
25 """Decorator to register a migration from `from_version` to `from_version + 1`."""
26
27 def decorator(fn):
28 _migrations[from_version] = fn
29 return fn
30
31 return decorator
32
33
34 # ── Core API ─────────────────────────────────────────────────────────────────
35
36
37 def get_schema_version(store: GraphStore) -> int:
38 """Read the current schema version from the graph (0 if unset)."""
39 result = store.query("MATCH (m:Meta {name: 'schema'}) RETURN m.version")
40 rows = result.result_set or []
41 if not rows or rows[0][0] is None:
42 return 0
43 return int(rows[0][0])
44
45
46 def set_schema_version(store: GraphStore, version: int) -> None:
47 """Write the schema version to the graph."""
48 store.query(
49 "MERGE (m:Meta {name: 'schema'}) SET m
--- tests/test_cli.py
+++ tests/test_cli.py
@@ -624,10 +624,52 @@
624624
assert result.exit_code == 0
625625
MockPI.return_value.ingest_batch.assert_called_once()
626626
627627
628628
# ── mcp command (lines 538-549) ───────────────────────────────────────────────
629
+
630
+# ── migrate ──────────────────────────────────────────────────────────────────
631
+
632
+class TestMigrateCommand:
633
+ def test_migrate_applies_migrations(self):
634
+ runner = CliRunner()
635
+ with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \
636
+ patch("navegador.graph.migrations.get_schema_version", return_value=0), \
637
+ patch("navegador.graph.migrations.migrate", return_value=[1, 2]) as mock_migrate, \
638
+ patch("navegador.graph.migrations.CURRENT_SCHEMA_VERSION", 2):
639
+ result = runner.invoke(main, ["migrate"])
640
+ assert result.exit_code == 0
641
+ assert "Migrated" in result.output
642
+
643
+ def test_migrate_already_current(self):
644
+ runner = CliRunner()
645
+ with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \
646
+ patch("navegador.graph.migrations.get_schema_version", return_value=2), \
647
+ patch("navegador.graph.migrations.migrate", return_value=[]):
648
+ result = runner.invoke(main, ["migrate"])
649
+ assert result.exit_code == 0
650
+ assert "up to date" in result.output
651
+
652
+ def test_migrate_check_needed(self):
653
+ runner = CliRunner()
654
+ with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \
655
+ patch("navegador.graph.migrations.get_schema_version", return_value=0), \
656
+ patch("navegador.graph.migrations.needs_migration", return_value=True), \
657
+ patch("navegador.graph.migrations.CURRENT_SCHEMA_VERSION", 2):
658
+ result = runner.invoke(main, ["migrate", "--check"])
659
+ assert result.exit_code == 0
660
+ assert "Migration needed" in result.output
661
+
662
+ def test_migrate_check_not_needed(self):
663
+ runner = CliRunner()
664
+ with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \
665
+ patch("navegador.graph.migrations.get_schema_version", return_value=2), \
666
+ patch("navegador.graph.migrations.needs_migration", return_value=False):
667
+ result = runner.invoke(main, ["migrate", "--check"])
668
+ assert result.exit_code == 0
669
+ assert "up to date" in result.output
670
+
629671
630672
class TestMcpCommand:
631673
def test_mcp_command_runs_server(self):
632674
from contextlib import asynccontextmanager
633675
634676
635677
ADDED tests/test_migrations.py
--- tests/test_cli.py
+++ tests/test_cli.py
@@ -624,10 +624,52 @@
624 assert result.exit_code == 0
625 MockPI.return_value.ingest_batch.assert_called_once()
626
627
628 # ── mcp command (lines 538-549) ───────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
629
630 class TestMcpCommand:
631 def test_mcp_command_runs_server(self):
632 from contextlib import asynccontextmanager
633
634
635 DDED tests/test_migrations.py
--- tests/test_cli.py
+++ tests/test_cli.py
@@ -624,10 +624,52 @@
624 assert result.exit_code == 0
625 MockPI.return_value.ingest_batch.assert_called_once()
626
627
628 # ── mcp command (lines 538-549) ───────────────────────────────────────────────
629
630 # ── migrate ──────────────────────────────────────────────────────────────────
631
632 class TestMigrateCommand:
633 def test_migrate_applies_migrations(self):
634 runner = CliRunner()
635 with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \
636 patch("navegador.graph.migrations.get_schema_version", return_value=0), \
637 patch("navegador.graph.migrations.migrate", return_value=[1, 2]) as mock_migrate, \
638 patch("navegador.graph.migrations.CURRENT_SCHEMA_VERSION", 2):
639 result = runner.invoke(main, ["migrate"])
640 assert result.exit_code == 0
641 assert "Migrated" in result.output
642
643 def test_migrate_already_current(self):
644 runner = CliRunner()
645 with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \
646 patch("navegador.graph.migrations.get_schema_version", return_value=2), \
647 patch("navegador.graph.migrations.migrate", return_value=[]):
648 result = runner.invoke(main, ["migrate"])
649 assert result.exit_code == 0
650 assert "up to date" in result.output
651
652 def test_migrate_check_needed(self):
653 runner = CliRunner()
654 with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \
655 patch("navegador.graph.migrations.get_schema_version", return_value=0), \
656 patch("navegador.graph.migrations.needs_migration", return_value=True), \
657 patch("navegador.graph.migrations.CURRENT_SCHEMA_VERSION", 2):
658 result = runner.invoke(main, ["migrate", "--check"])
659 assert result.exit_code == 0
660 assert "Migration needed" in result.output
661
662 def test_migrate_check_not_needed(self):
663 runner = CliRunner()
664 with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \
665 patch("navegador.graph.migrations.get_schema_version", return_value=2), \
666 patch("navegador.graph.migrations.needs_migration", return_value=False):
667 result = runner.invoke(main, ["migrate", "--check"])
668 assert result.exit_code == 0
669 assert "up to date" in result.output
670
671
672 class TestMcpCommand:
673 def test_mcp_command_runs_server(self):
674 from contextlib import asynccontextmanager
675
676
677 DDED tests/test_migrations.py
--- a/tests/test_migrations.py
+++ b/tests/test_migrations.py
@@ -0,0 +1,126 @@
1
+"""Tests for navegador.graph.migrations — schema versioning and migration."""
2
+
3
+from unittest.mock import MagicMock, call
4
+
5
+import pytest
6
+
7
+from navegador.graph.migrations import (
8
+ CURRENT_SCHEMA_VERSION,
9
+ _migrations,
10
+ get_schema_version,
11
+ migrate,
12
+ needs_migration,
13
+ set_schema_version,
14
+)
15
+
16
+
17
+def _mock_store(version=None):
18
+ store = MagicMock()
19
+ if version is None:
20
+ store.query.return_value = MagicMock(result_set=[])
21
+ else:
22
+ store.query.return_value = MagicMock(result_set=[[version]])
23
+ return store
24
+
25
+
26
+# ── get_schema_version ───────────────────────────────────────────────────────
27
+
28
+class TestGetSchemaVersion:
29
+ def test_returns_zero_for_empty_graph(self):
30
+ store = _mock_store(version=None)
31
+ assert get_schema_version(store) == 0
32
+
33
+ def test_returns_zero_for_null_version(self):
34
+ store = MagicMock()
35
+ store.query.return_value = MagicMock(result_set=[[None]])
36
+ assert get_schema_version(store) == 0
37
+
38
+ def test_returns_stored_version(self):
39
+ store = _mock_store(version=2)
40
+ assert get_schema_version(store) == 2
41
+
42
+
43
+# ── set_schema_version ──────────────────────────────────────────────────────
44
+
45
+class TestSetSchemaVersion:
46
+ def test_calls_query_with_merge(self):
47
+ store = MagicMock()
48
+ set_schema_version(store, 3)
49
+ store.query.assert_called_once()
50
+ cypher = store.query.call_args[0][0]
51
+ assert "MERGE" in cypher
52
+ assert store.query.call_args[0][1]["version"] == 3
53
+
54
+
55
+# ── needs_migration ──────────────────────────────────────────────────────────
56
+
57
+class TestNeedsMigration:
58
+ def test_true_when_behind(self):
59
+ store = _mock_store(version=0)
60
+ assert needs_migration(store) is True
61
+
62
+ def test_false_when_current(self):
63
+ store = _mock_store(version=CURRENT_SCHEMA_VERSION)
64
+ assert needs_migration(store) is False
65
+
66
+
67
+# ── migrate ──────────────────────────────────────────────────────────────────
68
+
69
+class TestMigrate:
70
+ def test_applies_all_migrations_from_zero(self):
71
+ call_log = []
72
+
73
+ def track_query(cypher, params=None):
74
+ call_log.append(cypher)
75
+ result = MagicMock()
76
+ # get_schema_version query returns no rows initially
77
+ if "Meta" in cypher and "RETURN" in cypher:
78
+ result.result_set = []
79
+ else:
80
+ result.result_set = []
81
+ return result
82
+
83
+ store = MagicMock()
84
+ store.query.side_effect = track_query
85
+
86
+ applied = migrate(store)
87
+ assert applied == list(range(1, CURRENT_SCHEMA_VERSION + 1))
88
+
89
+ def test_no_op_when_already_current(self):
90
+ store = _mock_store(version=CURRENT_SCHEMA_VERSION)
91
+ applied = migrate(store)
92
+ assert applied == []
93
+
94
+ def test_raises_on_missing_migration(self):
95
+ # Temporarily remove a migration to trigger the RuntimeError
96
+ saved = _migrations.pop(0)
97
+ try:
98
+ store = _mock_store(version=None)
99
+ with pytest.raises(RuntimeError, match="No migration registered"):
100
+ migrate(store)
101
+ finally:
102
+ _migrations[0] = saved
103
+
104
+
105
+# ── migrations registry ─────────────────────────────────────────────────────
106
+
107
+class TestMigrationsRegistry:
108
+ def test_has_migration_for_each_version(self):
109
+ for v in range(CURRENT_SCHEMA_VERSION):
110
+ assert v in _migrations, f"Missing migration for version {v} -> {v + 1}"
111
+
112
+ def test_current_version_is_positive(self):
113
+ assert CURRENT_SCHEMA_VERSION > 0
114
+
115
+ def test_migration_0_to_1_runs(self):
116
+ store = MagicMock()
117
+ store.query.return_value = MagicMock(result_set=[])
118
+ _migrations[0](store)
119
+
120
+ def test_migration_1_to_2_sets_content_hash(self):
121
+ store = MagicMock()
122
+ store.query.return_value = MagicMock(result_set=[])
123
+ _migrations[1](store)
124
+ store.query.assert_called_once()
125
+ cypher = store.query.call_args[0][0]
126
+ assert "content_hash" in cypher
--- a/tests/test_migrations.py
+++ b/tests/test_migrations.py
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_migrations.py
+++ b/tests/test_migrations.py
@@ -0,0 +1,126 @@
1 """Tests for navegador.graph.migrations — schema versioning and migration."""
2
3 from unittest.mock import MagicMock, call
4
5 import pytest
6
7 from navegador.graph.migrations import (
8 CURRENT_SCHEMA_VERSION,
9 _migrations,
10 get_schema_version,
11 migrate,
12 needs_migration,
13 set_schema_version,
14 )
15
16
17 def _mock_store(version=None):
18 store = MagicMock()
19 if version is None:
20 store.query.return_value = MagicMock(result_set=[])
21 else:
22 store.query.return_value = MagicMock(result_set=[[version]])
23 return store
24
25
26 # ── get_schema_version ───────────────────────────────────────────────────────
27
28 class TestGetSchemaVersion:
29 def test_returns_zero_for_empty_graph(self):
30 store = _mock_store(version=None)
31 assert get_schema_version(store) == 0
32
33 def test_returns_zero_for_null_version(self):
34 store = MagicMock()
35 store.query.return_value = MagicMock(result_set=[[None]])
36 assert get_schema_version(store) == 0
37
38 def test_returns_stored_version(self):
39 store = _mock_store(version=2)
40 assert get_schema_version(store) == 2
41
42
43 # ── set_schema_version ──────────────────────────────────────────────────────
44
45 class TestSetSchemaVersion:
46 def test_calls_query_with_merge(self):
47 store = MagicMock()
48 set_schema_version(store, 3)
49 store.query.assert_called_once()
50 cypher = store.query.call_args[0][0]
51 assert "MERGE" in cypher
52 assert store.query.call_args[0][1]["version"] == 3
53
54
55 # ── needs_migration ──────────────────────────────────────────────────────────
56
57 class TestNeedsMigration:
58 def test_true_when_behind(self):
59 store = _mock_store(version=0)
60 assert needs_migration(store) is True
61
62 def test_false_when_current(self):
63 store = _mock_store(version=CURRENT_SCHEMA_VERSION)
64 assert needs_migration(store) is False
65
66
67 # ── migrate ──────────────────────────────────────────────────────────────────
68
69 class TestMigrate:
70 def test_applies_all_migrations_from_zero(self):
71 call_log = []
72
73 def track_query(cypher, params=None):
74 call_log.append(cypher)
75 result = MagicMock()
76 # get_schema_version query returns no rows initially
77 if "Meta" in cypher and "RETURN" in cypher:
78 result.result_set = []
79 else:
80 result.result_set = []
81 return result
82
83 store = MagicMock()
84 store.query.side_effect = track_query
85
86 applied = migrate(store)
87 assert applied == list(range(1, CURRENT_SCHEMA_VERSION + 1))
88
89 def test_no_op_when_already_current(self):
90 store = _mock_store(version=CURRENT_SCHEMA_VERSION)
91 applied = migrate(store)
92 assert applied == []
93
94 def test_raises_on_missing_migration(self):
95 # Temporarily remove a migration to trigger the RuntimeError
96 saved = _migrations.pop(0)
97 try:
98 store = _mock_store(version=None)
99 with pytest.raises(RuntimeError, match="No migration registered"):
100 migrate(store)
101 finally:
102 _migrations[0] = saved
103
104
105 # ── migrations registry ─────────────────────────────────────────────────────
106
107 class TestMigrationsRegistry:
108 def test_has_migration_for_each_version(self):
109 for v in range(CURRENT_SCHEMA_VERSION):
110 assert v in _migrations, f"Missing migration for version {v} -> {v + 1}"
111
112 def test_current_version_is_positive(self):
113 assert CURRENT_SCHEMA_VERSION > 0
114
115 def test_migration_0_to_1_runs(self):
116 store = MagicMock()
117 store.query.return_value = MagicMock(result_set=[])
118 _migrations[0](store)
119
120 def test_migration_1_to_2_sets_content_hash(self):
121 store = MagicMock()
122 store.query.return_value = MagicMock(result_set=[])
123 _migrations[1](store)
124 store.query.assert_called_once()
125 cypher = store.query.call_args[0][0]
126 assert "content_hash" in cypher

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button