alma-memory 0.5.0__py3-none-any.whl → 0.5.1__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.
- alma/__init__.py +33 -1
- alma/core.py +124 -16
- alma/extraction/auto_learner.py +4 -3
- alma/graph/__init__.py +26 -1
- alma/graph/backends/__init__.py +14 -0
- alma/graph/backends/kuzu.py +624 -0
- alma/graph/backends/memgraph.py +432 -0
- alma/integration/claude_agents.py +22 -10
- alma/learning/protocols.py +3 -3
- alma/mcp/tools.py +9 -11
- alma/observability/__init__.py +84 -0
- alma/observability/config.py +302 -0
- alma/observability/logging.py +424 -0
- alma/observability/metrics.py +583 -0
- alma/observability/tracing.py +440 -0
- alma/retrieval/engine.py +65 -4
- alma/storage/__init__.py +29 -0
- alma/storage/azure_cosmos.py +343 -132
- alma/storage/base.py +58 -0
- alma/storage/constants.py +103 -0
- alma/storage/file_based.py +3 -8
- alma/storage/migrations/__init__.py +21 -0
- alma/storage/migrations/base.py +321 -0
- alma/storage/migrations/runner.py +323 -0
- alma/storage/migrations/version_stores.py +337 -0
- alma/storage/migrations/versions/__init__.py +11 -0
- alma/storage/migrations/versions/v1_0_0.py +373 -0
- alma/storage/postgresql.py +185 -78
- alma/storage/sqlite_local.py +149 -50
- alma/testing/__init__.py +46 -0
- alma/testing/factories.py +301 -0
- alma/testing/mocks.py +389 -0
- {alma_memory-0.5.0.dist-info → alma_memory-0.5.1.dist-info}/METADATA +42 -8
- {alma_memory-0.5.0.dist-info → alma_memory-0.5.1.dist-info}/RECORD +36 -19
- {alma_memory-0.5.0.dist-info → alma_memory-0.5.1.dist-info}/WHEEL +0 -0
- {alma_memory-0.5.0.dist-info → alma_memory-0.5.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ALMA Migration Framework - Migration Runner.
|
|
3
|
+
|
|
4
|
+
Executes migrations and manages schema version tracking.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import logging
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional, Protocol, Type
|
|
11
|
+
|
|
12
|
+
from alma.storage.migrations.base import (
|
|
13
|
+
Migration,
|
|
14
|
+
MigrationError,
|
|
15
|
+
MigrationRegistry,
|
|
16
|
+
SchemaVersion,
|
|
17
|
+
get_registry,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class VersionStore(Protocol):
|
|
24
|
+
"""Protocol for schema version storage."""
|
|
25
|
+
|
|
26
|
+
def get_current_version(self) -> Optional[str]:
|
|
27
|
+
"""Get the current schema version."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
def get_version_history(self) -> List[SchemaVersion]:
|
|
31
|
+
"""Get all applied versions in order."""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
def record_version(self, version: SchemaVersion) -> None:
|
|
35
|
+
"""Record a new version."""
|
|
36
|
+
...
|
|
37
|
+
|
|
38
|
+
def remove_version(self, version: str) -> None:
|
|
39
|
+
"""Remove a version record (for rollback)."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class MigrationRunner:
|
|
44
|
+
"""
|
|
45
|
+
Executes schema migrations and tracks versions.
|
|
46
|
+
|
|
47
|
+
Handles:
|
|
48
|
+
- Forward migrations (upgrades)
|
|
49
|
+
- Backward migrations (rollbacks)
|
|
50
|
+
- Version tracking
|
|
51
|
+
- Pre/post migration checks
|
|
52
|
+
- Dry run mode
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
# Current schema version for fresh installations
|
|
56
|
+
CURRENT_SCHEMA_VERSION = "1.0.0"
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
version_store: VersionStore,
|
|
61
|
+
registry: Optional[MigrationRegistry] = None,
|
|
62
|
+
backend: Optional[str] = None,
|
|
63
|
+
):
|
|
64
|
+
"""
|
|
65
|
+
Initialize migration runner.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
version_store: Storage for version tracking
|
|
69
|
+
registry: Migration registry (uses global if not provided)
|
|
70
|
+
backend: Backend name for filtering migrations
|
|
71
|
+
"""
|
|
72
|
+
self.version_store = version_store
|
|
73
|
+
self.registry = registry or get_registry()
|
|
74
|
+
self.backend = backend
|
|
75
|
+
self._hooks: Dict[str, List[Callable]] = {
|
|
76
|
+
"pre_migrate": [],
|
|
77
|
+
"post_migrate": [],
|
|
78
|
+
"pre_rollback": [],
|
|
79
|
+
"post_rollback": [],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
def add_hook(self, event: str, callback: Callable) -> None:
|
|
83
|
+
"""
|
|
84
|
+
Add a hook callback for migration events.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
event: Event name (pre_migrate, post_migrate, etc.)
|
|
88
|
+
callback: Function to call
|
|
89
|
+
"""
|
|
90
|
+
if event in self._hooks:
|
|
91
|
+
self._hooks[event].append(callback)
|
|
92
|
+
|
|
93
|
+
def _run_hooks(self, event: str, *args: Any, **kwargs: Any) -> None:
|
|
94
|
+
"""Run all hooks for an event."""
|
|
95
|
+
for callback in self._hooks.get(event, []):
|
|
96
|
+
try:
|
|
97
|
+
callback(*args, **kwargs)
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.warning(f"Hook {callback.__name__} failed: {e}")
|
|
100
|
+
|
|
101
|
+
def get_current_version(self) -> Optional[str]:
|
|
102
|
+
"""Get the current schema version."""
|
|
103
|
+
return self.version_store.get_current_version()
|
|
104
|
+
|
|
105
|
+
def get_pending_migrations(self) -> List[Type[Migration]]:
|
|
106
|
+
"""Get list of migrations that need to be applied."""
|
|
107
|
+
current = self.get_current_version()
|
|
108
|
+
return self.registry.get_pending_migrations(current, self.backend)
|
|
109
|
+
|
|
110
|
+
def needs_migration(self) -> bool:
|
|
111
|
+
"""Check if there are pending migrations."""
|
|
112
|
+
return len(self.get_pending_migrations()) > 0
|
|
113
|
+
|
|
114
|
+
def migrate(
|
|
115
|
+
self,
|
|
116
|
+
connection: Any,
|
|
117
|
+
target_version: Optional[str] = None,
|
|
118
|
+
dry_run: bool = False,
|
|
119
|
+
) -> List[str]:
|
|
120
|
+
"""
|
|
121
|
+
Apply pending migrations.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
connection: Database connection or storage instance
|
|
125
|
+
target_version: Optional target version (applies all if not specified)
|
|
126
|
+
dry_run: If True, show what would be done without making changes
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
List of applied migration versions
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
MigrationError: If a migration fails
|
|
133
|
+
"""
|
|
134
|
+
current = self.get_current_version()
|
|
135
|
+
pending = self.registry.get_pending_migrations(current, self.backend)
|
|
136
|
+
|
|
137
|
+
if target_version:
|
|
138
|
+
target_tuple = SchemaVersion._parse_version(target_version)
|
|
139
|
+
pending = [
|
|
140
|
+
m
|
|
141
|
+
for m in pending
|
|
142
|
+
if SchemaVersion._parse_version(m.version) <= target_tuple
|
|
143
|
+
]
|
|
144
|
+
|
|
145
|
+
if not pending:
|
|
146
|
+
logger.info("No pending migrations")
|
|
147
|
+
return []
|
|
148
|
+
|
|
149
|
+
applied = []
|
|
150
|
+
logger.info(f"Found {len(pending)} pending migrations")
|
|
151
|
+
|
|
152
|
+
for migration_class in pending:
|
|
153
|
+
version = migration_class.version
|
|
154
|
+
migration = migration_class()
|
|
155
|
+
|
|
156
|
+
if dry_run:
|
|
157
|
+
logger.info(
|
|
158
|
+
f"[DRY RUN] Would apply migration {version}: {migration.description}"
|
|
159
|
+
)
|
|
160
|
+
applied.append(version)
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
logger.info(f"Applying migration {version}: {migration.description}")
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
# Run pre-check
|
|
167
|
+
if not migration.pre_check(connection):
|
|
168
|
+
raise MigrationError(
|
|
169
|
+
f"Pre-check failed for migration {version}",
|
|
170
|
+
version=version,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Run pre-migrate hooks
|
|
174
|
+
self._run_hooks("pre_migrate", migration, connection)
|
|
175
|
+
|
|
176
|
+
# Apply migration
|
|
177
|
+
migration.upgrade(connection)
|
|
178
|
+
|
|
179
|
+
# Run post-check
|
|
180
|
+
if not migration.post_check(connection):
|
|
181
|
+
raise MigrationError(
|
|
182
|
+
f"Post-check failed for migration {version}",
|
|
183
|
+
version=version,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Record version
|
|
187
|
+
schema_version = SchemaVersion(
|
|
188
|
+
version=version,
|
|
189
|
+
applied_at=datetime.now(timezone.utc),
|
|
190
|
+
description=migration.description,
|
|
191
|
+
checksum=self._compute_checksum(migration_class),
|
|
192
|
+
)
|
|
193
|
+
self.version_store.record_version(schema_version)
|
|
194
|
+
|
|
195
|
+
# Run post-migrate hooks
|
|
196
|
+
self._run_hooks("post_migrate", migration, connection)
|
|
197
|
+
|
|
198
|
+
applied.append(version)
|
|
199
|
+
logger.info(f"Successfully applied migration {version}")
|
|
200
|
+
|
|
201
|
+
except MigrationError:
|
|
202
|
+
raise
|
|
203
|
+
except Exception as e:
|
|
204
|
+
raise MigrationError(
|
|
205
|
+
f"Migration {version} failed: {str(e)}",
|
|
206
|
+
version=version,
|
|
207
|
+
cause=e,
|
|
208
|
+
) from e
|
|
209
|
+
|
|
210
|
+
return applied
|
|
211
|
+
|
|
212
|
+
def rollback(
|
|
213
|
+
self,
|
|
214
|
+
connection: Any,
|
|
215
|
+
target_version: str,
|
|
216
|
+
dry_run: bool = False,
|
|
217
|
+
) -> List[str]:
|
|
218
|
+
"""
|
|
219
|
+
Roll back to a target version.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
connection: Database connection or storage instance
|
|
223
|
+
target_version: Version to roll back to
|
|
224
|
+
dry_run: If True, show what would be done without making changes
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
List of rolled back migration versions
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
MigrationError: If a rollback fails
|
|
231
|
+
"""
|
|
232
|
+
current = self.get_current_version()
|
|
233
|
+
if current is None:
|
|
234
|
+
logger.info("No migrations to roll back")
|
|
235
|
+
return []
|
|
236
|
+
|
|
237
|
+
rollback_migrations = self.registry.get_rollback_migrations(
|
|
238
|
+
current, target_version, self.backend
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if not rollback_migrations:
|
|
242
|
+
logger.info("No migrations to roll back")
|
|
243
|
+
return []
|
|
244
|
+
|
|
245
|
+
rolled_back = []
|
|
246
|
+
logger.info(f"Rolling back {len(rollback_migrations)} migrations")
|
|
247
|
+
|
|
248
|
+
for migration_class in rollback_migrations:
|
|
249
|
+
version = migration_class.version
|
|
250
|
+
migration = migration_class()
|
|
251
|
+
|
|
252
|
+
if dry_run:
|
|
253
|
+
logger.info(f"[DRY RUN] Would roll back migration {version}")
|
|
254
|
+
rolled_back.append(version)
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
logger.info(f"Rolling back migration {version}")
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
# Run pre-rollback hooks
|
|
261
|
+
self._run_hooks("pre_rollback", migration, connection)
|
|
262
|
+
|
|
263
|
+
# Apply downgrade
|
|
264
|
+
migration.downgrade(connection)
|
|
265
|
+
|
|
266
|
+
# Remove version record
|
|
267
|
+
self.version_store.remove_version(version)
|
|
268
|
+
|
|
269
|
+
# Run post-rollback hooks
|
|
270
|
+
self._run_hooks("post_rollback", migration, connection)
|
|
271
|
+
|
|
272
|
+
rolled_back.append(version)
|
|
273
|
+
logger.info(f"Successfully rolled back migration {version}")
|
|
274
|
+
|
|
275
|
+
except NotImplementedError as e:
|
|
276
|
+
raise MigrationError(
|
|
277
|
+
f"Migration {version} does not support rollback",
|
|
278
|
+
version=version,
|
|
279
|
+
) from e
|
|
280
|
+
except Exception as e:
|
|
281
|
+
raise MigrationError(
|
|
282
|
+
f"Rollback of {version} failed: {str(e)}",
|
|
283
|
+
version=version,
|
|
284
|
+
cause=e,
|
|
285
|
+
) from e
|
|
286
|
+
|
|
287
|
+
return rolled_back
|
|
288
|
+
|
|
289
|
+
def get_status(self) -> Dict[str, Any]:
|
|
290
|
+
"""
|
|
291
|
+
Get migration status information.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Dict with current version, pending migrations, and history
|
|
295
|
+
"""
|
|
296
|
+
current = self.get_current_version()
|
|
297
|
+
pending = self.get_pending_migrations()
|
|
298
|
+
history = self.version_store.get_version_history()
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
"current_version": current,
|
|
302
|
+
"target_version": self.CURRENT_SCHEMA_VERSION,
|
|
303
|
+
"pending_count": len(pending),
|
|
304
|
+
"pending_versions": [m.version for m in pending],
|
|
305
|
+
"applied_count": len(history),
|
|
306
|
+
"history": [
|
|
307
|
+
{
|
|
308
|
+
"version": v.version,
|
|
309
|
+
"applied_at": v.applied_at.isoformat(),
|
|
310
|
+
"description": v.description,
|
|
311
|
+
}
|
|
312
|
+
for v in history
|
|
313
|
+
],
|
|
314
|
+
"needs_migration": len(pending) > 0,
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
@staticmethod
|
|
318
|
+
def _compute_checksum(migration_class: Type[Migration]) -> str:
|
|
319
|
+
"""Compute checksum of migration for integrity tracking."""
|
|
320
|
+
import inspect
|
|
321
|
+
|
|
322
|
+
source = inspect.getsource(migration_class)
|
|
323
|
+
return hashlib.sha256(source.encode()).hexdigest()[:16]
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ALMA Migration Framework - Version Stores.
|
|
3
|
+
|
|
4
|
+
Implementations of version tracking for different storage backends.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import sqlite3
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, List, Optional
|
|
14
|
+
|
|
15
|
+
from alma.storage.migrations.base import SchemaVersion
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SQLiteVersionStore:
|
|
21
|
+
"""
|
|
22
|
+
Version store using SQLite for tracking schema versions.
|
|
23
|
+
|
|
24
|
+
Creates a `_schema_versions` table to track applied migrations.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, db_path: Path):
|
|
28
|
+
"""
|
|
29
|
+
Initialize SQLite version store.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
db_path: Path to SQLite database file
|
|
33
|
+
"""
|
|
34
|
+
self.db_path = Path(db_path)
|
|
35
|
+
self._init_version_table()
|
|
36
|
+
|
|
37
|
+
@contextmanager
|
|
38
|
+
def _get_connection(self):
|
|
39
|
+
"""Get database connection with context manager."""
|
|
40
|
+
conn = sqlite3.connect(self.db_path)
|
|
41
|
+
conn.row_factory = sqlite3.Row
|
|
42
|
+
try:
|
|
43
|
+
yield conn
|
|
44
|
+
conn.commit()
|
|
45
|
+
except Exception:
|
|
46
|
+
conn.rollback()
|
|
47
|
+
raise
|
|
48
|
+
finally:
|
|
49
|
+
conn.close()
|
|
50
|
+
|
|
51
|
+
def _init_version_table(self) -> None:
|
|
52
|
+
"""Create schema versions table if it doesn't exist."""
|
|
53
|
+
with self._get_connection() as conn:
|
|
54
|
+
cursor = conn.cursor()
|
|
55
|
+
cursor.execute("""
|
|
56
|
+
CREATE TABLE IF NOT EXISTS _schema_versions (
|
|
57
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
58
|
+
version TEXT NOT NULL UNIQUE,
|
|
59
|
+
applied_at TEXT NOT NULL,
|
|
60
|
+
description TEXT,
|
|
61
|
+
checksum TEXT
|
|
62
|
+
)
|
|
63
|
+
""")
|
|
64
|
+
cursor.execute(
|
|
65
|
+
"CREATE INDEX IF NOT EXISTS idx_schema_version "
|
|
66
|
+
"ON _schema_versions(version)"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def get_current_version(self) -> Optional[str]:
|
|
70
|
+
"""Get the current (latest) schema version."""
|
|
71
|
+
with self._get_connection() as conn:
|
|
72
|
+
cursor = conn.cursor()
|
|
73
|
+
cursor.execute("""
|
|
74
|
+
SELECT version FROM _schema_versions
|
|
75
|
+
ORDER BY id DESC LIMIT 1
|
|
76
|
+
""")
|
|
77
|
+
row = cursor.fetchone()
|
|
78
|
+
return row["version"] if row else None
|
|
79
|
+
|
|
80
|
+
def get_version_history(self) -> List[SchemaVersion]:
|
|
81
|
+
"""Get all applied versions in chronological order."""
|
|
82
|
+
with self._get_connection() as conn:
|
|
83
|
+
cursor = conn.cursor()
|
|
84
|
+
cursor.execute("""
|
|
85
|
+
SELECT version, applied_at, description, checksum
|
|
86
|
+
FROM _schema_versions
|
|
87
|
+
ORDER BY id ASC
|
|
88
|
+
""")
|
|
89
|
+
rows = cursor.fetchall()
|
|
90
|
+
|
|
91
|
+
return [
|
|
92
|
+
SchemaVersion(
|
|
93
|
+
version=row["version"],
|
|
94
|
+
applied_at=datetime.fromisoformat(row["applied_at"]),
|
|
95
|
+
description=row["description"] or "",
|
|
96
|
+
checksum=row["checksum"],
|
|
97
|
+
)
|
|
98
|
+
for row in rows
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
def record_version(self, version: SchemaVersion) -> None:
|
|
102
|
+
"""Record a new schema version."""
|
|
103
|
+
with self._get_connection() as conn:
|
|
104
|
+
cursor = conn.cursor()
|
|
105
|
+
cursor.execute(
|
|
106
|
+
"""
|
|
107
|
+
INSERT INTO _schema_versions (version, applied_at, description, checksum)
|
|
108
|
+
VALUES (?, ?, ?, ?)
|
|
109
|
+
""",
|
|
110
|
+
(
|
|
111
|
+
version.version,
|
|
112
|
+
version.applied_at.isoformat(),
|
|
113
|
+
version.description,
|
|
114
|
+
version.checksum,
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
logger.debug(f"Recorded schema version {version.version}")
|
|
118
|
+
|
|
119
|
+
def remove_version(self, version: str) -> None:
|
|
120
|
+
"""Remove a version record (for rollback)."""
|
|
121
|
+
with self._get_connection() as conn:
|
|
122
|
+
cursor = conn.cursor()
|
|
123
|
+
cursor.execute(
|
|
124
|
+
"DELETE FROM _schema_versions WHERE version = ?",
|
|
125
|
+
(version,),
|
|
126
|
+
)
|
|
127
|
+
logger.debug(f"Removed schema version {version}")
|
|
128
|
+
|
|
129
|
+
def has_version_table(self) -> bool:
|
|
130
|
+
"""Check if the version table exists."""
|
|
131
|
+
with self._get_connection() as conn:
|
|
132
|
+
cursor = conn.cursor()
|
|
133
|
+
cursor.execute("""
|
|
134
|
+
SELECT name FROM sqlite_master
|
|
135
|
+
WHERE type='table' AND name='_schema_versions'
|
|
136
|
+
""")
|
|
137
|
+
return cursor.fetchone() is not None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class PostgreSQLVersionStore:
|
|
141
|
+
"""
|
|
142
|
+
Version store using PostgreSQL for tracking schema versions.
|
|
143
|
+
|
|
144
|
+
Creates an `_schema_versions` table in the configured schema.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def __init__(self, pool: Any, schema: str = "public"):
|
|
148
|
+
"""
|
|
149
|
+
Initialize PostgreSQL version store.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
pool: psycopg connection pool
|
|
153
|
+
schema: Database schema name
|
|
154
|
+
"""
|
|
155
|
+
self._pool = pool
|
|
156
|
+
self.schema = schema
|
|
157
|
+
self._init_version_table()
|
|
158
|
+
|
|
159
|
+
@contextmanager
|
|
160
|
+
def _get_connection(self):
|
|
161
|
+
"""Get database connection from pool."""
|
|
162
|
+
with self._pool.connection() as conn:
|
|
163
|
+
yield conn
|
|
164
|
+
|
|
165
|
+
def _init_version_table(self) -> None:
|
|
166
|
+
"""Create schema versions table if it doesn't exist."""
|
|
167
|
+
with self._get_connection() as conn:
|
|
168
|
+
conn.execute(f"""
|
|
169
|
+
CREATE TABLE IF NOT EXISTS {self.schema}._schema_versions (
|
|
170
|
+
id SERIAL PRIMARY KEY,
|
|
171
|
+
version TEXT NOT NULL UNIQUE,
|
|
172
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
173
|
+
description TEXT,
|
|
174
|
+
checksum TEXT
|
|
175
|
+
)
|
|
176
|
+
""")
|
|
177
|
+
conn.execute(f"""
|
|
178
|
+
CREATE INDEX IF NOT EXISTS idx_schema_version
|
|
179
|
+
ON {self.schema}._schema_versions(version)
|
|
180
|
+
""")
|
|
181
|
+
conn.commit()
|
|
182
|
+
|
|
183
|
+
def get_current_version(self) -> Optional[str]:
|
|
184
|
+
"""Get the current (latest) schema version."""
|
|
185
|
+
with self._get_connection() as conn:
|
|
186
|
+
cursor = conn.execute(f"""
|
|
187
|
+
SELECT version FROM {self.schema}._schema_versions
|
|
188
|
+
ORDER BY id DESC LIMIT 1
|
|
189
|
+
""")
|
|
190
|
+
row = cursor.fetchone()
|
|
191
|
+
return row["version"] if row else None
|
|
192
|
+
|
|
193
|
+
def get_version_history(self) -> List[SchemaVersion]:
|
|
194
|
+
"""Get all applied versions in chronological order."""
|
|
195
|
+
with self._get_connection() as conn:
|
|
196
|
+
cursor = conn.execute(f"""
|
|
197
|
+
SELECT version, applied_at, description, checksum
|
|
198
|
+
FROM {self.schema}._schema_versions
|
|
199
|
+
ORDER BY id ASC
|
|
200
|
+
""")
|
|
201
|
+
rows = cursor.fetchall()
|
|
202
|
+
|
|
203
|
+
return [
|
|
204
|
+
SchemaVersion(
|
|
205
|
+
version=row["version"],
|
|
206
|
+
applied_at=row["applied_at"]
|
|
207
|
+
if isinstance(row["applied_at"], datetime)
|
|
208
|
+
else datetime.fromisoformat(str(row["applied_at"])),
|
|
209
|
+
description=row["description"] or "",
|
|
210
|
+
checksum=row["checksum"],
|
|
211
|
+
)
|
|
212
|
+
for row in rows
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
def record_version(self, version: SchemaVersion) -> None:
|
|
216
|
+
"""Record a new schema version."""
|
|
217
|
+
with self._get_connection() as conn:
|
|
218
|
+
conn.execute(
|
|
219
|
+
f"""
|
|
220
|
+
INSERT INTO {self.schema}._schema_versions
|
|
221
|
+
(version, applied_at, description, checksum)
|
|
222
|
+
VALUES (%s, %s, %s, %s)
|
|
223
|
+
""",
|
|
224
|
+
(
|
|
225
|
+
version.version,
|
|
226
|
+
version.applied_at,
|
|
227
|
+
version.description,
|
|
228
|
+
version.checksum,
|
|
229
|
+
),
|
|
230
|
+
)
|
|
231
|
+
conn.commit()
|
|
232
|
+
logger.debug(f"Recorded schema version {version.version}")
|
|
233
|
+
|
|
234
|
+
def remove_version(self, version: str) -> None:
|
|
235
|
+
"""Remove a version record (for rollback)."""
|
|
236
|
+
with self._get_connection() as conn:
|
|
237
|
+
conn.execute(
|
|
238
|
+
f"DELETE FROM {self.schema}._schema_versions WHERE version = %s",
|
|
239
|
+
(version,),
|
|
240
|
+
)
|
|
241
|
+
conn.commit()
|
|
242
|
+
logger.debug(f"Removed schema version {version}")
|
|
243
|
+
|
|
244
|
+
def has_version_table(self) -> bool:
|
|
245
|
+
"""Check if the version table exists."""
|
|
246
|
+
with self._get_connection() as conn:
|
|
247
|
+
cursor = conn.execute(
|
|
248
|
+
"""
|
|
249
|
+
SELECT EXISTS (
|
|
250
|
+
SELECT FROM information_schema.tables
|
|
251
|
+
WHERE table_schema = %s
|
|
252
|
+
AND table_name = '_schema_versions'
|
|
253
|
+
)
|
|
254
|
+
""",
|
|
255
|
+
(self.schema,),
|
|
256
|
+
)
|
|
257
|
+
row = cursor.fetchone()
|
|
258
|
+
return row["exists"] if row else False
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class FileBasedVersionStore:
|
|
262
|
+
"""
|
|
263
|
+
Version store using a JSON file for tracking schema versions.
|
|
264
|
+
|
|
265
|
+
Useful for file-based storage backends.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def __init__(self, storage_dir: Path):
|
|
269
|
+
"""
|
|
270
|
+
Initialize file-based version store.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
storage_dir: Directory to store version file
|
|
274
|
+
"""
|
|
275
|
+
self.storage_dir = Path(storage_dir)
|
|
276
|
+
self.version_file = self.storage_dir / "_schema_versions.json"
|
|
277
|
+
self._ensure_file_exists()
|
|
278
|
+
|
|
279
|
+
def _ensure_file_exists(self) -> None:
|
|
280
|
+
"""Create version file if it doesn't exist."""
|
|
281
|
+
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
|
282
|
+
if not self.version_file.exists():
|
|
283
|
+
self._write_versions([])
|
|
284
|
+
|
|
285
|
+
def _read_versions(self) -> List[dict]:
|
|
286
|
+
"""Read versions from file."""
|
|
287
|
+
try:
|
|
288
|
+
with open(self.version_file, "r") as f:
|
|
289
|
+
return json.load(f)
|
|
290
|
+
except (json.JSONDecodeError, FileNotFoundError):
|
|
291
|
+
return []
|
|
292
|
+
|
|
293
|
+
def _write_versions(self, versions: List[dict]) -> None:
|
|
294
|
+
"""Write versions to file."""
|
|
295
|
+
with open(self.version_file, "w") as f:
|
|
296
|
+
json.dump(versions, f, indent=2, default=str)
|
|
297
|
+
|
|
298
|
+
def get_current_version(self) -> Optional[str]:
|
|
299
|
+
"""Get the current (latest) schema version."""
|
|
300
|
+
versions = self._read_versions()
|
|
301
|
+
if not versions:
|
|
302
|
+
return None
|
|
303
|
+
return versions[-1]["version"]
|
|
304
|
+
|
|
305
|
+
def get_version_history(self) -> List[SchemaVersion]:
|
|
306
|
+
"""Get all applied versions in chronological order."""
|
|
307
|
+
versions = self._read_versions()
|
|
308
|
+
return [
|
|
309
|
+
SchemaVersion(
|
|
310
|
+
version=v["version"],
|
|
311
|
+
applied_at=datetime.fromisoformat(v["applied_at"]),
|
|
312
|
+
description=v.get("description", ""),
|
|
313
|
+
checksum=v.get("checksum"),
|
|
314
|
+
)
|
|
315
|
+
for v in versions
|
|
316
|
+
]
|
|
317
|
+
|
|
318
|
+
def record_version(self, version: SchemaVersion) -> None:
|
|
319
|
+
"""Record a new schema version."""
|
|
320
|
+
versions = self._read_versions()
|
|
321
|
+
versions.append(
|
|
322
|
+
{
|
|
323
|
+
"version": version.version,
|
|
324
|
+
"applied_at": version.applied_at.isoformat(),
|
|
325
|
+
"description": version.description,
|
|
326
|
+
"checksum": version.checksum,
|
|
327
|
+
}
|
|
328
|
+
)
|
|
329
|
+
self._write_versions(versions)
|
|
330
|
+
logger.debug(f"Recorded schema version {version.version}")
|
|
331
|
+
|
|
332
|
+
def remove_version(self, version: str) -> None:
|
|
333
|
+
"""Remove a version record (for rollback)."""
|
|
334
|
+
versions = self._read_versions()
|
|
335
|
+
versions = [v for v in versions if v["version"] != version]
|
|
336
|
+
self._write_versions(versions)
|
|
337
|
+
logger.debug(f"Removed schema version {version}")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ALMA Schema Migrations - Version Definitions.
|
|
3
|
+
|
|
4
|
+
Each version file contains migrations for that schema version.
|
|
5
|
+
Migrations are automatically registered when imported.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# Import all version modules to register migrations
|
|
9
|
+
from alma.storage.migrations.versions import v1_0_0
|
|
10
|
+
|
|
11
|
+
__all__ = ["v1_0_0"]
|