cinchdb 0.1.15__py3-none-any.whl → 0.1.18__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.
@@ -1,7 +1,15 @@
1
1
  """Path utilities for CinchDB."""
2
2
 
3
+ import hashlib
3
4
  from pathlib import Path
4
- from typing import List
5
+ from typing import List, Dict, Tuple, Optional
6
+ from cinchdb.infrastructure.metadata_connection_pool import get_metadata_db
7
+ from cinchdb.utils.name_validator import validate_name
8
+
9
+ # Cache for path calculations to avoid repeated string operations
10
+ _path_cache: Dict[Tuple[str, str, str], Path] = {}
11
+ _shard_cache: Dict[str, str] = {}
12
+ _MAX_CACHE_SIZE = 10000 # Limit cache size to prevent unbounded growth
5
13
 
6
14
 
7
15
  def get_project_root(start_path: Path) -> Path:
@@ -40,7 +48,7 @@ def get_database_path(project_root: Path, database: str) -> Path:
40
48
 
41
49
 
42
50
  def get_branch_path(project_root: Path, database: str, branch: str) -> Path:
43
- """Get path to a branch directory.
51
+ """Get path to a branch directory (now uses context root).
44
52
 
45
53
  Args:
46
54
  project_root: Project root directory
@@ -48,15 +56,16 @@ def get_branch_path(project_root: Path, database: str, branch: str) -> Path:
48
56
  branch: Branch name
49
57
 
50
58
  Returns:
51
- Path to branch directory
59
+ Path to branch directory (context root in tenant-first structure)
52
60
  """
53
- return get_database_path(project_root, database) / "branches" / branch
61
+ # In tenant-first structure, branch path is the context root
62
+ return get_context_root(project_root, database, branch)
54
63
 
55
64
 
56
65
  def get_tenant_path(
57
66
  project_root: Path, database: str, branch: str, tenant: str
58
67
  ) -> Path:
59
- """Get path to tenant directory.
68
+ """Get path to tenant directory (deprecated, use get_context_root instead).
60
69
 
61
70
  Args:
62
71
  project_root: Project root directory
@@ -65,9 +74,9 @@ def get_tenant_path(
65
74
  tenant: Tenant name
66
75
 
67
76
  Returns:
68
- Path to tenant directory
77
+ Path to context root (no longer has /tenants subfolder)
69
78
  """
70
- return get_branch_path(project_root, database, branch) / "tenants"
79
+ return get_branch_path(project_root, database, branch)
71
80
 
72
81
 
73
82
  def get_tenant_db_path(
@@ -83,18 +92,20 @@ def get_tenant_db_path(
83
92
 
84
93
  Returns:
85
94
  Path to tenant database file in sharded directory structure
95
+
96
+ Raises:
97
+ InvalidNameError: If any name contains path traversal characters
86
98
  """
87
- import hashlib
88
-
89
- # Calculate shard using SHA256 hash (same as TenantManager)
90
- hash_val = hashlib.sha256(tenant.encode('utf-8')).hexdigest()
91
- shard = hash_val[:2]
99
+ # Validate all names to prevent path traversal
100
+ # Exception: __empty__ is a system tenant and exempt from validation
101
+ validate_name(database, "database")
102
+ validate_name(branch, "branch")
103
+ if tenant != "__empty__": # System tenant exempt from validation
104
+ validate_name(tenant, "tenant")
92
105
 
93
- # Build sharded path: /tenants/{shard}/{tenant}.db
94
- tenants_dir = get_tenant_path(project_root, database, branch, tenant)
95
- shard_dir = tenants_dir / shard
96
-
97
- return shard_dir / f"{tenant}.db"
106
+ context_root = get_context_root(project_root, database, branch)
107
+ shard = calculate_shard(tenant)
108
+ return context_root / shard / f"{tenant}.db"
98
109
 
99
110
 
100
111
  def ensure_directory(path: Path) -> None:
@@ -119,10 +130,9 @@ def list_databases(project_root: Path) -> List[str]:
119
130
  if not metadata_db_path.exists():
120
131
  return []
121
132
 
122
- from cinchdb.infrastructure.metadata_db import MetadataDB
123
- with MetadataDB(project_root) as metadata_db:
124
- db_records = metadata_db.list_databases()
125
- return sorted(record['name'] for record in db_records)
133
+ metadata_db = get_metadata_db(project_root)
134
+ db_records = metadata_db.list_databases()
135
+ return sorted(record['name'] for record in db_records)
126
136
 
127
137
 
128
138
  def list_branches(project_root: Path, database: str) -> List[str]:
@@ -139,13 +149,12 @@ def list_branches(project_root: Path, database: str) -> List[str]:
139
149
  if not metadata_db_path.exists():
140
150
  return []
141
151
 
142
- from cinchdb.infrastructure.metadata_db import MetadataDB
143
- with MetadataDB(project_root) as metadata_db:
144
- db_info = metadata_db.get_database(database)
145
- if not db_info:
146
- return []
147
- branch_records = metadata_db.list_branches(db_info['id'])
148
- return sorted(record['name'] for record in branch_records)
152
+ metadata_db = get_metadata_db(project_root)
153
+ db_info = metadata_db.get_database(database)
154
+ if not db_info:
155
+ return []
156
+ branch_records = metadata_db.list_branches(db_info['id'])
157
+ return sorted(record['name'] for record in branch_records)
149
158
 
150
159
 
151
160
  def list_tenants(project_root: Path, database: str, branch: str) -> List[str]:
@@ -163,13 +172,177 @@ def list_tenants(project_root: Path, database: str, branch: str) -> List[str]:
163
172
  if not metadata_db_path.exists():
164
173
  return []
165
174
 
166
- from cinchdb.infrastructure.metadata_db import MetadataDB
167
- with MetadataDB(project_root) as metadata_db:
168
- db_info = metadata_db.get_database(database)
169
- if not db_info:
170
- return []
171
- branch_info = metadata_db.get_branch(db_info['id'], branch)
172
- if not branch_info:
173
- return []
174
- tenant_records = metadata_db.list_tenants(branch_info['id'])
175
- return sorted(record['name'] for record in tenant_records)
175
+ metadata_db = get_metadata_db(project_root)
176
+ db_info = metadata_db.get_database(database)
177
+ if not db_info:
178
+ return []
179
+ branch_info = metadata_db.get_branch(db_info['id'], branch)
180
+ if not branch_info:
181
+ return []
182
+ tenant_records = metadata_db.list_tenants(branch_info['id'])
183
+ return sorted(record['name'] for record in tenant_records)
184
+
185
+
186
+ # New tenant-first path utilities
187
+
188
+ def get_context_root(project_root: Path, database: str, branch: str) -> Path:
189
+ """Get root directory for a database-branch context with caching.
190
+
191
+ This is the new tenant-first approach where each database-branch combination
192
+ gets its own isolated directory with tenants stored as a flat hierarchy.
193
+
194
+ Args:
195
+ project_root: Project root directory
196
+ database: Database name
197
+ branch: Branch name
198
+
199
+ Returns:
200
+ Path to context root directory (e.g., .cinchdb/prod-main/)
201
+ """
202
+ cache_key = (str(project_root), database, branch)
203
+
204
+ if cache_key not in _path_cache:
205
+ # Check cache size and clear if needed
206
+ if len(_path_cache) >= _MAX_CACHE_SIZE:
207
+ _path_cache.clear()
208
+
209
+ _path_cache[cache_key] = project_root / ".cinchdb" / f"{database}-{branch}"
210
+
211
+ return _path_cache[cache_key]
212
+
213
+
214
+ def calculate_shard(tenant_name: str) -> str:
215
+ """Calculate the shard directory for a tenant using SHA256 hash with caching.
216
+
217
+ Args:
218
+ tenant_name: Name of the tenant
219
+
220
+ Returns:
221
+ Two-character hex string (e.g., "a0", "ff")
222
+ """
223
+ if tenant_name not in _shard_cache:
224
+ # Check cache size and clear if needed
225
+ if len(_shard_cache) >= _MAX_CACHE_SIZE:
226
+ _shard_cache.clear()
227
+
228
+ hash_val = hashlib.sha256(tenant_name.encode('utf-8')).hexdigest()
229
+ _shard_cache[tenant_name] = hash_val[:2]
230
+
231
+ return _shard_cache[tenant_name]
232
+
233
+
234
+ def get_tenant_db_path_in_context(context_root: Path, tenant: str) -> Path:
235
+ """Get tenant DB path within a context root.
236
+
237
+ Uses the tenant-first storage approach where tenants are stored as:
238
+ {context_root}/{shard}/{tenant}.db
239
+
240
+ Args:
241
+ context_root: Context root directory (from get_context_root)
242
+ tenant: Tenant name
243
+
244
+ Returns:
245
+ Path to tenant database file within the context
246
+ """
247
+ shard = calculate_shard(tenant)
248
+ return context_root / shard / f"{tenant}.db"
249
+
250
+
251
+
252
+
253
+ def ensure_context_directory(project_root: Path, database: str, branch: str) -> Path:
254
+ """Ensure context root directory exists and return it.
255
+
256
+ Args:
257
+ project_root: Project root directory
258
+ database: Database name
259
+ branch: Branch name
260
+
261
+ Returns:
262
+ Path to context root directory (created if necessary)
263
+ """
264
+ context_root = get_context_root(project_root, database, branch)
265
+ context_root.mkdir(parents=True, exist_ok=True)
266
+
267
+ return context_root
268
+
269
+
270
+ def ensure_tenant_db_path(project_root: Path, database: str, branch: str, tenant: str) -> Path:
271
+ """Ensure tenant database path exists (creates shard directory if needed).
272
+
273
+ This is the ONLY place where tenant shard directories should be created,
274
+ ensuring we have a single source of truth for the directory structure.
275
+
276
+ Args:
277
+ project_root: Project root directory
278
+ database: Database name
279
+ branch: Branch name
280
+ tenant: Tenant name
281
+
282
+ Returns:
283
+ Path to tenant database file (directory created if necessary)
284
+
285
+ Raises:
286
+ InvalidNameError: If any name contains path traversal characters
287
+ """
288
+ # Validation happens inside get_tenant_db_path
289
+ db_path = get_tenant_db_path(project_root, database, branch, tenant)
290
+
291
+ # Ensure the shard directory exists
292
+ db_path.parent.mkdir(parents=True, exist_ok=True)
293
+
294
+ return db_path
295
+
296
+
297
+ def invalidate_cache(database: Optional[str] = None,
298
+ branch: Optional[str] = None,
299
+ tenant: Optional[str] = None) -> None:
300
+ """Invalidate cache entries (write-through on delete operations).
301
+
302
+ This function is called when databases, branches, or tenants are deleted
303
+ to ensure cached paths don't point to non-existent resources.
304
+
305
+ Args:
306
+ database: Database name to invalidate (invalidates all branches/tenants if provided)
307
+ branch: Branch name to invalidate (requires database)
308
+ tenant: Tenant name to invalidate (only invalidates shard cache)
309
+ """
310
+ global _path_cache, _shard_cache
311
+
312
+ if database:
313
+ # Remove all cache entries for this database
314
+ keys_to_remove = []
315
+ for key in _path_cache:
316
+ # key is (project_root, database, branch)
317
+ if key[1] == database:
318
+ if branch is None or key[2] == branch:
319
+ keys_to_remove.append(key)
320
+
321
+ for key in keys_to_remove:
322
+ del _path_cache[key]
323
+
324
+ if tenant and tenant in _shard_cache:
325
+ del _shard_cache[tenant]
326
+
327
+
328
+ def clear_all_caches() -> None:
329
+ """Clear all path and shard caches.
330
+
331
+ Useful for testing or when major structural changes occur.
332
+ """
333
+ global _path_cache, _shard_cache
334
+ _path_cache.clear()
335
+ _shard_cache.clear()
336
+
337
+
338
+ def get_cache_stats() -> Dict[str, int]:
339
+ """Get statistics about cache usage.
340
+
341
+ Returns:
342
+ Dictionary with cache statistics
343
+ """
344
+ return {
345
+ "path_cache_size": len(_path_cache),
346
+ "shard_cache_size": len(_shard_cache),
347
+ "max_cache_size": _MAX_CACHE_SIZE
348
+ }
@@ -0,0 +1,216 @@
1
+ """Tenant activation and caching.
2
+
3
+ This module handles local caching of tenant databases with
4
+ LRU eviction and TTL management. WAL streaming is handled
5
+ at the api-server layer, not in the core library.
6
+ """
7
+
8
+ import os
9
+ import sqlite3
10
+ import hashlib
11
+ from pathlib import Path
12
+ from typing import Optional, Dict, Any
13
+ from datetime import datetime, timedelta
14
+
15
+ from cinchdb.core.connection import DatabaseConnection
16
+
17
+
18
+ class TenantCache:
19
+ """Manages local cache of activated tenant databases.
20
+
21
+ This implements an LRU cache with TTL for tenant SQLite files.
22
+ Tenants are activated on-demand from WAL replay and cached locally.
23
+ """
24
+
25
+ def __init__(self, cache_dir: Optional[Path] = None, max_size_gb: float = 10.0):
26
+ """Initialize tenant cache.
27
+
28
+ Args:
29
+ cache_dir: Directory for cached tenant databases
30
+ max_size_gb: Maximum cache size in GB before eviction
31
+ """
32
+ self.cache_dir = cache_dir or Path("/var/cache/cinchdb/tenants")
33
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
34
+ self.max_size_bytes = int(max_size_gb * 1024 * 1024 * 1024)
35
+
36
+ # Track cached tenants: tenant_key -> (path, version, last_access)
37
+ self.cache_index: Dict[str, Dict[str, Any]] = {}
38
+
39
+ def _get_cache_path(self, database: str, branch: str, tenant: str) -> Path:
40
+ """Get cache path for a tenant database.
41
+
42
+ Uses sharding to avoid too many files in one directory.
43
+ """
44
+ # Create tenant key
45
+ tenant_key = f"{database}_{branch}_{tenant}"
46
+
47
+ # Calculate shard (2-level sharding like production)
48
+ hash_val = hashlib.sha256(tenant_key.encode()).hexdigest()
49
+ shard1 = hash_val[:2]
50
+ shard2 = hash_val[2:4]
51
+
52
+ # Build path
53
+ cache_path = self.cache_dir / database / branch / shard1 / shard2
54
+ cache_path.mkdir(parents=True, exist_ok=True)
55
+
56
+ return cache_path / f"{tenant}.db"
57
+
58
+ def get_tenant_connection(
59
+ self,
60
+ database: str,
61
+ branch: str,
62
+ tenant: str,
63
+ wal_capture=None
64
+ ) -> DatabaseConnection:
65
+ """Get a database connection for a tenant.
66
+
67
+ If the tenant is not cached, activates it from WAL replay.
68
+
69
+ Args:
70
+ database: Database name
71
+ branch: Branch name
72
+ tenant: Tenant name
73
+ wal_capture: Optional WAL capture for streaming
74
+
75
+ Returns:
76
+ DatabaseConnection to the tenant database
77
+ """
78
+ tenant_key = f"{database}_{branch}_{tenant}"
79
+ cache_path = self._get_cache_path(database, branch, tenant)
80
+
81
+ # Check if already cached
82
+ if cache_path.exists():
83
+ # Update last access time
84
+ if tenant_key in self.cache_index:
85
+ self.cache_index[tenant_key]["last_access"] = datetime.now()
86
+
87
+ return DatabaseConnection(cache_path, wal_capture=wal_capture)
88
+
89
+ # Not cached - create empty database
90
+ # WAL streaming/activation happens at api-server layer if enabled
91
+ return self._create_empty_database(cache_path, wal_capture)
92
+
93
+ def _create_empty_database(
94
+ self,
95
+ cache_path: Path,
96
+ wal_capture=None
97
+ ) -> DatabaseConnection:
98
+ """Create an empty database (for lazy tenants or non-streaming mode).
99
+
100
+ Args:
101
+ cache_path: Path where database should be created
102
+ wal_capture: Optional WAL capture
103
+
104
+ Returns:
105
+ DatabaseConnection to the new database
106
+ """
107
+ # Create empty SQLite database
108
+ conn = sqlite3.connect(str(cache_path))
109
+
110
+ # Set up WAL mode
111
+ conn.execute("PRAGMA journal_mode = WAL")
112
+ conn.execute("PRAGMA synchronous = NORMAL")
113
+ conn.commit()
114
+ conn.close()
115
+
116
+ return DatabaseConnection(cache_path, wal_capture=wal_capture)
117
+
118
+
119
+ def _evict_if_needed(self):
120
+ """Evict least recently used tenants if cache is too large."""
121
+ # Calculate total cache size
122
+ total_size = sum(
123
+ info["size_bytes"] for info in self.cache_index.values()
124
+ )
125
+
126
+ if total_size <= self.max_size_bytes:
127
+ return # Cache is within limits
128
+
129
+ # Sort by last access time (LRU)
130
+ sorted_tenants = sorted(
131
+ self.cache_index.items(),
132
+ key=lambda x: x[1]["last_access"]
133
+ )
134
+
135
+ # Evict until under limit
136
+ for tenant_key, info in sorted_tenants:
137
+ if total_size <= self.max_size_bytes:
138
+ break
139
+
140
+ # Delete cached database
141
+ cache_path = info["path"]
142
+ if cache_path.exists():
143
+ # Delete SQLite files (main, WAL, SHM)
144
+ for suffix in ["", "-wal", "-shm"]:
145
+ file_path = Path(str(cache_path) + suffix)
146
+ if file_path.exists():
147
+ file_path.unlink()
148
+
149
+ print(f"Evicted cached tenant: {tenant_key}")
150
+
151
+ # Remove from index
152
+ total_size -= info["size_bytes"]
153
+ del self.cache_index[tenant_key]
154
+
155
+ def invalidate_tenant(self, database: str, branch: str, tenant: str):
156
+ """Invalidate a cached tenant (force re-activation on next access).
157
+
158
+ Args:
159
+ database: Database name
160
+ branch: Branch name
161
+ tenant: Tenant name
162
+ """
163
+ tenant_key = f"{database}_{branch}_{tenant}"
164
+
165
+ if tenant_key in self.cache_index:
166
+ info = self.cache_index[tenant_key]
167
+ cache_path = info["path"]
168
+
169
+ # Delete cached files
170
+ if cache_path.exists():
171
+ for suffix in ["", "-wal", "-shm"]:
172
+ file_path = Path(str(cache_path) + suffix)
173
+ if file_path.exists():
174
+ file_path.unlink()
175
+
176
+ # Remove from index
177
+ del self.cache_index[tenant_key]
178
+ print(f"Invalidated cached tenant: {tenant_key}")
179
+
180
+ def get_cache_stats(self) -> Dict[str, Any]:
181
+ """Get cache statistics.
182
+
183
+ Returns:
184
+ Dictionary with cache stats
185
+ """
186
+ total_size = sum(
187
+ info["size_bytes"] for info in self.cache_index.values()
188
+ )
189
+
190
+ return {
191
+ "cached_tenants": len(self.cache_index),
192
+ "total_size_bytes": total_size,
193
+ "total_size_mb": round(total_size / (1024 * 1024), 2),
194
+ "max_size_gb": self.max_size_bytes / (1024 * 1024 * 1024),
195
+ "cache_dir": str(self.cache_dir)
196
+ }
197
+
198
+
199
+ # Global cache instance (singleton)
200
+ _tenant_cache: Optional[TenantCache] = None
201
+
202
+
203
+ def get_tenant_cache() -> TenantCache:
204
+ """Get the global tenant cache instance.
205
+
206
+ Returns:
207
+ TenantCache singleton
208
+ """
209
+ global _tenant_cache
210
+
211
+ if _tenant_cache is None:
212
+ cache_dir = os.getenv("TENANT_CACHE_DIR", "/var/cache/cinchdb")
213
+ cache_size_gb = float(os.getenv("TENANT_CACHE_SIZE_GB", "10.0"))
214
+ _tenant_cache = TenantCache(Path(cache_dir), cache_size_gb)
215
+
216
+ return _tenant_cache
@@ -37,6 +37,7 @@ class MetadataDB:
37
37
  self.conn.execute("PRAGMA foreign_keys = ON")
38
38
  self.conn.execute("PRAGMA journal_mode = WAL") # Better concurrency
39
39
 
40
+
40
41
  def _create_tables(self):
41
42
  """Create the metadata tables if they don't exist."""
42
43
  with self.conn:
@@ -47,6 +48,9 @@ class MetadataDB:
47
48
  name TEXT NOT NULL UNIQUE,
48
49
  description TEXT,
49
50
  materialized BOOLEAN DEFAULT FALSE,
51
+ maintenance_mode BOOLEAN DEFAULT FALSE,
52
+ maintenance_reason TEXT,
53
+ maintenance_started_at TIMESTAMP,
50
54
  metadata JSON,
51
55
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
52
56
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
@@ -62,6 +66,9 @@ class MetadataDB:
62
66
  parent_branch TEXT,
63
67
  schema_version TEXT,
64
68
  materialized BOOLEAN DEFAULT FALSE,
69
+ maintenance_mode BOOLEAN DEFAULT FALSE,
70
+ maintenance_reason TEXT,
71
+ maintenance_started_at TIMESTAMP,
65
72
  metadata JSON,
66
73
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
67
74
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
@@ -116,9 +123,7 @@ class MetadataDB:
116
123
  CREATE INDEX IF NOT EXISTS idx_tenants_shard
117
124
  ON tenants(shard)
118
125
  """)
119
-
120
- # Add cdc_enabled field to branches table for plugin use
121
- # Note: This field is managed by plugins (like bdhcnic), not core CinchDB
126
+
122
127
  try:
123
128
  self.conn.execute("""
124
129
  ALTER TABLE branches ADD COLUMN cdc_enabled BOOLEAN DEFAULT FALSE
@@ -376,6 +381,94 @@ class MetadataDB:
376
381
 
377
382
  return len(tenants)
378
383
 
384
+ # Maintenance Mode Methods
385
+
386
+ def set_database_maintenance(self, database_name: str, enabled: bool, reason: Optional[str] = None) -> None:
387
+ """Set maintenance mode for a database."""
388
+ with self.conn:
389
+ if enabled:
390
+ self.conn.execute("""
391
+ UPDATE databases
392
+ SET maintenance_mode = TRUE,
393
+ maintenance_reason = ?,
394
+ maintenance_started_at = CURRENT_TIMESTAMP
395
+ WHERE name = ?
396
+ """, (reason, database_name))
397
+ else:
398
+ self.conn.execute("""
399
+ UPDATE databases
400
+ SET maintenance_mode = FALSE,
401
+ maintenance_reason = NULL,
402
+ maintenance_started_at = NULL
403
+ WHERE name = ?
404
+ """, (database_name,))
405
+
406
+ def set_branch_maintenance(self, database_name: str, branch_name: str, enabled: bool, reason: Optional[str] = None) -> None:
407
+ """Set maintenance mode for a branch."""
408
+ with self.conn:
409
+ if enabled:
410
+ self.conn.execute("""
411
+ UPDATE branches
412
+ SET maintenance_mode = TRUE,
413
+ maintenance_reason = ?,
414
+ maintenance_started_at = CURRENT_TIMESTAMP
415
+ WHERE database_id = (SELECT id FROM databases WHERE name = ?)
416
+ AND name = ?
417
+ """, (reason, database_name, branch_name))
418
+ else:
419
+ self.conn.execute("""
420
+ UPDATE branches
421
+ SET maintenance_mode = FALSE,
422
+ maintenance_reason = NULL,
423
+ maintenance_started_at = NULL
424
+ WHERE database_id = (SELECT id FROM databases WHERE name = ?)
425
+ AND name = ?
426
+ """, (database_name, branch_name))
427
+
428
+ def is_database_in_maintenance(self, database_name: str) -> bool:
429
+ """Check if database is in maintenance mode."""
430
+ cursor = self.conn.execute("""
431
+ SELECT maintenance_mode FROM databases WHERE name = ?
432
+ """, (database_name,))
433
+ row = cursor.fetchone()
434
+ return bool(row and row['maintenance_mode'])
435
+
436
+ def is_branch_in_maintenance(self, database_name: str, branch_name: str) -> bool:
437
+ """Check if branch is in maintenance mode."""
438
+ cursor = self.conn.execute("""
439
+ SELECT maintenance_mode FROM branches
440
+ WHERE database_id = (SELECT id FROM databases WHERE name = ?)
441
+ AND name = ?
442
+ """, (database_name, branch_name))
443
+ row = cursor.fetchone()
444
+ return bool(row and row['maintenance_mode'])
445
+
446
+ def get_maintenance_info(self, database_name: str, branch_name: Optional[str] = None) -> Optional[Dict[str, Any]]:
447
+ """Get maintenance mode information."""
448
+ if branch_name:
449
+ # Check branch maintenance
450
+ cursor = self.conn.execute("""
451
+ SELECT maintenance_mode, maintenance_reason, maintenance_started_at
452
+ FROM branches
453
+ WHERE database_id = (SELECT id FROM databases WHERE name = ?)
454
+ AND name = ?
455
+ """, (database_name, branch_name))
456
+ else:
457
+ # Check database maintenance
458
+ cursor = self.conn.execute("""
459
+ SELECT maintenance_mode, maintenance_reason, maintenance_started_at
460
+ FROM databases WHERE name = ?
461
+ """, (database_name,))
462
+
463
+ row = cursor.fetchone()
464
+ if row and row['maintenance_mode']:
465
+ return {
466
+ 'enabled': True,
467
+ 'reason': row['maintenance_reason'],
468
+ 'started_at': row['maintenance_started_at']
469
+ }
470
+ return None
471
+
379
472
  def close(self):
380
473
  """Close the database connection."""
381
474
  if self.conn: