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.
- cinchdb/cli/commands/tenant.py +50 -2
- cinchdb/config.py +4 -13
- cinchdb/core/connection.py +57 -19
- cinchdb/core/database.py +39 -12
- cinchdb/core/initializer.py +25 -40
- cinchdb/core/maintenance_utils.py +43 -0
- cinchdb/core/path_utils.py +211 -38
- cinchdb/core/tenant_activation.py +216 -0
- cinchdb/infrastructure/metadata_db.py +96 -3
- cinchdb/managers/change_applier.py +22 -23
- cinchdb/managers/codegen.py +372 -15
- cinchdb/managers/column.py +8 -5
- cinchdb/managers/data.py +17 -14
- cinchdb/managers/query.py +8 -5
- cinchdb/managers/table.py +9 -6
- cinchdb/managers/tenant.py +228 -72
- cinchdb/managers/view.py +1 -1
- cinchdb/plugins/__init__.py +7 -8
- cinchdb/plugins/base.py +55 -74
- cinchdb/plugins/decorators.py +36 -32
- cinchdb/plugins/manager.py +103 -71
- cinchdb/utils/name_validator.py +22 -12
- {cinchdb-0.1.15.dist-info → cinchdb-0.1.18.dist-info}/METADATA +39 -1
- {cinchdb-0.1.15.dist-info → cinchdb-0.1.18.dist-info}/RECORD +27 -26
- cinchdb/core/maintenance.py +0 -73
- {cinchdb-0.1.15.dist-info → cinchdb-0.1.18.dist-info}/WHEEL +0 -0
- {cinchdb-0.1.15.dist-info → cinchdb-0.1.18.dist-info}/entry_points.txt +0 -0
- {cinchdb-0.1.15.dist-info → cinchdb-0.1.18.dist-info}/licenses/LICENSE +0 -0
cinchdb/cli/commands/tenant.py
CHANGED
@@ -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
|
-
|
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
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
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
|
cinchdb/core/connection.py
CHANGED
@@ -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
|
-
#
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
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
|
-
#
|
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[
|
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
|
158
|
-
self._connections[
|
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[
|
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
|
-
|
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
|
|
cinchdb/core/initializer.py
CHANGED
@@ -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
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
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 =
|
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 =
|
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 =
|
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 =
|
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
|
-
#
|
230
|
-
|
231
|
-
|
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
|
-
"
|
228
|
+
"database": database_name,
|
229
|
+
"branch": branch_name,
|
239
230
|
"parent": None,
|
240
231
|
"description": description,
|
241
232
|
}
|
242
|
-
|
243
|
-
with open(
|
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(
|
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
|
-
|
257
|
-
|
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
|