Skip to content

Commit a0f20eb

Browse files
phernandezclaude
andauthored
chore: more Tenantless fixes (#457)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 78673d8 commit a0f20eb

8 files changed

Lines changed: 116 additions & 48 deletions

File tree

docker-compose-postgres.yml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Docker Compose configuration for Basic Memory with PostgreSQL
2+
# Use this for local development and testing with Postgres backend
3+
#
4+
# Usage:
5+
# docker-compose -f docker-compose-postgres.yml up -d
6+
# docker-compose -f docker-compose-postgres.yml down
7+
8+
services:
9+
postgres:
10+
image: postgres:17
11+
container_name: basic-memory-postgres
12+
environment:
13+
# Local development/test credentials - NOT for production
14+
# These values are referenced by tests and justfile commands
15+
POSTGRES_DB: basic_memory
16+
POSTGRES_USER: basic_memory_user
17+
POSTGRES_PASSWORD: dev_password # Simple password for local testing only
18+
ports:
19+
- "5433:5432"
20+
volumes:
21+
- postgres_data:/var/lib/postgresql/data
22+
healthcheck:
23+
test: ["CMD-SHELL", "pg_isready -U basic_memory_user -d basic_memory"]
24+
interval: 10s
25+
timeout: 5s
26+
retries: 5
27+
restart: unless-stopped
28+
29+
volumes:
30+
# Named volume for Postgres data
31+
postgres_data:
32+
driver: local
33+
34+
# Named volume for persistent configuration
35+
# Database will be stored in Postgres, not in this volume
36+
basic-memory-config:
37+
driver: local
38+
39+
# Network configuration (optional)
40+
# networks:
41+
# basic-memory-net:
42+
# driver: bridge

justfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ test-int-sqlite:
5151
# Note: Uses timeout due to FastMCP Client + asyncpg cleanup hang (tests pass, process hangs on exit)
5252
# See: https://github.com/jlowin/fastmcp/issues/1311
5353
test-int-postgres:
54-
timeout --signal=KILL 300 bash -c 'BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov test-int' || test $? -eq 137
54+
timeout --signal=KILL 600 bash -c 'BASIC_MEMORY_TEST_POSTGRES=1 uv run pytest -p pytest_mock -v --no-cov test-int' || test $? -eq 137
5555

5656
# Reset Postgres test database (drops and recreates schema)
5757
# Useful when Alembic migration state gets out of sync during development

src/basic_memory/api/routers/resource_router.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -185,21 +185,17 @@ async def write_resource(
185185
else:
186186
content_str = str(content)
187187

188-
# Get full file path
189-
full_path = Path(f"{config.home}/{file_path}")
190-
191-
# Ensure parent directory exists
192-
full_path.parent.mkdir(parents=True, exist_ok=True)
193-
194-
# Write content to file
195-
checksum = await file_service.write_file(full_path, content_str)
188+
# Cloud compatibility: do not assume a local filesystem path structure.
189+
# Delegate directory creation + writes to the configured FileService (local or S3).
190+
await file_service.ensure_directory(Path(file_path).parent)
191+
checksum = await file_service.write_file(file_path, content_str)
196192

197193
# Get file info
198-
file_metadata = await file_service.get_file_metadata(full_path)
194+
file_metadata = await file_service.get_file_metadata(file_path)
199195

200196
# Determine file details
201197
file_name = Path(file_path).name
202-
content_type = file_service.content_type(full_path)
198+
content_type = file_service.content_type(file_path)
203199

204200
entity_type = "canvas" if file_path.endswith(".canvas") else "file"
205201

src/basic_memory/api/routers/utils.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ async def to_graph_context(
2727
# First pass: collect all entity IDs needed for relations
2828
entity_ids_needed: set[int] = set()
2929
for context_item in context_result.results:
30-
for item in [context_item.primary_result] + context_item.observations + context_item.related_results:
30+
for item in (
31+
[context_item.primary_result] + context_item.observations + context_item.related_results
32+
):
3133
if item.type == SearchItemType.RELATION:
3234
if item.from_id: # pyright: ignore
3335
entity_ids_needed.add(item.from_id) # pyright: ignore

src/basic_memory/api/v2/routers/resource_router.py

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -135,21 +135,17 @@ async def create_resource(
135135
f"Use PUT /resource/{existing_entity.id} to update it.",
136136
)
137137

138-
# Get full file path
139-
full_path = Path(f"{config.home}/{data.file_path}")
140-
141-
# Ensure parent directory exists
142-
full_path.parent.mkdir(parents=True, exist_ok=True)
143-
144-
# Write content to file
145-
checksum = await file_service.write_file(full_path, data.content)
138+
# Cloud compatibility: avoid assuming a local filesystem path.
139+
# Delegate directory creation + writes to FileService (local or S3).
140+
await file_service.ensure_directory(Path(data.file_path).parent)
141+
checksum = await file_service.write_file(data.file_path, data.content)
146142

147143
# Get file info
148-
file_metadata = await file_service.get_file_metadata(full_path)
144+
file_metadata = await file_service.get_file_metadata(data.file_path)
149145

150146
# Determine file details
151147
file_name = Path(data.file_path).name
152-
content_type = file_service.content_type(full_path)
148+
content_type = file_service.content_type(data.file_path)
153149
entity_type = "canvas" if data.file_path.endswith(".canvas") else "file"
154150

155151
# Create a new entity model
@@ -234,30 +230,27 @@ async def update_resource(
234230
"Path must be relative and stay within project boundaries.",
235231
)
236232

237-
# Get full paths
238-
new_full_path = Path(f"{config.home}/{target_file_path}")
239-
240233
# If moving file, handle the move
241234
if data.file_path and data.file_path != entity.file_path:
242-
# Ensure new parent directory exists
243-
new_full_path.parent.mkdir(parents=True, exist_ok=True)
235+
# Ensure new parent directory exists (no-op for S3)
236+
await file_service.ensure_directory(Path(target_file_path).parent)
244237

245238
# If old file exists, remove it via file_service (for cloud compatibility)
246239
if await file_service.exists(entity.file_path):
247240
await file_service.delete_file(entity.file_path)
248241
else:
249242
# Ensure directory exists for in-place update
250-
new_full_path.parent.mkdir(parents=True, exist_ok=True)
243+
await file_service.ensure_directory(Path(target_file_path).parent)
251244

252245
# Write content to target file
253-
checksum = await file_service.write_file(new_full_path, data.content)
246+
checksum = await file_service.write_file(target_file_path, data.content)
254247

255248
# Get file info
256-
file_metadata = await file_service.get_file_metadata(new_full_path)
249+
file_metadata = await file_service.get_file_metadata(target_file_path)
257250

258251
# Determine file details
259252
file_name = Path(target_file_path).name
260-
content_type = file_service.content_type(new_full_path)
253+
content_type = file_service.content_type(target_file_path)
261254
entity_type = "canvas" if target_file_path.endswith(".canvas") else "file"
262255

263256
# Update entity

src/basic_memory/services/entity_service.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -792,23 +792,20 @@ async def move_entity(
792792
raise ValueError(f"Invalid destination path: {destination_path}")
793793

794794
# 3. Validate paths
795-
source_file = project_config.home / current_path
796-
destination_file = project_config.home / destination_path
797-
798-
# Validate source exists
799-
if not source_file.exists():
795+
# NOTE: In tenantless/cloud mode, we cannot rely on local filesystem paths.
796+
# Use FileService for existence checks and moving.
797+
if not await self.file_service.exists(current_path):
800798
raise ValueError(f"Source file not found: {current_path}")
801799

802-
# Check if destination already exists
803-
if destination_file.exists():
800+
if await self.file_service.exists(destination_path):
804801
raise ValueError(f"Destination already exists: {destination_path}")
805802

806803
try:
807-
# 4. Create destination directory if needed
808-
destination_file.parent.mkdir(parents=True, exist_ok=True)
804+
# 4. Ensure destination directory if needed (no-op for S3)
805+
await self.file_service.ensure_directory(Path(destination_path).parent)
809806

810-
# 5. Move physical file
811-
source_file.rename(destination_file)
807+
# 5. Move physical file via FileService (filesystem rename or cloud move)
808+
await self.file_service.move_file(current_path, destination_path)
812809
logger.info(f"Moved file: {current_path} -> {destination_path}")
813810

814811
# 6. Prepare database updates
@@ -847,12 +844,14 @@ async def move_entity(
847844

848845
except Exception as e:
849846
# Rollback: try to restore original file location if move succeeded
850-
if destination_file.exists() and not source_file.exists():
851-
try:
852-
destination_file.rename(source_file)
847+
try:
848+
if await self.file_service.exists(
849+
destination_path
850+
) and not await self.file_service.exists(current_path):
851+
await self.file_service.move_file(destination_path, current_path)
853852
logger.info(f"Rolled back file move: {destination_path} -> {current_path}")
854-
except Exception as rollback_error: # pragma: no cover
855-
logger.error(f"Failed to rollback file move: {rollback_error}")
853+
except Exception as rollback_error: # pragma: no cover
854+
logger.error(f"Failed to rollback file move: {rollback_error}")
856855

857856
# Re-raise the original error with context
858857
raise ValueError(f"Move failed: {str(e)}") from e

src/basic_memory/services/file_service.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,43 @@ async def delete_file(self, path: FilePath) -> None:
312312
full_path = path_obj if path_obj.is_absolute() else self.base_path / path_obj
313313
full_path.unlink(missing_ok=True)
314314

315+
async def move_file(self, source: FilePath, destination: FilePath) -> None:
316+
"""Move/rename a file from source to destination.
317+
318+
This method abstracts the underlying storage (filesystem vs cloud).
319+
Default implementation uses atomic filesystem rename, but cloud-backed
320+
implementations (e.g., S3) can override to copy+delete.
321+
322+
Args:
323+
source: Source path (relative to base_path or absolute)
324+
destination: Destination path (relative to base_path or absolute)
325+
326+
Raises:
327+
FileOperationError: If the move fails
328+
"""
329+
# Convert strings to Paths and resolve relative paths against base_path
330+
src_obj = self.base_path / source if isinstance(source, str) else source
331+
dst_obj = self.base_path / destination if isinstance(destination, str) else destination
332+
src_full = src_obj if src_obj.is_absolute() else self.base_path / src_obj
333+
dst_full = dst_obj if dst_obj.is_absolute() else self.base_path / dst_obj
334+
335+
try:
336+
# Ensure destination directory exists
337+
await self.ensure_directory(dst_full.parent)
338+
339+
# Use semaphore for concurrency control and run blocking rename in executor
340+
async with self._file_semaphore:
341+
loop = asyncio.get_event_loop()
342+
await loop.run_in_executor(None, lambda: src_full.rename(dst_full))
343+
except Exception as e:
344+
logger.exception(
345+
"File move error",
346+
source=str(src_full),
347+
destination=str(dst_full),
348+
error=str(e),
349+
)
350+
raise FileOperationError(f"Failed to move file {source} -> {destination}: {e}")
351+
315352
async def update_frontmatter(self, path: FilePath, updates: Dict[str, Any]) -> str:
316353
"""Update frontmatter fields in a file while preserving all content.
317354

tests/utils/test_timezone_utils.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from datetime import datetime, timezone
44

5-
import pytest
65

76
from basic_memory.utils import ensure_timezone_aware
87

0 commit comments

Comments
 (0)