cinchdb 0.1.17__tar.gz → 0.1.18__tar.gz
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-0.1.17 → cinchdb-0.1.18}/PKG-INFO +35 -1
- {cinchdb-0.1.17 → cinchdb-0.1.18}/README.md +33 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/pyproject.toml +9 -1
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/cli/commands/tenant.py +50 -2
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/core/connection.py +55 -18
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/core/database.py +24 -9
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/core/initializer.py +25 -40
- cinchdb-0.1.18/src/cinchdb/core/path_utils.py +348 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/managers/change_applier.py +1 -1
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/managers/codegen.py +372 -15
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/managers/column.py +7 -4
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/managers/data.py +16 -13
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/managers/query.py +8 -5
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/managers/table.py +8 -5
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/managers/tenant.py +227 -71
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/utils/name_validator.py +22 -12
- cinchdb-0.1.17/src/cinchdb/core/path_utils.py +0 -173
- {cinchdb-0.1.17 → cinchdb-0.1.18}/.gitignore +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/LICENSE +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/__init__.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/__main__.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/cli/__init__.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/cli/commands/__init__.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/cli/commands/branch.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/cli/commands/codegen.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/cli/commands/column.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/cli/commands/data.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/cli/commands/database.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/cli/commands/index.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/cli/commands/query.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/cli/commands/remote.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/cli/commands/table.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/cli/commands/view.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/cli/handlers/__init__.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/cli/handlers/codegen_handler.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/cli/main.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/cli/utils.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/config.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/core/__init__.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/core/maintenance_utils.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/core/tenant_activation.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/infrastructure/metadata_connection_pool.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/infrastructure/metadata_db.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/managers/__init__.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/managers/branch.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/managers/change_comparator.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/managers/change_tracker.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/managers/index.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/managers/merge_manager.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/managers/view.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/models/__init__.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/models/base.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/models/branch.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/models/change.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/models/database.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/models/project.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/models/table.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/models/tenant.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/models/view.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/plugins/__init__.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/plugins/base.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/plugins/decorators.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/plugins/manager.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/utils/__init__.py +0 -0
- {cinchdb-0.1.17 → cinchdb-0.1.18}/src/cinchdb/utils/sql_validator.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: cinchdb
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.18
|
4
4
|
Summary: A Git-like SQLite database management system with branching and multi-tenancy
|
5
5
|
Project-URL: Homepage, https://github.com/russellromney/cinchdb
|
6
6
|
Project-URL: Documentation, https://russellromney.github.io/cinchdb
|
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.11
|
19
19
|
Classifier: Programming Language :: Python :: 3.12
|
20
20
|
Requires-Python: >=3.10
|
21
|
+
Requires-Dist: httpx>=0.25.0
|
21
22
|
Requires-Dist: pydantic>=2.0.0
|
22
23
|
Requires-Dist: requests>=2.28.0
|
23
24
|
Requires-Dist: rich>=13.0.0
|
@@ -68,6 +69,10 @@ cinch branch merge-into-main feature
|
|
68
69
|
cinch tenant create customer_a
|
69
70
|
cinch query "SELECT * FROM users" --tenant customer_a
|
70
71
|
|
72
|
+
# Tenant encryption (bring your own keys)
|
73
|
+
cinch tenant create secure_customer --encrypt --key="your-secret-key"
|
74
|
+
cinch query "SELECT * FROM users" --tenant secure_customer --key="your-secret-key"
|
75
|
+
|
71
76
|
# Future: Remote connectivity planned for production deployment
|
72
77
|
|
73
78
|
# Autogenerate Python SDK from database
|
@@ -157,6 +162,35 @@ db.update("posts", post_id, {"content": "Updated content"})
|
|
157
162
|
|
158
163
|
## Architecture
|
159
164
|
|
165
|
+
### Storage Architecture
|
166
|
+
|
167
|
+
CinchDB uses a **tenant-first storage model** where database and branch are organizational metadata concepts, while tenants represent the actual isolated data stores:
|
168
|
+
|
169
|
+
```
|
170
|
+
.cinchdb/
|
171
|
+
├── metadata.db # Organizational metadata
|
172
|
+
└── {database}-{branch}/ # Context root (e.g., main-main, prod-feature)
|
173
|
+
├── {shard}/ # SHA256-based sharding (first 2 chars)
|
174
|
+
│ ├── {tenant}.db # Actual SQLite database
|
175
|
+
│ └── {tenant}.db-wal # WAL file
|
176
|
+
└── ...
|
177
|
+
```
|
178
|
+
|
179
|
+
**Key Design Decisions:**
|
180
|
+
- **Tenant-first**: Each tenant gets its own SQLite database file
|
181
|
+
- **Flat hierarchy**: Database/branch form a single context root, avoiding deep nesting
|
182
|
+
- **Hash sharding**: Tenants are distributed across 256 shards using SHA256 for scalability
|
183
|
+
- **Lazy initialization**: Tenant databases are created on first access, not on tenant creation
|
184
|
+
- **WAL mode**: All databases use Write-Ahead Logging for better concurrency
|
185
|
+
|
186
|
+
This architecture enables:
|
187
|
+
- True multi-tenant isolation at the file system level
|
188
|
+
- Efficient branching without duplicating tenant data
|
189
|
+
- Simple backup/restore per tenant
|
190
|
+
- Horizontal scaling through sharding
|
191
|
+
|
192
|
+
### Components
|
193
|
+
|
160
194
|
- **Python SDK**: Core functionality for local development
|
161
195
|
- **CLI**: Full-featured command-line interface
|
162
196
|
|
@@ -39,6 +39,10 @@ cinch branch merge-into-main feature
|
|
39
39
|
cinch tenant create customer_a
|
40
40
|
cinch query "SELECT * FROM users" --tenant customer_a
|
41
41
|
|
42
|
+
# Tenant encryption (bring your own keys)
|
43
|
+
cinch tenant create secure_customer --encrypt --key="your-secret-key"
|
44
|
+
cinch query "SELECT * FROM users" --tenant secure_customer --key="your-secret-key"
|
45
|
+
|
42
46
|
# Future: Remote connectivity planned for production deployment
|
43
47
|
|
44
48
|
# Autogenerate Python SDK from database
|
@@ -128,6 +132,35 @@ db.update("posts", post_id, {"content": "Updated content"})
|
|
128
132
|
|
129
133
|
## Architecture
|
130
134
|
|
135
|
+
### Storage Architecture
|
136
|
+
|
137
|
+
CinchDB uses a **tenant-first storage model** where database and branch are organizational metadata concepts, while tenants represent the actual isolated data stores:
|
138
|
+
|
139
|
+
```
|
140
|
+
.cinchdb/
|
141
|
+
├── metadata.db # Organizational metadata
|
142
|
+
└── {database}-{branch}/ # Context root (e.g., main-main, prod-feature)
|
143
|
+
├── {shard}/ # SHA256-based sharding (first 2 chars)
|
144
|
+
│ ├── {tenant}.db # Actual SQLite database
|
145
|
+
│ └── {tenant}.db-wal # WAL file
|
146
|
+
└── ...
|
147
|
+
```
|
148
|
+
|
149
|
+
**Key Design Decisions:**
|
150
|
+
- **Tenant-first**: Each tenant gets its own SQLite database file
|
151
|
+
- **Flat hierarchy**: Database/branch form a single context root, avoiding deep nesting
|
152
|
+
- **Hash sharding**: Tenants are distributed across 256 shards using SHA256 for scalability
|
153
|
+
- **Lazy initialization**: Tenant databases are created on first access, not on tenant creation
|
154
|
+
- **WAL mode**: All databases use Write-Ahead Logging for better concurrency
|
155
|
+
|
156
|
+
This architecture enables:
|
157
|
+
- True multi-tenant isolation at the file system level
|
158
|
+
- Efficient branching without duplicating tenant data
|
159
|
+
- Simple backup/restore per tenant
|
160
|
+
- Horizontal scaling through sharding
|
161
|
+
|
162
|
+
### Components
|
163
|
+
|
131
164
|
- **Python SDK**: Core functionality for local development
|
132
165
|
- **CLI**: Full-featured command-line interface
|
133
166
|
|
@@ -1,6 +1,6 @@
|
|
1
1
|
[project]
|
2
2
|
name = "cinchdb"
|
3
|
-
version = "0.1.
|
3
|
+
version = "0.1.18"
|
4
4
|
description = "A Git-like SQLite database management system with branching and multi-tenancy"
|
5
5
|
readme = "README.md"
|
6
6
|
requires-python = ">=3.10"
|
@@ -24,6 +24,7 @@ dependencies = [
|
|
24
24
|
"typer>=0.9.0",
|
25
25
|
"rich>=13.0.0",
|
26
26
|
"requests>=2.28.0",
|
27
|
+
"httpx>=0.25.0",
|
27
28
|
]
|
28
29
|
|
29
30
|
[project.optional-dependencies]
|
@@ -80,7 +81,14 @@ packages = ["src/cinchdb"]
|
|
80
81
|
dev-dependencies = [
|
81
82
|
"pytest>=8.0.0",
|
82
83
|
"pytest-cov>=4.0.0",
|
84
|
+
"pytest-asyncio>=0.21.0",
|
85
|
+
"pytest-timeout>=2.4.0",
|
83
86
|
"ruff>=0.5.0",
|
84
87
|
"mypy>=1.0.0",
|
85
88
|
"responses>=0.23.0",
|
86
89
|
]
|
90
|
+
|
91
|
+
[tool.pytest.ini_options]
|
92
|
+
timeout = 5
|
93
|
+
timeout_method = "thread"
|
94
|
+
addopts = "--timeout=5 --timeout-method=thread"
|
@@ -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)
|
@@ -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
|
-
#
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
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
|
-
#
|
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[
|
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
|
159
|
-
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)
|
160
197
|
|
161
|
-
return self._connections[
|
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.
|
@@ -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
|
-
|
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.
|