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.
@@ -68,10 +68,25 @@ def create(
68
68
  description: Optional[str] = typer.Option(
69
69
  None, "--description", "-d", help="Tenant description"
70
70
  ),
71
+ encrypt: bool = typer.Option(
72
+ False, "--encrypt", help="Create encrypted tenant database"
73
+ ),
74
+ key: Optional[str] = typer.Option(
75
+ None, "--key", help="Encryption key for encrypted tenant (required with --encrypt)"
76
+ ),
71
77
  ):
72
78
  """Create a new tenant."""
73
79
  name = validate_required_arg(name, "name", ctx)
74
80
 
81
+ # Validate encryption parameters
82
+ if encrypt and not key:
83
+ console.print("[red]❌ --key is required when using --encrypt[/red]")
84
+ raise typer.Exit(1)
85
+
86
+ if key and not encrypt:
87
+ console.print("[red]❌ --encrypt is required when using --key[/red]")
88
+ raise typer.Exit(1)
89
+
75
90
  # Validate tenant name
76
91
  try:
77
92
  validate_name(name, "tenant")
@@ -85,8 +100,13 @@ def create(
85
100
 
86
101
  try:
87
102
  tenant_mgr = TenantManager(config.project_dir, db_name, branch_name)
88
- tenant_mgr.create_tenant(name, description)
89
- console.print(f"[green]✅ Created tenant '{name}'[/green]")
103
+ tenant_mgr.create_tenant(name, description, encrypt=encrypt, encryption_key=key)
104
+
105
+ if encrypt:
106
+ console.print(f"[green]✅ Created encrypted tenant '{name}'[/green]")
107
+ console.print("[yellow]Note: Keep your encryption key secure - losing it means losing your data[/yellow]")
108
+ else:
109
+ console.print(f"[green]✅ Created tenant '{name}'[/green]")
90
110
  console.print("[yellow]Note: Tenant has same schema as main tenant[/yellow]")
91
111
 
92
112
  except ValueError as e:
@@ -235,3 +255,31 @@ def vacuum(
235
255
  except ValueError as e:
236
256
  console.print(f"[red]❌ {e}[/red]")
237
257
  raise typer.Exit(1)
258
+
259
+
260
+ @app.command(name="rotate-key")
261
+ def rotate_key(
262
+ tenant_name: str = typer.Argument(..., help="Name of the tenant to rotate encryption key for"),
263
+ ):
264
+ """Rotate encryption key for a tenant (requires plugged extension)."""
265
+ validate_required_arg(tenant_name, "tenant name")
266
+
267
+ config, config_data = get_config_with_data()
268
+ db_name = config_data.active_database
269
+ branch_name = config_data.active_branch
270
+
271
+ try:
272
+ tenant_mgr = TenantManager(config.project_dir, db_name, branch_name)
273
+
274
+ console.print(f"[yellow]🔐 Rotating encryption key for tenant '{tenant_name}'...[/yellow]")
275
+
276
+ new_key = tenant_mgr.rotate_tenant_key(tenant_name)
277
+
278
+ console.print("[green]✅ Encryption key rotated successfully[/green]")
279
+ console.print(f" Tenant: {tenant_name}")
280
+ console.print(f" New key generated (version incremented)")
281
+ console.print("[blue]ℹ️ Historical data remains accessible with previous key versions[/blue]")
282
+
283
+ except ValueError as e:
284
+ console.print(f"[red]❌ {e}[/red]")
285
+ raise typer.Exit(1)
cinchdb/config.py CHANGED
@@ -134,17 +134,8 @@ class Config:
134
134
  with open(self.config_path, "w") as f:
135
135
  toml.dump(config_dict, f)
136
136
 
137
- def init_project(self) -> ProjectConfig:
138
- """Initialize a new CinchDB project with default configuration.
139
-
140
- This method now delegates to the ProjectInitializer for the actual
141
- initialization logic.
142
- """
143
- from cinchdb.core.initializer import ProjectInitializer
144
-
145
- initializer = ProjectInitializer(self.project_dir)
146
- config = initializer.init_project()
137
+ @property
138
+ def base_dir(self) -> Path:
139
+ """Get the base project directory."""
140
+ return self.project_dir
147
141
 
148
- # Load the config into this instance
149
- self._config = config
150
- return config
@@ -1,8 +1,9 @@
1
1
  """SQLite connection management for CinchDB."""
2
2
 
3
+ import os
3
4
  import sqlite3
4
5
  from pathlib import Path
5
- from typing import Optional, Dict, List
6
+ from typing import Optional, Dict, List, Any
6
7
  from contextlib import contextmanager
7
8
  from datetime import datetime
8
9
 
@@ -28,13 +29,19 @@ sqlite3.register_converter("DATETIME", convert_datetime)
28
29
  class DatabaseConnection:
29
30
  """Manages a SQLite database connection with WAL mode."""
30
31
 
31
- def __init__(self, path: Path):
32
+ def __init__(self, path: Path, tenant_id: Optional[str] = None, encryption_manager=None, encryption_key: Optional[str] = None):
32
33
  """Initialize database connection.
33
34
 
34
35
  Args:
35
36
  path: Path to SQLite database file
37
+ tenant_id: Tenant ID for per-tenant encryption
38
+ encryption_manager: EncryptionManager instance for encrypted connections
39
+ encryption_key: Encryption key for encrypted databases
36
40
  """
37
41
  self.path = Path(path)
42
+ self.tenant_id = tenant_id
43
+ self.encryption_manager = encryption_manager
44
+ self.encryption_key = encryption_key
38
45
  self._conn: Optional[sqlite3.Connection] = None
39
46
  self._connect()
40
47
 
@@ -43,19 +50,45 @@ class DatabaseConnection:
43
50
  # Ensure directory exists
44
51
  self.path.parent.mkdir(parents=True, exist_ok=True)
45
52
 
46
- # Connect with row factory for dict-like access
47
- # detect_types=PARSE_DECLTYPES tells SQLite to use our registered converters
48
- self._conn = sqlite3.connect(
49
- str(self.path),
50
- detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES,
51
- )
53
+ # Use EncryptionManager if available
54
+ if self.encryption_manager:
55
+ self._conn = self.encryption_manager.get_connection(self.path, tenant_id=self.tenant_id)
56
+ elif self.encryption_key:
57
+ # Direct encryption key provided - use SQLCipher
58
+ self._conn = sqlite3.connect(
59
+ str(self.path),
60
+ detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES,
61
+ )
62
+
63
+ # Try to set encryption key (this will fail if SQLCipher is not available)
64
+ try:
65
+ self._conn.execute(f"PRAGMA key = '{self.encryption_key}'")
66
+ except sqlite3.OperationalError as e:
67
+ self._conn.close()
68
+ raise ValueError(
69
+ "SQLCipher is required for encryption but not available. "
70
+ "Please install pysqlcipher3 or sqlite3 with SQLCipher support."
71
+ ) from e
72
+
73
+ # Configure WAL mode and settings
74
+ self._conn.execute("PRAGMA journal_mode = WAL")
75
+ self._conn.execute("PRAGMA synchronous = NORMAL")
76
+ self._conn.execute("PRAGMA wal_autocheckpoint = 0")
77
+ else:
78
+ # Fallback to standard SQLite connection
79
+ # Connect with row factory for dict-like access
80
+ # detect_types=PARSE_DECLTYPES tells SQLite to use our registered converters
81
+ self._conn = sqlite3.connect(
82
+ str(self.path),
83
+ detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES,
84
+ )
85
+
86
+ # Configure WAL mode and settings
87
+ self._conn.execute("PRAGMA journal_mode = WAL")
88
+ self._conn.execute("PRAGMA synchronous = NORMAL")
89
+ self._conn.execute("PRAGMA wal_autocheckpoint = 0")
52
90
 
53
- # Configure WAL mode and settings
54
- self._conn.execute("PRAGMA journal_mode = WAL")
55
- self._conn.execute("PRAGMA synchronous = NORMAL")
56
- self._conn.execute("PRAGMA wal_autocheckpoint = 0")
57
-
58
- # Set row factory and foreign keys
91
+ # Set row factory and foreign keys (both encrypted and unencrypted)
59
92
  self._conn.row_factory = sqlite3.Row
60
93
  self._conn.execute("PRAGMA foreign_keys = ON")
61
94
  self._conn.commit()
@@ -141,23 +174,28 @@ class ConnectionPool:
141
174
 
142
175
  def __init__(self):
143
176
  """Initialize connection pool."""
144
- self._connections: Dict[Path, DatabaseConnection] = {}
177
+ self._connections: Dict[Any, DatabaseConnection] = {}
145
178
 
146
- def get_connection(self, path: Path) -> DatabaseConnection:
179
+ def get_connection(self, path: Path, tenant_id: Optional[str] = None, encryption_manager=None) -> DatabaseConnection:
147
180
  """Get or create a connection for the given path.
148
181
 
149
182
  Args:
150
183
  path: Database file path
184
+ tenant_id: Tenant ID for per-tenant encryption
185
+ encryption_manager: EncryptionManager instance for encrypted connections
151
186
 
152
187
  Returns:
153
188
  Database connection
154
189
  """
155
190
  path = Path(path).resolve()
191
+
192
+ # Create a cache key that includes tenant_id to handle per-tenant connections
193
+ cache_key = (str(path), tenant_id) if tenant_id else str(path)
156
194
 
157
- if path not in self._connections:
158
- self._connections[path] = DatabaseConnection(path)
195
+ if cache_key not in self._connections:
196
+ self._connections[cache_key] = DatabaseConnection(path, tenant_id=tenant_id, encryption_manager=encryption_manager)
159
197
 
160
- return self._connections[path]
198
+ return self._connections[cache_key]
161
199
 
162
200
  def close_connection(self, path: Path) -> None:
163
201
  """Close and remove a specific connection.
cinchdb/core/database.py CHANGED
@@ -1,11 +1,13 @@
1
1
  """Unified database connection interface for CinchDB."""
2
2
 
3
+ import os
3
4
  from pathlib import Path
4
5
  from typing import List, Dict, Any, Optional, TYPE_CHECKING
5
6
 
6
7
  from cinchdb.models import Column, Change
7
8
  from cinchdb.core.path_utils import get_project_root
8
9
  from cinchdb.utils import validate_query_safe
10
+ from cinchdb.infrastructure.metadata_connection_pool import get_metadata_db
9
11
 
10
12
  if TYPE_CHECKING:
11
13
  from cinchdb.managers.table import TableManager
@@ -61,6 +63,8 @@ class CinchDB:
61
63
  project_dir: Optional[Path] = None,
62
64
  api_url: Optional[str] = None,
63
65
  api_key: Optional[str] = None,
66
+ encryption_manager=None,
67
+ encryption_key: Optional[str] = None,
64
68
  ):
65
69
  """Initialize CinchDB connection.
66
70
 
@@ -71,6 +75,8 @@ class CinchDB:
71
75
  project_dir: Path to project directory for local connection
72
76
  api_url: Base URL for remote API connection
73
77
  api_key: API key for remote connection
78
+ encryption_manager: EncryptionManager instance for encrypted connections
79
+ encryption_key: Encryption key for encrypted tenant databases
74
80
 
75
81
  Raises:
76
82
  ValueError: If neither local nor remote connection params provided
@@ -78,7 +84,9 @@ class CinchDB:
78
84
  self.database = database
79
85
  self.branch = branch
80
86
  self.tenant = tenant
81
-
87
+ self.encryption_manager = encryption_manager
88
+ self.encryption_key = encryption_key
89
+
82
90
  # Determine connection type
83
91
  if project_dir is not None:
84
92
  # Local connection
@@ -120,16 +128,29 @@ class CinchDB:
120
128
  return
121
129
 
122
130
  # Check if this is a lazy database using metadata DB
123
- from cinchdb.infrastructure.metadata_db import MetadataDB
124
-
125
- with MetadataDB(self.project_dir) as metadata_db:
126
- db_info = metadata_db.get_database(self.database)
131
+ metadata_db = get_metadata_db(self.project_dir)
132
+ db_info = metadata_db.get_database(self.database)
127
133
 
128
134
  if db_info and not db_info['materialized']:
129
135
  # Database exists in metadata but not materialized
130
136
  from cinchdb.core.initializer import ProjectInitializer
131
137
  initializer = ProjectInitializer(self.project_dir)
132
138
  initializer.materialize_database(self.database)
139
+
140
+ def get_connection(self, db_path, tenant_id: Optional[str] = None, encryption_manager=None, encryption_key: Optional[str] = None) -> "DatabaseConnection":
141
+ """Get a database connection.
142
+
143
+ Args:
144
+ db_path: Path to database file
145
+ tenant_id: Tenant ID for per-tenant encryption
146
+ encryption_manager: EncryptionManager instance for encrypted connections
147
+ encryption_key: Encryption key for encrypted databases
148
+
149
+ Returns:
150
+ DatabaseConnection instance
151
+ """
152
+ from cinchdb.core.connection import DatabaseConnection
153
+ return DatabaseConnection(db_path, tenant_id=tenant_id, encryption_manager=encryption_manager, encryption_key=encryption_key)
133
154
 
134
155
  @property
135
156
  def session(self):
@@ -220,7 +241,7 @@ class CinchDB:
220
241
  from cinchdb.managers.table import TableManager
221
242
 
222
243
  self._table_manager = TableManager(
223
- self.project_dir, self.database, self.branch, self.tenant
244
+ self.project_dir, self.database, self.branch, self.tenant, self.encryption_manager
224
245
  )
225
246
  return self._table_manager
226
247
 
@@ -235,7 +256,7 @@ class CinchDB:
235
256
  from cinchdb.managers.column import ColumnManager
236
257
 
237
258
  self._column_manager = ColumnManager(
238
- self.project_dir, self.database, self.branch, self.tenant
259
+ self.project_dir, self.database, self.branch, self.tenant, self.encryption_manager
239
260
  )
240
261
  return self._column_manager
241
262
 
@@ -278,7 +299,7 @@ class CinchDB:
278
299
  from cinchdb.managers.tenant import TenantManager
279
300
 
280
301
  self._tenant_manager = TenantManager(
281
- self.project_dir, self.database, self.branch
302
+ self.project_dir, self.database, self.branch, self.encryption_manager
282
303
  )
283
304
  return self._tenant_manager
284
305
 
@@ -293,7 +314,7 @@ class CinchDB:
293
314
  from cinchdb.managers.data import DataManager
294
315
 
295
316
  self._data_manager = DataManager(
296
- self.project_dir, self.database, self.branch, self.tenant
317
+ self.project_dir, self.database, self.branch, self.tenant, self.encryption_manager
297
318
  )
298
319
  return self._data_manager
299
320
 
@@ -370,9 +391,9 @@ class CinchDB:
370
391
  from cinchdb.managers.query import QueryManager
371
392
 
372
393
  self._query_manager = QueryManager(
373
- self.project_dir, self.database, self.branch, self.tenant
394
+ self.project_dir, self.database, self.branch, self.tenant, self.encryption_manager
374
395
  )
375
- return self._query_manager.execute(sql, params)
396
+ return self._query_manager.execute(sql, params, skip_validation)
376
397
  else:
377
398
  # Remote query
378
399
  data = {"sql": sql}
@@ -854,6 +875,7 @@ def connect(
854
875
  branch: str = "main",
855
876
  tenant: str = "main",
856
877
  project_dir: Optional[Path] = None,
878
+ encryption_key: Optional[str] = None,
857
879
  ) -> CinchDB:
858
880
  """Connect to a local CinchDB database.
859
881
 
@@ -862,6 +884,7 @@ def connect(
862
884
  branch: Branch name (default: main)
863
885
  tenant: Tenant name (default: main)
864
886
  project_dir: Path to project directory (optional, will search for .cinchdb)
887
+ encryption_key: Encryption key for encrypted tenant databases
865
888
 
866
889
  Returns:
867
890
  CinchDB connection instance
@@ -873,6 +896,9 @@ def connect(
873
896
  # Connect to specific branch
874
897
  db = connect("mydb", "feature-branch")
875
898
 
899
+ # Connect to encrypted tenant
900
+ db = connect("mydb", tenant="customer_a", encryption_key="my-secret-key")
901
+
876
902
  # Connect with explicit project directory
877
903
  db = connect("mydb", project_dir=Path("/path/to/project"))
878
904
  """
@@ -883,7 +909,8 @@ def connect(
883
909
  raise ValueError("No .cinchdb directory found. Run 'cinchdb init' first.")
884
910
 
885
911
  return CinchDB(
886
- database=database, branch=branch, tenant=tenant, project_dir=project_dir
912
+ database=database, branch=branch, tenant=tenant, project_dir=project_dir,
913
+ encryption_key=encryption_key
887
914
  )
888
915
 
889
916
 
@@ -1,6 +1,5 @@
1
1
  """Project initialization for CinchDB."""
2
2
 
3
- import hashlib
4
3
  import json
5
4
  import uuid
6
5
  from datetime import datetime, timezone
@@ -11,19 +10,12 @@ from cinchdb.core.connection import DatabaseConnection
11
10
  from cinchdb.config import ProjectConfig
12
11
  from cinchdb.infrastructure.metadata_db import MetadataDB
13
12
  from cinchdb.infrastructure.metadata_connection_pool import get_metadata_db
14
-
15
-
16
- def _calculate_shard(tenant_name: str) -> str:
17
- """Calculate the shard directory for a tenant using SHA256 hash.
18
-
19
- Args:
20
- tenant_name: Name of the tenant
21
-
22
- Returns:
23
- Two-character hex string (e.g., "a0", "ff")
24
- """
25
- hash_val = hashlib.sha256(tenant_name.encode('utf-8')).hexdigest()
26
- return hash_val[:2]
13
+ from cinchdb.core.path_utils import (
14
+ calculate_shard,
15
+ ensure_tenant_db_path,
16
+ get_context_root,
17
+ ensure_context_directory,
18
+ )
27
19
 
28
20
 
29
21
  class ProjectInitializer:
@@ -106,7 +98,7 @@ class ProjectInitializer:
106
98
 
107
99
  # Also create main tenant in metadata
108
100
  tenant_id = str(uuid.uuid4())
109
- main_shard = _calculate_shard("main")
101
+ main_shard = calculate_shard("main")
110
102
  self.metadata_db.create_tenant(
111
103
  tenant_id, branch_id, "main", main_shard,
112
104
  metadata={"created_at": datetime.now(timezone.utc).isoformat()}
@@ -115,7 +107,7 @@ class ProjectInitializer:
115
107
 
116
108
  # Create __empty__ tenant in metadata (for lazy tenant reads)
117
109
  empty_tenant_id = str(uuid.uuid4())
118
- empty_shard = _calculate_shard("__empty__")
110
+ empty_shard = calculate_shard("__empty__")
119
111
  self.metadata_db.create_tenant(
120
112
  empty_tenant_id, branch_id, "__empty__", empty_shard,
121
113
  metadata={
@@ -188,7 +180,7 @@ class ProjectInitializer:
188
180
 
189
181
  # Create main tenant entry in metadata (will be materialized if database is not lazy)
190
182
  main_tenant_id = str(uuid.uuid4())
191
- main_shard = _calculate_shard("main")
183
+ main_shard = calculate_shard("main")
192
184
  self.metadata_db.create_tenant(
193
185
  main_tenant_id, branch_id, "main", main_shard,
194
186
  metadata={"description": "Default tenant", "created_at": datetime.now(timezone.utc).isoformat()}
@@ -197,7 +189,7 @@ class ProjectInitializer:
197
189
  # Create __empty__ tenant entry in metadata (lazy)
198
190
  # This serves as a template for all lazy tenants in this branch
199
191
  empty_tenant_id = str(uuid.uuid4())
200
- empty_shard = _calculate_shard("__empty__")
192
+ empty_shard = calculate_shard("__empty__")
201
193
  self.metadata_db.create_tenant(
202
194
  empty_tenant_id, branch_id, "__empty__", empty_shard,
203
195
  metadata={"system": True, "description": "Template for lazy tenants"}
@@ -219,44 +211,37 @@ class ProjectInitializer:
219
211
  description: Optional[str] = None,
220
212
  create_tenant_files: bool = False,
221
213
  ) -> None:
222
- """Create the directory structure for a database.
214
+ """Create the directory structure for a database using tenant-first storage.
223
215
 
224
216
  Args:
225
217
  database_name: Name of the database
226
218
  branch_name: Name of the initial branch
227
219
  description: Optional description
220
+ create_tenant_files: Whether to create actual tenant files
228
221
  """
229
- # Create database branch path
230
- branch_path = (
231
- self.config_dir / "databases" / database_name / "branches" / branch_name
232
- )
233
- branch_path.mkdir(parents=True, exist_ok=True)
234
-
235
- # Create metadata file
222
+ # Use tenant-first structure: .cinchdb/{database}-{branch}/
223
+ context_root = ensure_context_directory(self.project_dir, database_name, branch_name)
224
+
225
+ # Create metadata file in context root
236
226
  metadata = {
237
227
  "created_at": datetime.now(timezone.utc).isoformat(),
238
- "name": branch_name,
228
+ "database": database_name,
229
+ "branch": branch_name,
239
230
  "parent": None,
240
231
  "description": description,
241
232
  }
242
-
243
- with open(branch_path / "metadata.json", "w") as f:
233
+
234
+ with open(context_root / "metadata.json", "w") as f:
244
235
  json.dump(metadata, f, indent=2)
245
-
236
+
246
237
  # Create empty changes file
247
- with open(branch_path / "changes.json", "w") as f:
238
+ with open(context_root / "changes.json", "w") as f:
248
239
  json.dump([], f, indent=2)
249
-
250
- # Create tenants directory
251
- tenant_dir = branch_path / "tenants"
252
- tenant_dir.mkdir(exist_ok=True)
253
-
240
+
254
241
  # Create main tenant database in sharded directory (only if requested)
255
242
  if create_tenant_files:
256
- main_shard = _calculate_shard("main")
257
- main_shard_dir = tenant_dir / main_shard
258
- main_shard_dir.mkdir(parents=True, exist_ok=True)
259
- self._init_tenant_database(main_shard_dir / "main.db")
243
+ main_db_path = ensure_tenant_db_path(self.project_dir, database_name, branch_name, "main")
244
+ self._init_tenant_database(main_db_path)
260
245
 
261
246
  def _init_tenant_database(self, db_path: Path) -> None:
262
247
  """Initialize a tenant database with proper PRAGMAs.
@@ -0,0 +1,43 @@
1
+ """Maintenance utilities for CinchDB operations."""
2
+
3
+ from pathlib import Path
4
+ from cinchdb.infrastructure.metadata_connection_pool import get_metadata_db
5
+
6
+
7
+ class MaintenanceError(Exception):
8
+ """Exception raised when operation blocked by maintenance mode."""
9
+ pass
10
+
11
+
12
+ def check_maintenance_mode(project_root: Path, database: str, branch: str = None) -> None:
13
+ """Check if database or branch is in maintenance mode and raise error if so.
14
+
15
+ Args:
16
+ project_root: Path to project root
17
+ database: Database name
18
+ branch: Branch name (optional)
19
+
20
+ Raises:
21
+ MaintenanceError: If database or branch is in maintenance mode
22
+ """
23
+ try:
24
+ metadata_db = get_metadata_db(project_root)
25
+
26
+ # Check database-level maintenance
27
+ if metadata_db.is_database_in_maintenance(database):
28
+ info = metadata_db.get_maintenance_info(database)
29
+ reason = info.get("reason", "Database maintenance in progress") if info else "Database maintenance in progress"
30
+ raise MaintenanceError(f"Database '{database}' is in maintenance mode: {reason}")
31
+
32
+ # Check branch-level maintenance if branch specified
33
+ if branch and metadata_db.is_branch_in_maintenance(database, branch):
34
+ info = metadata_db.get_maintenance_info(database, branch)
35
+ reason = info.get("reason", "Branch maintenance in progress") if info else "Branch maintenance in progress"
36
+ raise MaintenanceError(f"Branch '{database}/{branch}' is in maintenance mode: {reason}")
37
+
38
+ except MaintenanceError:
39
+ raise # Re-raise maintenance errors
40
+ except Exception:
41
+ # If we can't check maintenance status, allow the operation to proceed
42
+ # This prevents maintenance check failures from blocking normal operations
43
+ pass