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.
@@ -1,8 +1,15 @@
1
1
  """Path utilities for CinchDB."""
2
2
 
3
+ import hashlib
3
4
  from pathlib import Path
4
- from typing import List
5
+ from typing import List, Dict, Tuple, Optional
5
6
  from cinchdb.infrastructure.metadata_connection_pool import get_metadata_db
7
+ from cinchdb.utils.name_validator import validate_name
8
+
9
+ # Cache for path calculations to avoid repeated string operations
10
+ _path_cache: Dict[Tuple[str, str, str], Path] = {}
11
+ _shard_cache: Dict[str, str] = {}
12
+ _MAX_CACHE_SIZE = 10000 # Limit cache size to prevent unbounded growth
6
13
 
7
14
 
8
15
  def get_project_root(start_path: Path) -> Path:
@@ -41,7 +48,7 @@ def get_database_path(project_root: Path, database: str) -> Path:
41
48
 
42
49
 
43
50
  def get_branch_path(project_root: Path, database: str, branch: str) -> Path:
44
- """Get path to a branch directory.
51
+ """Get path to a branch directory (now uses context root).
45
52
 
46
53
  Args:
47
54
  project_root: Project root directory
@@ -49,15 +56,16 @@ def get_branch_path(project_root: Path, database: str, branch: str) -> Path:
49
56
  branch: Branch name
50
57
 
51
58
  Returns:
52
- Path to branch directory
59
+ Path to branch directory (context root in tenant-first structure)
53
60
  """
54
- return get_database_path(project_root, database) / "branches" / branch
61
+ # In tenant-first structure, branch path is the context root
62
+ return get_context_root(project_root, database, branch)
55
63
 
56
64
 
57
65
  def get_tenant_path(
58
66
  project_root: Path, database: str, branch: str, tenant: str
59
67
  ) -> Path:
60
- """Get path to tenant directory.
68
+ """Get path to tenant directory (deprecated, use get_context_root instead).
61
69
 
62
70
  Args:
63
71
  project_root: Project root directory
@@ -66,9 +74,9 @@ def get_tenant_path(
66
74
  tenant: Tenant name
67
75
 
68
76
  Returns:
69
- Path to tenant directory
77
+ Path to context root (no longer has /tenants subfolder)
70
78
  """
71
- return get_branch_path(project_root, database, branch) / "tenants"
79
+ return get_branch_path(project_root, database, branch)
72
80
 
73
81
 
74
82
  def get_tenant_db_path(
@@ -84,18 +92,20 @@ def get_tenant_db_path(
84
92
 
85
93
  Returns:
86
94
  Path to tenant database file in sharded directory structure
95
+
96
+ Raises:
97
+ InvalidNameError: If any name contains path traversal characters
87
98
  """
88
- import hashlib
89
-
90
- # Calculate shard using SHA256 hash (same as TenantManager)
91
- hash_val = hashlib.sha256(tenant.encode('utf-8')).hexdigest()
92
- shard = hash_val[:2]
99
+ # Validate all names to prevent path traversal
100
+ # Exception: __empty__ is a system tenant and exempt from validation
101
+ validate_name(database, "database")
102
+ validate_name(branch, "branch")
103
+ if tenant != "__empty__": # System tenant exempt from validation
104
+ validate_name(tenant, "tenant")
93
105
 
94
- # Build sharded path: /tenants/{shard}/{tenant}.db
95
- tenants_dir = get_tenant_path(project_root, database, branch, tenant)
96
- shard_dir = tenants_dir / shard
97
-
98
- return shard_dir / f"{tenant}.db"
106
+ context_root = get_context_root(project_root, database, branch)
107
+ shard = calculate_shard(tenant)
108
+ return context_root / shard / f"{tenant}.db"
99
109
 
100
110
 
101
111
  def ensure_directory(path: Path) -> None:
@@ -171,3 +181,168 @@ def list_tenants(project_root: Path, database: str, branch: str) -> List[str]:
171
181
  return []
172
182
  tenant_records = metadata_db.list_tenants(branch_info['id'])
173
183
  return sorted(record['name'] for record in tenant_records)
184
+
185
+
186
+ # New tenant-first path utilities
187
+
188
+ def get_context_root(project_root: Path, database: str, branch: str) -> Path:
189
+ """Get root directory for a database-branch context with caching.
190
+
191
+ This is the new tenant-first approach where each database-branch combination
192
+ gets its own isolated directory with tenants stored as a flat hierarchy.
193
+
194
+ Args:
195
+ project_root: Project root directory
196
+ database: Database name
197
+ branch: Branch name
198
+
199
+ Returns:
200
+ Path to context root directory (e.g., .cinchdb/prod-main/)
201
+ """
202
+ cache_key = (str(project_root), database, branch)
203
+
204
+ if cache_key not in _path_cache:
205
+ # Check cache size and clear if needed
206
+ if len(_path_cache) >= _MAX_CACHE_SIZE:
207
+ _path_cache.clear()
208
+
209
+ _path_cache[cache_key] = project_root / ".cinchdb" / f"{database}-{branch}"
210
+
211
+ return _path_cache[cache_key]
212
+
213
+
214
+ def calculate_shard(tenant_name: str) -> str:
215
+ """Calculate the shard directory for a tenant using SHA256 hash with caching.
216
+
217
+ Args:
218
+ tenant_name: Name of the tenant
219
+
220
+ Returns:
221
+ Two-character hex string (e.g., "a0", "ff")
222
+ """
223
+ if tenant_name not in _shard_cache:
224
+ # Check cache size and clear if needed
225
+ if len(_shard_cache) >= _MAX_CACHE_SIZE:
226
+ _shard_cache.clear()
227
+
228
+ hash_val = hashlib.sha256(tenant_name.encode('utf-8')).hexdigest()
229
+ _shard_cache[tenant_name] = hash_val[:2]
230
+
231
+ return _shard_cache[tenant_name]
232
+
233
+
234
+ def get_tenant_db_path_in_context(context_root: Path, tenant: str) -> Path:
235
+ """Get tenant DB path within a context root.
236
+
237
+ Uses the tenant-first storage approach where tenants are stored as:
238
+ {context_root}/{shard}/{tenant}.db
239
+
240
+ Args:
241
+ context_root: Context root directory (from get_context_root)
242
+ tenant: Tenant name
243
+
244
+ Returns:
245
+ Path to tenant database file within the context
246
+ """
247
+ shard = calculate_shard(tenant)
248
+ return context_root / shard / f"{tenant}.db"
249
+
250
+
251
+
252
+
253
+ def ensure_context_directory(project_root: Path, database: str, branch: str) -> Path:
254
+ """Ensure context root directory exists and return it.
255
+
256
+ Args:
257
+ project_root: Project root directory
258
+ database: Database name
259
+ branch: Branch name
260
+
261
+ Returns:
262
+ Path to context root directory (created if necessary)
263
+ """
264
+ context_root = get_context_root(project_root, database, branch)
265
+ context_root.mkdir(parents=True, exist_ok=True)
266
+
267
+ return context_root
268
+
269
+
270
+ def ensure_tenant_db_path(project_root: Path, database: str, branch: str, tenant: str) -> Path:
271
+ """Ensure tenant database path exists (creates shard directory if needed).
272
+
273
+ This is the ONLY place where tenant shard directories should be created,
274
+ ensuring we have a single source of truth for the directory structure.
275
+
276
+ Args:
277
+ project_root: Project root directory
278
+ database: Database name
279
+ branch: Branch name
280
+ tenant: Tenant name
281
+
282
+ Returns:
283
+ Path to tenant database file (directory created if necessary)
284
+
285
+ Raises:
286
+ InvalidNameError: If any name contains path traversal characters
287
+ """
288
+ # Validation happens inside get_tenant_db_path
289
+ db_path = get_tenant_db_path(project_root, database, branch, tenant)
290
+
291
+ # Ensure the shard directory exists
292
+ db_path.parent.mkdir(parents=True, exist_ok=True)
293
+
294
+ return db_path
295
+
296
+
297
+ def invalidate_cache(database: Optional[str] = None,
298
+ branch: Optional[str] = None,
299
+ tenant: Optional[str] = None) -> None:
300
+ """Invalidate cache entries (write-through on delete operations).
301
+
302
+ This function is called when databases, branches, or tenants are deleted
303
+ to ensure cached paths don't point to non-existent resources.
304
+
305
+ Args:
306
+ database: Database name to invalidate (invalidates all branches/tenants if provided)
307
+ branch: Branch name to invalidate (requires database)
308
+ tenant: Tenant name to invalidate (only invalidates shard cache)
309
+ """
310
+ global _path_cache, _shard_cache
311
+
312
+ if database:
313
+ # Remove all cache entries for this database
314
+ keys_to_remove = []
315
+ for key in _path_cache:
316
+ # key is (project_root, database, branch)
317
+ if key[1] == database:
318
+ if branch is None or key[2] == branch:
319
+ keys_to_remove.append(key)
320
+
321
+ for key in keys_to_remove:
322
+ del _path_cache[key]
323
+
324
+ if tenant and tenant in _shard_cache:
325
+ del _shard_cache[tenant]
326
+
327
+
328
+ def clear_all_caches() -> None:
329
+ """Clear all path and shard caches.
330
+
331
+ Useful for testing or when major structural changes occur.
332
+ """
333
+ global _path_cache, _shard_cache
334
+ _path_cache.clear()
335
+ _shard_cache.clear()
336
+
337
+
338
+ def get_cache_stats() -> Dict[str, int]:
339
+ """Get statistics about cache usage.
340
+
341
+ Returns:
342
+ Dictionary with cache statistics
343
+ """
344
+ return {
345
+ "path_cache_size": len(_path_cache),
346
+ "shard_cache_size": len(_shard_cache),
347
+ "max_cache_size": _MAX_CACHE_SIZE
348
+ }
@@ -10,7 +10,7 @@ from cinchdb.models import Change, ChangeType, Tenant
10
10
  from cinchdb.managers.change_tracker import ChangeTracker
11
11
  from cinchdb.managers.tenant import TenantManager
12
12
  from cinchdb.core.connection import DatabaseConnection
13
- from cinchdb.core.path_utils import get_tenant_db_path, get_branch_path
13
+ from cinchdb.core.path_utils import get_tenant_db_path as get_tenant_db_path, get_branch_path
14
14
  from cinchdb.infrastructure.metadata_connection_pool import get_metadata_db
15
15
 
16
16
  logger = logging.getLogger(__name__)
@@ -379,21 +379,58 @@ class CodegenManager:
379
379
  results: Dict[str, Any],
380
380
  ) -> Dict[str, Any]:
381
381
  """Generate TypeScript interface models."""
382
- # TODO: Implement TypeScript generation
383
- # For now, return placeholder
384
- results["files_generated"].append("typescript_generation_todo.md")
385
-
386
- placeholder_path = output_dir / "typescript_generation_todo.md"
387
- with open(placeholder_path, "w") as f:
388
- content = "# TypeScript Generation\n\nTypeScript model generation will be implemented in a future update.\n"
389
- if include_tables:
390
- content += "\nTables will be generated as TypeScript interfaces.\n"
391
- if include_views:
392
- content += (
393
- "\nViews will be generated as read-only TypeScript interfaces.\n"
394
- )
395
- f.write(content)
396
-
382
+ # Generate interfaces for tables
383
+ if include_tables:
384
+ tables = self.table_manager.list_tables()
385
+ for table in tables:
386
+ interface_content = self._generate_typescript_table_interface(table)
387
+ file_name = f"{self._to_pascal_case(table.name)}.ts"
388
+ file_path = output_dir / file_name
389
+
390
+ with open(file_path, "w") as f:
391
+ f.write(interface_content)
392
+
393
+ results["tables_processed"].append(table.name)
394
+ results["files_generated"].append(file_name)
395
+
396
+ # Generate interfaces for views
397
+ if include_views:
398
+ views = self.view_manager.list_views()
399
+ for view in views:
400
+ interface_content = self._generate_typescript_view_interface(view)
401
+ file_name = f"{self._to_pascal_case(view.name)}View.ts"
402
+ file_path = output_dir / file_name
403
+
404
+ with open(file_path, "w") as f:
405
+ f.write(interface_content)
406
+
407
+ results["views_processed"].append(view.name)
408
+ results["files_generated"].append(file_name)
409
+
410
+ # Generate main index file
411
+ index_content = self._generate_typescript_index(
412
+ results["tables_processed"],
413
+ results["views_processed"]
414
+ )
415
+ index_path = output_dir / "index.ts"
416
+ with open(index_path, "w") as f:
417
+ f.write(index_content)
418
+ results["files_generated"].append("index.ts")
419
+
420
+ # Generate API client
421
+ client_content = self._generate_typescript_client()
422
+ client_path = output_dir / "client.ts"
423
+ with open(client_path, "w") as f:
424
+ f.write(client_content)
425
+ results["files_generated"].append("client.ts")
426
+
427
+ # Generate types file
428
+ types_content = self._generate_typescript_types()
429
+ types_path = output_dir / "types.ts"
430
+ with open(types_path, "w") as f:
431
+ f.write(types_content)
432
+ results["files_generated"].append("types.ts")
433
+
397
434
  return results
398
435
 
399
436
  def _get_view_columns(self, view_name: str) -> Dict[str, str]:
@@ -454,6 +491,326 @@ class CodegenManager:
454
491
  # Split on underscores and capitalize each part
455
492
  parts = name.replace("-", "_").split("_")
456
493
  return "".join(word.capitalize() for word in parts if word)
494
+
495
+ def _sqlite_to_typescript_type(self, sqlite_type: str, column_name: str = "") -> str:
496
+ """Convert SQLite type to TypeScript type string."""
497
+ sqlite_type = sqlite_type.upper()
498
+
499
+ # Special case for timestamp fields
500
+ if column_name in ["created_at", "updated_at"]:
501
+ return "string" # ISO date strings
502
+
503
+ if "INT" in sqlite_type:
504
+ return "number"
505
+ elif sqlite_type in ["REAL", "FLOAT", "DOUBLE", "NUMERIC"]:
506
+ return "number"
507
+ elif sqlite_type == "BLOB":
508
+ return "Uint8Array"
509
+ elif sqlite_type == "BOOLEAN":
510
+ return "boolean"
511
+ else:
512
+ # TEXT, VARCHAR, etc.
513
+ return "string"
514
+
515
+ def _generate_typescript_table_interface(self, table: Table) -> str:
516
+ """Generate TypeScript interface for a table."""
517
+ interface_name = self._to_pascal_case(table.name)
518
+
519
+ content = [
520
+ f"/**",
521
+ f" * Generated interface for {table.name} table",
522
+ f" */",
523
+ f"export interface {interface_name} {{",
524
+ ]
525
+
526
+ # Generate properties for each column
527
+ for column in table.columns:
528
+ ts_type = self._sqlite_to_typescript_type(column.type, column.name)
529
+ optional = "?" if column.nullable and column.name not in ["id", "created_at"] else ""
530
+ content.append(f" {column.name}{optional}: {ts_type};")
531
+
532
+ content.append("}")
533
+ content.append("")
534
+
535
+ # Generate input interface (for create/update operations)
536
+ content.extend([
537
+ f"/**",
538
+ f" * Input interface for creating/updating {table.name} records",
539
+ f" */",
540
+ f"export interface {interface_name}Input {{",
541
+ ])
542
+
543
+ for column in table.columns:
544
+ # Skip auto-generated fields in input
545
+ if column.name in ["id", "created_at", "updated_at"]:
546
+ continue
547
+ ts_type = self._sqlite_to_typescript_type(column.type, column.name)
548
+ optional = "?" if column.nullable else ""
549
+ content.append(f" {column.name}{optional}: {ts_type};")
550
+
551
+ content.append("}")
552
+ content.append("")
553
+
554
+ return "\n".join(content)
555
+
556
+ def _generate_typescript_view_interface(self, view: View) -> str:
557
+ """Generate TypeScript interface for a view."""
558
+ interface_name = f"{self._to_pascal_case(view.name)}View"
559
+
560
+ # Get view columns from PRAGMA
561
+ columns = self._get_view_columns(view.name)
562
+
563
+ content = [
564
+ f"/**",
565
+ f" * Generated interface for {view.name} view (read-only)",
566
+ f" */",
567
+ f"export interface {interface_name} {{",
568
+ ]
569
+
570
+ # Generate properties for each column
571
+ for column_name, column_type in columns.items():
572
+ ts_type = self._sqlite_to_typescript_type(column_type, column_name)
573
+ content.append(f" {column_name}: {ts_type};")
574
+
575
+ content.append("}")
576
+ content.append("")
577
+
578
+ return "\n".join(content)
579
+
580
+ def _generate_typescript_index(self, tables: List[str], views: List[str]) -> str:
581
+ """Generate TypeScript index file exporting all interfaces."""
582
+ content = [
583
+ "/**",
584
+ " * Generated TypeScript models for CinchDB",
585
+ " */",
586
+ "",
587
+ ]
588
+
589
+ # Export tables
590
+ for table_name in tables:
591
+ interface_name = self._to_pascal_case(table_name)
592
+ content.append(f"export {{ {interface_name}, {interface_name}Input }} from './{interface_name}';")
593
+
594
+ # Export views
595
+ for view_name in views:
596
+ interface_name = f"{self._to_pascal_case(view_name)}View"
597
+ content.append(f"export {{ {interface_name} }} from './{interface_name}';")
598
+
599
+ # Export client and types
600
+ content.extend([
601
+ "",
602
+ "export { CinchDBClient } from './client';",
603
+ "export * from './types';",
604
+ "",
605
+ ])
606
+
607
+ return "\n".join(content)
608
+
609
+ def _generate_typescript_client(self) -> str:
610
+ """Generate TypeScript API client class."""
611
+ content = [
612
+ "/**",
613
+ " * CinchDB TypeScript API Client",
614
+ " */",
615
+ "",
616
+ "import { QueryResult, CreateResult, UpdateResult, DeleteResult } from './types';",
617
+ "",
618
+ "export class CinchDBClient {",
619
+ " private baseUrl: string;",
620
+ " private headers: HeadersInit;",
621
+ "",
622
+ " constructor(baseUrl: string, apiKey: string) {",
623
+ " this.baseUrl = baseUrl;",
624
+ " this.headers = {",
625
+ " 'Content-Type': 'application/json',",
626
+ " 'X-API-Key': apiKey,",
627
+ " };",
628
+ " }",
629
+ "",
630
+ " /**",
631
+ " * Execute a query against the database",
632
+ " */",
633
+ " async query<T = any>(sql: string, params?: any[]): Promise<QueryResult<T>> {",
634
+ " const response = await fetch(`${this.baseUrl}/api/v1/query`, {",
635
+ " method: 'POST',",
636
+ " headers: this.headers,",
637
+ " body: JSON.stringify({ sql, params }),",
638
+ " });",
639
+ "",
640
+ " if (!response.ok) {",
641
+ " throw new Error(`Query failed: ${response.statusText}`);",
642
+ " }",
643
+ "",
644
+ " return response.json();",
645
+ " }",
646
+ "",
647
+ " /**",
648
+ " * Select records from a table",
649
+ " */",
650
+ " async select<T = any>(",
651
+ " table: string,",
652
+ " filters?: Record<string, any>,",
653
+ " limit?: number,",
654
+ " offset?: number",
655
+ " ): Promise<T[]> {",
656
+ " const params = new URLSearchParams();",
657
+ " if (filters) {",
658
+ " Object.entries(filters).forEach(([key, value]) => {",
659
+ " params.append(key, String(value));",
660
+ " });",
661
+ " }",
662
+ " if (limit !== undefined) params.append('limit', String(limit));",
663
+ " if (offset !== undefined) params.append('offset', String(offset));",
664
+ "",
665
+ " const response = await fetch(",
666
+ " `${this.baseUrl}/api/v1/tables/${table}/records?${params}`,",
667
+ " {",
668
+ " method: 'GET',",
669
+ " headers: this.headers,",
670
+ " }",
671
+ " );",
672
+ "",
673
+ " if (!response.ok) {",
674
+ " throw new Error(`Select failed: ${response.statusText}`);",
675
+ " }",
676
+ "",
677
+ " const result = await response.json();",
678
+ " return result.records;",
679
+ " }",
680
+ "",
681
+ " /**",
682
+ " * Create a new record",
683
+ " */",
684
+ " async create<T = any>(table: string, data: Partial<T>): Promise<CreateResult<T>> {",
685
+ " const response = await fetch(`${this.baseUrl}/api/v1/tables/${table}/records`, {",
686
+ " method: 'POST',",
687
+ " headers: this.headers,",
688
+ " body: JSON.stringify(data),",
689
+ " });",
690
+ "",
691
+ " if (!response.ok) {",
692
+ " throw new Error(`Create failed: ${response.statusText}`);",
693
+ " }",
694
+ "",
695
+ " return response.json();",
696
+ " }",
697
+ "",
698
+ " /**",
699
+ " * Update a record by ID",
700
+ " */",
701
+ " async update<T = any>(",
702
+ " table: string,",
703
+ " id: string,",
704
+ " data: Partial<T>",
705
+ " ): Promise<UpdateResult<T>> {",
706
+ " const response = await fetch(",
707
+ " `${this.baseUrl}/api/v1/tables/${table}/records/${id}`,",
708
+ " {",
709
+ " method: 'PUT',",
710
+ " headers: this.headers,",
711
+ " body: JSON.stringify(data),",
712
+ " }",
713
+ " );",
714
+ "",
715
+ " if (!response.ok) {",
716
+ " throw new Error(`Update failed: ${response.statusText}`);",
717
+ " }",
718
+ "",
719
+ " return response.json();",
720
+ " }",
721
+ "",
722
+ " /**",
723
+ " * Delete a record by ID",
724
+ " */",
725
+ " async delete(table: string, id: string): Promise<DeleteResult> {",
726
+ " const response = await fetch(",
727
+ " `${this.baseUrl}/api/v1/tables/${table}/records/${id}`,",
728
+ " {",
729
+ " method: 'DELETE',",
730
+ " headers: this.headers,",
731
+ " }",
732
+ " );",
733
+ "",
734
+ " if (!response.ok) {",
735
+ " throw new Error(`Delete failed: ${response.statusText}`);",
736
+ " }",
737
+ "",
738
+ " return response.json();",
739
+ " }",
740
+ "",
741
+ " /**",
742
+ " * Bulk create multiple records",
743
+ " */",
744
+ " async bulkCreate<T = any>(",
745
+ " table: string,",
746
+ " records: Partial<T>[]",
747
+ " ): Promise<CreateResult<T>[]> {",
748
+ " const response = await fetch(",
749
+ " `${this.baseUrl}/api/v1/tables/${table}/records/bulk`,",
750
+ " {",
751
+ " method: 'POST',",
752
+ " headers: this.headers,",
753
+ " body: JSON.stringify({ records }),",
754
+ " }",
755
+ " );",
756
+ "",
757
+ " if (!response.ok) {",
758
+ " throw new Error(`Bulk create failed: ${response.statusText}`);",
759
+ " }",
760
+ "",
761
+ " return response.json();",
762
+ " }",
763
+ "}",
764
+ "",
765
+ ]
766
+
767
+ return "\n".join(content)
768
+
769
+ def _generate_typescript_types(self) -> str:
770
+ """Generate TypeScript type definitions."""
771
+ content = [
772
+ "/**",
773
+ " * Common TypeScript type definitions for CinchDB",
774
+ " */",
775
+ "",
776
+ "export interface QueryResult<T = any> {",
777
+ " success: boolean;",
778
+ " data: T[];",
779
+ " rowCount: number;",
780
+ " error?: string;",
781
+ "}",
782
+ "",
783
+ "export interface CreateResult<T = any> {",
784
+ " success: boolean;",
785
+ " data: T;",
786
+ " error?: string;",
787
+ "}",
788
+ "",
789
+ "export interface UpdateResult<T = any> {",
790
+ " success: boolean;",
791
+ " data: T;",
792
+ " rowsAffected: number;",
793
+ " error?: string;",
794
+ "}",
795
+ "",
796
+ "export interface DeleteResult {",
797
+ " success: boolean;",
798
+ " rowsAffected: number;",
799
+ " error?: string;",
800
+ "}",
801
+ "",
802
+ "export interface PaginationParams {",
803
+ " limit?: number;",
804
+ " offset?: number;",
805
+ "}",
806
+ "",
807
+ "export interface FilterParams {",
808
+ " [key: string]: any;",
809
+ "}",
810
+ "",
811
+ ]
812
+
813
+ return "\n".join(content)
457
814
 
458
815
  def _generate_cinch_models_class(self) -> str:
459
816
  """Generate the CinchModels container class."""