get-claudia 1.46.0 → 1.47.0
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.
- package/memory-daemon/claudia_memory/__main__.py +201 -3
- package/memory-daemon/claudia_memory/config.py +14 -0
- package/memory-daemon/claudia_memory/daemon/scheduler.py +36 -0
- package/memory-daemon/claudia_memory/database.py +86 -6
- package/memory-daemon/claudia_memory/migration.py +1164 -0
- package/memory-daemon/scripts/migrate-legacy-db.py +177 -0
- package/memory-daemon/tests/test_daemon_lifecycle.py +5 -2
- package/memory-daemon/tests/test_migration.py +799 -0
- package/package.json +1 -1
|
@@ -143,13 +143,14 @@ def _check_and_repair_database(db_path: Path) -> None:
|
|
|
143
143
|
return
|
|
144
144
|
|
|
145
145
|
backup_pattern = str(db_path) + ".backup-*.db"
|
|
146
|
-
backups = sorted(glob.glob(backup_pattern))
|
|
146
|
+
backups = sorted(glob.glob(backup_pattern), key=os.path.getmtime)
|
|
147
147
|
if backups:
|
|
148
148
|
latest = backups[-1]
|
|
149
149
|
logger.warning(f"Restoring database from backup: {latest}")
|
|
150
150
|
shutil.copy2(latest, db_path)
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
# Clean up stale WAL files using direct path concatenation
|
|
152
|
+
for suffix in ("-shm", "-wal"):
|
|
153
|
+
stale = Path(str(db_path) + suffix)
|
|
153
154
|
if stale.exists():
|
|
154
155
|
stale.unlink()
|
|
155
156
|
logger.info(f"Removed stale WAL file: {stale}")
|
|
@@ -162,6 +163,104 @@ def _check_and_repair_database(db_path: Path) -> None:
|
|
|
162
163
|
)
|
|
163
164
|
|
|
164
165
|
|
|
166
|
+
def _auto_migrate_legacy() -> None:
|
|
167
|
+
"""Auto-migrate data from legacy claudia.db if it exists.
|
|
168
|
+
|
|
169
|
+
When Claudia switched from a single claudia.db to project-hash naming
|
|
170
|
+
({sha256[:12]}.db), no data migration was performed. This function
|
|
171
|
+
detects the orphaned legacy database and migrates its data into the
|
|
172
|
+
active project-specific database.
|
|
173
|
+
|
|
174
|
+
Properties:
|
|
175
|
+
- Idempotent: checks _meta flag, won't run twice
|
|
176
|
+
- Safe: backs up before touching anything, preserves original
|
|
177
|
+
- Non-fatal: catches all exceptions, logs, continues
|
|
178
|
+
"""
|
|
179
|
+
from .migration import (
|
|
180
|
+
check_legacy_database,
|
|
181
|
+
is_migration_completed,
|
|
182
|
+
mark_migration_completed,
|
|
183
|
+
migrate_legacy_database,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
config = get_config()
|
|
188
|
+
legacy_path = Path.home() / ".claudia" / "memory" / "claudia.db"
|
|
189
|
+
active_path = Path(config.db_path)
|
|
190
|
+
|
|
191
|
+
# Skip if active db IS the legacy db (no project isolation active)
|
|
192
|
+
try:
|
|
193
|
+
if legacy_path.resolve() == active_path.resolve():
|
|
194
|
+
return
|
|
195
|
+
except OSError:
|
|
196
|
+
if str(legacy_path) == str(active_path):
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
# Skip if legacy database doesn't exist
|
|
200
|
+
if not legacy_path.exists():
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
# Skip if migration already completed (idempotent)
|
|
204
|
+
db = get_db()
|
|
205
|
+
if is_migration_completed(db):
|
|
206
|
+
return
|
|
207
|
+
|
|
208
|
+
# Check if legacy database has meaningful data
|
|
209
|
+
legacy_stats = check_legacy_database(legacy_path)
|
|
210
|
+
if not legacy_stats:
|
|
211
|
+
# Empty or unreadable legacy db -- mark complete so we don't check again
|
|
212
|
+
mark_migration_completed(db, {"skipped": "no_data"})
|
|
213
|
+
logger.info("Legacy claudia.db exists but has no data worth migrating")
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
logger.info(
|
|
217
|
+
f"Found legacy claudia.db with {legacy_stats.get('entities', 0)} entities "
|
|
218
|
+
f"and {legacy_stats.get('memories', 0)} memories"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Create pre-migration backup of active database (if it has data)
|
|
222
|
+
if active_path.exists():
|
|
223
|
+
try:
|
|
224
|
+
backup_path = db.backup(label="pre-migration")
|
|
225
|
+
logger.info(f"Pre-migration backup created: {backup_path}")
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.warning(f"Pre-migration backup failed: {e}")
|
|
228
|
+
# Continue anyway -- the migration is additive, not destructive
|
|
229
|
+
|
|
230
|
+
# Run the migration
|
|
231
|
+
logger.info(f"Starting legacy database migration: {legacy_path} -> {active_path}")
|
|
232
|
+
results = migrate_legacy_database(legacy_path, active_path)
|
|
233
|
+
|
|
234
|
+
# Mark migration as completed
|
|
235
|
+
mark_migration_completed(db, results)
|
|
236
|
+
|
|
237
|
+
# Rename the legacy database (preserve, don't delete)
|
|
238
|
+
from datetime import datetime as dt
|
|
239
|
+
date_suffix = dt.now().strftime("%Y-%m-%d")
|
|
240
|
+
migrated_path = legacy_path.with_suffix(f".db.migrated-{date_suffix}")
|
|
241
|
+
try:
|
|
242
|
+
legacy_path.rename(migrated_path)
|
|
243
|
+
logger.info(f"Renamed legacy database: {legacy_path} -> {migrated_path}")
|
|
244
|
+
except OSError as e:
|
|
245
|
+
logger.warning(f"Could not rename legacy database: {e}")
|
|
246
|
+
|
|
247
|
+
# Log summary
|
|
248
|
+
logger.info(
|
|
249
|
+
f"Legacy migration complete: "
|
|
250
|
+
f"{results.get('entities_created', 0)} entities created, "
|
|
251
|
+
f"{results.get('entities_mapped', 0)} mapped, "
|
|
252
|
+
f"{results.get('memories_migrated', 0)} memories migrated, "
|
|
253
|
+
f"{results.get('links_migrated', 0)} links migrated, "
|
|
254
|
+
f"{results.get('relationships_migrated', 0)} relationships migrated"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
except Exception as e:
|
|
258
|
+
# Non-fatal: log error and continue with whatever data we have
|
|
259
|
+
logger.error(f"Legacy migration failed (non-fatal): {e}")
|
|
260
|
+
logger.info("Daemon will continue with current database. "
|
|
261
|
+
"Run --migrate-legacy manually to retry.")
|
|
262
|
+
|
|
263
|
+
|
|
165
264
|
def run_daemon(mcp_mode: bool = True, debug: bool = False, project_id: str = None) -> None:
|
|
166
265
|
"""
|
|
167
266
|
Run the Claudia Memory Daemon.
|
|
@@ -207,6 +306,9 @@ def run_daemon(mcp_mode: bool = True, debug: bool = False, project_id: str = Non
|
|
|
207
306
|
db.initialize()
|
|
208
307
|
logger.info(f"Database initialized at {get_config().db_path}")
|
|
209
308
|
|
|
309
|
+
# Auto-migrate legacy claudia.db if it exists
|
|
310
|
+
_auto_migrate_legacy()
|
|
311
|
+
|
|
210
312
|
# Start health server and scheduler - ONLY in standalone mode.
|
|
211
313
|
# MCP server processes are ephemeral and session-bound; the standalone
|
|
212
314
|
# daemon (LaunchAgent/systemd) owns port 3848 and handles scheduling.
|
|
@@ -324,6 +426,21 @@ def main():
|
|
|
324
426
|
action="store_true",
|
|
325
427
|
help="Preview mode for --migrate-vault-para: show routing plan without making changes",
|
|
326
428
|
)
|
|
429
|
+
parser.add_argument(
|
|
430
|
+
"--migrate-legacy",
|
|
431
|
+
action="store_true",
|
|
432
|
+
help="Manually migrate data from legacy claudia.db to project-specific database",
|
|
433
|
+
)
|
|
434
|
+
parser.add_argument(
|
|
435
|
+
"--dry-run",
|
|
436
|
+
action="store_true",
|
|
437
|
+
help="Preview migration without making changes (use with --migrate-legacy)",
|
|
438
|
+
)
|
|
439
|
+
parser.add_argument(
|
|
440
|
+
"--legacy-db",
|
|
441
|
+
type=str,
|
|
442
|
+
help="Path to legacy database (default: ~/.claudia/memory/claudia.db)",
|
|
443
|
+
)
|
|
327
444
|
|
|
328
445
|
args = parser.parse_args()
|
|
329
446
|
|
|
@@ -857,6 +974,87 @@ def main():
|
|
|
857
974
|
run_para_migration(vault_path, db=db, preview=args.preview)
|
|
858
975
|
return
|
|
859
976
|
|
|
977
|
+
if args.migrate_legacy:
|
|
978
|
+
# Manual legacy database migration
|
|
979
|
+
setup_logging(debug=args.debug)
|
|
980
|
+
from .migration import (
|
|
981
|
+
check_legacy_database,
|
|
982
|
+
is_migration_completed,
|
|
983
|
+
mark_migration_completed,
|
|
984
|
+
migrate_legacy_database,
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
db = get_db()
|
|
988
|
+
db.initialize()
|
|
989
|
+
config = get_config()
|
|
990
|
+
|
|
991
|
+
# Resolve paths
|
|
992
|
+
legacy_path = Path(args.legacy_db) if args.legacy_db else (
|
|
993
|
+
Path.home() / ".claudia" / "memory" / "claudia.db"
|
|
994
|
+
)
|
|
995
|
+
active_path = Path(config.db_path)
|
|
996
|
+
|
|
997
|
+
if not legacy_path.exists():
|
|
998
|
+
print(f"Legacy database not found: {legacy_path}")
|
|
999
|
+
sys.exit(1)
|
|
1000
|
+
|
|
1001
|
+
if str(legacy_path.resolve()) == str(active_path.resolve()):
|
|
1002
|
+
print("Error: Legacy and active databases are the same file.")
|
|
1003
|
+
print("Use --project-dir to specify a project for isolation.")
|
|
1004
|
+
sys.exit(1)
|
|
1005
|
+
|
|
1006
|
+
# Check legacy data
|
|
1007
|
+
legacy_stats = check_legacy_database(legacy_path)
|
|
1008
|
+
if not legacy_stats:
|
|
1009
|
+
print(f"Legacy database at {legacy_path} has no data to migrate.")
|
|
1010
|
+
return
|
|
1011
|
+
|
|
1012
|
+
print(f"\nLegacy database: {legacy_path}")
|
|
1013
|
+
print(f"Active database: {active_path}")
|
|
1014
|
+
print(f" Entities: {legacy_stats.get('entities', 0)}")
|
|
1015
|
+
print(f" Memories: {legacy_stats.get('memories', 0)}")
|
|
1016
|
+
print(f" Links: {legacy_stats.get('links', 0)}")
|
|
1017
|
+
print(f" Relationships: {legacy_stats.get('relationships', 0)}")
|
|
1018
|
+
if legacy_stats.get("earliest"):
|
|
1019
|
+
print(f" Date range: {legacy_stats['earliest']} to {legacy_stats['latest']}")
|
|
1020
|
+
|
|
1021
|
+
if is_migration_completed(db):
|
|
1022
|
+
print("\nNote: Migration was already completed previously.")
|
|
1023
|
+
if not args.dry_run:
|
|
1024
|
+
confirm = input("Run again? (y/N): ").strip().lower()
|
|
1025
|
+
if confirm != "y":
|
|
1026
|
+
print("Cancelled.")
|
|
1027
|
+
return
|
|
1028
|
+
|
|
1029
|
+
if args.dry_run:
|
|
1030
|
+
print("\nDry run mode -- no changes will be made.\n")
|
|
1031
|
+
results = migrate_legacy_database(legacy_path, active_path, dry_run=True)
|
|
1032
|
+
else:
|
|
1033
|
+
# Backup active database before migration
|
|
1034
|
+
if active_path.exists():
|
|
1035
|
+
backup_path = db.backup(label="pre-migration")
|
|
1036
|
+
print(f"\nBackup created: {backup_path}")
|
|
1037
|
+
|
|
1038
|
+
print("\nMigrating...")
|
|
1039
|
+
results = migrate_legacy_database(legacy_path, active_path)
|
|
1040
|
+
mark_migration_completed(db, results)
|
|
1041
|
+
|
|
1042
|
+
# Rename legacy database
|
|
1043
|
+
from datetime import datetime as dt
|
|
1044
|
+
date_suffix = dt.now().strftime("%Y-%m-%d")
|
|
1045
|
+
migrated_path = legacy_path.with_suffix(f".db.migrated-{date_suffix}")
|
|
1046
|
+
try:
|
|
1047
|
+
legacy_path.rename(migrated_path)
|
|
1048
|
+
print(f"Renamed: {legacy_path.name} -> {migrated_path.name}")
|
|
1049
|
+
except OSError as e:
|
|
1050
|
+
print(f"Warning: Could not rename legacy database: {e}")
|
|
1051
|
+
|
|
1052
|
+
print(f"\nResults:")
|
|
1053
|
+
for key, value in results.items():
|
|
1054
|
+
if value > 0:
|
|
1055
|
+
print(f" {key}: {value}")
|
|
1056
|
+
return
|
|
1057
|
+
|
|
860
1058
|
# Run the daemon
|
|
861
1059
|
run_daemon(mcp_mode=not args.standalone, debug=args.debug, project_id=project_id)
|
|
862
1060
|
|
|
@@ -80,6 +80,8 @@ class MemoryConfig:
|
|
|
80
80
|
# Backup settings
|
|
81
81
|
backup_retention_count: int = 3 # Number of rolling backups to keep
|
|
82
82
|
enable_pre_consolidation_backup: bool = True # Auto-backup before consolidation
|
|
83
|
+
backup_daily_retention: int = 7 # Keep 7 daily labeled backups (1 week)
|
|
84
|
+
backup_weekly_retention: int = 4 # Keep 4 weekly labeled backups (1 month)
|
|
83
85
|
|
|
84
86
|
# Retention settings (data cleanup during consolidation)
|
|
85
87
|
audit_log_retention_days: int = 90
|
|
@@ -154,6 +156,10 @@ class MemoryConfig:
|
|
|
154
156
|
config.backup_retention_count = data["backup_retention_count"]
|
|
155
157
|
if "enable_pre_consolidation_backup" in data:
|
|
156
158
|
config.enable_pre_consolidation_backup = data["enable_pre_consolidation_backup"]
|
|
159
|
+
if "backup_daily_retention" in data:
|
|
160
|
+
config.backup_daily_retention = data["backup_daily_retention"]
|
|
161
|
+
if "backup_weekly_retention" in data:
|
|
162
|
+
config.backup_weekly_retention = data["backup_weekly_retention"]
|
|
157
163
|
if "audit_log_retention_days" in data:
|
|
158
164
|
config.audit_log_retention_days = data["audit_log_retention_days"]
|
|
159
165
|
if "prediction_retention_days" in data:
|
|
@@ -241,6 +247,12 @@ class MemoryConfig:
|
|
|
241
247
|
if self.backup_retention_count < 1:
|
|
242
248
|
logger.warning(f"backup_retention_count={self.backup_retention_count} below minimum, using 1")
|
|
243
249
|
self.backup_retention_count = 1
|
|
250
|
+
if self.backup_daily_retention < 1:
|
|
251
|
+
logger.warning(f"backup_daily_retention={self.backup_daily_retention} below minimum, using 1")
|
|
252
|
+
self.backup_daily_retention = 1
|
|
253
|
+
if self.backup_weekly_retention < 1:
|
|
254
|
+
logger.warning(f"backup_weekly_retention={self.backup_weekly_retention} below minimum, using 1")
|
|
255
|
+
self.backup_weekly_retention = 1
|
|
244
256
|
for attr in ("audit_log_retention_days", "prediction_retention_days", "turn_buffer_retention_days", "metrics_retention_days"):
|
|
245
257
|
val = getattr(self, attr)
|
|
246
258
|
if val < 1:
|
|
@@ -305,6 +317,8 @@ class MemoryConfig:
|
|
|
305
317
|
"enable_auto_dedupe": self.enable_auto_dedupe,
|
|
306
318
|
"auto_dedupe_threshold": self.auto_dedupe_threshold,
|
|
307
319
|
"graph_proximity_weight": self.graph_proximity_weight,
|
|
320
|
+
"backup_daily_retention": self.backup_daily_retention,
|
|
321
|
+
"backup_weekly_retention": self.backup_weekly_retention,
|
|
308
322
|
}
|
|
309
323
|
|
|
310
324
|
with open(config_path, "w") as f:
|
|
@@ -64,6 +64,24 @@ class MemoryScheduler:
|
|
|
64
64
|
replace_existing=True,
|
|
65
65
|
)
|
|
66
66
|
|
|
67
|
+
# Daily at 2:30am: Labeled daily backup (7-day retention)
|
|
68
|
+
self.scheduler.add_job(
|
|
69
|
+
self._run_daily_backup,
|
|
70
|
+
CronTrigger(hour=2, minute=30),
|
|
71
|
+
id="daily_backup",
|
|
72
|
+
name="Daily backup",
|
|
73
|
+
replace_existing=True,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Weekly on Sunday at 2:45am: Labeled weekly backup (4-week retention)
|
|
77
|
+
self.scheduler.add_job(
|
|
78
|
+
self._run_weekly_backup,
|
|
79
|
+
CronTrigger(day_of_week="sun", hour=2, minute=45),
|
|
80
|
+
id="weekly_backup",
|
|
81
|
+
name="Weekly backup",
|
|
82
|
+
replace_existing=True,
|
|
83
|
+
)
|
|
84
|
+
|
|
67
85
|
# Daily at 3:15am: Vault sync (after consolidation)
|
|
68
86
|
if self.config.vault_sync_enabled:
|
|
69
87
|
self.scheduler.add_job(
|
|
@@ -128,6 +146,24 @@ class MemoryScheduler:
|
|
|
128
146
|
except Exception as e:
|
|
129
147
|
logger.exception("Error in full consolidation")
|
|
130
148
|
|
|
149
|
+
def _run_daily_backup(self) -> None:
|
|
150
|
+
"""Create a labeled daily backup with 7-day retention."""
|
|
151
|
+
try:
|
|
152
|
+
from ..database import get_db
|
|
153
|
+
backup_path = get_db().backup(label="daily")
|
|
154
|
+
logger.info(f"Daily backup created: {backup_path}")
|
|
155
|
+
except Exception as e:
|
|
156
|
+
logger.exception("Error in daily backup")
|
|
157
|
+
|
|
158
|
+
def _run_weekly_backup(self) -> None:
|
|
159
|
+
"""Create a labeled weekly backup with 4-week retention."""
|
|
160
|
+
try:
|
|
161
|
+
from ..database import get_db
|
|
162
|
+
backup_path = get_db().backup(label="weekly")
|
|
163
|
+
logger.info(f"Weekly backup created: {backup_path}")
|
|
164
|
+
except Exception as e:
|
|
165
|
+
logger.exception("Error in weekly backup")
|
|
166
|
+
|
|
131
167
|
def _run_vault_sync(self) -> None:
|
|
132
168
|
"""Run Obsidian vault sync + canvas regeneration"""
|
|
133
169
|
try:
|
|
@@ -254,6 +254,9 @@ class Database:
|
|
|
254
254
|
# Store workspace path in _meta for database identification
|
|
255
255
|
self._store_workspace_path(conn)
|
|
256
256
|
|
|
257
|
+
# Register database in central registry
|
|
258
|
+
self._register_database()
|
|
259
|
+
|
|
257
260
|
self._initialized = True
|
|
258
261
|
|
|
259
262
|
# All vec0 virtual tables and their primary key columns
|
|
@@ -1133,6 +1136,49 @@ class Database:
|
|
|
1133
1136
|
conn.commit()
|
|
1134
1137
|
logger.debug(f"Stored workspace path in _meta: {workspace_path}")
|
|
1135
1138
|
|
|
1139
|
+
def _register_database(self) -> None:
|
|
1140
|
+
"""Register this database in the central registry.
|
|
1141
|
+
|
|
1142
|
+
Maintains ~/.claudia/memory/registry.json with all known databases,
|
|
1143
|
+
their workspace paths, and last-seen timestamps. Used by the visualizer
|
|
1144
|
+
and /databases command to enumerate databases.
|
|
1145
|
+
"""
|
|
1146
|
+
registry_path = Path.home() / ".claudia" / "memory" / "registry.json"
|
|
1147
|
+
|
|
1148
|
+
try:
|
|
1149
|
+
if registry_path.exists():
|
|
1150
|
+
with open(registry_path) as f:
|
|
1151
|
+
registry = json.load(f)
|
|
1152
|
+
else:
|
|
1153
|
+
registry = {"databases": []}
|
|
1154
|
+
|
|
1155
|
+
# Find or create entry for this database path
|
|
1156
|
+
db_str = str(self.db_path)
|
|
1157
|
+
entry = next(
|
|
1158
|
+
(d for d in registry["databases"] if d["path"] == db_str), None
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
workspace = os.environ.get("CLAUDIA_WORKSPACE_PATH", "")
|
|
1162
|
+
name = self.db_path.stem # e.g., "6af67351bcfa" or "claudia"
|
|
1163
|
+
|
|
1164
|
+
if entry:
|
|
1165
|
+
entry["workspace"] = workspace or entry.get("workspace", "")
|
|
1166
|
+
entry["last_seen"] = datetime.now().isoformat()
|
|
1167
|
+
else:
|
|
1168
|
+
registry["databases"].append({
|
|
1169
|
+
"path": db_str,
|
|
1170
|
+
"workspace": workspace,
|
|
1171
|
+
"name": name,
|
|
1172
|
+
"registered_at": datetime.now().isoformat(),
|
|
1173
|
+
"last_seen": datetime.now().isoformat(),
|
|
1174
|
+
})
|
|
1175
|
+
|
|
1176
|
+
registry_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1177
|
+
with open(registry_path, "w") as f:
|
|
1178
|
+
json.dump(registry, f, indent=2)
|
|
1179
|
+
except Exception as e:
|
|
1180
|
+
logger.debug(f"Registry update failed (non-fatal): {e}")
|
|
1181
|
+
|
|
1136
1182
|
def execute(
|
|
1137
1183
|
self, sql: str, params: Tuple = (), fetch: bool = False
|
|
1138
1184
|
) -> Optional[List[sqlite3.Row]]:
|
|
@@ -1213,9 +1259,13 @@ class Database:
|
|
|
1213
1259
|
rows = self.query(table, columns, where, where_params, limit=1)
|
|
1214
1260
|
return rows[0] if rows else None
|
|
1215
1261
|
|
|
1216
|
-
def backup(self) -> Path:
|
|
1262
|
+
def backup(self, label: str = None) -> Path:
|
|
1217
1263
|
"""Create a backup of the database using SQLite's online backup API.
|
|
1218
1264
|
|
|
1265
|
+
Args:
|
|
1266
|
+
label: Optional label for categorized backups (e.g., "daily", "weekly",
|
|
1267
|
+
"pre-migration"). Labeled backups have independent retention counts.
|
|
1268
|
+
|
|
1219
1269
|
Returns:
|
|
1220
1270
|
Path to the created backup file
|
|
1221
1271
|
"""
|
|
@@ -1223,7 +1273,10 @@ class Database:
|
|
|
1223
1273
|
|
|
1224
1274
|
config = get_config()
|
|
1225
1275
|
timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
|
1226
|
-
|
|
1276
|
+
if label:
|
|
1277
|
+
backup_path = Path(f"{self.db_path}.backup-{label}-{timestamp}.db")
|
|
1278
|
+
else:
|
|
1279
|
+
backup_path = Path(f"{self.db_path}.backup-{timestamp}.db")
|
|
1227
1280
|
|
|
1228
1281
|
# Create backup using SQLite's built-in backup API
|
|
1229
1282
|
backup_conn = sqlite3.connect(str(backup_path))
|
|
@@ -1232,12 +1285,30 @@ class Database:
|
|
|
1232
1285
|
finally:
|
|
1233
1286
|
backup_conn.close()
|
|
1234
1287
|
|
|
1288
|
+
# Verify backup integrity
|
|
1289
|
+
try:
|
|
1290
|
+
verify_conn = sqlite3.connect(str(backup_path), timeout=5)
|
|
1291
|
+
result = verify_conn.execute("PRAGMA integrity_check").fetchone()
|
|
1292
|
+
verify_conn.close()
|
|
1293
|
+
if result and result[0] != "ok":
|
|
1294
|
+
logger.error(f"Backup verification FAILED: {result}")
|
|
1295
|
+
backup_path.unlink(missing_ok=True)
|
|
1296
|
+
raise RuntimeError(f"Backup integrity check failed: {result}")
|
|
1297
|
+
except sqlite3.Error as e:
|
|
1298
|
+
logger.warning(f"Backup verification could not run: {e}")
|
|
1299
|
+
|
|
1235
1300
|
logger.info(f"Database backed up to {backup_path}")
|
|
1236
1301
|
|
|
1237
|
-
# Rolling retention
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1302
|
+
# Rolling retention (per-label if labeled)
|
|
1303
|
+
if label:
|
|
1304
|
+
pattern = f"{self.db_path}.backup-{label}-*.db"
|
|
1305
|
+
retention = self._get_label_retention(label)
|
|
1306
|
+
else:
|
|
1307
|
+
pattern = f"{self.db_path}.backup-*.db"
|
|
1308
|
+
retention = config.backup_retention_count
|
|
1309
|
+
|
|
1310
|
+
backups = sorted(glob.glob(pattern), key=os.path.getmtime)
|
|
1311
|
+
while len(backups) > retention:
|
|
1241
1312
|
oldest = backups.pop(0)
|
|
1242
1313
|
try:
|
|
1243
1314
|
Path(oldest).unlink()
|
|
@@ -1247,6 +1318,15 @@ class Database:
|
|
|
1247
1318
|
|
|
1248
1319
|
return backup_path
|
|
1249
1320
|
|
|
1321
|
+
def _get_label_retention(self, label: str) -> int:
|
|
1322
|
+
"""Get retention count for a labeled backup category."""
|
|
1323
|
+
config = get_config()
|
|
1324
|
+
retention_map = {
|
|
1325
|
+
"daily": config.backup_daily_retention,
|
|
1326
|
+
"weekly": config.backup_weekly_retention,
|
|
1327
|
+
}
|
|
1328
|
+
return retention_map.get(label, config.backup_retention_count)
|
|
1329
|
+
|
|
1250
1330
|
def close(self) -> None:
|
|
1251
1331
|
"""Close the thread-local connection"""
|
|
1252
1332
|
if hasattr(self._local, "connection") and self._local.connection:
|