spatial-memory-mcp 1.0.3__py3-none-any.whl → 1.6.0__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.

Potentially problematic release.


This version of spatial-memory-mcp might be problematic. Click here for more details.

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