|
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.version = $version", |
|
50
|
{"version": version}, |
|
51
|
) |
|
52
|
|
|
53
|
|
|
54
|
def migrate(store: GraphStore) -> list[int]: |
|
55
|
""" |
|
56
|
Apply all pending migrations to bring the graph up to CURRENT_SCHEMA_VERSION. |
|
57
|
|
|
58
|
Returns: |
|
59
|
List of versions that were applied. |
|
60
|
""" |
|
61
|
current = get_schema_version(store) |
|
62
|
applied: list[int] = [] |
|
63
|
|
|
64
|
while current < CURRENT_SCHEMA_VERSION: |
|
65
|
fn = _migrations.get(current) |
|
66
|
if fn is None: |
|
67
|
raise RuntimeError(f"No migration registered for version {current} -> {current + 1}") |
|
68
|
logger.info("Applying migration %d -> %d", current, current + 1) |
|
69
|
fn(store) |
|
70
|
current += 1 |
|
71
|
set_schema_version(store, current) |
|
72
|
applied.append(current) |
|
73
|
|
|
74
|
return applied |
|
75
|
|
|
76
|
|
|
77
|
def needs_migration(store: GraphStore) -> bool: |
|
78
|
"""Check if the graph needs migration.""" |
|
79
|
return get_schema_version(store) < CURRENT_SCHEMA_VERSION |
|
80
|
|
|
81
|
|
|
82
|
# ── Migrations ─────────────────────────────────────────────────────────────── |
|
83
|
|
|
84
|
|
|
85
|
@migration(0) |
|
86
|
def _migrate_0_to_1(store: GraphStore) -> None: |
|
87
|
"""Initial schema — set version on existing graphs.""" |
|
88
|
# No structural changes needed — this just stamps the version. |
|
89
|
logger.info("Stamping initial schema version") |
|
90
|
|
|
91
|
|
|
92
|
@migration(1) |
|
93
|
def _migrate_1_to_2(store: GraphStore) -> None: |
|
94
|
"""Add content_hash property to File nodes for incremental ingestion.""" |
|
95
|
# Set content_hash to empty string on existing File nodes that lack it. |
|
96
|
store.query("MATCH (f:File) WHERE f.content_hash IS NULL SET f.content_hash = ''") |
|
97
|
logger.info("Added content_hash to File nodes") |
|
98
|
|