cinchdb 0.1.14__py3-none-any.whl → 0.1.17__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/managers/data.py CHANGED
@@ -9,7 +9,7 @@ from pydantic import BaseModel
9
9
 
10
10
  from cinchdb.core.connection import DatabaseConnection
11
11
  from cinchdb.core.path_utils import get_tenant_db_path
12
- from cinchdb.core.maintenance import check_maintenance_mode
12
+ from cinchdb.core.maintenance_utils import check_maintenance_mode
13
13
  from cinchdb.managers.table import TableManager
14
14
  from cinchdb.managers.query import QueryManager
15
15
 
@@ -283,8 +283,8 @@ class DataManager:
283
283
  conn.rollback()
284
284
  raise
285
285
 
286
- def delete_by_id(self, model_class: Type[T], record_id: str) -> bool:
287
- """Delete a single record by ID.
286
+ def delete_model_by_id(self, model_class: Type[T], record_id: str) -> bool:
287
+ """Delete a single record by ID using model class.
288
288
 
289
289
  Args:
290
290
  model_class: Pydantic model class representing the table
@@ -420,12 +420,13 @@ class DataManager:
420
420
  return snake_case
421
421
 
422
422
  def _build_where_clause(
423
- self, filters: Dict[str, Any]
423
+ self, filters: Dict[str, Any], operator: str = "AND"
424
424
  ) -> tuple[str, Dict[str, Any]]:
425
425
  """Build WHERE clause and parameters from filters.
426
426
 
427
427
  Args:
428
428
  filters: Dictionary of column filters
429
+ operator: Logical operator to combine conditions - "AND" (default) or "OR"
429
430
 
430
431
  Returns:
431
432
  Tuple of (where_clause, parameters)
@@ -433,31 +434,34 @@ class DataManager:
433
434
  if not filters:
434
435
  return "", {}
435
436
 
437
+ if operator not in ("AND", "OR"):
438
+ raise ValueError(f"Operator must be 'AND' or 'OR', got: {operator}")
439
+
436
440
  conditions = []
437
441
  params = {}
438
442
 
439
443
  for key, value in filters.items():
440
444
  # Handle special operators (column__operator format)
441
445
  if "__" in key:
442
- column, operator = key.split("__", 1)
443
- param_key = f"{column}_{operator}"
446
+ column, op = key.split("__", 1)
447
+ param_key = f"{column}_{op}"
444
448
 
445
- if operator == "gte":
449
+ if op == "gte":
446
450
  conditions.append(f"{column} >= :{param_key}")
447
451
  params[param_key] = value
448
- elif operator == "lte":
452
+ elif op == "lte":
449
453
  conditions.append(f"{column} <= :{param_key}")
450
454
  params[param_key] = value
451
- elif operator == "gt":
455
+ elif op == "gt":
452
456
  conditions.append(f"{column} > :{param_key}")
453
457
  params[param_key] = value
454
- elif operator == "lt":
458
+ elif op == "lt":
455
459
  conditions.append(f"{column} < :{param_key}")
456
460
  params[param_key] = value
457
- elif operator == "like":
461
+ elif op == "like":
458
462
  conditions.append(f"{column} LIKE :{param_key}")
459
463
  params[param_key] = value
460
- elif operator == "in":
464
+ elif op == "in":
461
465
  if not isinstance(value, (list, tuple)):
462
466
  raise ValueError(
463
467
  f"'in' operator requires list or tuple, got {type(value)}"
@@ -467,10 +471,182 @@ class DataManager:
467
471
  for i, v in enumerate(value):
468
472
  params[f"{param_key}_{i}"] = v
469
473
  else:
470
- raise ValueError(f"Unsupported operator: {operator}")
474
+ raise ValueError(f"Unsupported operator: {op}")
471
475
  else:
472
476
  # Exact match
473
477
  conditions.append(f"{key} = :{key}")
474
478
  params[key] = value
475
479
 
476
- return " AND ".join(conditions), params
480
+ return f" {operator} ".join(conditions), params
481
+
482
+ def delete_where(self, table: str, operator: str = "AND", **filters) -> int:
483
+ """Delete records from a table based on filter criteria.
484
+
485
+ Args:
486
+ table: Table name
487
+ operator: Logical operator to combine conditions - "AND" (default) or "OR"
488
+ **filters: Filter criteria (supports operators like __gt, __lt, __in, __like, __not)
489
+ Multiple conditions are combined with the specified operator
490
+
491
+ Returns:
492
+ Number of records deleted
493
+
494
+ Examples:
495
+ # Delete records where status = 'inactive'
496
+ count = dm.delete_where('users', status='inactive')
497
+
498
+ # Delete records where status = 'inactive' AND age > 65 (default AND)
499
+ count = dm.delete_where('users', status='inactive', age__gt=65)
500
+
501
+ # Delete records where status = 'inactive' OR age > 65
502
+ count = dm.delete_where('users', operator='OR', status='inactive', age__gt=65)
503
+
504
+ # Delete records where age > 65
505
+ count = dm.delete_where('users', age__gt=65)
506
+
507
+ # Delete records where id in [1, 2, 3]
508
+ count = dm.delete_where('users', id__in=[1, 2, 3])
509
+
510
+ # Delete records where name like 'test%'
511
+ count = dm.delete_where('users', name__like='test%')
512
+ """
513
+ # Check maintenance mode
514
+ check_maintenance_mode(self.project_root, self.database, self.branch)
515
+
516
+ if not filters:
517
+ raise ValueError("delete_where requires at least one filter condition")
518
+
519
+ # Build WHERE clause
520
+ where_clause, params = self._build_where_clause(filters, operator)
521
+
522
+ sql = f"DELETE FROM {table} WHERE {where_clause}"
523
+
524
+ with DatabaseConnection(self.db_path) as conn:
525
+ cursor = conn.execute(sql, params)
526
+ conn.commit()
527
+ return cursor.rowcount
528
+
529
+ def update_where(self, table: str, data: Dict[str, Any], operator: str = "AND", **filters) -> int:
530
+ """Update records in a table based on filter criteria.
531
+
532
+ Args:
533
+ table: Table name
534
+ data: Dictionary of column-value pairs to update
535
+ operator: Logical operator to combine conditions - "AND" (default) or "OR"
536
+ **filters: Filter criteria (supports operators like __gt, __lt, __in, __like, __not)
537
+ Multiple conditions are combined with the specified operator
538
+
539
+ Returns:
540
+ Number of records updated
541
+
542
+ Examples:
543
+ # Update status for all users with age > 65
544
+ count = dm.update_where('users', {'status': 'senior'}, age__gt=65)
545
+
546
+ # Update status where age > 65 AND status = 'active' (default AND)
547
+ count = dm.update_where('users', {'status': 'senior'}, age__gt=65, status='active')
548
+
549
+ # Update status where age > 65 OR status = 'pending'
550
+ count = dm.update_where('users', {'status': 'senior'}, operator='OR', age__gt=65, status='pending')
551
+
552
+ # Update multiple fields where id in specific list
553
+ count = dm.update_where(
554
+ 'users',
555
+ {'status': 'inactive', 'updated_at': datetime.now()},
556
+ id__in=[1, 2, 3]
557
+ )
558
+
559
+ # Update where name like pattern
560
+ count = dm.update_where('users', {'category': 'test'}, name__like='test%')
561
+ """
562
+ # Check maintenance mode
563
+ check_maintenance_mode(self.project_root, self.database, self.branch)
564
+
565
+ if not filters:
566
+ raise ValueError("update_where requires at least one filter condition")
567
+
568
+ if not data:
569
+ raise ValueError("update_where requires data to update")
570
+
571
+ # Build WHERE clause
572
+ where_clause, where_params = self._build_where_clause(filters, operator)
573
+
574
+ # Build SET clause
575
+ set_clauses = []
576
+ update_params = {}
577
+
578
+ for key, value in data.items():
579
+ param_key = f"update_{key}"
580
+ set_clauses.append(f"{key} = :{param_key}")
581
+ update_params[param_key] = value
582
+
583
+ # Combine parameters (update params first to avoid conflicts)
584
+ all_params = {**update_params, **where_params}
585
+
586
+ sql = f"UPDATE {table} SET {', '.join(set_clauses)} WHERE {where_clause}"
587
+
588
+ with DatabaseConnection(self.db_path) as conn:
589
+ cursor = conn.execute(sql, all_params)
590
+ conn.commit()
591
+ return cursor.rowcount
592
+
593
+ def update_by_id(self, table: str, record_id: str, data: Dict[str, Any]) -> Dict[str, Any]:
594
+ """Update a single record by ID.
595
+
596
+ Args:
597
+ table: Table name
598
+ record_id: Record ID to update
599
+ data: Dictionary of column-value pairs to update
600
+
601
+ Returns:
602
+ Updated record
603
+ """
604
+ # Build SET clause
605
+ set_parts = []
606
+ params = []
607
+
608
+ for key, value in data.items():
609
+ set_parts.append(f"{key} = ?")
610
+ params.append(value)
611
+
612
+ if not set_parts:
613
+ raise ValueError("No data provided for update")
614
+
615
+ set_clause = ", ".join(set_parts)
616
+ sql = f"UPDATE {table} SET {set_clause} WHERE id = ?"
617
+ params.append(record_id)
618
+
619
+ with DatabaseConnection(self.db_path) as conn:
620
+ cursor = conn.execute(sql, params)
621
+ conn.commit()
622
+
623
+ if cursor.rowcount == 0:
624
+ raise ValueError(f"No record found with id: {record_id}")
625
+
626
+ # Return the updated record
627
+ result = conn.execute(f"SELECT * FROM {table} WHERE id = ?", [record_id])
628
+ row = result.fetchone()
629
+ if row:
630
+ return dict(row)
631
+ else:
632
+ raise ValueError(f"Record not found after update: {record_id}")
633
+
634
+ def delete_by_id(self, table: str, record_id: str) -> bool:
635
+ """Delete a single record by ID using table name.
636
+
637
+ This method is used by the high-level database API.
638
+
639
+ Args:
640
+ table: Table name
641
+ record_id: Record ID to delete
642
+
643
+ Returns:
644
+ True if record was deleted, False if not found
645
+ """
646
+ sql = f"DELETE FROM {table} WHERE id = ?"
647
+
648
+ with DatabaseConnection(self.db_path) as conn:
649
+ cursor = conn.execute(sql, [record_id])
650
+ conn.commit()
651
+ return cursor.rowcount > 0
652
+
cinchdb/managers/index.py CHANGED
@@ -3,7 +3,6 @@
3
3
  from pathlib import Path
4
4
  from typing import List, Dict, Any, Optional
5
5
  import sqlite3
6
- import json
7
6
  from datetime import datetime, timezone
8
7
  import uuid
9
8
 
@@ -263,7 +262,7 @@ class IndexManager:
263
262
 
264
263
  # Get index statistics
265
264
  xinfo_result = conn.execute(f"PRAGMA index_xinfo({index_name})")
266
- extended_info = xinfo_result.fetchall()
265
+ xinfo_result.fetchall()
267
266
 
268
267
  return {
269
268
  "name": index_name,
cinchdb/managers/query.py CHANGED
@@ -6,7 +6,6 @@ from typing import List, Dict, Any, Optional, Type, TypeVar, Union
6
6
  from pydantic import BaseModel, ValidationError
7
7
 
8
8
  from cinchdb.core.connection import DatabaseConnection
9
- from cinchdb.core.path_utils import get_tenant_db_path
10
9
  from cinchdb.utils import validate_query_safe
11
10
  from cinchdb.managers.tenant import TenantManager
12
11
 
cinchdb/managers/table.py CHANGED
@@ -9,7 +9,7 @@ if TYPE_CHECKING:
9
9
  from cinchdb.models import Index
10
10
  from cinchdb.core.connection import DatabaseConnection
11
11
  from cinchdb.core.path_utils import get_tenant_db_path
12
- from cinchdb.core.maintenance import check_maintenance_mode
12
+ from cinchdb.core.maintenance_utils import check_maintenance_mode
13
13
  from cinchdb.managers.change_tracker import ChangeTracker
14
14
  from cinchdb.managers.tenant import TenantManager
15
15
 
@@ -19,6 +19,9 @@ class TableManager:
19
19
 
20
20
  # Protected column names that users cannot use
21
21
  PROTECTED_COLUMNS = {"id", "created_at", "updated_at"}
22
+
23
+ # Protected table name prefixes that users cannot use
24
+ PROTECTED_TABLE_PREFIXES = ("__", "sqlite_")
22
25
 
23
26
  def __init__(
24
27
  self, project_root: Path, database: str, branch: str, tenant: str = "main"
@@ -48,18 +51,24 @@ class TableManager:
48
51
  tables = []
49
52
 
50
53
  with DatabaseConnection(self.db_path) as conn:
51
- # Get all user tables (exclude sqlite internal tables)
54
+ # Get all tables first, then filter in Python (more reliable than SQL LIKE)
52
55
  cursor = conn.execute(
53
56
  """
54
57
  SELECT name FROM sqlite_master
55
58
  WHERE type='table'
56
- AND name NOT LIKE 'sqlite_%'
57
59
  ORDER BY name
58
60
  """
59
61
  )
60
-
61
- for row in cursor.fetchall():
62
- table = self.get_table(row["name"])
62
+
63
+ # Filter out system tables and protected tables using Python
64
+ all_table_names = [row["name"] for row in cursor.fetchall()]
65
+ user_table_names = [
66
+ name for name in all_table_names
67
+ if not name.startswith('sqlite_') and not name.startswith('__')
68
+ ]
69
+
70
+ for table_name in user_table_names:
71
+ table = self.get_table(table_name)
63
72
  tables.append(table)
64
73
 
65
74
  return tables
@@ -82,6 +91,14 @@ class TableManager:
82
91
  """
83
92
  # Check maintenance mode
84
93
  check_maintenance_mode(self.project_root, self.database, self.branch)
94
+
95
+ # Validate table name doesn't use protected prefixes
96
+ for prefix in self.PROTECTED_TABLE_PREFIXES:
97
+ if table_name.startswith(prefix):
98
+ raise ValueError(
99
+ f"Table name '{table_name}' is not allowed. "
100
+ f"Table names cannot start with '{prefix}' as these are reserved for system use."
101
+ )
85
102
 
86
103
  # Validate table doesn't exist
87
104
  if self._table_exists(table_name):
@@ -325,6 +342,14 @@ class TableManager:
325
342
  """
326
343
  # Check maintenance mode
327
344
  check_maintenance_mode(self.project_root, self.database, self.branch)
345
+
346
+ # Validate target table name doesn't use protected prefixes
347
+ for prefix in self.PROTECTED_TABLE_PREFIXES:
348
+ if target_table.startswith(prefix):
349
+ raise ValueError(
350
+ f"Table name '{target_table}' is not allowed. "
351
+ f"Table names cannot start with '{prefix}' as these are reserved for system use."
352
+ )
328
353
 
329
354
  if not self._table_exists(source_table):
330
355
  raise ValueError(f"Source table '{source_table}' does not exist")
@@ -12,11 +12,10 @@ from cinchdb.models import Tenant
12
12
  from cinchdb.core.path_utils import (
13
13
  get_branch_path,
14
14
  get_tenant_db_path,
15
- get_database_path,
16
15
  list_tenants,
17
16
  )
18
17
  from cinchdb.core.connection import DatabaseConnection
19
- from cinchdb.core.maintenance import check_maintenance_mode
18
+ from cinchdb.core.maintenance_utils import check_maintenance_mode
20
19
  from cinchdb.utils.name_validator import validate_name
21
20
  from cinchdb.infrastructure.metadata_db import MetadataDB
22
21
  from cinchdb.infrastructure.metadata_connection_pool import get_metadata_db
@@ -279,18 +278,18 @@ class TenantManager:
279
278
  with DatabaseConnection(empty_db_path):
280
279
  pass # Just initialize with PRAGMAs
281
280
 
282
- # Optimize with small page size for empty template
281
+ # Set reasonable default page size for template
283
282
  # We need to rebuild the database with new page size
284
283
  temp_path = empty_db_path.with_suffix('.tmp')
285
284
 
286
- # Create new database with 512-byte pages
285
+ # Create new database with 4KB pages (SQLite default, good balance for general use)
287
286
  vacuum_conn = sqlite3.connect(str(empty_db_path))
288
287
  vacuum_conn.isolation_level = None
289
- vacuum_conn.execute("PRAGMA page_size = 512")
288
+ vacuum_conn.execute("PRAGMA page_size = 4096")
290
289
  vacuum_conn.execute(f"VACUUM INTO '{temp_path}'")
291
290
  vacuum_conn.close()
292
291
 
293
- # Replace original with optimized version
292
+ # Replace original with default optimized version
294
293
  shutil.move(str(temp_path), str(empty_db_path))
295
294
 
296
295
  # Mark as materialized now that the file exists
@@ -351,150 +350,6 @@ class TenantManager:
351
350
  if shm_path.exists():
352
351
  shm_path.unlink()
353
352
 
354
- def optimize_all_tenants(self, force: bool = False) -> dict:
355
- """Optimize storage for all materialized tenants in the branch.
356
-
357
- This is designed to be called periodically (e.g., every minute) to:
358
- - Reclaim unused space with VACUUM
359
- - Adjust page sizes as databases grow
360
- - Keep small databases compact
361
-
362
- Args:
363
- force: If True, optimize all tenants regardless of size
364
-
365
- Returns:
366
- Dictionary with optimization results:
367
- - optimized: List of tenant names that were optimized
368
- - skipped: List of tenant names that were skipped
369
- - errors: List of tuples (tenant_name, error_message)
370
- """
371
- results = {
372
- "optimized": [],
373
- "skipped": [],
374
- "errors": []
375
- }
376
-
377
- # Ensure initialization
378
- self._ensure_initialized()
379
-
380
- if not self.branch_id:
381
- return results
382
-
383
- # Get all materialized tenants for this branch
384
- tenants = self.metadata_db.list_tenants(self.branch_id, materialized_only=True)
385
-
386
- for tenant in tenants:
387
- tenant_name = tenant['name']
388
-
389
- # Skip system tenants unless forced
390
- if not force and tenant_name in ["main", self._empty_tenant_name]:
391
- results["skipped"].append(tenant_name)
392
- continue
393
-
394
- try:
395
- optimized = self.optimize_tenant_storage(tenant_name, force=force)
396
- if optimized:
397
- results["optimized"].append(tenant_name)
398
- else:
399
- results["skipped"].append(tenant_name)
400
- except Exception as e:
401
- results["errors"].append((tenant_name, str(e)))
402
-
403
- return results
404
-
405
- def optimize_tenant_storage(self, tenant_name: str, force: bool = False) -> bool:
406
- """Optimize tenant database storage with VACUUM and optional page size adjustment.
407
-
408
- This performs:
409
- 1. Always: VACUUM to reclaim unused space and defragment
410
- 2. If needed: Rebuild with optimal page size based on database size
411
-
412
- Args:
413
- tenant_name: Name of tenant to optimize
414
- force: If True, always perform VACUUM even if page size is optimal
415
-
416
- Returns:
417
- True if optimization was performed, False if tenant doesn't exist
418
- """
419
- # Ensure initialization
420
- self._ensure_initialized()
421
-
422
- if not self.branch_id:
423
- return False
424
-
425
- # Skip system tenants
426
- if tenant_name in ["main", self._empty_tenant_name]:
427
- return False
428
-
429
- # Get tenant info
430
- tenant_info = self.metadata_db.get_tenant(self.branch_id, tenant_name)
431
- if not tenant_info or not tenant_info['materialized']:
432
- return False
433
-
434
- db_path = get_tenant_db_path(
435
- self.project_root, self.database, self.branch, tenant_name
436
- )
437
-
438
- if not db_path.exists():
439
- return False
440
-
441
- # Check current page size
442
- conn = sqlite3.connect(str(db_path))
443
- current_page_size = conn.execute("PRAGMA page_size").fetchone()[0]
444
- conn.close()
445
-
446
- # Determine optimal page size
447
- optimal_page_size = self._get_optimal_page_size(db_path)
448
-
449
- # Decide if we need to rebuild with new page size
450
- needs_page_size_change = (current_page_size != optimal_page_size and
451
- db_path.stat().st_size > 1024 * 1024) # Only if > 1MB
452
-
453
- if needs_page_size_change:
454
- # Rebuild with new page size using VACUUM INTO
455
- temp_path = db_path.with_suffix('.tmp')
456
- conn = sqlite3.connect(str(db_path))
457
- conn.isolation_level = None
458
- conn.execute(f"PRAGMA page_size = {optimal_page_size}")
459
- conn.execute(f"VACUUM INTO '{temp_path}'")
460
- conn.close()
461
-
462
- # Replace original with optimized version
463
- shutil.move(str(temp_path), str(db_path))
464
- return True
465
- elif force or current_page_size == 512:
466
- # Just run regular VACUUM to defragment and reclaim space
467
- # Always vacuum 512-byte page databases to keep them compact
468
- conn = sqlite3.connect(str(db_path))
469
- conn.isolation_level = None
470
- conn.execute("VACUUM")
471
- conn.close()
472
- return True
473
-
474
- return False
475
-
476
- def _get_optimal_page_size(self, db_path: Path) -> int:
477
- """Determine optimal page size based on database file size.
478
-
479
- Args:
480
- db_path: Path to database file
481
-
482
- Returns:
483
- Optimal page size in bytes
484
- """
485
- if not db_path.exists():
486
- return 512 # Default for new/empty databases
487
-
488
- size_mb = db_path.stat().st_size / (1024 * 1024)
489
-
490
- if size_mb < 0.1: # < 100KB
491
- return 512
492
- elif size_mb < 10: # < 10MB
493
- return 4096 # 4KB - good balance for small-medium DBs
494
- elif size_mb < 100: # < 100MB
495
- return 8192 # 8KB - better for larger rows
496
- else: # >= 100MB
497
- return 16384 # 16KB - optimal for bulk operations
498
353
 
499
354
  def materialize_tenant(self, tenant_name: str) -> None:
500
355
  """Materialize a lazy tenant into an actual database file.
@@ -899,3 +754,88 @@ class TenantManager:
899
754
  """
900
755
  db_path = self.get_tenant_db_path_for_operation(tenant_name, is_write)
901
756
  return DatabaseConnection(db_path)
757
+
758
+ def vacuum_tenant(self, tenant_name: str) -> dict:
759
+ """Run VACUUM operation on a specific tenant to reclaim space and optimize performance.
760
+
761
+ This performs SQLite's VACUUM command which:
762
+ - Reclaims space from deleted records
763
+ - Defragments the database file
764
+ - Can improve query performance
765
+ - Rebuilds database statistics
766
+
767
+ Args:
768
+ tenant_name: Name of the tenant to vacuum
769
+
770
+ Returns:
771
+ Dictionary with vacuum results:
772
+ - success: Whether vacuum completed successfully
773
+ - tenant: Name of the tenant
774
+ - size_before: Size in bytes before vacuum
775
+ - size_after: Size in bytes after vacuum
776
+ - space_reclaimed: Bytes reclaimed by vacuum
777
+ - duration_seconds: Time taken for vacuum operation
778
+
779
+ Raises:
780
+ ValueError: If tenant doesn't exist or is not materialized
781
+ """
782
+ import time
783
+
784
+ # Ensure initialization
785
+ self._ensure_initialized()
786
+
787
+ # Check if tenant exists
788
+ if tenant_name != self._empty_tenant_name:
789
+ if not self.branch_id:
790
+ raise ValueError(f"Branch '{self.branch}' not found")
791
+
792
+ tenant_info = self.metadata_db.get_tenant(self.branch_id, tenant_name)
793
+ if not tenant_info:
794
+ raise ValueError(f"Tenant '{tenant_name}' does not exist")
795
+
796
+ # Check if tenant is materialized
797
+ if self.is_tenant_lazy(tenant_name):
798
+ raise ValueError(f"Cannot vacuum lazy tenant '{tenant_name}'. Tenant must be materialized first.")
799
+
800
+ # Get database path
801
+ db_path = self._get_sharded_tenant_db_path(tenant_name)
802
+
803
+ if not db_path.exists():
804
+ raise ValueError(f"Database file for tenant '{tenant_name}' does not exist")
805
+
806
+ # Get size before vacuum
807
+ size_before = db_path.stat().st_size
808
+
809
+ # Perform vacuum operation
810
+ start_time = time.time()
811
+ success = False
812
+ error_message = None
813
+
814
+ try:
815
+ with DatabaseConnection(db_path) as conn:
816
+ # Run VACUUM command
817
+ conn.execute("VACUUM")
818
+ success = True
819
+ except Exception as e:
820
+ error_message = str(e)
821
+
822
+ duration = time.time() - start_time
823
+
824
+ # Get size after vacuum
825
+ size_after = db_path.stat().st_size if db_path.exists() else 0
826
+ space_reclaimed = max(0, size_before - size_after)
827
+
828
+ result = {
829
+ "success": success,
830
+ "tenant": tenant_name,
831
+ "size_before": size_before,
832
+ "size_after": size_after,
833
+ "space_reclaimed": space_reclaimed,
834
+ "space_reclaimed_mb": round(space_reclaimed / (1024 * 1024), 2),
835
+ "duration_seconds": round(duration, 2)
836
+ }
837
+
838
+ if not success:
839
+ result["error"] = error_message
840
+
841
+ return result
cinchdb/managers/view.py CHANGED
@@ -6,7 +6,7 @@ from typing import List
6
6
  from cinchdb.models import View, Change, ChangeType
7
7
  from cinchdb.core.connection import DatabaseConnection
8
8
  from cinchdb.core.path_utils import get_tenant_db_path
9
- from cinchdb.core.maintenance import check_maintenance_mode
9
+ from cinchdb.core.maintenance_utils import check_maintenance_mode
10
10
  from cinchdb.managers.change_tracker import ChangeTracker
11
11
 
12
12
 
@@ -0,0 +1,16 @@
1
+ """
2
+ Simple Plugin System for CinchDB
3
+
4
+ Easy-to-use plugin architecture for CinchDB.
5
+ """
6
+
7
+ from .base import Plugin
8
+ from .manager import PluginManager
9
+ from .decorators import database_method, auto_extend
10
+
11
+ __all__ = [
12
+ "Plugin",
13
+ "PluginManager",
14
+ "database_method",
15
+ "auto_extend",
16
+ ]