cinchdb 0.1.17__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)
@@ -29,13 +29,19 @@ sqlite3.register_converter("DATETIME", convert_datetime)
29
29
  class DatabaseConnection:
30
30
  """Manages a SQLite database connection with WAL mode."""
31
31
 
32
- def __init__(self, path: Path):
32
+ def __init__(self, path: Path, tenant_id: Optional[str] = None, encryption_manager=None, encryption_key: Optional[str] = None):
33
33
  """Initialize database connection.
34
34
 
35
35
  Args:
36
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
37
40
  """
38
41
  self.path = Path(path)
42
+ self.tenant_id = tenant_id
43
+ self.encryption_manager = encryption_manager
44
+ self.encryption_key = encryption_key
39
45
  self._conn: Optional[sqlite3.Connection] = None
40
46
  self._connect()
41
47
 
@@ -44,19 +50,45 @@ class DatabaseConnection:
44
50
  # Ensure directory exists
45
51
  self.path.parent.mkdir(parents=True, exist_ok=True)
46
52
 
47
- # Connect with row factory for dict-like access
48
- # detect_types=PARSE_DECLTYPES tells SQLite to use our registered converters
49
- self._conn = sqlite3.connect(
50
- str(self.path),
51
- detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES,
52
- )
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")
53
90
 
54
- # Configure WAL mode and settings
55
- self._conn.execute("PRAGMA journal_mode = WAL")
56
- self._conn.execute("PRAGMA synchronous = NORMAL")
57
- self._conn.execute("PRAGMA wal_autocheckpoint = 0")
58
-
59
- # Set row factory and foreign keys
91
+ # Set row factory and foreign keys (both encrypted and unencrypted)
60
92
  self._conn.row_factory = sqlite3.Row
61
93
  self._conn.execute("PRAGMA foreign_keys = ON")
62
94
  self._conn.commit()
@@ -142,23 +174,28 @@ class ConnectionPool:
142
174
 
143
175
  def __init__(self):
144
176
  """Initialize connection pool."""
145
- self._connections: Dict[Path, DatabaseConnection] = {}
177
+ self._connections: Dict[Any, DatabaseConnection] = {}
146
178
 
147
- def get_connection(self, path: Path) -> DatabaseConnection:
179
+ def get_connection(self, path: Path, tenant_id: Optional[str] = None, encryption_manager=None) -> DatabaseConnection:
148
180
  """Get or create a connection for the given path.
149
181
 
150
182
  Args:
151
183
  path: Database file path
184
+ tenant_id: Tenant ID for per-tenant encryption
185
+ encryption_manager: EncryptionManager instance for encrypted connections
152
186
 
153
187
  Returns:
154
188
  Database connection
155
189
  """
156
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)
157
194
 
158
- if path not in self._connections:
159
- 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)
160
197
 
161
- return self._connections[path]
198
+ return self._connections[cache_key]
162
199
 
163
200
  def close_connection(self, path: Path) -> None:
164
201
  """Close and remove a specific connection.
cinchdb/core/database.py CHANGED
@@ -63,6 +63,8 @@ class CinchDB:
63
63
  project_dir: Optional[Path] = None,
64
64
  api_url: Optional[str] = None,
65
65
  api_key: Optional[str] = None,
66
+ encryption_manager=None,
67
+ encryption_key: Optional[str] = None,
66
68
  ):
67
69
  """Initialize CinchDB connection.
68
70
 
@@ -73,6 +75,8 @@ class CinchDB:
73
75
  project_dir: Path to project directory for local connection
74
76
  api_url: Base URL for remote API connection
75
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
76
80
 
77
81
  Raises:
78
82
  ValueError: If neither local nor remote connection params provided
@@ -80,6 +84,8 @@ class CinchDB:
80
84
  self.database = database
81
85
  self.branch = branch
82
86
  self.tenant = tenant
87
+ self.encryption_manager = encryption_manager
88
+ self.encryption_key = encryption_key
83
89
 
84
90
  # Determine connection type
85
91
  if project_dir is not None:
@@ -131,17 +137,20 @@ class CinchDB:
131
137
  initializer = ProjectInitializer(self.project_dir)
132
138
  initializer.materialize_database(self.database)
133
139
 
134
- def get_connection(self, db_path) -> "DatabaseConnection":
140
+ def get_connection(self, db_path, tenant_id: Optional[str] = None, encryption_manager=None, encryption_key: Optional[str] = None) -> "DatabaseConnection":
135
141
  """Get a database connection.
136
142
 
137
143
  Args:
138
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
139
148
 
140
149
  Returns:
141
150
  DatabaseConnection instance
142
151
  """
143
152
  from cinchdb.core.connection import DatabaseConnection
144
- return DatabaseConnection(db_path)
153
+ return DatabaseConnection(db_path, tenant_id=tenant_id, encryption_manager=encryption_manager, encryption_key=encryption_key)
145
154
 
146
155
  @property
147
156
  def session(self):
@@ -232,7 +241,7 @@ class CinchDB:
232
241
  from cinchdb.managers.table import TableManager
233
242
 
234
243
  self._table_manager = TableManager(
235
- self.project_dir, self.database, self.branch, self.tenant
244
+ self.project_dir, self.database, self.branch, self.tenant, self.encryption_manager
236
245
  )
237
246
  return self._table_manager
238
247
 
@@ -247,7 +256,7 @@ class CinchDB:
247
256
  from cinchdb.managers.column import ColumnManager
248
257
 
249
258
  self._column_manager = ColumnManager(
250
- self.project_dir, self.database, self.branch, self.tenant
259
+ self.project_dir, self.database, self.branch, self.tenant, self.encryption_manager
251
260
  )
252
261
  return self._column_manager
253
262
 
@@ -290,7 +299,7 @@ class CinchDB:
290
299
  from cinchdb.managers.tenant import TenantManager
291
300
 
292
301
  self._tenant_manager = TenantManager(
293
- self.project_dir, self.database, self.branch
302
+ self.project_dir, self.database, self.branch, self.encryption_manager
294
303
  )
295
304
  return self._tenant_manager
296
305
 
@@ -305,7 +314,7 @@ class CinchDB:
305
314
  from cinchdb.managers.data import DataManager
306
315
 
307
316
  self._data_manager = DataManager(
308
- self.project_dir, self.database, self.branch, self.tenant
317
+ self.project_dir, self.database, self.branch, self.tenant, self.encryption_manager
309
318
  )
310
319
  return self._data_manager
311
320
 
@@ -382,9 +391,9 @@ class CinchDB:
382
391
  from cinchdb.managers.query import QueryManager
383
392
 
384
393
  self._query_manager = QueryManager(
385
- self.project_dir, self.database, self.branch, self.tenant
394
+ self.project_dir, self.database, self.branch, self.tenant, self.encryption_manager
386
395
  )
387
- return self._query_manager.execute(sql, params)
396
+ return self._query_manager.execute(sql, params, skip_validation)
388
397
  else:
389
398
  # Remote query
390
399
  data = {"sql": sql}
@@ -866,6 +875,7 @@ def connect(
866
875
  branch: str = "main",
867
876
  tenant: str = "main",
868
877
  project_dir: Optional[Path] = None,
878
+ encryption_key: Optional[str] = None,
869
879
  ) -> CinchDB:
870
880
  """Connect to a local CinchDB database.
871
881
 
@@ -874,6 +884,7 @@ def connect(
874
884
  branch: Branch name (default: main)
875
885
  tenant: Tenant name (default: main)
876
886
  project_dir: Path to project directory (optional, will search for .cinchdb)
887
+ encryption_key: Encryption key for encrypted tenant databases
877
888
 
878
889
  Returns:
879
890
  CinchDB connection instance
@@ -885,6 +896,9 @@ def connect(
885
896
  # Connect to specific branch
886
897
  db = connect("mydb", "feature-branch")
887
898
 
899
+ # Connect to encrypted tenant
900
+ db = connect("mydb", tenant="customer_a", encryption_key="my-secret-key")
901
+
888
902
  # Connect with explicit project directory
889
903
  db = connect("mydb", project_dir=Path("/path/to/project"))
890
904
  """
@@ -895,7 +909,8 @@ def connect(
895
909
  raise ValueError("No .cinchdb directory found. Run 'cinchdb init' first.")
896
910
 
897
911
  return CinchDB(
898
- 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
899
914
  )
900
915
 
901
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.