spatial-memory-mcp 1.9.1__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.
Files changed (55) hide show
  1. spatial_memory/__init__.py +97 -0
  2. spatial_memory/__main__.py +271 -0
  3. spatial_memory/adapters/__init__.py +7 -0
  4. spatial_memory/adapters/lancedb_repository.py +880 -0
  5. spatial_memory/config.py +769 -0
  6. spatial_memory/core/__init__.py +118 -0
  7. spatial_memory/core/cache.py +317 -0
  8. spatial_memory/core/circuit_breaker.py +297 -0
  9. spatial_memory/core/connection_pool.py +220 -0
  10. spatial_memory/core/consolidation_strategies.py +401 -0
  11. spatial_memory/core/database.py +3072 -0
  12. spatial_memory/core/db_idempotency.py +242 -0
  13. spatial_memory/core/db_indexes.py +576 -0
  14. spatial_memory/core/db_migrations.py +588 -0
  15. spatial_memory/core/db_search.py +512 -0
  16. spatial_memory/core/db_versioning.py +178 -0
  17. spatial_memory/core/embeddings.py +558 -0
  18. spatial_memory/core/errors.py +317 -0
  19. spatial_memory/core/file_security.py +701 -0
  20. spatial_memory/core/filesystem.py +178 -0
  21. spatial_memory/core/health.py +289 -0
  22. spatial_memory/core/helpers.py +79 -0
  23. spatial_memory/core/import_security.py +433 -0
  24. spatial_memory/core/lifecycle_ops.py +1067 -0
  25. spatial_memory/core/logging.py +194 -0
  26. spatial_memory/core/metrics.py +192 -0
  27. spatial_memory/core/models.py +660 -0
  28. spatial_memory/core/rate_limiter.py +326 -0
  29. spatial_memory/core/response_types.py +500 -0
  30. spatial_memory/core/security.py +588 -0
  31. spatial_memory/core/spatial_ops.py +430 -0
  32. spatial_memory/core/tracing.py +300 -0
  33. spatial_memory/core/utils.py +110 -0
  34. spatial_memory/core/validation.py +406 -0
  35. spatial_memory/factory.py +444 -0
  36. spatial_memory/migrations/__init__.py +40 -0
  37. spatial_memory/ports/__init__.py +11 -0
  38. spatial_memory/ports/repositories.py +630 -0
  39. spatial_memory/py.typed +0 -0
  40. spatial_memory/server.py +1214 -0
  41. spatial_memory/services/__init__.py +70 -0
  42. spatial_memory/services/decay_manager.py +411 -0
  43. spatial_memory/services/export_import.py +1031 -0
  44. spatial_memory/services/lifecycle.py +1139 -0
  45. spatial_memory/services/memory.py +412 -0
  46. spatial_memory/services/spatial.py +1152 -0
  47. spatial_memory/services/utility.py +429 -0
  48. spatial_memory/tools/__init__.py +5 -0
  49. spatial_memory/tools/definitions.py +695 -0
  50. spatial_memory/verify.py +140 -0
  51. spatial_memory_mcp-1.9.1.dist-info/METADATA +509 -0
  52. spatial_memory_mcp-1.9.1.dist-info/RECORD +55 -0
  53. spatial_memory_mcp-1.9.1.dist-info/WHEEL +4 -0
  54. spatial_memory_mcp-1.9.1.dist-info/entry_points.txt +2 -0
  55. spatial_memory_mcp-1.9.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,588 @@
1
+ """Schema migration system for LanceDB database.
2
+
3
+ This module provides a migration framework for managing schema changes
4
+ over time. It supports:
5
+ - Forward migrations (up)
6
+ - Rollback migrations (down)
7
+ - Dry-run mode for previewing changes
8
+ - Automatic snapshot creation before migrations
9
+
10
+ Migrations are versioned using semantic versioning (e.g., "1.0.0", "1.1.0").
11
+
12
+ Usage:
13
+ from spatial_memory.core.db_migrations import MigrationManager
14
+
15
+ manager = MigrationManager(database)
16
+ manager.register_builtin_migrations()
17
+
18
+ # Check pending migrations
19
+ pending = manager.get_pending_migrations()
20
+
21
+ # Run migrations
22
+ applied = manager.run_pending(dry_run=False)
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import logging
28
+ from abc import ABC, abstractmethod
29
+ from dataclasses import dataclass, field
30
+ from datetime import datetime
31
+ from typing import TYPE_CHECKING, Any
32
+
33
+ import pyarrow as pa
34
+
35
+ from spatial_memory.core.errors import MigrationError, StorageError
36
+ from spatial_memory.core.utils import utc_now
37
+
38
+ if TYPE_CHECKING:
39
+ from spatial_memory.core.database import Database
40
+ from spatial_memory.ports.repositories import EmbeddingServiceProtocol
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+
45
+ # Schema metadata table name
46
+ SCHEMA_VERSIONS_TABLE = "_schema_versions"
47
+
48
+ # Current schema version
49
+ CURRENT_SCHEMA_VERSION = "1.0.0"
50
+
51
+
52
+ # =============================================================================
53
+ # Migration Data Types
54
+ # =============================================================================
55
+
56
+
57
+ @dataclass
58
+ class MigrationRecord:
59
+ """Record of an applied migration.
60
+
61
+ Stored in the _schema_versions table to track migration history.
62
+ """
63
+
64
+ version: str
65
+ description: str
66
+ applied_at: datetime
67
+ embedding_model: str | None = None
68
+ embedding_dimensions: int | None = None
69
+
70
+
71
+ @dataclass
72
+ class MigrationResult:
73
+ """Result of running migrations.
74
+
75
+ Attributes:
76
+ migrations_applied: List of version strings that were applied.
77
+ dry_run: Whether this was a dry run.
78
+ current_version: Version after migrations.
79
+ errors: List of error messages if any migrations failed.
80
+ """
81
+
82
+ migrations_applied: list[str] = field(default_factory=list)
83
+ dry_run: bool = True
84
+ current_version: str = "0.0.0"
85
+ errors: list[str] = field(default_factory=list)
86
+
87
+
88
+ # =============================================================================
89
+ # Migration Protocol/Base Class
90
+ # =============================================================================
91
+
92
+
93
+ class Migration(ABC):
94
+ """Abstract base class for schema migrations.
95
+
96
+ Each migration should:
97
+ 1. Have a unique version string (semantic versioning)
98
+ 2. Implement up() for forward migration
99
+ 3. Optionally implement down() for rollback
100
+ 4. Provide a description of what the migration does
101
+
102
+ Example:
103
+ class Migration001AddExpiresAt(Migration):
104
+ version = "1.1.0"
105
+ description = "Add expires_at column for TTL support"
106
+
107
+ def up(self, db: Database, embeddings: EmbeddingServiceProtocol | None) -> None:
108
+ # Add new column or modify schema
109
+ pass
110
+
111
+ def down(self, db: Database) -> None:
112
+ # Rollback changes (optional)
113
+ raise NotImplementedError("Rollback not supported")
114
+ """
115
+
116
+ @property
117
+ @abstractmethod
118
+ def version(self) -> str:
119
+ """Semantic version string (e.g., '1.1.0')."""
120
+ ...
121
+
122
+ @property
123
+ @abstractmethod
124
+ def description(self) -> str:
125
+ """Human-readable description of the migration."""
126
+ ...
127
+
128
+ @abstractmethod
129
+ def up(
130
+ self,
131
+ db: Database,
132
+ embeddings: EmbeddingServiceProtocol | None = None,
133
+ ) -> None:
134
+ """Apply the migration forward.
135
+
136
+ Args:
137
+ db: Database instance to migrate.
138
+ embeddings: Optional embedding service for re-embedding operations.
139
+
140
+ Raises:
141
+ MigrationError: If migration fails.
142
+ """
143
+ ...
144
+
145
+ def down(self, db: Database) -> None:
146
+ """Rollback the migration (optional).
147
+
148
+ By default, rollback is not supported. Override this method
149
+ to enable rollback for a specific migration.
150
+
151
+ Args:
152
+ db: Database instance to rollback.
153
+
154
+ Raises:
155
+ NotImplementedError: If rollback is not supported.
156
+ """
157
+ raise NotImplementedError(
158
+ f"Rollback not supported for migration {self.version}"
159
+ )
160
+
161
+
162
+ # =============================================================================
163
+ # Migration Manager
164
+ # =============================================================================
165
+
166
+
167
+ class MigrationManager:
168
+ """Manages database schema migrations.
169
+
170
+ The manager:
171
+ - Tracks applied migrations in a metadata table
172
+ - Supports forward migrations (up) and rollbacks (down)
173
+ - Creates snapshots before applying migrations for safety
174
+ - Supports dry-run mode for previewing changes
175
+
176
+ Example:
177
+ manager = MigrationManager(database)
178
+ manager.register_builtin_migrations()
179
+
180
+ # Preview pending migrations
181
+ pending = manager.get_pending_migrations()
182
+ for m in pending:
183
+ print(f"Pending: {m.version} - {m.description}")
184
+
185
+ # Apply migrations
186
+ result = manager.run_pending(dry_run=False)
187
+ print(f"Applied: {result.migrations_applied}")
188
+ """
189
+
190
+ def __init__(
191
+ self,
192
+ db: Database,
193
+ embeddings: EmbeddingServiceProtocol | None = None,
194
+ ) -> None:
195
+ """Initialize the migration manager.
196
+
197
+ Args:
198
+ db: Database instance to manage.
199
+ embeddings: Optional embedding service for migrations that re-embed.
200
+ """
201
+ self._db = db
202
+ self._embeddings = embeddings
203
+ self._migrations: dict[str, Migration] = {}
204
+ self._schema_table_checked = False
205
+
206
+ def _ensure_schema_table(self) -> None:
207
+ """Ensure the schema versions table exists.
208
+
209
+ Creates the table if it doesn't exist. This is called lazily
210
+ on first access to avoid issues with fresh databases.
211
+ """
212
+ if self._schema_table_checked:
213
+ return
214
+
215
+ try:
216
+ lance_db = self._db._db
217
+ assert lance_db is not None, "Database connection not initialized"
218
+ table_names = lance_db.table_names()
219
+
220
+ if SCHEMA_VERSIONS_TABLE not in table_names:
221
+ # Create schema versions table
222
+ schema = pa.schema([
223
+ pa.field("version", pa.string()),
224
+ pa.field("description", pa.string()),
225
+ pa.field("applied_at", pa.timestamp("us")),
226
+ pa.field("embedding_model", pa.string()),
227
+ pa.field("embedding_dimensions", pa.int32()),
228
+ ])
229
+ # Create empty table with schema
230
+ empty_table = pa.table(
231
+ {
232
+ "version": pa.array([], type=pa.string()),
233
+ "description": pa.array([], type=pa.string()),
234
+ "applied_at": pa.array([], type=pa.timestamp("us")),
235
+ "embedding_model": pa.array([], type=pa.string()),
236
+ "embedding_dimensions": pa.array([], type=pa.int32()),
237
+ },
238
+ schema=schema,
239
+ )
240
+ lance_db.create_table(SCHEMA_VERSIONS_TABLE, empty_table)
241
+ logger.info("Created schema versions table")
242
+
243
+ self._schema_table_checked = True
244
+ except Exception as e:
245
+ raise StorageError(f"Failed to create schema versions table: {e}") from e
246
+
247
+ def register(self, migration: Migration) -> None:
248
+ """Register a migration.
249
+
250
+ Args:
251
+ migration: Migration instance to register.
252
+
253
+ Raises:
254
+ ValueError: If a migration with the same version already exists.
255
+ """
256
+ if migration.version in self._migrations:
257
+ raise ValueError(f"Migration {migration.version} already registered")
258
+ self._migrations[migration.version] = migration
259
+
260
+ def register_builtin_migrations(self) -> None:
261
+ """Register all built-in migrations.
262
+
263
+ Called automatically to set up standard migrations.
264
+ """
265
+ # Register the initial schema migration
266
+ self.register(InitialSchemaMigration())
267
+ # Future migrations would be registered here:
268
+ # self.register(Migration001AddExpiresAt())
269
+
270
+ def get_current_version(self) -> str:
271
+ """Get the current schema version from the database.
272
+
273
+ Returns:
274
+ Current version string, or "0.0.0" if no migrations applied.
275
+ """
276
+ self._ensure_schema_table()
277
+
278
+ try:
279
+ lance_db = self._db._db
280
+ assert lance_db is not None, "Database connection not initialized"
281
+ table = lance_db.open_table(SCHEMA_VERSIONS_TABLE)
282
+ arrow_table = table.to_arrow()
283
+
284
+ if arrow_table.num_rows == 0:
285
+ return "0.0.0"
286
+
287
+ # Get the latest version by comparing all versions
288
+ versions: list[str] = arrow_table.column("version").to_pylist()
289
+ if not versions:
290
+ return "0.0.0"
291
+
292
+ # Find the maximum version using semantic comparison
293
+ return max(versions, key=lambda v: tuple(int(x) for x in v.split(".")))
294
+ except Exception as e:
295
+ logger.warning(f"Could not get current version: {e}")
296
+ return "0.0.0"
297
+
298
+ def get_applied_migrations(self) -> list[MigrationRecord]:
299
+ """Get list of all applied migrations.
300
+
301
+ Returns:
302
+ List of MigrationRecord for each applied migration.
303
+ """
304
+ self._ensure_schema_table()
305
+
306
+ try:
307
+ lance_db = self._db._db
308
+ assert lance_db is not None, "Database connection not initialized"
309
+ table = lance_db.open_table(SCHEMA_VERSIONS_TABLE)
310
+ arrow_table = table.to_arrow()
311
+
312
+ if arrow_table.num_rows == 0:
313
+ return []
314
+
315
+ records = []
316
+ versions = arrow_table.column("version").to_pylist()
317
+ descriptions = arrow_table.column("description").to_pylist()
318
+ applied_ats = arrow_table.column("applied_at").to_pylist()
319
+ embedding_models = arrow_table.column("embedding_model").to_pylist()
320
+ embedding_dims = arrow_table.column("embedding_dimensions").to_pylist()
321
+
322
+ for i in range(arrow_table.num_rows):
323
+ # Handle timestamp conversion
324
+ applied_at = applied_ats[i]
325
+ if hasattr(applied_at, "as_py"):
326
+ applied_at = applied_at.as_py()
327
+
328
+ records.append(MigrationRecord(
329
+ version=versions[i],
330
+ description=descriptions[i],
331
+ applied_at=applied_at,
332
+ embedding_model=embedding_models[i],
333
+ embedding_dimensions=embedding_dims[i],
334
+ ))
335
+ return records
336
+ except Exception as e:
337
+ logger.warning(f"Could not get applied migrations: {e}")
338
+ return []
339
+
340
+ def get_pending_migrations(self) -> list[Migration]:
341
+ """Get list of migrations that haven't been applied yet.
342
+
343
+ Returns:
344
+ List of Migration instances that need to be applied.
345
+ """
346
+ current = self.get_current_version()
347
+ pending = []
348
+
349
+ for version in sorted(self._migrations.keys()):
350
+ if self._compare_versions(version, current) > 0:
351
+ pending.append(self._migrations[version])
352
+
353
+ return pending
354
+
355
+ def run_pending(self, dry_run: bool = True) -> MigrationResult:
356
+ """Run all pending migrations.
357
+
358
+ Args:
359
+ dry_run: If True, preview migrations without applying.
360
+
361
+ Returns:
362
+ MigrationResult with applied migrations and any errors.
363
+ """
364
+ pending = self.get_pending_migrations()
365
+ result = MigrationResult(
366
+ dry_run=dry_run,
367
+ current_version=self.get_current_version(),
368
+ )
369
+
370
+ if not pending:
371
+ logger.info("No pending migrations")
372
+ return result
373
+
374
+ for migration in pending:
375
+ logger.info(
376
+ f"{'Would apply' if dry_run else 'Applying'} migration "
377
+ f"{migration.version}: {migration.description}"
378
+ )
379
+
380
+ if not dry_run:
381
+ try:
382
+ # Create snapshot before migration
383
+ snapshot_tag = f"pre-migration-{migration.version}"
384
+ snapshot_version = self._db.create_snapshot(snapshot_tag)
385
+ logger.info(f"Created pre-migration snapshot at version {snapshot_version}")
386
+
387
+ # Apply migration
388
+ migration.up(self._db, self._embeddings)
389
+
390
+ # Record migration
391
+ self._record_migration(migration)
392
+
393
+ result.migrations_applied.append(migration.version)
394
+ result.current_version = migration.version
395
+ except Exception as e:
396
+ error_msg = f"Migration {migration.version} failed: {e}"
397
+ logger.error(error_msg)
398
+ result.errors.append(error_msg)
399
+
400
+ # Stop on first error
401
+ break
402
+ else:
403
+ result.migrations_applied.append(migration.version)
404
+
405
+ return result
406
+
407
+ def rollback(self, target_version: str) -> MigrationResult:
408
+ """Rollback to a specific version.
409
+
410
+ Args:
411
+ target_version: Version to rollback to.
412
+
413
+ Returns:
414
+ MigrationResult with rolled back migrations and any errors.
415
+
416
+ Raises:
417
+ MigrationError: If rollback fails.
418
+ """
419
+ current = self.get_current_version()
420
+ result = MigrationResult(
421
+ dry_run=False,
422
+ current_version=current,
423
+ )
424
+
425
+ if self._compare_versions(target_version, current) >= 0:
426
+ logger.info(f"Already at or before version {target_version}")
427
+ return result
428
+
429
+ # Find migrations to rollback (newest first)
430
+ to_rollback = []
431
+ for version in sorted(self._migrations.keys(), reverse=True):
432
+ if self._compare_versions(version, target_version) > 0:
433
+ if self._compare_versions(version, current) <= 0:
434
+ to_rollback.append(self._migrations[version])
435
+
436
+ for migration in to_rollback:
437
+ logger.info(f"Rolling back migration {migration.version}")
438
+ try:
439
+ migration.down(self._db)
440
+ # Remove migration record
441
+ self._remove_migration_record(migration.version)
442
+ result.migrations_applied.append(migration.version)
443
+ except NotImplementedError:
444
+ error_msg = f"Rollback not supported for {migration.version}"
445
+ logger.error(error_msg)
446
+ result.errors.append(error_msg)
447
+ break
448
+ except Exception as e:
449
+ error_msg = f"Rollback of {migration.version} failed: {e}"
450
+ logger.error(error_msg)
451
+ result.errors.append(error_msg)
452
+ break
453
+
454
+ result.current_version = self.get_current_version()
455
+ return result
456
+
457
+ def _record_migration(self, migration: Migration) -> None:
458
+ """Record that a migration was applied.
459
+
460
+ Args:
461
+ migration: Migration that was applied.
462
+ """
463
+ self._ensure_schema_table()
464
+
465
+ try:
466
+ lance_db = self._db._db
467
+ assert lance_db is not None, "Database connection not initialized"
468
+ table = lance_db.open_table(SCHEMA_VERSIONS_TABLE)
469
+
470
+ # Get embedding info if available
471
+ embedding_model = None
472
+ embedding_dim = None
473
+ if self._embeddings:
474
+ embedding_model = getattr(self._embeddings, "model_name", None)
475
+ embedding_dim = getattr(self._embeddings, "dimensions", None)
476
+
477
+ record = pa.table({
478
+ "version": [migration.version],
479
+ "description": [migration.description],
480
+ "applied_at": [utc_now()],
481
+ "embedding_model": [embedding_model],
482
+ "embedding_dimensions": [embedding_dim],
483
+ })
484
+ table.add(record)
485
+ except Exception as e:
486
+ raise MigrationError(f"Failed to record migration: {e}") from e
487
+
488
+ def _remove_migration_record(self, version: str) -> None:
489
+ """Remove a migration record (for rollback).
490
+
491
+ Args:
492
+ version: Version to remove.
493
+ """
494
+ self._ensure_schema_table()
495
+
496
+ try:
497
+ lance_db = self._db._db
498
+ assert lance_db is not None, "Database connection not initialized"
499
+ table = lance_db.open_table(SCHEMA_VERSIONS_TABLE)
500
+ table.delete(f'version = "{version}"')
501
+ except Exception as e:
502
+ logger.warning(f"Failed to remove migration record: {e}")
503
+
504
+ @staticmethod
505
+ def _compare_versions(v1: str, v2: str) -> int:
506
+ """Compare two semantic version strings.
507
+
508
+ Args:
509
+ v1: First version.
510
+ v2: Second version.
511
+
512
+ Returns:
513
+ -1 if v1 < v2, 0 if equal, 1 if v1 > v2.
514
+ """
515
+ def parse(v: str) -> tuple[int, ...]:
516
+ return tuple(int(x) for x in v.split("."))
517
+
518
+ p1, p2 = parse(v1), parse(v2)
519
+ if p1 < p2:
520
+ return -1
521
+ if p1 > p2:
522
+ return 1
523
+ return 0
524
+
525
+
526
+ # =============================================================================
527
+ # Built-in Migrations
528
+ # =============================================================================
529
+
530
+
531
+ class InitialSchemaMigration(Migration):
532
+ """Initial schema setup migration.
533
+
534
+ This migration represents the initial schema state. It doesn't
535
+ actually change anything but serves as a baseline version marker.
536
+ """
537
+
538
+ @property
539
+ def version(self) -> str:
540
+ return "1.0.0"
541
+
542
+ @property
543
+ def description(self) -> str:
544
+ return "Initial schema version"
545
+
546
+ def up(
547
+ self,
548
+ db: Database,
549
+ embeddings: EmbeddingServiceProtocol | None = None,
550
+ ) -> None:
551
+ """No-op for initial schema - just marks version."""
552
+ logger.info("Initial schema version marker applied")
553
+
554
+ def down(self, db: Database) -> None:
555
+ """Cannot rollback initial schema."""
556
+ raise NotImplementedError("Cannot rollback initial schema")
557
+
558
+
559
+ # =============================================================================
560
+ # Helper Functions
561
+ # =============================================================================
562
+
563
+
564
+ def check_migration_status(db: Database) -> dict[str, Any]:
565
+ """Check the migration status of a database.
566
+
567
+ Args:
568
+ db: Database to check.
569
+
570
+ Returns:
571
+ Dictionary with migration status information.
572
+ """
573
+ manager = MigrationManager(db)
574
+ manager.register_builtin_migrations()
575
+
576
+ current = manager.get_current_version()
577
+ pending = manager.get_pending_migrations()
578
+
579
+ return {
580
+ "current_version": current,
581
+ "target_version": CURRENT_SCHEMA_VERSION,
582
+ "pending_count": len(pending),
583
+ "pending_migrations": [
584
+ {"version": m.version, "description": m.description}
585
+ for m in pending
586
+ ],
587
+ "needs_migration": len(pending) > 0,
588
+ }