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/plugins/base.py
CHANGED
@@ -1,90 +1,73 @@
|
|
1
1
|
"""
|
2
|
-
|
2
|
+
Simple base class for CinchDB plugins.
|
3
3
|
"""
|
4
4
|
|
5
|
-
from
|
6
|
-
from typing import Any, Dict, List, Callable
|
7
|
-
from enum import Enum
|
5
|
+
from typing import Any, Dict, Optional
|
8
6
|
|
9
7
|
|
10
|
-
class
|
11
|
-
"""
|
12
|
-
# Database lifecycle hooks
|
13
|
-
DATABASE_INIT = "database_init"
|
14
|
-
DATABASE_CONNECT = "database_connect"
|
15
|
-
DATABASE_DISCONNECT = "database_disconnect"
|
8
|
+
class Plugin:
|
9
|
+
"""Simple base class for CinchDB plugins."""
|
16
10
|
|
17
|
-
#
|
18
|
-
|
19
|
-
|
20
|
-
|
11
|
+
# Plugin metadata - override in subclass
|
12
|
+
name: str = "unnamed_plugin"
|
13
|
+
version: str = "1.0.0"
|
14
|
+
description: str = ""
|
21
15
|
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
# Tenant hooks
|
28
|
-
TENANT_CREATE = "tenant_create"
|
29
|
-
TENANT_DROP = "tenant_drop"
|
30
|
-
|
31
|
-
# Branch hooks
|
32
|
-
BRANCH_CREATE = "branch_create"
|
33
|
-
BRANCH_SWITCH = "branch_switch"
|
34
|
-
BRANCH_MERGE = "branch_merge"
|
35
|
-
|
36
|
-
# CLI hooks
|
37
|
-
CLI_COMMAND_BEFORE = "cli_command_before"
|
38
|
-
CLI_COMMAND_AFTER = "cli_command_after"
|
39
|
-
|
40
|
-
|
41
|
-
class BasePlugin(ABC):
|
42
|
-
"""Base class for all CinchDB plugins."""
|
43
|
-
|
44
|
-
def __init__(self):
|
45
|
-
self.name = self.__class__.__name__
|
46
|
-
self.version = "1.0.0"
|
47
|
-
self.description = ""
|
48
|
-
self._hooks: Dict[PluginHook, List[Callable]] = {}
|
49
|
-
self._methods: Dict[str, Callable] = {}
|
16
|
+
def extend_database(self, db) -> None:
|
17
|
+
"""Add methods or modify the database instance.
|
18
|
+
|
19
|
+
Override this method to add custom methods to database instances:
|
50
20
|
|
51
|
-
|
52
|
-
|
53
|
-
|
21
|
+
Example:
|
22
|
+
def extend_database(self, db):
|
23
|
+
db.my_custom_method = self.my_custom_method
|
24
|
+
"""
|
54
25
|
pass
|
55
26
|
|
56
|
-
def
|
57
|
-
"""
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
27
|
+
def before_query(self, sql: str, params: Optional[tuple] = None) -> tuple:
|
28
|
+
"""Called before executing any SQL query.
|
29
|
+
|
30
|
+
Args:
|
31
|
+
sql: The SQL statement to be executed
|
32
|
+
params: Parameters for the SQL statement
|
33
|
+
|
34
|
+
Returns:
|
35
|
+
Tuple of (modified_sql, modified_params)
|
36
|
+
"""
|
37
|
+
return sql, params
|
65
38
|
|
66
|
-
def
|
67
|
-
"""
|
68
|
-
|
39
|
+
def after_query(self, sql: str, params: Optional[tuple], result: Any) -> Any:
|
40
|
+
"""Called after executing any SQL query.
|
41
|
+
|
42
|
+
Args:
|
43
|
+
sql: The SQL statement that was executed
|
44
|
+
params: Parameters that were used
|
45
|
+
result: The query result
|
46
|
+
|
47
|
+
Returns:
|
48
|
+
Modified result (or original result)
|
49
|
+
"""
|
50
|
+
return result
|
69
51
|
|
70
|
-
def
|
71
|
-
"""
|
72
|
-
|
52
|
+
def on_connect(self, db_path: str, connection) -> None:
|
53
|
+
"""Called when a database connection is established.
|
54
|
+
|
55
|
+
Args:
|
56
|
+
db_path: Path to the database file
|
57
|
+
connection: SQLite connection object
|
58
|
+
"""
|
59
|
+
pass
|
73
60
|
|
74
|
-
def
|
75
|
-
"""
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
except Exception as e:
|
82
|
-
# Log error but don't break other plugins
|
83
|
-
print(f"Plugin {self.name} hook {hook} failed: {e}")
|
84
|
-
return results
|
61
|
+
def on_disconnect(self, db_path: str) -> None:
|
62
|
+
"""Called when a database connection is closed.
|
63
|
+
|
64
|
+
Args:
|
65
|
+
db_path: Path to the database file
|
66
|
+
"""
|
67
|
+
pass
|
85
68
|
|
86
69
|
def cleanup(self) -> None:
|
87
|
-
"""
|
70
|
+
"""Called when plugin is being unloaded."""
|
88
71
|
pass
|
89
72
|
|
90
73
|
@property
|
@@ -94,6 +77,4 @@ class BasePlugin(ABC):
|
|
94
77
|
"name": self.name,
|
95
78
|
"version": self.version,
|
96
79
|
"description": self.description,
|
97
|
-
"hooks": list(self._hooks.keys()),
|
98
|
-
"methods": list(self._methods.keys()),
|
99
80
|
}
|
cinchdb/plugins/decorators.py
CHANGED
@@ -1,45 +1,49 @@
|
|
1
1
|
"""
|
2
|
-
|
2
|
+
Simple decorators for plugin development.
|
3
3
|
"""
|
4
4
|
|
5
5
|
from typing import Callable
|
6
6
|
|
7
|
-
from .base import PluginHook
|
8
7
|
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
def plugin_method(method_name: str):
|
19
|
-
"""Decorator to register a method to be added to CinchDB instances."""
|
8
|
+
def database_method(method_name: str):
|
9
|
+
"""Decorator to mark a method for database extension.
|
10
|
+
|
11
|
+
Usage:
|
12
|
+
class Plugin:
|
13
|
+
@database_method("my_method")
|
14
|
+
def my_custom_method(self, db):
|
15
|
+
return "Hello from plugin!"
|
16
|
+
"""
|
20
17
|
def decorator(func: Callable) -> Callable:
|
21
|
-
func.
|
18
|
+
func._database_method_name = method_name
|
22
19
|
return func
|
23
20
|
return decorator
|
24
21
|
|
25
22
|
|
26
|
-
|
27
|
-
"""
|
23
|
+
def auto_extend(plugin_class):
|
24
|
+
"""Class decorator to automatically extend databases with decorated methods.
|
25
|
+
|
26
|
+
Usage:
|
27
|
+
@auto_extend
|
28
|
+
class Plugin:
|
29
|
+
@database_method("custom_query")
|
30
|
+
def custom_query_method(self, db, query):
|
31
|
+
# Method will be added to db instances as db.custom_query()
|
32
|
+
return "Custom result"
|
33
|
+
"""
|
34
|
+
original_extend = getattr(plugin_class, 'extend_database', None)
|
28
35
|
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
36
|
+
def new_extend_database(self, db):
|
37
|
+
# Call original extend_database if it exists
|
38
|
+
if original_extend:
|
39
|
+
original_extend(self, db)
|
40
|
+
|
41
|
+
# Auto-add decorated methods
|
42
|
+
for attr_name in dir(self):
|
43
|
+
attr = getattr(self, attr_name)
|
44
|
+
if callable(attr) and hasattr(attr, '_database_method_name'):
|
45
|
+
method_name = attr._database_method_name
|
46
|
+
setattr(db, method_name, lambda *args, **kwargs: attr(db, *args, **kwargs))
|
37
47
|
|
38
|
-
|
39
|
-
|
40
|
-
"""Collect and register plugin methods from a plugin instance."""
|
41
|
-
for attr_name in dir(plugin_instance):
|
42
|
-
attr = getattr(plugin_instance, attr_name)
|
43
|
-
if callable(attr) and hasattr(attr, '_plugin_method_name'):
|
44
|
-
method_name = attr._plugin_method_name
|
45
|
-
plugin_instance.register_method(method_name, attr)
|
48
|
+
plugin_class.extend_database = new_extend_database
|
49
|
+
return plugin_class
|
cinchdb/plugins/manager.py
CHANGED
@@ -1,43 +1,31 @@
|
|
1
1
|
"""
|
2
|
-
|
2
|
+
Simple plugin manager for CinchDB.
|
3
3
|
"""
|
4
4
|
|
5
5
|
import importlib
|
6
6
|
import importlib.util
|
7
7
|
import logging
|
8
8
|
from pathlib import Path
|
9
|
-
from typing import Dict, List, Optional, Any
|
10
|
-
try:
|
11
|
-
from importlib.metadata import entry_points
|
12
|
-
except ImportError:
|
13
|
-
# Fallback for Python < 3.8
|
14
|
-
from importlib_metadata import entry_points
|
9
|
+
from typing import Dict, List, Optional, Any, Union
|
15
10
|
|
16
|
-
from .base import
|
11
|
+
from .base import Plugin
|
17
12
|
|
18
13
|
logger = logging.getLogger(__name__)
|
19
14
|
|
20
15
|
|
21
16
|
class PluginManager:
|
22
|
-
"""
|
17
|
+
"""Simple plugin manager for CinchDB."""
|
23
18
|
|
24
19
|
def __init__(self):
|
25
|
-
self.plugins: Dict[str,
|
26
|
-
self.
|
20
|
+
self.plugins: Dict[str, Plugin] = {}
|
21
|
+
self._database_instances: List[Any] = []
|
27
22
|
|
28
|
-
def
|
29
|
-
"""
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
try:
|
35
|
-
plugin.initialize(instance)
|
36
|
-
except Exception as e:
|
37
|
-
logger.error(f"Failed to initialize plugin {plugin.name}: {e}")
|
38
|
-
|
39
|
-
def register_plugin(self, plugin: BasePlugin) -> None:
|
40
|
-
"""Register a plugin instance."""
|
23
|
+
def register_plugin(self, plugin: Union[Plugin, type]) -> None:
|
24
|
+
"""Register a plugin instance or class."""
|
25
|
+
# If it's a class, instantiate it
|
26
|
+
if isinstance(plugin, type):
|
27
|
+
plugin = plugin()
|
28
|
+
|
41
29
|
plugin_name = plugin.name
|
42
30
|
|
43
31
|
if plugin_name in self.plugins:
|
@@ -45,13 +33,12 @@ class PluginManager:
|
|
45
33
|
|
46
34
|
self.plugins[plugin_name] = plugin
|
47
35
|
|
48
|
-
#
|
49
|
-
|
36
|
+
# Apply to existing database instances
|
37
|
+
for db_instance in self._database_instances:
|
50
38
|
try:
|
51
|
-
plugin.
|
52
|
-
self._apply_plugin_methods(plugin)
|
39
|
+
plugin.extend_database(db_instance)
|
53
40
|
except Exception as e:
|
54
|
-
logger.error(f"Failed to
|
41
|
+
logger.error(f"Failed to extend database with plugin {plugin_name}: {e}")
|
55
42
|
|
56
43
|
logger.info(f"Plugin {plugin_name} registered successfully")
|
57
44
|
|
@@ -72,18 +59,24 @@ class PluginManager:
|
|
72
59
|
try:
|
73
60
|
module = importlib.import_module(module_name)
|
74
61
|
|
75
|
-
# Look for
|
62
|
+
# Look for Plugin class first
|
63
|
+
if hasattr(module, 'Plugin'):
|
64
|
+
plugin_instance = module.Plugin()
|
65
|
+
self.register_plugin(plugin_instance)
|
66
|
+
return
|
67
|
+
|
68
|
+
# Fallback: look for any Plugin subclass
|
76
69
|
for attr_name in dir(module):
|
77
70
|
attr = getattr(module, attr_name)
|
78
71
|
if (isinstance(attr, type) and
|
79
|
-
issubclass(attr,
|
80
|
-
attr !=
|
72
|
+
issubclass(attr, Plugin) and
|
73
|
+
attr != Plugin):
|
81
74
|
|
82
75
|
plugin_instance = attr()
|
83
76
|
self.register_plugin(plugin_instance)
|
84
77
|
return
|
85
78
|
|
86
|
-
logger.warning(f"No
|
79
|
+
logger.warning(f"No Plugin class found in module {module_name}")
|
87
80
|
|
88
81
|
except ImportError as e:
|
89
82
|
logger.error(f"Failed to import plugin module {module_name}: {e}")
|
@@ -98,69 +91,108 @@ class PluginManager:
|
|
98
91
|
module = importlib.util.module_from_spec(spec)
|
99
92
|
spec.loader.exec_module(module)
|
100
93
|
|
101
|
-
# Look for
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
attr != BasePlugin):
|
107
|
-
|
108
|
-
plugin_instance = attr()
|
109
|
-
self.register_plugin(plugin_instance)
|
110
|
-
return
|
94
|
+
# Look for Plugin class
|
95
|
+
if hasattr(module, 'Plugin'):
|
96
|
+
plugin_instance = module.Plugin()
|
97
|
+
self.register_plugin(plugin_instance)
|
98
|
+
return
|
111
99
|
|
112
|
-
logger.warning(f"No
|
100
|
+
logger.warning(f"No Plugin class found in file {file_path}")
|
113
101
|
|
114
102
|
except Exception as e:
|
115
103
|
logger.error(f"Failed to load plugin from file {file_path}: {e}")
|
116
104
|
|
105
|
+
def load_plugins_from_directory(self, plugins_dir: Path) -> None:
|
106
|
+
"""Load all plugins from a directory."""
|
107
|
+
if not plugins_dir.exists():
|
108
|
+
logger.info(f"Plugins directory {plugins_dir} does not exist")
|
109
|
+
return
|
110
|
+
|
111
|
+
for plugin_file in plugins_dir.glob("*.py"):
|
112
|
+
if plugin_file.name == "__init__.py":
|
113
|
+
continue
|
114
|
+
self.load_plugin_from_file(plugin_file)
|
115
|
+
|
117
116
|
def discover_plugins(self) -> None:
|
118
|
-
"""Discover plugins using entry points."""
|
117
|
+
"""Discover plugins using entry points and plugins directory."""
|
118
|
+
# Try entry points for installed plugins
|
119
119
|
try:
|
120
|
+
try:
|
121
|
+
from importlib.metadata import entry_points
|
122
|
+
except ImportError:
|
123
|
+
from importlib_metadata import entry_points
|
124
|
+
|
120
125
|
eps = entry_points()
|
121
|
-
# Handle both old and new entry_points API
|
122
126
|
if hasattr(eps, 'select'):
|
123
|
-
# New API (Python 3.10+)
|
124
127
|
plugin_eps = eps.select(group='cinchdb.plugins')
|
125
128
|
else:
|
126
|
-
# Old API
|
127
129
|
plugin_eps = eps.get('cinchdb.plugins', [])
|
128
130
|
|
129
131
|
for entry_point in plugin_eps:
|
130
132
|
try:
|
131
133
|
plugin_class = entry_point.load()
|
132
|
-
|
133
|
-
plugin_instance = plugin_class()
|
134
|
-
self.register_plugin(plugin_instance)
|
134
|
+
self.register_plugin(plugin_class)
|
135
135
|
except Exception as e:
|
136
136
|
logger.error(f"Failed to load plugin {entry_point.name}: {e}")
|
137
137
|
except Exception as e:
|
138
|
-
logger.
|
138
|
+
logger.debug(f"Entry points not available: {e}")
|
139
|
+
|
140
|
+
# Also check for local plugins directory
|
141
|
+
plugins_dir = Path("plugins")
|
142
|
+
if plugins_dir.exists():
|
143
|
+
self.load_plugins_from_directory(plugins_dir)
|
139
144
|
|
140
|
-
def
|
141
|
-
"""
|
142
|
-
|
143
|
-
return
|
144
|
-
|
145
|
-
for method_name, method in plugin.get_methods().items():
|
146
|
-
# Bind method to the instance
|
147
|
-
bound_method = method.__get__(self._cinchdb_instance, type(self._cinchdb_instance))
|
148
|
-
setattr(self._cinchdb_instance, method_name, bound_method)
|
149
|
-
|
150
|
-
def call_hook(self, hook: PluginHook, *args, **kwargs) -> List[Any]:
|
151
|
-
"""Call all plugin hooks for a specific event."""
|
152
|
-
results = []
|
145
|
+
def register_database(self, db_instance) -> None:
|
146
|
+
"""Register a database instance with all plugins."""
|
147
|
+
self._database_instances.append(db_instance)
|
153
148
|
|
149
|
+
# Apply all plugins to this database instance
|
154
150
|
for plugin in self.plugins.values():
|
155
151
|
try:
|
156
|
-
|
157
|
-
results.extend(plugin_results)
|
152
|
+
plugin.extend_database(db_instance)
|
158
153
|
except Exception as e:
|
159
|
-
logger.error(f"
|
160
|
-
|
161
|
-
|
154
|
+
logger.error(f"Failed to extend database with plugin {plugin.name}: {e}")
|
155
|
+
|
156
|
+
def unregister_database(self, db_instance) -> None:
|
157
|
+
"""Unregister a database instance."""
|
158
|
+
if db_instance in self._database_instances:
|
159
|
+
self._database_instances.remove(db_instance)
|
160
|
+
|
161
|
+
def before_query(self, sql: str, params: Optional[tuple] = None) -> tuple:
|
162
|
+
"""Call before_query on all plugins."""
|
163
|
+
for plugin in self.plugins.values():
|
164
|
+
try:
|
165
|
+
sql, params = plugin.before_query(sql, params)
|
166
|
+
except Exception as e:
|
167
|
+
logger.error(f"Plugin {plugin.name} before_query failed: {e}")
|
168
|
+
return sql, params
|
169
|
+
|
170
|
+
def after_query(self, sql: str, params: Optional[tuple], result: Any) -> Any:
|
171
|
+
"""Call after_query on all plugins."""
|
172
|
+
for plugin in self.plugins.values():
|
173
|
+
try:
|
174
|
+
result = plugin.after_query(sql, params, result)
|
175
|
+
except Exception as e:
|
176
|
+
logger.error(f"Plugin {plugin.name} after_query failed: {e}")
|
177
|
+
return result
|
178
|
+
|
179
|
+
def on_connect(self, db_path: str, connection) -> None:
|
180
|
+
"""Call on_connect on all plugins."""
|
181
|
+
for plugin in self.plugins.values():
|
182
|
+
try:
|
183
|
+
plugin.on_connect(db_path, connection)
|
184
|
+
except Exception as e:
|
185
|
+
logger.error(f"Plugin {plugin.name} on_connect failed: {e}")
|
186
|
+
|
187
|
+
def on_disconnect(self, db_path: str) -> None:
|
188
|
+
"""Call on_disconnect on all plugins."""
|
189
|
+
for plugin in self.plugins.values():
|
190
|
+
try:
|
191
|
+
plugin.on_disconnect(db_path)
|
192
|
+
except Exception as e:
|
193
|
+
logger.error(f"Plugin {plugin.name} on_disconnect failed: {e}")
|
162
194
|
|
163
|
-
def get_plugin(self, name: str) -> Optional[
|
195
|
+
def get_plugin(self, name: str) -> Optional[Plugin]:
|
164
196
|
"""Get a plugin by name."""
|
165
197
|
return self.plugins.get(name)
|
166
198
|
|
cinchdb/utils/name_validator.py
CHANGED
@@ -7,8 +7,9 @@ and follow consistent naming conventions.
|
|
7
7
|
import re
|
8
8
|
|
9
9
|
|
10
|
-
# Regex pattern for valid names: lowercase letters, numbers, dash, underscore
|
11
|
-
|
10
|
+
# Regex pattern for valid names: lowercase letters, numbers, dash, underscore
|
11
|
+
# Period removed to prevent directory traversal attempts like "../"
|
12
|
+
VALID_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9\-_]*[a-z0-9]$|^[a-z0-9]$")
|
12
13
|
|
13
14
|
# Reserved names that cannot be used
|
14
15
|
RESERVED_NAMES = {
|
@@ -52,6 +53,7 @@ def validate_name(name: str, entity_type: str = "entity") -> None:
|
|
52
53
|
- Be at least 1 character long
|
53
54
|
- Not exceed 255 characters (filesystem limit)
|
54
55
|
- Not be a reserved name
|
56
|
+
- Not contain path traversal sequences
|
55
57
|
|
56
58
|
Args:
|
57
59
|
name: The name to validate
|
@@ -67,6 +69,19 @@ def validate_name(name: str, entity_type: str = "entity") -> None:
|
|
67
69
|
raise InvalidNameError(
|
68
70
|
f"{entity_type.capitalize()} name cannot exceed 255 characters"
|
69
71
|
)
|
72
|
+
|
73
|
+
# Critical: Check for path traversal attempts
|
74
|
+
if ".." in name or "/" in name or "\\" in name or "~" in name:
|
75
|
+
raise InvalidNameError(
|
76
|
+
f"Security violation: {entity_type} name '{name}' contains "
|
77
|
+
f"forbidden path traversal characters"
|
78
|
+
)
|
79
|
+
|
80
|
+
# Check for null bytes and other control characters
|
81
|
+
if "\x00" in name or any(ord(c) < 32 for c in name):
|
82
|
+
raise InvalidNameError(
|
83
|
+
f"Security violation: {entity_type} name contains invalid control characters"
|
84
|
+
)
|
70
85
|
|
71
86
|
# Check for lowercase requirement
|
72
87
|
if name != name.lower():
|
@@ -80,7 +95,7 @@ def validate_name(name: str, entity_type: str = "entity") -> None:
|
|
80
95
|
raise InvalidNameError(
|
81
96
|
f"Invalid {entity_type} name '{name}'. "
|
82
97
|
f"Names must contain only lowercase letters (a-z), numbers (0-9), "
|
83
|
-
f"dash (-), underscore (_)
|
98
|
+
f"dash (-), and underscore (_). "
|
84
99
|
f"Names must start and end with alphanumeric characters."
|
85
100
|
)
|
86
101
|
|
@@ -90,11 +105,6 @@ def validate_name(name: str, entity_type: str = "entity") -> None:
|
|
90
105
|
or "__" in name
|
91
106
|
or "-_" in name
|
92
107
|
or "_-" in name
|
93
|
-
or ".." in name
|
94
|
-
or ".-" in name
|
95
|
-
or "-." in name
|
96
|
-
or "._" in name
|
97
|
-
or "_." in name
|
98
108
|
):
|
99
109
|
raise InvalidNameError(
|
100
110
|
f"Invalid {entity_type} name '{name}'. "
|
@@ -132,14 +142,14 @@ def clean_name(name: str) -> str:
|
|
132
142
|
# Replace spaces with dashes
|
133
143
|
cleaned = cleaned.replace(" ", "-")
|
134
144
|
|
135
|
-
# Remove invalid characters
|
136
|
-
cleaned = re.sub(r"[^a-z0-9\-_
|
145
|
+
# Remove invalid characters (period no longer allowed)
|
146
|
+
cleaned = re.sub(r"[^a-z0-9\-_]", "", cleaned)
|
137
147
|
|
138
148
|
# Remove consecutive special characters
|
139
|
-
cleaned = re.sub(r"[-_
|
149
|
+
cleaned = re.sub(r"[-_]{2,}", "-", cleaned)
|
140
150
|
|
141
151
|
# Remove leading/trailing special characters
|
142
|
-
cleaned = cleaned.strip("-_
|
152
|
+
cleaned = cleaned.strip("-_")
|
143
153
|
|
144
154
|
return cleaned
|
145
155
|
|
@@ -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
|
@@ -31,6 +32,10 @@ Description-Content-Type: text/markdown
|
|
31
32
|
|
32
33
|
**Git-like SQLite database management with branching and multi-tenancy**
|
33
34
|
|
35
|
+
[](https://badge.fury.io/py/cinchdb)
|
36
|
+
[](https://www.python.org/downloads/)
|
37
|
+
|
38
|
+
|
34
39
|
NOTE: CinchDB is in early alpha. This is project to test out an idea. Do not use this in production.
|
35
40
|
|
36
41
|
CinchDB is for projects that need fast queries, isolated data per-tenant [or even per-user](https://turso.tech/blog/give-each-of-your-users-their-own-sqlite-database-b74445f4), and a branchable database that makes it easy to merge changes between branches.
|
@@ -64,6 +69,10 @@ cinch branch merge-into-main feature
|
|
64
69
|
cinch tenant create customer_a
|
65
70
|
cinch query "SELECT * FROM users" --tenant customer_a
|
66
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
|
+
|
67
76
|
# Future: Remote connectivity planned for production deployment
|
68
77
|
|
69
78
|
# Autogenerate Python SDK from database
|
@@ -153,6 +162,35 @@ db.update("posts", post_id, {"content": "Updated content"})
|
|
153
162
|
|
154
163
|
## Architecture
|
155
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
|
+
|
156
194
|
- **Python SDK**: Core functionality for local development
|
157
195
|
- **CLI**: Full-featured command-line interface
|
158
196
|
|