Compare commits

..

1 Commits

Author SHA1 Message Date
claude[bot] f69c08e832 test: Add integration test to reproduce issue #397
Add test_remove_default_project_issue_397 that reproduces the bug
where a removed project still appears in 'bm project list'.

The test:
- Adds a new project and sets it as default
- Removes the original default project
- Verifies the removed project no longer appears in the list

This test will fail while the bug exists, confirming the issue.

Co-authored-by: Paul Hernandez <phernandez@users.noreply.github.com>
2025-10-23 13:09:16 +00:00
123 changed files with 12264 additions and 9162 deletions
+17 -94
View File
@@ -15,16 +15,10 @@ Create a stable release using the automated justfile target with comprehensive v
You are an expert release manager for the Basic Memory project. When the user runs `/release`, execute the following steps:
### Step 1: Pre-flight Validation
#### Version Check
1. Check current version in `src/basic_memory/__init__.py`
2. Verify new version format matches `v\d+\.\d+\.\d+` pattern
3. Confirm version is higher than current version
#### Git Status
1. Check current git status for uncommitted changes
2. Verify we're on the `main` branch
3. Confirm no existing tag with this version
1. Verify version format matches `v\d+\.\d+\.\d+` pattern
2. Check current git status for uncommitted changes
3. Verify we're on the `main` branch
4. Confirm no existing tag with this version
#### Documentation Validation
1. **Changelog Check**
@@ -45,83 +39,19 @@ The justfile target handles:
- ✅ Version update in `src/basic_memory/__init__.py`
- ✅ Automatic commit with proper message
- ✅ Tag creation and pushing to GitHub
- ✅ Release workflow trigger (automatic on tag push)
The GitHub Actions workflow (`.github/workflows/release.yml`) then:
- ✅ Builds the package using `uv build`
- ✅ Creates GitHub release with auto-generated notes
- ✅ Publishes to PyPI
- ✅ Updates Homebrew formula (stable releases only)
- ✅ Release workflow trigger
### Step 3: Monitor Release Process
1. Verify tag push triggered the workflow (should start automatically within seconds)
2. Monitor workflow progress at: https://github.com/basicmachines-co/basic-memory/actions
3. Watch for successful completion of both jobs:
- `release` - Builds package and publishes to PyPI
- `homebrew` - Updates Homebrew formula (stable releases only)
4. Check for any workflow failures and investigate logs if needed
1. Check that GitHub Actions workflow starts successfully
2. Monitor workflow completion at: https://github.com/basicmachines-co/basic-memory/actions
3. Verify PyPI publication
4. Test installation: `uv tool install basic-memory`
### Step 4: Post-Release Validation
#### GitHub Release
1. Verify GitHub release is created at: https://github.com/basicmachines-co/basic-memory/releases/tag/<version>
2. Check that release notes are auto-generated from commits
3. Validate release assets (`.whl` and `.tar.gz` files are attached)
#### PyPI Publication
1. Verify package published at: https://pypi.org/project/basic-memory/<version>/
2. Test installation: `uv tool install basic-memory`
3. Verify installed version: `basic-memory --version`
#### Homebrew Formula (Stable Releases Only)
1. Check formula update at: https://github.com/basicmachines-co/homebrew-basic-memory
2. Verify formula version matches release
3. Test Homebrew installation: `brew install basicmachines-co/basic-memory/basic-memory`
#### Website Updates
**1. basicmachines.co** (`/Users/drew/code/basicmachines.co`)
- **Goal**: Update version number displayed on the homepage
- **Location**: Search for "Basic Memory v0." in the codebase to find version displays
- **What to update**:
- Hero section heading that shows "Basic Memory v{VERSION}"
- "What's New in v{VERSION}" section heading
- Feature highlights array (look for array of features with title/description)
- **Process**:
1. Pull latest from GitHub: `git pull origin main`
2. Create release branch: `git checkout -b release/v{VERSION}`
3. Search codebase for current version number (e.g., "v0.16.1")
4. Update version numbers to new release version
5. Update feature highlights with 3-5 key features from this release (extract from CHANGELOG.md)
6. Commit changes: `git commit -m "chore: update to v{VERSION}"`
7. Push branch: `git push origin release/v{VERSION}`
- **Deploy**: Follow deployment process for basicmachines.co
**2. docs.basicmemory.com** (`/Users/drew/code/docs.basicmemory.com`)
- **Goal**: Add new release notes section to the latest-releases page
- **File**: `src/pages/latest-releases.mdx`
- **What to do**:
1. Pull latest from GitHub: `git pull origin main`
2. Create release branch: `git checkout -b release/v{VERSION}`
3. Read the existing file to understand the format and structure
4. Read `/Users/drew/code/basic-memory/CHANGELOG.md` to get release content
5. Add new release section **at the top** (after MDX imports, before other releases)
6. Follow the existing pattern:
- Heading: `## [v{VERSION}](github-link) — YYYY-MM-DD`
- Focus statement if applicable
- `<Info>` block with highlights (3-5 key items)
- Sections for Features, Bug Fixes, Breaking Changes, etc.
- Link to full changelog at the end
- Separator `---` between releases
7. Commit changes: `git commit -m "docs: add v{VERSION} release notes"`
8. Push branch: `git push origin release/v{VERSION}`
- **Source content**: Extract and format sections from CHANGELOG.md for this version
- **Deploy**: Follow deployment process for docs.basicmemory.com
**4. Announce Release**
- Post to Discord community if significant changes
- Update social media if major release
- Notify users via appropriate channels
1. Verify GitHub release is created automatically
2. Check PyPI publication
3. Validate release assets
4. Update any post-release documentation
## Pre-conditions Check
Before starting, verify:
@@ -144,18 +74,13 @@ Before starting, verify:
🏷️ Tag: v0.13.2
📋 GitHub Release: https://github.com/basicmachines-co/basic-memory/releases/tag/v0.13.2
📦 PyPI: https://pypi.org/project/basic-memory/0.13.2/
🍺 Homebrew: https://github.com/basicmachines-co/homebrew-basic-memory
🚀 GitHub Actions: Completed
Install with pip/uv:
uv tool install basic-memory
Install with Homebrew:
brew install basicmachines-co/basic-memory/basic-memory
Install with:
uv tool install basic-memory
Users can now upgrade:
uv tool upgrade basic-memory
brew upgrade basic-memory
uv tool upgrade basic-memory
```
## Context
@@ -164,6 +89,4 @@ Users can now upgrade:
- Uses the automated justfile target for consistency
- Version is automatically updated in `__init__.py`
- Triggers automated GitHub release with changelog
- Package is published to PyPI for `pip` and `uv` users
- Homebrew formula is automatically updated for stable releases
- Supports multiple installation methods (uv, pip, Homebrew)
- Leverages uv-dynamic-versioning for package version management
+20 -16
View File
@@ -1,19 +1,17 @@
---
allowed-tools: mcp__basic-memory__write_note, mcp__basic-memory__read_note, mcp__basic-memory__search_notes, mcp__basic-memory__edit_note
argument-hint: [create|status|show|review] [spec-name]
allowed-tools: mcp__basic-memory__write_note, mcp__basic-memory__read_note, mcp__basic-memory__search_notes, mcp__basic-memory__edit_note, Task
argument-hint: [create|status|implement|review] [spec-name]
description: Manage specifications in our development process
---
## Context
Specifications are managed in the Basic Memory "specs" project. All specs live in a centralized location accessible across all repositories via MCP tools.
See SPEC-1 and SPEC-2 in the "specs" project for the full specification-driven development process.
You are managing specifications using our specification-driven development process defined in @docs/specs/SPEC-001.md.
Available commands:
- `create [name]` - Create new specification
- `status` - Show all spec statuses
- `show [spec-name]` - Read a specific spec
- `implement [spec-name]` - Hand spec to appropriate agent
- `review [spec-name]` - Review implementation against spec
## Your task
@@ -21,19 +19,23 @@ Available commands:
Execute the spec command: `/spec $ARGUMENTS`
### If command is "create":
1. Get next SPEC number by searching existing specs in "specs" project
2. Create new spec using template from SPEC-2
3. Use mcp__basic-memory__write_note with project="specs"
1. Get next SPEC number by searching existing specs
2. Create new spec using template from @docs/specs/Slash\ Commands\ Reference.md
3. Place in `/specs` folder with title "SPEC-XXX: [name]"
4. Include standard sections: Why, What, How, How to Evaluate
### If command is "status":
1. Use mcp__basic-memory__search_notes with project="specs"
2. Display table with spec number, title, and progress
3. Show completion status from checkboxes in content
1. Search all notes in `/specs` folder
2. Display table with spec number, title, and status
3. Show any dependencies or assigned agents
### If command is "show":
1. Use mcp__basic-memory__read_note with project="specs"
2. Display the full spec content
### If command is "implement":
1. Read the specified spec
2. Determine appropriate agent based on content:
- Frontend/UI → vue-developer
- Architecture/system → system-architect
- Backend/API → python-developer
3. Launch Task tool with appropriate agent and spec context
### If command is "review":
1. Read the specified spec and its "How to Evaluate" section
@@ -47,5 +49,7 @@ Execute the spec command: `/spec $ARGUMENTS`
- **Architecture compliance** - Component isolation, state management patterns
- **Documentation completeness** - Implementation matches specification
3. Provide honest, accurate assessment - do not overstate completeness
4. Document findings and update spec with review results using mcp__basic-memory__edit_note
4. Document findings and update spec with review results
5. If gaps found, clearly identify what still needs to be implemented/tested
Use the agent definitions from @docs/specs/Agent\ Definitions.md for implementation handoffs.
-28
View File
@@ -1,28 +0,0 @@
# Basic Memory Environment Variables Example
# Copy this file to .env and customize as needed
# Note: .env files are gitignored and should never be committed
# ============================================================================
# PostgreSQL Test Database Configuration
# ============================================================================
# These variables allow you to override the default test database credentials
# Default values match docker-compose-postgres.yml for local development
#
# Only needed if you want to use different credentials or a remote test database
# By default, tests use: postgresql://basic_memory_user:dev_password@localhost:5433/basic_memory_test
# Full PostgreSQL test database URL (used by tests and migrations)
# POSTGRES_TEST_URL=postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory_test
# Individual components (used by justfile postgres-reset command)
# POSTGRES_USER=basic_memory_user
# POSTGRES_TEST_DB=basic_memory_test
# ============================================================================
# Production Database Configuration
# ============================================================================
# For production use, set these in your deployment environment
# DO NOT use the test credentials above in production!
# BASIC_MEMORY_DATABASE_BACKEND=postgres # or "sqlite"
# BASIC_MEMORY_DATABASE_URL=postgresql+asyncpg://user:password@host:port/database
+1 -4
View File
@@ -71,12 +71,9 @@ jobs:
- [ ] Proper error handling and logging
- [ ] Performance considerations addressed
- [ ] No sensitive data in logs or commits
## Compatability
- [ ] File path comparisons must be windows compatible
- [ ] Avoid using emojis and unicode characters in console and log output
Read the CLAUDE.md file for detailed project context. For each checklist item, verify if it's satisfied and comment on any that need attention. Use inline comments for specific code issues and post a summary with checklist results.
# Allow broader tool access for thorough code review
claude_args: '--allowed-tools "Bash(gh pr:*),Bash(gh issue:*),Bash(gh api:*),Bash(git log:*),Bash(git show:*),Read,Grep,Glob"'
+3 -60
View File
@@ -13,8 +13,7 @@ on:
branches: [ "main" ]
jobs:
test-sqlite:
name: Test SQLite (${{ matrix.os }}, Python ${{ matrix.python-version }})
test:
strategy:
fail-fast: false
matrix:
@@ -65,63 +64,7 @@ jobs:
run: |
just lint
- name: Run tests (SQLite)
- name: Run tests
run: |
uv pip install pytest pytest-cov
just test-sqlite
test-postgres:
name: Test Postgres (Python ${{ matrix.python-version }})
strategy:
fail-fast: false
matrix:
python-version: [ "3.12", "3.13" ]
runs-on: ubuntu-latest
# Postgres service (only available on Linux runners)
services:
postgres:
image: postgres:17
env:
POSTGRES_DB: basic_memory_test
POSTGRES_USER: basic_memory_user
POSTGRES_PASSWORD: dev_password
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5433:5432
steps:
- uses: actions/checkout@v4
with:
submodules: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
cache: 'pip'
- name: Install uv
run: |
pip install uv
- name: Install just
run: |
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to /usr/local/bin
- name: Create virtual env
run: |
uv venv
- name: Install dependencies
run: |
uv pip install -e .[dev]
- name: Run tests (Postgres)
run: |
uv pip install pytest pytest-cov
just test-postgres
just test
-267
View File
@@ -1,272 +1,5 @@
# CHANGELOG
## v0.16.2 (2025-11-16)
### Bug Fixes
- **#429**: Use platform-native path separators in config.json
([`6517e98`](https://github.com/basicmachines-co/basic-memory/commit/6517e98))
- Fixes config.json path separator issues on Windows
- Uses os.path.join for platform-native path construction
- Ensures consistent path handling across platforms
- **#427**: Add rclone installation checks for Windows bisync commands
([`1af0539`](https://github.com/basicmachines-co/basic-memory/commit/1af0539))
- Validates rclone installation before running bisync commands
- Provides clear error messages when rclone is not installed
- Improves user experience on Windows
- **#421**: Main project always recreated on project list command
([`cad7019`](https://github.com/basicmachines-co/basic-memory/commit/cad7019))
- Fixes issue where main project was recreated unnecessarily
- Improves project list command reliability
- Reduces unnecessary file system operations
## v0.16.1 (2025-11-11)
### Bug Fixes
- **#422**: Handle Windows line endings in rclone bisync
([`e9d0a94`](https://github.com/basicmachines-co/basic-memory/commit/e9d0a94))
- Added `--compare=modtime` flag to rclone bisync to ignore size differences from line ending conversions
- Fixes issue where LF→CRLF conversion on Windows was treated as file corruption
- Resolves "corrupted on transfer: sizes differ" errors during cloud sync on Windows
- Users will need to run `--resync` once after updating to establish new baseline
## v0.16.0 (2025-11-10)
### Features
- **#417**: Add run_in_background parameter to sync endpoint
([`7ccec7e`](https://github.com/basicmachines-co/basic-memory/commit/7ccec7e))
- New `run_in_background` parameter for async sync operations
- Improved API flexibility for long-running sync tasks
- Comprehensive test coverage for background sync behavior
- **#405**: SPEC-20 Simplified Project-Scoped Rclone Sync
([`0b3272a`](https://github.com/basicmachines-co/basic-memory/commit/0b3272a))
- Simplified and more reliable cloud synchronization
- Project-scoped rclone configuration
- Better error handling and status reporting
- **#384**: Streaming Foundation & Async I/O Consolidation (SPEC-19)
([`e78345f`](https://github.com/basicmachines-co/basic-memory/commit/e78345f))
- Foundation for streaming support in future releases
- Consolidated async I/O patterns across codebase
- Improved performance and resource management
- **#364**: Add circuit breaker for file sync failures
([`434cdf2`](https://github.com/basicmachines-co/basic-memory/commit/434cdf2))
- Prevents cascading failures during sync operations
- Automatic recovery from transient errors
- Better resilience in cloud sync scenarios
- **#362**: Add --verbose and --no-gitignore options to cloud upload
([`7f9c1a9`](https://github.com/basicmachines-co/basic-memory/commit/7f9c1a9))
- Enhanced upload control with verbose logging
- Option to bypass gitignore filtering when needed
- Better debugging and troubleshooting capabilities
- **#391**: Add delete_notes parameter to remove project endpoint
([`c9946ec`](https://github.com/basicmachines-co/basic-memory/commit/c9946ec))
- Option to delete notes when removing projects
- Safer project cleanup workflows
- Prevents accidental data loss
### Bug Fixes
- **#420**: Skip archive files during cloud upload
([`49b2adc`](https://github.com/basicmachines-co/basic-memory/commit/49b2adc))
- Prevents uploading of zip, tar, gz and other archive files
- Reduces storage usage and upload time
- Better file filtering during cloud operations
- **#419**: Rename write_note entity_type to note_type for clarity
([`1646572`](https://github.com/basicmachines-co/basic-memory/commit/1646572))
- Clearer parameter naming in write_note tool
- Improved API consistency and documentation
- Better developer experience
- **#418**: Quote string values in YAML frontmatter to handle special characters
([`f0d7398`](https://github.com/basicmachines-co/basic-memory/commit/f0d7398))
- Fixes YAML parsing errors with special characters
- More robust frontmatter handling
- Prevents data corruption in edge cases
- **#415**: Handle dict objects in write_resource endpoint
([`4614fd0`](https://github.com/basicmachines-co/basic-memory/commit/4614fd0))
- Fixes errors when writing dictionary resources
- Better type handling in resource endpoints
- Improved API robustness
- **#414**: Replace Unicode arrows with ASCII for Windows compatibility
([`fc01f6a`](https://github.com/basicmachines-co/basic-memory/commit/fc01f6a))
- Fixes display issues on Windows terminals
- Better cross-platform compatibility
- Improved CLI user experience on Windows
- **#411**: Windows CLI Unicode encoding errors
([`0ba6f21`](https://github.com/basicmachines-co/basic-memory/commit/0ba6f21))
- Resolves Unicode encoding issues on Windows
- Better handling of international characters
- Improved Windows platform support
- **#410**: Various rclone fixes for cloud sync on Windows
([`c9946ec`](https://github.com/basicmachines-co/basic-memory/commit/c9946ec))
- Fixes cloud sync reliability on Windows
- Better path handling for Windows filesystem
- Improved rclone integration on Windows
- **#402**: Normalize YAML frontmatter types to prevent AttributeError
([`a7d7cc5`](https://github.com/basicmachines-co/basic-memory/commit/a7d7cc5))
- Fixes AttributeError when reading frontmatter
- More robust type normalization
- Better error handling in markdown parsing
- **#396**: Strip duplicate headers in edit_note replace_section
([`021af74`](https://github.com/basicmachines-co/basic-memory/commit/021af74))
- Prevents duplicate headers when replacing sections
- Cleaner note editing behavior
- Better content consistency
- **#395**: Simplify search_notes schema by removing Optional wrappers
([`d775f7b`](https://github.com/basicmachines-co/basic-memory/commit/d775f7b))
- Cleaner API schema definition
- Better type safety and validation
- Improved developer experience
- **#394**: Add explicit type annotations to MCP tool parameters
([`581b7b1`](https://github.com/basicmachines-co/basic-memory/commit/581b7b1))
- Better type safety in MCP tools
- Improved IDE support and autocomplete
- Clearer tool documentation
- **#389**: Handle null, empty, and string 'None' title in markdown frontmatter
([`bb8da31`](https://github.com/basicmachines-co/basic-memory/commit/bb8da31))
- Fixes errors with malformed frontmatter titles
- More robust title handling
- Better error recovery
- **#380**: Optimize sync memory usage to prevent OOM on large projects
([`4fd6d0c`](https://github.com/basicmachines-co/basic-memory/commit/4fd6d0c))
- Prevents out-of-memory errors on large knowledge bases
- Better memory management during sync
- Improved scalability
- **#379**: Handle YAML parsing errors gracefully in update_frontmatter
([`32236cd`](https://github.com/basicmachines-co/basic-memory/commit/32236cd))
- Better error handling for malformed YAML
- Graceful degradation instead of crashes
- Improved robustness
- **#377**: Preserve mtime on WebDAV upload
([`e6c8e36`](https://github.com/basicmachines-co/basic-memory/commit/e6c8e36))
- Maintains file modification times during upload
- Better sync accuracy
- Prevents unnecessary re-syncing
- **#370**: Prevent deleted projects from being recreated by background sync
([`449b62d`](https://github.com/basicmachines-co/basic-memory/commit/449b62d))
- Fixes race condition with project deletion
- Better lifecycle management
- Prevents unwanted project recreation
- **#369**: Use filesystem timestamps for entity sync instead of database operation time
([`b7497d7`](https://github.com/basicmachines-co/basic-memory/commit/b7497d7))
- More accurate sync detection
- Better handling of external file modifications
- Improved sync reliability
- **#368**: Handle YAML parsing errors and missing entity_type in markdown files
([`d1431bd`](https://github.com/basicmachines-co/basic-memory/commit/d1431bd))
- Better error handling for malformed markdown
- Graceful handling of missing metadata
- Improved robustness
- **#367**: Resolve UNIQUE constraint violation in entity upsert with observations
([`171bef7`](https://github.com/basicmachines-co/basic-memory/commit/171bef7))
- Fixes database constraint errors during sync
- Better handling of duplicate observations
- Improved data integrity
- **#366**: Terminate sync immediately when project is deleted
([`729a5a3`](https://github.com/basicmachines-co/basic-memory/commit/729a5a3))
- Faster project deletion
- Better resource cleanup
- Improved user experience
- **#357**: Make project creation endpoint idempotent
([`53fb13b`](https://github.com/basicmachines-co/basic-memory/commit/53fb13b))
- Prevents errors when creating existing projects
- Better API reliability
- Improved cloud integration
- **#353**: Handle None text values in Claude conversations importer
([`bd6c834`](https://github.com/basicmachines-co/basic-memory/commit/bd6c834))
- Fixes import errors with empty messages
- Better error handling in importers
- Improved data migration
### Performance Improvements
- Force full database sync after project sync/bisync operations
([`2ad0ee9`](https://github.com/basicmachines-co/basic-memory/commit/2ad0ee9))
- Ensures database consistency after cloud operations
- Better sync reliability
- Improved data integrity
### Documentation
- Add free trial information to README
([`a7d7cc5`](https://github.com/basicmachines-co/basic-memory/commit/a7d7cc5), [`8aaddb6`](https://github.com/basicmachines-co/basic-memory/commit/8aaddb6))
- Updated README with Basic Memory Cloud trial info
- Better onboarding experience
- Clearer pricing information
- Announce Basic Memory Cloud launch in README
([`d756531`](https://github.com/basicmachines-co/basic-memory/commit/d756531))
- Official cloud service announcement
- Updated documentation for cloud features
- Improved product positioning
### Migration Guide
No manual migration required. Upgrade with:
```bash
# Update via uv
uv tool upgrade basic-memory
# Or install fresh
uv tool install basic-memory
```
**What's New in v0.16.0:**
- Streaming foundation and consolidated async I/O (SPEC-19)
- Simplified project-scoped rclone sync (SPEC-20)
- Circuit breaker for sync failure resilience
- Enhanced Windows platform support
- Improved cloud upload with verbose and gitignore options
- Better error handling across YAML parsing and frontmatter
- Memory optimization for large projects
- Archive file filtering during upload
**Breaking Changes:**
- `write_note` parameter renamed: `entity_type``note_type` for clarity
### Installation
```bash
# Latest stable release
uv tool install basic-memory
# Update existing installation
uv tool upgrade basic-memory
# Docker
docker pull ghcr.io/basicmachines-co/basic-memory:v0.16.0
```
## v0.15.2 (2025-10-14)
### Features
+1 -62
View File
@@ -7,16 +7,6 @@
![](https://badge.mcpx.dev?type=dev 'MCP Dev')
[![smithery badge](https://smithery.ai/badge/@basicmachines-co/basic-memory)](https://smithery.ai/server/@basicmachines-co/basic-memory)
## 🚀 Basic Memory Cloud is Live!
- **Cross-device and multi-platform support is here.** Your knowledge graph now works on desktop, web, and mobile - seamlessly synced across all your AI tools (Claude, ChatGPT, Gemini, Claude Code, and Codex)
- **Early Supporter Pricing:** Early users get 25% off forever.
The open source project continues as always. Cloud just makes it work everywhere.
[Sign up now →](https://basicmemory.com/beta)
with a 7 day free trial
# Basic Memory
Basic Memory lets you build persistent knowledge through natural conversations with Large Language Models (LLMs) like
@@ -433,57 +423,6 @@ See the [Documentation](https://memory.basicmachines.co/) for more info, includi
- [Managing multiple Projects](https://docs.basicmemory.com/guides/cli-reference/#project)
- [Importing data from OpenAI/Claude Projects](https://docs.basicmemory.com/guides/cli-reference/#import)
## Development
### Running Tests
Basic Memory supports dual database backends (SQLite and Postgres). Tests are parametrized to run against both backends automatically.
**Quick Start:**
```bash
# Run SQLite tests (default, no Docker needed)
just test-sqlite
# Run Postgres tests (requires Docker)
just test-postgres
```
**Available Test Commands:**
- `just test-sqlite` - Run tests against SQLite only (fastest, no Docker needed)
- `just test-postgres` - Run tests against Postgres only (requires Docker)
- `just test-windows` - Run Windows-specific tests (auto-skips on other platforms)
- `just test-benchmark` - Run performance benchmark tests
- `just test-all` - Run all tests including Windows, Postgres, and benchmarks
**Postgres Testing Requirements:**
To run Postgres tests, you need to start the test database:
```bash
docker-compose -f docker-compose-postgres.yml up -d
```
Tests will connect to `localhost:5433/basic_memory_test`.
**Test Markers:**
Tests use pytest markers for selective execution:
- `postgres` - Tests that run against Postgres backend
- `windows` - Windows-specific database optimizations
- `benchmark` - Performance tests (excluded from default runs)
**Other Development Commands:**
```bash
just install # Install with dev dependencies
just lint # Run linting checks
just typecheck # Run type checking
just format # Format code with ruff
just check # Run all quality checks
just migration "msg" # Create database migration
```
See the [justfile](justfile) for the complete list of development commands.
## License
AGPL-3.0
@@ -501,4 +440,4 @@ and submitting PRs.
</picture>
</a>
Built with ♥️ by Basic Machines
Built with ♥️ by Basic Machines
-42
View File
@@ -1,42 +0,0 @@
# Docker Compose configuration for Basic Memory with PostgreSQL
# Use this for local development and testing with Postgres backend
#
# Usage:
# docker-compose -f docker-compose-postgres.yml up -d
# docker-compose -f docker-compose-postgres.yml down
services:
postgres:
image: postgres:17
container_name: basic-memory-postgres
environment:
# Local development/test credentials - NOT for production
# These values are referenced by tests and justfile commands
POSTGRES_DB: basic_memory
POSTGRES_USER: basic_memory_user
POSTGRES_PASSWORD: dev_password # Simple password for local testing only
ports:
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U basic_memory_user -d basic_memory"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
volumes:
# Named volume for Postgres data
postgres_data:
driver: local
# Named volume for persistent configuration
# Database will be stored in Postgres, not in this volume
basic-memory-config:
driver: local
# Network configuration (optional)
# networks:
# basic-memory-net:
# driver: bridge
+26 -7
View File
@@ -77,6 +77,25 @@ SQLite Database (Index)
3. Files are parsed and indexed in SQLite
4. MCP server exposes indexed data to AI
5. AI can query, traverse, and update knowledge graph
### Version 0.15.0 Changes
**Breaking Change: Stateless Architecture**
- All MCP tools now require explicit `project` parameter
- No implicit project context carried between calls
- Exception: `default_project_mode` config option enables fallback
**Three-Tier Project Resolution**:
1. CLI constraint: `--project name` flag (highest priority)
2. Explicit parameter: `project="name"` in tool calls
3. Default mode: `default_project_mode=true` in config (fallback)
**Why This Matters**:
- More predictable behavior across sessions
- Explicit project selection prevents errors
- Multi-project workflows more reliable
- Single-project users can enable default mode for convenience
---
## Project Management
@@ -557,7 +576,7 @@ Adopt GraphQL instead of REST for our API layer.
""",
folder="decisions",
tags=["decision", "api", "graphql"],
note_type="decision",
entity_type="decision",
project="main"
)
```
@@ -594,7 +613,7 @@ await write_note(
""",
folder="meetings",
tags=["meeting", "api", "team"],
note_type="meeting",
entity_type="meeting",
project="main"
)
```
@@ -646,7 +665,7 @@ Specification for user authentication system using JWT tokens.
""",
folder="specs",
tags=["spec", "auth", "security"],
note_type="spec",
entity_type="spec",
project="main"
)
```
@@ -1612,7 +1631,7 @@ await write_note(
{related_entities}
""",
folder="decisions",
note_type="decision",
entity_type="decision",
project="main"
)
```
@@ -2687,14 +2706,14 @@ await write_note(
### Content Management
**write_note(title, content, folder, tags, note_type, project)**
**write_note(title, content, folder, tags, entity_type, project)**
- Create or update markdown notes
- Parameters:
- `title` (required): Note title
- `content` (required): Markdown content
- `folder` (required): Destination folder
- `tags` (optional): List of tags
- `note_type` (optional): Type of note (stored in frontmatter). Can be "note", "person", "meeting", "guide", etc.
- `entity_type` (optional): Entity type (note, person, meeting, etc.)
- `project` (required unless default_project_mode): Target project
- Returns: Created/updated entity with permalink
- Example:
@@ -2704,7 +2723,7 @@ await write_note(
content="# API Design\n...",
folder="specs",
tags=["api", "design"],
note_type="spec",
entity_type="spec",
project="main"
)
```
+581 -577
View File
File diff suppressed because it is too large Load Diff
+5 -67
View File
@@ -2,83 +2,21 @@
# Install dependencies
install:
uv pip install -e ".[dev]"
pip install -e ".[dev]"
uv sync
@echo ""
@echo "💡 Remember to activate the virtual environment by running: source .venv/bin/activate"
# Run all tests with unified coverage report
test: test-unit test-int
# Run unit tests only (fast, no coverage)
test-unit:
uv run pytest -p pytest_mock -v --no-cov tests
uv run pytest -p pytest_mock -v --no-cov -n auto tests
# Run integration tests only (fast, no coverage)
test-int:
uv run pytest -p pytest_mock -v --no-cov test-int
uv run pytest -p pytest_mock -v --no-cov -n auto test-int
# ==============================================================================
# DATABASE BACKEND TESTING
# ==============================================================================
# Basic Memory supports dual database backends (SQLite and Postgres).
# Tests are parametrized to run against both backends automatically.
#
# Quick Start:
# just test-sqlite # Run SQLite tests (default, no Docker needed)
# just test-postgres # Run Postgres tests (requires Docker)
#
# For Postgres tests, first start the database:
# docker-compose -f docker-compose-postgres.yml up -d
# ==============================================================================
# Run tests against SQLite only (default backend, skip Postgres/Benchmark tests)
# This is the fastest option and doesn't require any Docker setup.
# Use this for local development and quick feedback.
# Includes Windows-specific tests which will auto-skip on non-Windows platforms.
test-sqlite:
uv run pytest -p pytest_mock -v --no-cov -m "not postgres and not benchmark" tests test-int
# Run tests against Postgres only (requires docker-compose-postgres.yml up)
# First start Postgres: docker-compose -f docker-compose-postgres.yml up -d
# Tests will connect to localhost:5433/basic_memory_test
# To reset the database: just postgres-reset
test-postgres:
uv run pytest -p pytest_mock -v --no-cov -m "postgres and not benchmark" tests test-int
# Reset Postgres test database (drops and recreates schema)
# Useful when Alembic migration state gets out of sync during development
# Uses credentials from docker-compose-postgres.yml
postgres-reset:
docker exec basic-memory-postgres psql -U ${POSTGRES_USER:-basic_memory_user} -d ${POSTGRES_TEST_DB:-basic_memory_test} -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
@echo "✅ Postgres test database reset"
# Run Alembic migrations manually against Postgres test database
# Useful for debugging migration issues
# Uses credentials from docker-compose-postgres.yml (can override with env vars)
postgres-migrate:
@cd src/basic_memory/alembic && \
BASIC_MEMORY_DATABASE_BACKEND=postgres \
BASIC_MEMORY_DATABASE_URL=${POSTGRES_TEST_URL:-postgresql://basic_memory_user:dev_password@localhost:5433/basic_memory_test} \
uv run alembic upgrade head
@echo "✅ Migrations applied to Postgres test database"
# Run Windows-specific tests only (only works on Windows platform)
# These tests verify Windows-specific database optimizations (locking mode, NullPool)
# Will be skipped automatically on non-Windows platforms
test-windows:
uv run pytest -p pytest_mock -v --no-cov -m windows tests test-int
# Run benchmark tests only (performance testing)
# These are slow tests that measure sync performance with various file counts
# Excluded from default test runs to keep CI fast
test-benchmark:
uv run pytest -p pytest_mock -v --no-cov -m benchmark tests test-int
# Run all tests including Windows, Postgres, and Benchmarks (for CI/comprehensive testing)
# Use this before releasing to ensure everything works across all backends and platforms
test-all:
uv run pytest -p pytest_mock -v --no-cov tests test-int
# Run all tests with unified coverage report
test: test-unit test-int
# Generate HTML coverage report
coverage:
-5
View File
@@ -36,7 +36,6 @@ dependencies = [
"pytest-aio>=1.9.0",
"aiofiles>=24.1.0", # Async file I/O
"logfire>=0.73.0", # Optional observability (disabled by default via config)
"asyncpg>=0.30.0",
]
@@ -62,8 +61,6 @@ asyncio_default_fixture_loop_scope = "function"
markers = [
"benchmark: Performance benchmark tests (deselect with '-m \"not benchmark\"')",
"slow: Slow-running tests (deselect with '-m \"not slow\"')",
"postgres: Tests that run against Postgres backend (deselect with '-m \"not postgres\"')",
"windows: Windows-specific tests (deselect with '-m \"not windows\"')",
]
[tool.ruff]
@@ -81,8 +78,6 @@ dev = [
"pytest-xdist>=3.0.0",
"ruff>=0.1.6",
"freezegun>=1.5.5",
"nest-asyncio>=1.6.0",
"psycopg2-binary>=2.9.0", # For Alembic migrations with Postgres
]
[tool.hatch.version]
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,7 +1,7 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
# Package version - updated by release automation
__version__ = "0.16.2"
__version__ = "0.15.2"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
+5 -21
View File
@@ -8,7 +8,7 @@ from sqlalchemy import pool
from alembic import context
from basic_memory.config import ConfigManager, DatabaseBackend
from basic_memory.config import ConfigManager
# set config.env to "test" for pytest to prevent logging to file in utils.setup_logging()
os.environ["BASIC_MEMORY_ENV"] = "test"
@@ -20,28 +20,12 @@ from basic_memory.models import Base # noqa: E402
# access to the values within the .ini file in use.
config = context.config
# Load app config - this will read environment variables (BASIC_MEMORY_DATABASE_BACKEND, etc.)
# due to Pydantic's env_prefix="BASIC_MEMORY_" setting
app_config = ConfigManager().config
# Set the SQLAlchemy URL from our app config
sqlalchemy_url = f"sqlite:///{app_config.database_path}"
config.set_main_option("sqlalchemy.url", sqlalchemy_url)
# Set the SQLAlchemy URL based on database backend configuration
# If the URL is already set in config (e.g., from run_migrations), use that
# Otherwise, get it from app config
# Note: alembic.ini has a placeholder URL "driver://user:pass@localhost/dbname" that we need to override
current_url = config.get_main_option("sqlalchemy.url")
if not current_url or current_url == "driver://user:pass@localhost/dbname":
from basic_memory.db import DatabaseType
sqlalchemy_url = DatabaseType.get_db_url(
app_config.database_path, DatabaseType.FILESYSTEM, app_config
)
# For Postgres, Alembic needs synchronous driver (psycopg2), not async (asyncpg)
if app_config.database_backend == DatabaseBackend.POSTGRES:
# Convert asyncpg URL to psycopg2 URL for Alembic
sqlalchemy_url = sqlalchemy_url.replace("postgresql+asyncpg://", "postgresql://")
config.set_main_option("sqlalchemy.url", sqlalchemy_url)
# print(f"Using SQLAlchemy URL: {sqlalchemy_url}")
# Interpret the config file for Python logging.
if config.config_file_name is not None:
@@ -1,131 +0,0 @@
"""Add Postgres full-text search support with tsvector and GIN indexes
Revision ID: 314f1ea54dc4
Revises: e7e1f4367280
Create Date: 2025-11-15 18:05:01.025405
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "314f1ea54dc4"
down_revision: Union[str, None] = "e7e1f4367280"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add PostgreSQL full-text search support.
This migration:
1. Creates search_index table for Postgres (SQLite uses FTS5 virtual table)
2. Adds generated tsvector column for full-text search
3. Creates GIN index on the tsvector column for fast text queries
4. Creates GIN index on metadata JSONB column for fast containment queries
Note: These changes only apply to Postgres. SQLite continues to use FTS5 virtual tables.
"""
# Check if we're using Postgres
connection = op.get_bind()
if connection.dialect.name == "postgresql":
# Create search_index table for Postgres
# For SQLite, this is a FTS5 virtual table created elsewhere
from sqlalchemy.dialects.postgresql import JSONB
op.create_table(
"search_index",
sa.Column("id", sa.Integer(), nullable=False), # Entity IDs are integers
sa.Column("project_id", sa.Integer(), nullable=False), # Multi-tenant isolation
sa.Column("title", sa.Text(), nullable=True),
sa.Column("content_stems", sa.Text(), nullable=True),
sa.Column("content_snippet", sa.Text(), nullable=True),
sa.Column("permalink", sa.String(), nullable=True), # Nullable for non-markdown files
sa.Column("file_path", sa.String(), nullable=True),
sa.Column("type", sa.String(), nullable=True),
sa.Column("from_id", sa.Integer(), nullable=True), # Relation IDs are integers
sa.Column("to_id", sa.Integer(), nullable=True), # Relation IDs are integers
sa.Column("relation_type", sa.String(), nullable=True),
sa.Column("entity_id", sa.Integer(), nullable=True), # Entity IDs are integers
sa.Column("category", sa.String(), nullable=True),
sa.Column("metadata", JSONB(), nullable=True), # Use JSONB for Postgres
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint(
"id", "type", "project_id"
), # Composite key: id can repeat across types
sa.ForeignKeyConstraint(
["project_id"],
["project.id"],
name="fk_search_index_project_id",
ondelete="CASCADE",
),
if_not_exists=True,
)
# Create index on project_id for efficient multi-tenant queries
op.create_index(
"ix_search_index_project_id",
"search_index",
["project_id"],
unique=False,
)
# Create unique partial index on permalink for markdown files
# Non-markdown files don't have permalinks, so we use a partial index
op.execute("""
CREATE UNIQUE INDEX uix_search_index_permalink_project
ON search_index (permalink, project_id)
WHERE permalink IS NOT NULL
""")
# Add tsvector column as a GENERATED ALWAYS column
# This automatically updates when title or content_stems change
op.execute("""
ALTER TABLE search_index
ADD COLUMN textsearchable_index_col tsvector
GENERATED ALWAYS AS (
to_tsvector('english',
coalesce(title, '') || ' ' ||
coalesce(content_stems, '')
)
) STORED
""")
# Create GIN index on tsvector column for fast full-text search
op.create_index(
"idx_search_index_fts",
"search_index",
["textsearchable_index_col"],
unique=False,
postgresql_using="gin",
)
# Create GIN index on metadata JSONB for fast containment queries
# Using jsonb_path_ops for smaller index size and better performance
op.execute("""
CREATE INDEX idx_search_index_metadata_gin
ON search_index
USING GIN (metadata jsonb_path_ops)
""")
def downgrade() -> None:
"""Remove PostgreSQL full-text search support."""
connection = op.get_bind()
if connection.dialect.name == "postgresql":
# Drop indexes first
op.execute("DROP INDEX IF EXISTS idx_search_index_metadata_gin")
op.drop_index("idx_search_index_fts", table_name="search_index")
op.execute("DROP INDEX IF EXISTS uix_search_index_permalink_project")
op.drop_index("ix_search_index_project_id", table_name="search_index")
# Drop the generated column
op.execute("ALTER TABLE search_index DROP COLUMN IF EXISTS textsearchable_index_col")
# Drop the search_index table
op.drop_table("search_index")
@@ -21,12 +21,6 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# SQLite FTS5 virtual table handling is SQLite-specific
# For Postgres, search_index is a regular table managed by ORM
connection = op.get_bind()
is_sqlite = connection.dialect.name == "sqlite"
op.create_table(
"project",
sa.Column("id", sa.Integer(), nullable=False),
@@ -61,9 +55,7 @@ def upgrade() -> None:
batch_op.add_column(sa.Column("project_id", sa.Integer(), nullable=False))
batch_op.drop_index(
"uix_entity_permalink",
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL")
if is_sqlite
else None,
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
)
batch_op.drop_index("ix_entity_file_path")
batch_op.create_index(batch_op.f("ix_entity_file_path"), ["file_path"], unique=False)
@@ -75,16 +67,12 @@ def upgrade() -> None:
"uix_entity_permalink_project",
["permalink", "project_id"],
unique=True,
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL")
if is_sqlite
else None,
sqlite_where=sa.text("content_type = 'text/markdown' AND permalink IS NOT NULL"),
)
batch_op.create_foreign_key("fk_entity_project_id", "project", ["project_id"], ["id"])
# drop the search index table. it will be recreated
# Only drop for SQLite - Postgres uses regular table managed by ORM
if is_sqlite:
op.drop_table("search_index")
op.drop_table("search_index")
# ### end Alembic commands ###
@@ -25,51 +25,43 @@ def upgrade() -> None:
The UNIQUE constraint prevents multiple projects from having is_default=FALSE,
which breaks project creation when the service sets is_default=False.
SQLite: Recreate the table without the constraint (no ALTER TABLE support)
Postgres: Use ALTER TABLE to drop the constraint directly
Since SQLite doesn't support dropping specific constraints easily, we'll
recreate the table without the problematic constraint.
"""
connection = op.get_bind()
is_sqlite = connection.dialect.name == "sqlite"
# For SQLite, we need to recreate the table without the UNIQUE constraint
# Create a new table without the UNIQUE constraint on is_default
op.create_table(
"project_new",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("permalink", sa.String(), nullable=False),
sa.Column("path", sa.String(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("is_default", sa.Boolean(), nullable=True), # No UNIQUE constraint!
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
sa.UniqueConstraint("permalink"),
)
if is_sqlite:
# For SQLite, we need to recreate the table without the UNIQUE constraint
# Create a new table without the UNIQUE constraint on is_default
op.create_table(
"project_new",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("permalink", sa.String(), nullable=False),
sa.Column("path", sa.String(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("is_default", sa.Boolean(), nullable=True), # No UNIQUE constraint!
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
sa.UniqueConstraint("permalink"),
)
# Copy data from old table to new table
op.execute("INSERT INTO project_new SELECT * FROM project")
# Copy data from old table to new table
op.execute("INSERT INTO project_new SELECT * FROM project")
# Drop the old table
op.drop_table("project")
# Drop the old table
op.drop_table("project")
# Rename the new table
op.rename_table("project_new", "project")
# Rename the new table
op.rename_table("project_new", "project")
# Recreate the indexes
with op.batch_alter_table("project", schema=None) as batch_op:
batch_op.create_index("ix_project_created_at", ["created_at"], unique=False)
batch_op.create_index("ix_project_name", ["name"], unique=True)
batch_op.create_index("ix_project_path", ["path"], unique=False)
batch_op.create_index("ix_project_permalink", ["permalink"], unique=True)
batch_op.create_index("ix_project_updated_at", ["updated_at"], unique=False)
else:
# For Postgres, we can simply drop the constraint
with op.batch_alter_table("project", schema=None) as batch_op:
batch_op.drop_constraint("project_is_default_key", type_="unique")
# Recreate the indexes
with op.batch_alter_table("project", schema=None) as batch_op:
batch_op.create_index("ix_project_created_at", ["created_at"], unique=False)
batch_op.create_index("ix_project_name", ["name"], unique=True)
batch_op.create_index("ix_project_path", ["path"], unique=False)
batch_op.create_index("ix_project_permalink", ["permalink"], unique=True)
batch_op.create_index("ix_project_updated_at", ["updated_at"], unique=False)
def downgrade() -> None:
@@ -21,12 +21,6 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade database schema to use new search index with content_stems and content_snippet."""
# This migration is SQLite-specific (FTS5 virtual tables)
# For Postgres, the search_index table is created via ORM models
connection = op.get_bind()
if connection.dialect.name != "sqlite":
return
# First, drop the existing search_index table
op.execute("DROP TABLE IF EXISTS search_index")
@@ -65,13 +59,6 @@ def upgrade() -> None:
def downgrade() -> None:
"""Downgrade database schema to use old search index."""
# This migration is SQLite-specific (FTS5 virtual tables)
# For Postgres, the search_index table is managed via ORM models
connection = op.get_bind()
if connection.dialect.name != "sqlite":
return
# Drop the updated search_index table
op.execute("DROP TABLE IF EXISTS search_index")
+9 -29
View File
@@ -18,7 +18,6 @@ from basic_memory.schemas.project_info import (
ProjectInfoRequest,
ProjectStatusResponse,
)
from basic_memory.utils import normalize_project_path
# Router for resources in a specific project
# The ProjectPathDep is used in the path as a prefix, so the request path is like /{project}/project/info
@@ -51,7 +50,7 @@ async def get_project(
return ProjectItem(
name=found_project.name,
path=normalize_project_path(found_project.path),
path=found_project.path,
is_default=found_project.is_default or False,
)
@@ -110,10 +109,6 @@ async def sync_project(
background_tasks: BackgroundTasks,
sync_service: SyncServiceDep,
project_config: ProjectConfigDep,
force_full: bool = Query(
False, description="Force full scan, bypassing watermark optimization"
),
run_in_background: bool = Query(True, description="Run in background"),
):
"""Force project filesystem sync to database.
@@ -123,32 +118,17 @@ async def sync_project(
background_tasks: FastAPI background tasks
sync_service: Sync service for this project
project_config: Project configuration
force_full: If True, force a full scan even if watermark exists
run_in_background: If True, run sync in background and return immediately
Returns:
Response confirming sync was initiated (background) or SyncReportResponse (foreground)
Response confirming sync was initiated
"""
if run_in_background:
background_tasks.add_task(
sync_service.sync, project_config.home, project_config.name, force_full=force_full
)
logger.info(
f"Filesystem sync initiated for project: {project_config.name} (force_full={force_full})"
)
background_tasks.add_task(sync_service.sync, project_config.home, project_config.name)
logger.info(f"Filesystem sync initiated for project: {project_config.name}")
return {
"status": "sync_started",
"message": f"Filesystem sync initiated for project '{project_config.name}'",
}
else:
report = await sync_service.sync(
project_config.home, project_config.name, force_full=force_full
)
logger.info(
f"Filesystem sync completed for project: {project_config.name} (force_full={force_full})"
)
return SyncReportResponse.from_sync_report(report)
return {
"status": "sync_started",
"message": f"Filesystem sync initiated for project '{project_config.name}'",
}
@project_router.post("/status", response_model=SyncReportResponse)
@@ -187,7 +167,7 @@ async def list_projects(
project_items = [
ProjectItem(
name=project.name,
path=normalize_project_path(project.path),
path=project.path,
is_default=project.is_default or False,
)
for project in projects
@@ -151,20 +151,6 @@ async def write_resource(
try:
# Get content from request body
# Defensive type checking: ensure content is a string
# FastAPI should validate this, but if a dict somehow gets through
# (e.g., via JSON body parsing), we need to catch it here
if isinstance(content, dict):
logger.error(
f"Error writing resource {file_path}: "
f"content is a dict, expected string. Keys: {list(content.keys())}"
)
raise HTTPException(
status_code=400,
detail="content must be a string, not a dict. "
"Ensure request body is sent as raw string content, not JSON object.",
)
# Ensure it's UTF-8 string content
if isinstance(content, bytes): # pragma: no cover
content_str = content.decode("utf-8")
+5 -5
View File
@@ -77,7 +77,7 @@ class CLIAuth:
verification_uri = device_response["verification_uri"]
verification_uri_complete = device_response.get("verification_uri_complete")
console.print("\n[bold blue]Authentication Required[/bold blue]")
console.print("\n[bold blue]🔐 Authentication Required[/bold blue]")
console.print("\nTo authenticate, please visit:")
console.print(f"[bold cyan]{verification_uri}[/bold cyan]")
console.print(f"\nAnd enter this code: [bold yellow]{user_code}[/bold yellow]")
@@ -171,7 +171,7 @@ class CLIAuth:
# Secure the token file
os.chmod(self.token_file, 0o600)
console.print(f"[green]Tokens saved to {self.token_file}[/green]")
console.print(f"[green]Tokens saved to {self.token_file}[/green]")
def load_tokens(self) -> dict | None:
"""Load tokens from .bm-auth.json file."""
@@ -233,7 +233,7 @@ class CLIAuth:
if new_tokens:
# Save new tokens (may include rotated refresh token)
self.save_tokens(new_tokens)
console.print("[green]Token refreshed successfully[/green]")
console.print("[green]Token refreshed successfully[/green]")
return new_tokens["access_token"]
else:
console.print("[yellow]Token refresh failed. Please run 'login' again.[/yellow]")
@@ -265,13 +265,13 @@ class CLIAuth:
# Step 4: Save tokens
self.save_tokens(tokens)
console.print("\n[green]Successfully authenticated with Basic Memory Cloud![/green]")
console.print("\n[green]Successfully authenticated with Basic Memory Cloud![/green]")
return True
def logout(self) -> None:
"""Remove stored authentication tokens."""
if self.token_file.exists():
self.token_file.unlink()
console.print("[green]Logged out successfully[/green]")
console.print("[green]Logged out successfully[/green]")
else:
console.print("[yellow]No stored authentication found[/yellow]")
+2 -1
View File
@@ -1,10 +1,11 @@
"""CLI commands for basic-memory."""
from . import status, db, import_memory_json, mcp, import_claude_conversations
from . import status, sync, db, import_memory_json, mcp, import_claude_conversations
from . import import_claude_projects, import_chatgpt, tool, project
__all__ = [
"status",
"sync",
"db",
"import_memory_json",
"mcp",
@@ -1,11 +1,33 @@
"""Cloud bisync utility functions for Basic Memory CLI."""
"""Cloud bisync commands for Basic Memory CLI."""
import asyncio
import subprocess
import time
from datetime import datetime
from pathlib import Path
from typing import Optional
from basic_memory.cli.commands.cloud.api_client import make_api_request
import typer
from rich.console import Console
from rich.table import Table
from basic_memory.cli.commands.cloud.api_client import CloudAPIError, make_api_request
from basic_memory.cli.commands.cloud.cloud_utils import (
create_cloud_project,
fetch_cloud_projects,
)
from basic_memory.cli.commands.cloud.rclone_config import (
add_tenant_to_rclone_config,
)
from basic_memory.cli.commands.cloud.rclone_installer import RcloneInstallError, install_rclone
from basic_memory.config import ConfigManager
from basic_memory.ignore_utils import create_default_bmignore, get_bmignore_path
from basic_memory.schemas.cloud import MountCredentials, TenantMountInfo
from basic_memory.ignore_utils import get_bmignore_path, create_default_bmignore
from basic_memory.schemas.cloud import (
TenantMountInfo,
MountCredentials,
)
console = Console()
class BisyncError(Exception):
@@ -14,6 +36,52 @@ class BisyncError(Exception):
pass
class RcloneBisyncProfile:
"""Bisync profile with safety settings."""
def __init__(
self,
name: str,
conflict_resolve: str,
max_delete: int,
check_access: bool,
description: str,
extra_args: Optional[list[str]] = None,
):
self.name = name
self.conflict_resolve = conflict_resolve
self.max_delete = max_delete
self.check_access = check_access
self.description = description
self.extra_args = extra_args or []
# Bisync profiles based on SPEC-9 Phase 2.1
BISYNC_PROFILES = {
"safe": RcloneBisyncProfile(
name="safe",
conflict_resolve="none",
max_delete=10,
check_access=False,
description="Safe mode with conflict preservation (keeps both versions)",
),
"balanced": RcloneBisyncProfile(
name="balanced",
conflict_resolve="newer",
max_delete=25,
check_access=False,
description="Balanced mode - auto-resolve to newer file (recommended)",
),
"fast": RcloneBisyncProfile(
name="fast",
conflict_resolve="newer",
max_delete=50,
check_access=False,
description="Fast mode for rapid iteration (skip verification)",
),
}
async def get_mount_info() -> TenantMountInfo:
"""Get current tenant information from cloud API."""
try:
@@ -42,6 +110,75 @@ async def generate_mount_credentials(tenant_id: str) -> MountCredentials:
raise BisyncError(f"Failed to generate credentials: {e}") from e
def scan_local_directories(sync_dir: Path) -> list[str]:
"""Scan local sync directory for project folders.
Args:
sync_dir: Path to bisync directory
Returns:
List of directory names (project names)
"""
if not sync_dir.exists():
return []
directories = []
for item in sync_dir.iterdir():
if item.is_dir() and not item.name.startswith("."):
directories.append(item.name)
return directories
def get_bisync_state_path(tenant_id: str) -> Path:
"""Get path to bisync state directory."""
return Path.home() / ".basic-memory" / "bisync-state" / tenant_id
def get_bisync_directory() -> Path:
"""Get bisync directory from config.
Returns:
Path to bisync directory (default: ~/basic-memory-cloud-sync)
"""
config_manager = ConfigManager()
config = config_manager.config
sync_dir = config.bisync_config.get("sync_dir", str(Path.home() / "basic-memory-cloud-sync"))
return Path(sync_dir).expanduser().resolve()
def validate_bisync_directory(bisync_dir: Path) -> None:
"""Validate bisync directory doesn't conflict with mount.
Raises:
BisyncError: If bisync directory conflicts with mount directory
"""
# Get fixed mount directory
mount_dir = (Path.home() / "basic-memory-cloud").resolve()
# Check if bisync dir is the same as mount dir
if bisync_dir == mount_dir:
raise BisyncError(
f"Cannot use {bisync_dir} for bisync - it's the mount directory!\n"
f"Mount and bisync must use different directories.\n\n"
f"Options:\n"
f" 1. Use default: ~/basic-memory-cloud-sync/\n"
f" 2. Specify different directory: --dir ~/my-sync-folder"
)
# Check if mount is active at this location
result = subprocess.run(["mount"], capture_output=True, text=True)
if str(bisync_dir) in result.stdout and "rclone" in result.stdout:
raise BisyncError(
f"{bisync_dir} is currently mounted via 'bm cloud mount'\n"
f"Cannot use mounted directory for bisync.\n\n"
f"Either:\n"
f" 1. Unmount first: bm cloud unmount\n"
f" 2. Use different directory for bisync"
)
def convert_bmignore_to_rclone_filters() -> Path:
"""Convert .bmignore patterns to rclone filter format.
@@ -108,3 +245,521 @@ def get_bisync_filter_path() -> Path:
Path to rclone filter file
"""
return convert_bmignore_to_rclone_filters()
def bisync_state_exists(tenant_id: str) -> bool:
"""Check if bisync state exists (has been initialized)."""
state_path = get_bisync_state_path(tenant_id)
return state_path.exists() and any(state_path.iterdir())
def build_bisync_command(
tenant_id: str,
bucket_name: str,
local_path: Path,
profile: RcloneBisyncProfile,
dry_run: bool = False,
resync: bool = False,
verbose: bool = False,
) -> list[str]:
"""Build rclone bisync command with profile settings."""
# Sync with the entire bucket root (all projects)
rclone_remote = f"basic-memory-{tenant_id}:{bucket_name}"
filter_path = get_bisync_filter_path()
state_path = get_bisync_state_path(tenant_id)
# Ensure state directory exists
state_path.mkdir(parents=True, exist_ok=True)
cmd = [
"rclone",
"bisync",
str(local_path),
rclone_remote,
"--create-empty-src-dirs",
"--resilient",
f"--conflict-resolve={profile.conflict_resolve}",
f"--max-delete={profile.max_delete}",
"--filters-file",
str(filter_path),
"--workdir",
str(state_path),
]
# Add verbosity flags
if verbose:
cmd.append("--verbose") # Full details with file-by-file output
else:
# Show progress bar during transfers
cmd.append("--progress")
if profile.check_access:
cmd.append("--check-access")
if dry_run:
cmd.append("--dry-run")
if resync:
cmd.append("--resync")
cmd.extend(profile.extra_args)
return cmd
def setup_cloud_bisync(sync_dir: Optional[str] = None) -> None:
"""Set up cloud bisync with rclone installation and configuration.
Args:
sync_dir: Optional custom sync directory path. If not provided, uses config default.
"""
console.print("[bold blue]Basic Memory Cloud Bisync Setup[/bold blue]")
console.print("Setting up bidirectional sync to your cloud tenant...\n")
try:
# Step 1: Install rclone
console.print("[blue]Step 1: Installing rclone...[/blue]")
install_rclone()
# Step 2: Get mount info (for tenant_id, bucket)
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
tenant_info = asyncio.run(get_mount_info())
tenant_id = tenant_info.tenant_id
bucket_name = tenant_info.bucket_name
console.print(f"[green]✓ Found tenant: {tenant_id}[/green]")
console.print(f"[green]✓ Bucket: {bucket_name}[/green]")
# Step 3: Generate credentials
console.print("\n[blue]Step 3: Generating sync credentials...[/blue]")
creds = asyncio.run(generate_mount_credentials(tenant_id))
access_key = creds.access_key
secret_key = creds.secret_key
console.print("[green]✓ Generated secure credentials[/green]")
# Step 4: Configure rclone
console.print("\n[blue]Step 4: Configuring rclone...[/blue]")
add_tenant_to_rclone_config(
tenant_id=tenant_id,
bucket_name=bucket_name,
access_key=access_key,
secret_key=secret_key,
)
# Step 5: Configure and create local directory
console.print("\n[blue]Step 5: Configuring sync directory...[/blue]")
# If custom sync_dir provided, save to config
if sync_dir:
config_manager = ConfigManager()
config = config_manager.load_config()
config.bisync_config["sync_dir"] = sync_dir
config_manager.save_config(config)
console.print("[green]✓ Saved custom sync directory to config[/green]")
# Get bisync directory (from config or default)
local_path = get_bisync_directory()
# Validate bisync directory
validate_bisync_directory(local_path)
# Create directory
local_path.mkdir(parents=True, exist_ok=True)
console.print(f"[green]✓ Created sync directory: {local_path}[/green]")
# Step 6: Perform initial resync
console.print("\n[blue]Step 6: Performing initial sync...[/blue]")
console.print("[yellow]This will establish the baseline for bidirectional sync.[/yellow]")
run_bisync(
tenant_id=tenant_id,
bucket_name=bucket_name,
local_path=local_path,
profile_name="balanced",
resync=True,
)
console.print("\n[bold green]✓ Bisync setup completed successfully![/bold green]")
console.print("\nYour local files will now sync bidirectionally with the cloud!")
console.print(f"\nLocal directory: {local_path}")
console.print("\nUseful commands:")
console.print(" bm sync # Run sync (recommended)")
console.print(" bm sync --watch # Start watch mode")
console.print(" bm cloud status # Check sync status")
console.print(" bm cloud check # Verify file integrity")
console.print(" bm cloud bisync --dry-run # Preview changes (advanced)")
except (RcloneInstallError, BisyncError, CloudAPIError) as e:
console.print(f"\n[red]Setup failed: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"\n[red]Unexpected error during setup: {e}[/red]")
raise typer.Exit(1)
def run_bisync(
tenant_id: Optional[str] = None,
bucket_name: Optional[str] = None,
local_path: Optional[Path] = None,
profile_name: str = "balanced",
dry_run: bool = False,
resync: bool = False,
verbose: bool = False,
) -> bool:
"""Run rclone bisync with specified profile."""
try:
# Get tenant info if not provided
if not tenant_id or not bucket_name:
tenant_info = asyncio.run(get_mount_info())
tenant_id = tenant_info.tenant_id
bucket_name = tenant_info.bucket_name
# Set default local path if not provided
if not local_path:
local_path = get_bisync_directory()
# Validate bisync directory
validate_bisync_directory(local_path)
# Check if local path exists
if not local_path.exists():
raise BisyncError(
f"Local directory {local_path} does not exist. Run 'basic-memory cloud bisync-setup' first."
)
# Get bisync profile
if profile_name not in BISYNC_PROFILES:
raise BisyncError(
f"Unknown profile: {profile_name}. Available: {list(BISYNC_PROFILES.keys())}"
)
profile = BISYNC_PROFILES[profile_name]
# Auto-register projects before sync (unless dry-run or resync)
if not dry_run and not resync:
try:
console.print("[dim]Checking for new projects...[/dim]")
# Fetch cloud projects and extract directory names from paths
cloud_data = asyncio.run(fetch_cloud_projects())
cloud_projects = cloud_data.projects
# Extract directory names from cloud project paths
# Compare directory names, not project names
# Cloud path /app/data/basic-memory -> directory name "basic-memory"
cloud_dir_names = set()
for p in cloud_projects:
path = p.path
# Strip /app/data/ prefix if present (cloud mode)
if path.startswith("/app/data/"):
path = path[len("/app/data/") :]
# Get the last segment (directory name)
dir_name = Path(path).name
cloud_dir_names.add(dir_name)
# Scan local directories
local_dirs = scan_local_directories(local_path)
# Create missing cloud projects
new_projects = []
for dir_name in local_dirs:
if dir_name not in cloud_dir_names:
new_projects.append(dir_name)
if new_projects:
console.print(
f"[blue]Found {len(new_projects)} new local project(s), creating on cloud...[/blue]"
)
for project_name in new_projects:
try:
asyncio.run(create_cloud_project(project_name))
console.print(f"[green] ✓ Created project: {project_name}[/green]")
except BisyncError as e:
console.print(
f"[yellow] ⚠ Could not create {project_name}: {e}[/yellow]"
)
else:
console.print("[dim]All local projects already registered on cloud[/dim]")
except Exception as e:
console.print(f"[yellow]Warning: Project auto-registration failed: {e}[/yellow]")
console.print("[yellow]Continuing with sync anyway...[/yellow]")
# Check if first run and require resync
if not resync and not bisync_state_exists(tenant_id) and not dry_run:
raise BisyncError(
"First bisync requires --resync to establish baseline. "
"Run: basic-memory cloud bisync --resync"
)
# Build and execute bisync command
bisync_cmd = build_bisync_command(
tenant_id,
bucket_name,
local_path,
profile,
dry_run=dry_run,
resync=resync,
verbose=verbose,
)
if dry_run:
console.print("[yellow]DRY RUN MODE - No changes will be made[/yellow]")
console.print(
f"[blue]Running bisync with profile '{profile_name}' ({profile.description})...[/blue]"
)
console.print(f"[dim]Command: {' '.join(bisync_cmd)}[/dim]")
console.print() # Blank line before output
# Stream output in real-time so user sees progress
result = subprocess.run(bisync_cmd, text=True)
if result.returncode != 0:
raise BisyncError(f"Bisync command failed with code {result.returncode}")
console.print() # Blank line after output
if dry_run:
console.print("[green]✓ Dry run completed successfully[/green]")
elif resync:
console.print("[green]✓ Initial sync baseline established[/green]")
else:
console.print("[green]✓ Sync completed successfully[/green]")
# Notify container to refresh cache (if not dry run)
if not dry_run:
try:
asyncio.run(notify_container_sync(tenant_id))
except Exception as e:
console.print(f"[yellow]Warning: Could not notify container: {e}[/yellow]")
return True
except BisyncError:
raise
except Exception as e:
raise BisyncError(f"Unexpected error during bisync: {e}") from e
async def notify_container_sync(tenant_id: str) -> None:
"""Sync all projects after bisync completes."""
try:
from basic_memory.cli.commands.command_utils import run_sync
# Fetch all projects and sync each one
cloud_data = await fetch_cloud_projects()
projects = cloud_data.projects
if not projects:
console.print("[dim]No projects to sync[/dim]")
return
console.print(f"[blue]Notifying cloud to index {len(projects)} project(s)...[/blue]")
for project in projects:
project_name = project.name
if project_name:
try:
await run_sync(project=project_name)
except Exception as e:
# Non-critical, log and continue
console.print(f"[yellow] ⚠ Sync failed for {project_name}: {e}[/yellow]")
console.print("[dim]Note: Cloud indexing has started and may take a few moments[/dim]")
except Exception as e:
# Non-critical, don't fail the bisync
console.print(f"[yellow]Warning: Post-sync failed: {e}[/yellow]")
def run_bisync_watch(
tenant_id: Optional[str] = None,
bucket_name: Optional[str] = None,
local_path: Optional[Path] = None,
profile_name: str = "balanced",
interval_seconds: int = 60,
) -> None:
"""Run bisync in watch mode with periodic syncs."""
console.print("[bold blue]Starting bisync watch mode[/bold blue]")
console.print(f"Sync interval: {interval_seconds} seconds")
console.print("Press Ctrl+C to stop\n")
try:
while True:
try:
start_time = time.time()
run_bisync(
tenant_id=tenant_id,
bucket_name=bucket_name,
local_path=local_path,
profile_name=profile_name,
)
elapsed = time.time() - start_time
console.print(f"[dim]Sync completed in {elapsed:.1f}s[/dim]")
# Wait for next interval
time.sleep(interval_seconds)
except BisyncError as e:
console.print(f"[red]Sync error: {e}[/red]")
console.print(f"[yellow]Retrying in {interval_seconds} seconds...[/yellow]")
time.sleep(interval_seconds)
except KeyboardInterrupt:
console.print("\n[yellow]Watch mode stopped[/yellow]")
def show_bisync_status() -> None:
"""Show current bisync status and configuration."""
try:
# Get tenant info
tenant_info = asyncio.run(get_mount_info())
tenant_id = tenant_info.tenant_id
local_path = get_bisync_directory()
state_path = get_bisync_state_path(tenant_id)
# Create status table
table = Table(title="Cloud Bisync Status", show_header=True, header_style="bold blue")
table.add_column("Property", style="green", min_width=20)
table.add_column("Value", style="dim", min_width=30)
# Check initialization status
is_initialized = bisync_state_exists(tenant_id)
init_status = (
"[green]✓ Initialized[/green]" if is_initialized else "[red]✗ Not initialized[/red]"
)
table.add_row("Tenant ID", tenant_id)
table.add_row("Local Directory", str(local_path))
table.add_row("Status", init_status)
table.add_row("State Directory", str(state_path))
# Check for last sync info
if is_initialized:
# Look for most recent state file
state_files = list(state_path.glob("*.lst"))
if state_files:
latest = max(state_files, key=lambda p: p.stat().st_mtime)
last_sync = datetime.fromtimestamp(latest.stat().st_mtime)
table.add_row("Last Sync", last_sync.strftime("%Y-%m-%d %H:%M:%S"))
console.print(table)
# Show bisync profiles
console.print("\n[bold]Available bisync profiles:[/bold]")
for name, profile in BISYNC_PROFILES.items():
console.print(f" {name}: {profile.description}")
console.print(f" - Conflict resolution: {profile.conflict_resolve}")
console.print(f" - Max delete: {profile.max_delete} files")
console.print("\n[dim]To use a profile: bm cloud bisync --profile <name>[/dim]")
# Show setup instructions if not initialized
if not is_initialized:
console.print("\n[yellow]To initialize bisync, run:[/yellow]")
console.print(" bm cloud setup")
console.print(" or")
console.print(" bm cloud bisync --resync")
except Exception as e:
console.print(f"[red]Error getting bisync status: {e}[/red]")
raise typer.Exit(1)
def run_check(
tenant_id: Optional[str] = None,
bucket_name: Optional[str] = None,
local_path: Optional[Path] = None,
one_way: bool = False,
) -> bool:
"""Check file integrity between local and cloud using rclone check.
Args:
tenant_id: Cloud tenant ID (auto-detected if not provided)
bucket_name: S3 bucket name (auto-detected if not provided)
local_path: Local bisync directory (uses config default if not provided)
one_way: If True, only check for missing files on destination (faster)
Returns:
True if check passed (files match), False if differences found
"""
try:
# Check if rclone is installed
from basic_memory.cli.commands.cloud.rclone_installer import is_rclone_installed
if not is_rclone_installed():
raise BisyncError(
"rclone is not installed. Run 'bm cloud bisync-setup' first to set up cloud sync."
)
# Get tenant info if not provided
if not tenant_id or not bucket_name:
tenant_info = asyncio.run(get_mount_info())
tenant_id = tenant_id or tenant_info.tenant_id
bucket_name = bucket_name or tenant_info.bucket_name
# Get local path from config
if not local_path:
local_path = get_bisync_directory()
# Check if bisync is initialized
if not bisync_state_exists(tenant_id):
raise BisyncError(
"Bisync not initialized. Run 'bm cloud bisync --resync' to establish baseline."
)
# Build rclone check command
rclone_remote = f"basic-memory-{tenant_id}:{bucket_name}"
filter_path = get_bisync_filter_path()
cmd = [
"rclone",
"check",
str(local_path),
rclone_remote,
"--filter-from",
str(filter_path),
]
if one_way:
cmd.append("--one-way")
console.print("[bold blue]Checking file integrity between local and cloud[/bold blue]")
console.print(f"[dim]Local: {local_path}[/dim]")
console.print(f"[dim]Remote: {rclone_remote}[/dim]")
console.print(f"[dim]Command: {' '.join(cmd)}[/dim]")
console.print()
# Run check command
result = subprocess.run(cmd, capture_output=True, text=True)
# rclone check returns:
# 0 = success (all files match)
# non-zero = differences found or error
if result.returncode == 0:
console.print("[green]✓ All files match between local and cloud[/green]")
return True
else:
console.print("[yellow]⚠ Differences found:[/yellow]")
if result.stderr:
console.print(result.stderr)
if result.stdout:
console.print(result.stdout)
console.print("\n[dim]To sync differences, run: bm sync[/dim]")
return False
except BisyncError:
raise
except Exception as e:
raise BisyncError(f"Check failed: {e}") from e
@@ -69,17 +69,16 @@ async def create_cloud_project(project_name: str) -> CloudProjectCreateResponse:
raise CloudUtilsError(f"Failed to create cloud project '{project_name}': {e}") from e
async def sync_project(project_name: str, force_full: bool = False) -> None:
async def sync_project(project_name: str) -> None:
"""Trigger sync for a specific project on cloud.
Args:
project_name: Name of project to sync
force_full: If True, force a full scan bypassing watermark optimization
"""
try:
from basic_memory.cli.commands.command_utils import run_sync
await run_sync(project=project_name, force_full=force_full)
await run_sync(project=project_name)
except Exception as e:
raise CloudUtilsError(f"Failed to sync project '{project_name}': {e}") from e
@@ -1,6 +1,7 @@
"""Core cloud commands for Basic Memory CLI."""
import asyncio
from typing import Optional
import typer
from rich.console import Console
@@ -14,16 +15,21 @@ from basic_memory.cli.commands.cloud.api_client import (
get_cloud_config,
make_api_request,
)
from basic_memory.cli.commands.cloud.mount_commands import (
mount_cloud_files,
setup_cloud_mount,
show_mount_status,
unmount_cloud_files,
)
from basic_memory.cli.commands.cloud.bisync_commands import (
BisyncError,
generate_mount_credentials,
get_mount_info,
)
from basic_memory.cli.commands.cloud.rclone_config import configure_rclone_remote
from basic_memory.cli.commands.cloud.rclone_installer import (
RcloneInstallError,
install_rclone,
run_bisync,
run_bisync_watch,
run_check,
setup_cloud_bisync,
show_bisync_status,
)
from basic_memory.cli.commands.cloud.rclone_config import MOUNT_PROFILES
from basic_memory.cli.commands.cloud.bisync_commands import BISYNC_PROFILES
console = Console()
@@ -52,11 +58,11 @@ def login():
config.cloud_mode = True
config_manager.save_config(config)
console.print("[green]Cloud mode enabled[/green]")
console.print("[green]Cloud mode enabled[/green]")
console.print(f"[dim]All CLI commands now work against {host_url}[/dim]")
except SubscriptionRequiredError as e:
console.print("\n[red]Subscription Required[/red]\n")
console.print("\n[red]Subscription Required[/red]\n")
console.print(f"[yellow]{e.args[0]}[/yellow]\n")
console.print(f"Subscribe at: [blue underline]{e.subscribe_url}[/blue underline]\n")
console.print(
@@ -77,13 +83,23 @@ def logout():
config.cloud_mode = False
config_manager.save_config(config)
console.print("[green]Cloud mode disabled[/green]")
console.print("[green]Cloud mode disabled[/green]")
console.print("[dim]All CLI commands now work locally[/dim]")
@cloud_app.command("status")
def status() -> None:
"""Check cloud mode status and cloud instance health."""
def status(
bisync: bool = typer.Option(
True,
"--bisync/--mount",
help="Show bisync status (default) or mount status",
),
) -> None:
"""Check cloud mode status and cloud instance health.
Shows cloud mode status, instance health, and sync/mount status.
Use --bisync (default) to show bisync status or --mount for mount status.
"""
# Check cloud mode
config_manager = ConfigManager()
config = config_manager.load_config()
@@ -126,7 +142,12 @@ def status() -> None:
if "timestamp" in health_data:
console.print(f" Timestamp: {health_data['timestamp']}")
console.print("\n[dim]To sync projects, use: bm project bisync --name <project>[/dim]")
# Show sync/mount status based on flag
console.print()
if bisync:
show_bisync_status()
else:
show_mount_status()
except CloudAPIError as e:
console.print(f"[red]Error checking cloud health: {e}[/red]")
@@ -136,60 +157,132 @@ def status() -> None:
raise typer.Exit(1)
# Mount commands
@cloud_app.command("setup")
def setup() -> None:
"""Set up cloud sync by installing rclone and configuring credentials.
def setup(
bisync: bool = typer.Option(
True,
"--bisync/--mount",
help="Use bidirectional sync (recommended) or mount as network drive",
),
sync_dir: Optional[str] = typer.Option(
None,
"--dir",
help="Custom sync directory for bisync (default: ~/basic-memory-cloud-sync)",
),
) -> None:
"""Set up cloud file access with automatic rclone installation and configuration.
SPEC-20: Simplified to project-scoped workflow.
After setup, use project commands for syncing:
bm project add <name> <path> --local-path ~/projects/<name>
bm project bisync --name <name> --resync # First time
bm project bisync --name <name> # Subsequent syncs
Default: Sets up bidirectional sync (recommended).\n
Use --mount: Sets up mount as network drive (alternative workflow).\n
Examples:\n
bm cloud setup # Setup bisync (default)\n
bm cloud setup --mount # Setup mount instead\n
bm cloud setup --dir ~/sync # Custom bisync directory\n
"""
console.print("[bold blue]Basic Memory Cloud Setup[/bold blue]")
console.print("Setting up cloud sync with rclone...\n")
if bisync:
setup_cloud_bisync(sync_dir=sync_dir)
else:
setup_cloud_mount()
@cloud_app.command("mount")
def mount(
profile: str = typer.Option(
"balanced", help=f"Mount profile: {', '.join(MOUNT_PROFILES.keys())}"
),
path: Optional[str] = typer.Option(
None, help="Custom mount path (default: ~/basic-memory-{tenant-id})"
),
) -> None:
"""Mount cloud files locally for editing."""
try:
# Step 1: Install rclone
console.print("[blue]Step 1: Installing rclone...[/blue]")
install_rclone()
# Step 2: Get tenant info
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
tenant_info = asyncio.run(get_mount_info())
console.print(f"[green]Found tenant: {tenant_info.tenant_id}[/green]")
# Step 3: Generate credentials
console.print("\n[blue]Step 3: Generating sync credentials...[/blue]")
creds = asyncio.run(generate_mount_credentials(tenant_info.tenant_id))
console.print("[green]Generated secure credentials[/green]")
# Step 4: Configure rclone remote
console.print("\n[blue]Step 4: Configuring rclone remote...[/blue]")
configure_rclone_remote(
access_key=creds.access_key,
secret_key=creds.secret_key,
)
console.print("\n[bold green]Cloud setup completed successfully![/bold green]")
console.print("\n[bold]Next steps:[/bold]")
console.print("1. Add a project with local sync path:")
console.print(" bm project add research --local-path ~/Documents/research")
console.print("\n Or configure sync for an existing project:")
console.print(" bm project sync-setup research ~/Documents/research")
console.print("\n2. Preview the initial sync (recommended):")
console.print(" bm project bisync --name research --resync --dry-run")
console.print("\n3. If all looks good, run the actual sync:")
console.print(" bm project bisync --name research --resync")
console.print("\n4. Subsequent syncs (no --resync needed):")
console.print(" bm project bisync --name research")
console.print(
"\n[dim]Tip: Always use --dry-run first to preview changes before syncing[/dim]"
)
except (RcloneInstallError, BisyncError, CloudAPIError) as e:
console.print(f"\n[red]Setup failed: {e}[/red]")
raise typer.Exit(1)
mount_cloud_files(profile_name=profile)
except Exception as e:
console.print(f"\n[red]Unexpected error during setup: {e}[/red]")
console.print(f"[red]Mount failed: {e}[/red]")
raise typer.Exit(1)
@cloud_app.command("unmount")
def unmount() -> None:
"""Unmount cloud files."""
try:
unmount_cloud_files()
except Exception as e:
console.print(f"[red]Unmount failed: {e}[/red]")
raise typer.Exit(1)
# Bisync commands
@cloud_app.command("bisync")
def bisync(
profile: str = typer.Option(
"balanced", help=f"Bisync profile: {', '.join(BISYNC_PROFILES.keys())}"
),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
resync: bool = typer.Option(False, "--resync", help="Force resync to establish new baseline"),
watch: bool = typer.Option(False, "--watch", help="Run continuous sync in watch mode"),
interval: int = typer.Option(60, "--interval", help="Sync interval in seconds for watch mode"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed sync output"),
) -> None:
"""Run bidirectional sync between local files and cloud storage.
Examples:
basic-memory cloud bisync # Manual sync with balanced profile
basic-memory cloud bisync --dry-run # Preview what would be synced
basic-memory cloud bisync --resync # Establish new baseline
basic-memory cloud bisync --watch # Continuous sync every 60s
basic-memory cloud bisync --watch --interval 30 # Continuous sync every 30s
basic-memory cloud bisync --profile safe # Use safe profile (keep conflicts)
basic-memory cloud bisync --verbose # Show detailed file sync output
"""
try:
if watch:
run_bisync_watch(profile_name=profile, interval_seconds=interval)
else:
run_bisync(profile_name=profile, dry_run=dry_run, resync=resync, verbose=verbose)
except Exception as e:
console.print(f"[red]Bisync failed: {e}[/red]")
raise typer.Exit(1)
@cloud_app.command("bisync-status")
def bisync_status() -> None:
"""Show current bisync status and configuration.
DEPRECATED: Use 'bm cloud status' instead (bisync is now the default).
"""
console.print(
"[yellow]Note: 'bisync-status' is deprecated. Use 'bm cloud status' instead.[/yellow]"
)
console.print("[dim]Showing bisync status...[/dim]\n")
show_bisync_status()
@cloud_app.command("check")
def check(
one_way: bool = typer.Option(
False,
"--one-way",
help="Only check for missing files on destination (faster)",
),
) -> None:
"""Check file integrity between local and cloud storage using rclone check.
Verifies that files match between your local bisync directory and cloud storage
without transferring any data. This is useful for validating sync integrity.
Examples:
bm cloud check # Full integrity check
bm cloud check --one-way # Faster check (missing files only)
"""
try:
run_check(one_way=one_way)
except Exception as e:
console.print(f"[red]Check failed: {e}[/red]")
raise typer.Exit(1)
@@ -0,0 +1,295 @@
"""Cloud mount commands for Basic Memory CLI."""
import asyncio
import subprocess
import time
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
from rich.table import Table
from basic_memory.cli.commands.cloud.api_client import CloudAPIError, make_api_request
from basic_memory.cli.commands.cloud.rclone_config import (
MOUNT_PROFILES,
add_tenant_to_rclone_config,
build_mount_command,
cleanup_orphaned_rclone_processes,
get_default_mount_path,
get_rclone_processes,
is_path_mounted,
unmount_path,
)
from basic_memory.cli.commands.cloud.rclone_installer import RcloneInstallError, install_rclone
from basic_memory.config import ConfigManager
console = Console()
class MountError(Exception):
"""Exception raised for mount-related errors."""
pass
async def get_tenant_info() -> dict:
"""Get current tenant information from cloud API."""
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
response = await make_api_request(method="GET", url=f"{host_url}/tenant/mount/info")
return response.json()
except Exception as e:
raise MountError(f"Failed to get tenant info: {e}") from e
async def generate_mount_credentials(tenant_id: str) -> dict:
"""Generate scoped credentials for mounting."""
try:
config_manager = ConfigManager()
config = config_manager.config
host_url = config.cloud_host.rstrip("/")
response = await make_api_request(method="POST", url=f"{host_url}/tenant/mount/credentials")
return response.json()
except Exception as e:
raise MountError(f"Failed to generate mount credentials: {e}") from e
def setup_cloud_mount() -> None:
"""Set up cloud mount with rclone installation and configuration."""
console.print("[bold blue]Basic Memory Cloud Setup[/bold blue]")
console.print("Setting up local file access to your cloud tenant...\n")
try:
# Step 1: Install rclone
console.print("[blue]Step 1: Installing rclone...[/blue]")
install_rclone()
# Step 2: Get tenant info
console.print("\n[blue]Step 2: Getting tenant information...[/blue]")
tenant_info = asyncio.run(get_tenant_info())
tenant_id = tenant_info.get("tenant_id")
bucket_name = tenant_info.get("bucket_name")
if not tenant_id or not bucket_name:
raise MountError("Invalid tenant information received from cloud API")
console.print(f"[green]✓ Found tenant: {tenant_id}[/green]")
console.print(f"[green]✓ Bucket: {bucket_name}[/green]")
# Step 3: Generate mount credentials
console.print("\n[blue]Step 3: Generating mount credentials...[/blue]")
creds = asyncio.run(generate_mount_credentials(tenant_id))
access_key = creds.get("access_key")
secret_key = creds.get("secret_key")
if not access_key or not secret_key:
raise MountError("Failed to generate mount credentials")
console.print("[green]✓ Generated secure credentials[/green]")
# Step 4: Configure rclone
console.print("\n[blue]Step 4: Configuring rclone...[/blue]")
add_tenant_to_rclone_config(
tenant_id=tenant_id,
bucket_name=bucket_name,
access_key=access_key,
secret_key=secret_key,
)
# Step 5: Perform initial mount
console.print("\n[blue]Step 5: Mounting cloud files...[/blue]")
mount_path = get_default_mount_path()
MOUNT_PROFILES["balanced"]
mount_cloud_files(
tenant_id=tenant_id,
bucket_name=bucket_name,
mount_path=mount_path,
profile_name="balanced",
)
console.print("\n[bold green]✓ Cloud setup completed successfully![/bold green]")
console.print("\nYour cloud files are now accessible at:")
console.print(f" {mount_path}")
console.print("\nYou can now edit files locally and they will sync to the cloud!")
console.print("\nUseful commands:")
console.print(" basic-memory cloud mount-status # Check mount status")
console.print(" basic-memory cloud unmount # Unmount files")
console.print(" basic-memory cloud mount --profile fast # Remount with faster sync")
except (RcloneInstallError, MountError, CloudAPIError) as e:
console.print(f"\n[red]Setup failed: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"\n[red]Unexpected error during setup: {e}[/red]")
raise typer.Exit(1)
def mount_cloud_files(
tenant_id: Optional[str] = None,
bucket_name: Optional[str] = None,
mount_path: Optional[Path] = None,
profile_name: str = "balanced",
) -> None:
"""Mount cloud files with specified profile."""
try:
# Get tenant info if not provided
if not tenant_id or not bucket_name:
tenant_info = asyncio.run(get_tenant_info())
tenant_id = tenant_info.get("tenant_id")
bucket_name = tenant_info.get("bucket_name")
if not tenant_id or not bucket_name:
raise MountError("Could not determine tenant information")
# Set default mount path if not provided
if not mount_path:
mount_path = get_default_mount_path()
# Get mount profile
if profile_name not in MOUNT_PROFILES:
raise MountError(
f"Unknown profile: {profile_name}. Available: {list(MOUNT_PROFILES.keys())}"
)
profile = MOUNT_PROFILES[profile_name]
# Check if already mounted
if is_path_mounted(mount_path):
console.print(f"[yellow]Path {mount_path} is already mounted[/yellow]")
console.print("Use 'basic-memory cloud unmount' first, or mount to a different path")
return
# Create mount directory
mount_path.mkdir(parents=True, exist_ok=True)
# Build and execute mount command
mount_cmd = build_mount_command(tenant_id, bucket_name, mount_path, profile)
console.print(
f"[blue]Mounting with profile '{profile_name}' ({profile.description})...[/blue]"
)
console.print(f"[dim]Command: {' '.join(mount_cmd)}[/dim]")
result = subprocess.run(mount_cmd, capture_output=True, text=True)
if result.returncode != 0:
error_msg = result.stderr or "Unknown error"
raise MountError(f"Mount command failed: {error_msg}")
# Wait a moment for mount to establish
time.sleep(2)
# Verify mount
if is_path_mounted(mount_path):
console.print(f"[green]✓ Successfully mounted to {mount_path}[/green]")
console.print(f"[green]✓ Sync profile: {profile.description}[/green]")
else:
raise MountError("Mount command succeeded but path is not mounted")
except MountError:
raise
except Exception as e:
raise MountError(f"Unexpected error during mount: {e}") from e
def unmount_cloud_files(tenant_id: Optional[str] = None) -> None:
"""Unmount cloud files."""
try:
# Get tenant info if not provided
if not tenant_id:
tenant_info = asyncio.run(get_tenant_info())
tenant_id = tenant_info.get("tenant_id")
if not tenant_id:
raise MountError("Could not determine tenant ID")
mount_path = get_default_mount_path()
if not is_path_mounted(mount_path):
console.print(f"[yellow]Path {mount_path} is not mounted[/yellow]")
return
console.print(f"[blue]Unmounting {mount_path}...[/blue]")
# Unmount the path
if unmount_path(mount_path):
console.print(f"[green]✓ Successfully unmounted {mount_path}[/green]")
# Clean up any orphaned rclone processes
killed_count = cleanup_orphaned_rclone_processes()
if killed_count > 0:
console.print(
f"[green]✓ Cleaned up {killed_count} orphaned rclone process(es)[/green]"
)
else:
console.print(f"[red]✗ Failed to unmount {mount_path}[/red]")
console.print("You may need to manually unmount or restart your system")
except MountError:
raise
except Exception as e:
raise MountError(f"Unexpected error during unmount: {e}") from e
def show_mount_status() -> None:
"""Show current mount status and running processes."""
try:
# Get tenant info
tenant_info = asyncio.run(get_tenant_info())
tenant_id = tenant_info.get("tenant_id")
if not tenant_id:
console.print("[red]Could not determine tenant ID[/red]")
return
mount_path = get_default_mount_path()
# Create status table
table = Table(title="Cloud Mount Status", show_header=True, header_style="bold blue")
table.add_column("Property", style="green", min_width=15)
table.add_column("Value", style="dim", min_width=30)
# Check mount status
is_mounted = is_path_mounted(mount_path)
mount_status = "[green]✓ Mounted[/green]" if is_mounted else "[red]✗ Not mounted[/red]"
table.add_row("Tenant ID", tenant_id)
table.add_row("Mount Path", str(mount_path))
table.add_row("Status", mount_status)
# Get rclone processes
processes = get_rclone_processes()
if processes:
table.add_row("rclone Processes", f"{len(processes)} running")
else:
table.add_row("rclone Processes", "None")
console.print(table)
# Show running processes details
if processes:
console.print("\n[bold]Running rclone processes:[/bold]")
for proc in processes:
console.print(f" PID {proc['pid']}: {proc['command'][:80]}...")
# Show mount profiles
console.print("\n[bold]Available mount profiles:[/bold]")
for name, profile in MOUNT_PROFILES.items():
console.print(f" {name}: {profile.description}")
except Exception as e:
console.print(f"[red]Error getting mount status: {e}[/red]")
raise typer.Exit(1)
@@ -1,326 +0,0 @@
"""Project-scoped rclone sync commands for Basic Memory Cloud.
This module provides simplified, project-scoped rclone operations:
- Each project syncs independently
- Uses single "basic-memory-cloud" remote (not tenant-specific)
- Balanced defaults from SPEC-8 Phase 4 testing
- Per-project bisync state tracking
Replaces tenant-wide sync with project-scoped workflows.
"""
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from rich.console import Console
from basic_memory.cli.commands.cloud.rclone_installer import is_rclone_installed
from basic_memory.utils import normalize_project_path
console = Console()
class RcloneError(Exception):
"""Exception raised for rclone command errors."""
pass
def check_rclone_installed() -> None:
"""Check if rclone is installed and raise helpful error if not.
Raises:
RcloneError: If rclone is not installed with installation instructions
"""
if not is_rclone_installed():
raise RcloneError(
"rclone is not installed.\n\n"
"Install rclone by running: bm cloud setup\n"
"Or install manually from: https://rclone.org/downloads/\n\n"
"Windows users: Ensure you have a package manager installed (winget, chocolatey, or scoop)"
)
@dataclass
class SyncProject:
"""Project configured for cloud sync.
Attributes:
name: Project name
path: Cloud path (e.g., "app/data/research")
local_sync_path: Local directory for syncing (optional)
"""
name: str
path: str
local_sync_path: Optional[str] = None
def get_bmignore_filter_path() -> Path:
"""Get path to rclone filter file.
Uses ~/.basic-memory/.bmignore converted to rclone format.
File is automatically created with default patterns on first use.
Returns:
Path to rclone filter file
"""
# Import here to avoid circular dependency
from basic_memory.cli.commands.cloud.bisync_commands import (
convert_bmignore_to_rclone_filters,
)
return convert_bmignore_to_rclone_filters()
def get_project_bisync_state(project_name: str) -> Path:
"""Get path to project's bisync state directory.
Args:
project_name: Name of the project
Returns:
Path to bisync state directory for this project
"""
return Path.home() / ".basic-memory" / "bisync-state" / project_name
def bisync_initialized(project_name: str) -> bool:
"""Check if bisync has been initialized for this project.
Args:
project_name: Name of the project
Returns:
True if bisync state exists, False otherwise
"""
state_path = get_project_bisync_state(project_name)
return state_path.exists() and any(state_path.iterdir())
def get_project_remote(project: SyncProject, bucket_name: str) -> str:
"""Build rclone remote path for project.
Args:
project: Project with cloud path
bucket_name: S3 bucket name
Returns:
Remote path like "basic-memory-cloud:bucket-name/basic-memory-llc"
Note:
The API returns paths like "/app/data/basic-memory-llc" because the S3 bucket
is mounted at /app/data on the fly machine. We need to strip the /app/data/
prefix to get the actual S3 path within the bucket.
"""
# Normalize path to strip /app/data/ mount point prefix
cloud_path = normalize_project_path(project.path).lstrip("/")
return f"basic-memory-cloud:{bucket_name}/{cloud_path}"
def project_sync(
project: SyncProject,
bucket_name: str,
dry_run: bool = False,
verbose: bool = False,
) -> bool:
"""One-way sync: local → cloud.
Makes cloud identical to local using rclone sync.
Args:
project: Project to sync
bucket_name: S3 bucket name
dry_run: Preview changes without applying
verbose: Show detailed output
Returns:
True if sync succeeded, False otherwise
Raises:
RcloneError: If project has no local_sync_path configured or rclone not installed
"""
check_rclone_installed()
if not project.local_sync_path:
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
local_path = Path(project.local_sync_path).expanduser()
remote_path = get_project_remote(project, bucket_name)
filter_path = get_bmignore_filter_path()
cmd = [
"rclone",
"sync",
str(local_path),
remote_path,
"--filter-from",
str(filter_path),
]
if verbose:
cmd.append("--verbose")
else:
cmd.append("--progress")
if dry_run:
cmd.append("--dry-run")
result = subprocess.run(cmd, text=True)
return result.returncode == 0
def project_bisync(
project: SyncProject,
bucket_name: str,
dry_run: bool = False,
resync: bool = False,
verbose: bool = False,
) -> bool:
"""Two-way sync: local ↔ cloud.
Uses rclone bisync with balanced defaults:
- conflict_resolve: newer (auto-resolve to most recent)
- max_delete: 25 (safety limit)
- compare: modtime (ignore size differences from line ending conversions)
- check_access: false (skip for performance)
Args:
project: Project to sync
bucket_name: S3 bucket name
dry_run: Preview changes without applying
resync: Force resync to establish new baseline
verbose: Show detailed output
Returns:
True if bisync succeeded, False otherwise
Raises:
RcloneError: If project has no local_sync_path, needs --resync, or rclone not installed
"""
check_rclone_installed()
if not project.local_sync_path:
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
local_path = Path(project.local_sync_path).expanduser()
remote_path = get_project_remote(project, bucket_name)
filter_path = get_bmignore_filter_path()
state_path = get_project_bisync_state(project.name)
# Ensure state directory exists
state_path.mkdir(parents=True, exist_ok=True)
cmd = [
"rclone",
"bisync",
str(local_path),
remote_path,
"--create-empty-src-dirs",
"--resilient",
"--conflict-resolve=newer",
"--max-delete=25",
"--compare=modtime", # Ignore size differences from line ending conversions
"--filter-from",
str(filter_path),
"--workdir",
str(state_path),
]
if verbose:
cmd.append("--verbose")
else:
cmd.append("--progress")
if dry_run:
cmd.append("--dry-run")
if resync:
cmd.append("--resync")
# Check if first run requires resync
if not resync and not bisync_initialized(project.name) and not dry_run:
raise RcloneError(
f"First bisync for {project.name} requires --resync to establish baseline.\n"
f"Run: bm project bisync --name {project.name} --resync"
)
result = subprocess.run(cmd, text=True)
return result.returncode == 0
def project_check(
project: SyncProject,
bucket_name: str,
one_way: bool = False,
) -> bool:
"""Check integrity between local and cloud.
Verifies files match without transferring data.
Args:
project: Project to check
bucket_name: S3 bucket name
one_way: Only check for missing files on destination (faster)
Returns:
True if files match, False if differences found
Raises:
RcloneError: If project has no local_sync_path configured or rclone not installed
"""
check_rclone_installed()
if not project.local_sync_path:
raise RcloneError(f"Project {project.name} has no local_sync_path configured")
local_path = Path(project.local_sync_path).expanduser()
remote_path = get_project_remote(project, bucket_name)
filter_path = get_bmignore_filter_path()
cmd = [
"rclone",
"check",
str(local_path),
remote_path,
"--filter-from",
str(filter_path),
]
if one_way:
cmd.append("--one-way")
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0
def project_ls(
project: SyncProject,
bucket_name: str,
path: Optional[str] = None,
) -> list[str]:
"""List files in remote project.
Args:
project: Project to list files from
bucket_name: S3 bucket name
path: Optional subdirectory within project
Returns:
List of file paths
Raises:
subprocess.CalledProcessError: If rclone command fails
RcloneError: If rclone is not installed
"""
check_rclone_installed()
remote_path = get_project_remote(project, bucket_name)
if path:
remote_path = f"{remote_path}/{path}"
cmd = ["rclone", "ls", remote_path]
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
return result.stdout.splitlines()
@@ -1,14 +1,11 @@
"""rclone configuration management for Basic Memory Cloud.
This module provides simplified rclone configuration for SPEC-20.
Uses a single "basic-memory-cloud" remote for all operations.
"""
"""rclone configuration management for Basic Memory Cloud."""
import configparser
import os
import shutil
import subprocess
from pathlib import Path
from typing import Optional
from typing import Dict, List, Optional
from rich.console import Console
@@ -21,6 +18,64 @@ class RcloneConfigError(Exception):
pass
class RcloneMountProfile:
"""Mount profile with optimized settings."""
def __init__(
self,
name: str,
cache_time: str,
poll_interval: str,
attr_timeout: str,
write_back: str,
description: str,
extra_args: Optional[List[str]] = None,
):
self.name = name
self.cache_time = cache_time
self.poll_interval = poll_interval
self.attr_timeout = attr_timeout
self.write_back = write_back
self.description = description
self.extra_args = extra_args or []
# Mount profiles based on SPEC-7 Phase 4 testing
MOUNT_PROFILES = {
"fast": RcloneMountProfile(
name="fast",
cache_time="5s",
poll_interval="3s",
attr_timeout="3s",
write_back="1s",
description="Ultra-fast development (5s sync, higher bandwidth)",
),
"balanced": RcloneMountProfile(
name="balanced",
cache_time="10s",
poll_interval="5s",
attr_timeout="5s",
write_back="2s",
description="Fast development (10-15s sync, recommended)",
),
"safe": RcloneMountProfile(
name="safe",
cache_time="15s",
poll_interval="10s",
attr_timeout="10s",
write_back="5s",
description="Conflict-aware mount with backup",
extra_args=[
"--conflict-suffix",
".conflict-{DateTimeExt}",
"--backup-dir",
"~/.basic-memory/conflicts",
"--track-renames",
],
),
}
def get_rclone_config_path() -> Path:
"""Get the path to rclone configuration file."""
config_dir = Path.home() / ".config" / "rclone"
@@ -61,50 +116,173 @@ def save_rclone_config(config: configparser.ConfigParser) -> None:
console.print(f"[dim]Updated rclone config: {config_path}[/dim]")
def configure_rclone_remote(
def add_tenant_to_rclone_config(
tenant_id: str,
bucket_name: str,
access_key: str,
secret_key: str,
endpoint: str = "https://fly.storage.tigris.dev",
region: str = "auto",
) -> str:
"""Configure single rclone remote named 'basic-memory-cloud'.
"""Add tenant configuration to rclone config file."""
This is the simplified approach from SPEC-20 that uses one remote
for all Basic Memory cloud operations (not tenant-specific).
Args:
access_key: S3 access key ID
secret_key: S3 secret access key
endpoint: S3-compatible endpoint URL
region: S3 region (default: auto)
Returns:
The remote name: "basic-memory-cloud"
"""
# Backup existing config
backup_rclone_config()
# Load existing config
config = load_rclone_config()
# Single remote name (not tenant-specific)
REMOTE_NAME = "basic-memory-cloud"
# Create section name
section_name = f"basic-memory-{tenant_id}"
# Add/update the remote section
if not config.has_section(REMOTE_NAME):
config.add_section(REMOTE_NAME)
# Add/update the tenant section
if not config.has_section(section_name):
config.add_section(section_name)
config.set(section_name, "type", "s3")
config.set(section_name, "provider", "Other")
config.set(section_name, "access_key_id", access_key)
config.set(section_name, "secret_access_key", secret_key)
config.set(section_name, "endpoint", endpoint)
config.set(section_name, "region", region)
config.set(REMOTE_NAME, "type", "s3")
config.set(REMOTE_NAME, "provider", "Other")
config.set(REMOTE_NAME, "access_key_id", access_key)
config.set(REMOTE_NAME, "secret_access_key", secret_key)
config.set(REMOTE_NAME, "endpoint", endpoint)
config.set(REMOTE_NAME, "region", region)
# Prevent unnecessary encoding of filenames (only encode slashes and invalid UTF-8)
# This prevents files with spaces like "Hello World.md" from being quoted
config.set(REMOTE_NAME, "encoding", "Slash,InvalidUtf8")
# Save updated config
save_rclone_config(config)
console.print(f"[green]Configured rclone remote: {REMOTE_NAME}[/green]")
return REMOTE_NAME
console.print(f"[green]✓ Added tenant {tenant_id} to rclone config[/green]")
return section_name
def remove_tenant_from_rclone_config(tenant_id: str) -> bool:
"""Remove tenant configuration from rclone config."""
config = load_rclone_config()
section_name = f"basic-memory-{tenant_id}"
if config.has_section(section_name):
backup_rclone_config()
config.remove_section(section_name)
save_rclone_config(config)
console.print(f"[green]✓ Removed tenant {tenant_id} from rclone config[/green]")
return True
return False
def get_default_mount_path() -> Path:
"""Get default mount path (fixed location per SPEC-9).
Returns:
Fixed mount path: ~/basic-memory-cloud/
"""
return Path.home() / "basic-memory-cloud"
def build_mount_command(
tenant_id: str, bucket_name: str, mount_path: Path, profile: RcloneMountProfile
) -> List[str]:
"""Build rclone mount command with optimized settings."""
rclone_remote = f"basic-memory-{tenant_id}:{bucket_name}"
cmd = [
"rclone",
"nfsmount",
rclone_remote,
str(mount_path),
"--vfs-cache-mode",
"writes",
"--dir-cache-time",
profile.cache_time,
"--vfs-cache-poll-interval",
profile.poll_interval,
"--attr-timeout",
profile.attr_timeout,
"--vfs-write-back",
profile.write_back,
"--daemon",
]
# Add profile-specific extra arguments
cmd.extend(profile.extra_args)
return cmd
def is_path_mounted(mount_path: Path) -> bool:
"""Check if a path is currently mounted."""
if not mount_path.exists():
return False
try:
# Check if mount point is actually mounted by looking for mount table entry
result = subprocess.run(["mount"], capture_output=True, text=True, check=False)
if result.returncode == 0:
# Look for our mount path in mount output
mount_str = str(mount_path.resolve())
return mount_str in result.stdout
return False
except Exception:
return False
def get_rclone_processes() -> List[Dict[str, str]]:
"""Get list of running rclone processes."""
try:
# Use ps to find rclone processes
result = subprocess.run(
["ps", "-eo", "pid,args"], capture_output=True, text=True, check=False
)
processes = []
if result.returncode == 0:
for line in result.stdout.split("\n"):
if "rclone" in line and "basic-memory" in line:
parts = line.strip().split(None, 1)
if len(parts) >= 2:
processes.append({"pid": parts[0], "command": parts[1]})
return processes
except Exception:
return []
def kill_rclone_process(pid: str) -> bool:
"""Kill a specific rclone process."""
try:
subprocess.run(["kill", pid], check=True)
console.print(f"[green]✓ Killed rclone process {pid}[/green]")
return True
except subprocess.CalledProcessError:
console.print(f"[red]✗ Failed to kill rclone process {pid}[/red]")
return False
def unmount_path(mount_path: Path) -> bool:
"""Unmount a mounted path."""
if not is_path_mounted(mount_path):
return True
try:
subprocess.run(["umount", str(mount_path)], check=True)
console.print(f"[green]✓ Unmounted {mount_path}[/green]")
return True
except subprocess.CalledProcessError as e:
console.print(f"[red]✗ Failed to unmount {mount_path}: {e}[/red]")
return False
def cleanup_orphaned_rclone_processes() -> int:
"""Clean up orphaned rclone processes for basic-memory."""
processes = get_rclone_processes()
killed_count = 0
for proc in processes:
console.print(
f"[yellow]Found rclone process: {proc['pid']} - {proc['command'][:80]}...[/yellow]"
)
if kill_rclone_process(proc["pid"]):
killed_count += 1
return killed_count
@@ -1,6 +1,5 @@
"""Cross-platform rclone installation utilities."""
import os
import platform
import shutil
import subprocess
@@ -59,7 +58,7 @@ def install_rclone_macos() -> None:
try:
console.print("[blue]Installing rclone via Homebrew...[/blue]")
run_command(["brew", "install", "rclone"])
console.print("[green]rclone installed via Homebrew[/green]")
console.print("[green]rclone installed via Homebrew[/green]")
return
except RcloneInstallError:
console.print(
@@ -70,7 +69,7 @@ def install_rclone_macos() -> None:
console.print("[blue]Installing rclone via official script...[/blue]")
try:
run_command(["sh", "-c", "curl https://rclone.org/install.sh | sudo bash"])
console.print("[green]rclone installed via official script[/green]")
console.print("[green]rclone installed via official script[/green]")
except RcloneInstallError:
raise RcloneInstallError(
"Failed to install rclone. Please install manually: brew install rclone"
@@ -84,7 +83,7 @@ def install_rclone_linux() -> None:
try:
console.print("[blue]Installing rclone via snap...[/blue]")
run_command(["sudo", "snap", "install", "rclone"])
console.print("[green]rclone installed via snap[/green]")
console.print("[green]rclone installed via snap[/green]")
return
except RcloneInstallError:
console.print("[yellow]Snap installation failed, trying apt...[/yellow]")
@@ -95,7 +94,7 @@ def install_rclone_linux() -> None:
console.print("[blue]Installing rclone via apt...[/blue]")
run_command(["sudo", "apt", "update"])
run_command(["sudo", "apt", "install", "-y", "rclone"])
console.print("[green]rclone installed via apt[/green]")
console.print("[green]rclone installed via apt[/green]")
return
except RcloneInstallError:
console.print("[yellow]apt installation failed, trying official script...[/yellow]")
@@ -104,7 +103,7 @@ def install_rclone_linux() -> None:
console.print("[blue]Installing rclone via official script...[/blue]")
try:
run_command(["sh", "-c", "curl https://rclone.org/install.sh | sudo bash"])
console.print("[green]rclone installed via official script[/green]")
console.print("[green]rclone installed via official script[/green]")
except RcloneInstallError:
raise RcloneInstallError(
"Failed to install rclone. Please install manually: sudo snap install rclone"
@@ -117,16 +116,8 @@ def install_rclone_windows() -> None:
if shutil.which("winget"):
try:
console.print("[blue]Installing rclone via winget...[/blue]")
run_command(
[
"winget",
"install",
"Rclone.Rclone",
"--accept-source-agreements",
"--accept-package-agreements",
]
)
console.print("[green]rclone installed via winget[/green]")
run_command(["winget", "install", "Rclone.Rclone"])
console.print("[green]✓ rclone installed via winget[/green]")
return
except RcloneInstallError:
console.print("[yellow]winget installation failed, trying chocolatey...[/yellow]")
@@ -136,7 +127,7 @@ def install_rclone_windows() -> None:
try:
console.print("[blue]Installing rclone via chocolatey...[/blue]")
run_command(["choco", "install", "rclone", "-y"])
console.print("[green]rclone installed via chocolatey[/green]")
console.print("[green]rclone installed via chocolatey[/green]")
return
except RcloneInstallError:
console.print("[yellow]chocolatey installation failed, trying scoop...[/yellow]")
@@ -146,30 +137,16 @@ def install_rclone_windows() -> None:
try:
console.print("[blue]Installing rclone via scoop...[/blue]")
run_command(["scoop", "install", "rclone"])
console.print("[green]rclone installed via scoop[/green]")
console.print("[green]rclone installed via scoop[/green]")
return
except RcloneInstallError:
console.print("[yellow]scoop installation failed[/yellow]")
# No package manager available - provide detailed instructions
error_msg = (
"Could not install rclone automatically.\n\n"
"Windows requires a package manager to install rclone. Options:\n\n"
"1. Install winget (recommended, built into Windows 11):\n"
" - Windows 11: Already installed\n"
" - Windows 10: Install 'App Installer' from Microsoft Store\n"
" - Then run: bm cloud setup\n\n"
"2. Install chocolatey:\n"
" - Visit: https://chocolatey.org/install\n"
" - Then run: bm cloud setup\n\n"
"3. Install scoop:\n"
" - Visit: https://scoop.sh\n"
" - Then run: bm cloud setup\n\n"
"4. Manual installation:\n"
" - Download from: https://rclone.org/downloads/\n"
" - Extract and add to PATH\n"
# No package manager available
raise RcloneInstallError(
"Could not install rclone automatically. Please install a package manager "
"(winget, chocolatey, or scoop) or install rclone manually from https://rclone.org/downloads/"
)
raise RcloneInstallError(error_msg)
def install_rclone(platform_override: Optional[str] = None) -> None:
@@ -188,7 +165,6 @@ def install_rclone(platform_override: Optional[str] = None) -> None:
install_rclone_linux()
elif platform_name == "windows":
install_rclone_windows()
refresh_windows_path()
else:
raise RcloneInstallError(f"Unsupported platform: {platform_name}")
@@ -196,7 +172,7 @@ def install_rclone(platform_override: Optional[str] = None) -> None:
if not is_rclone_installed():
raise RcloneInstallError("rclone installation completed but command not found in PATH")
console.print("[green]rclone installation completed successfully[/green]")
console.print("[green]rclone installation completed successfully[/green]")
except RcloneInstallError:
raise
@@ -204,47 +180,6 @@ def install_rclone(platform_override: Optional[str] = None) -> None:
raise RcloneInstallError(f"Unexpected error during installation: {e}") from e
def refresh_windows_path() -> None:
"""Refresh the Windows PATH environment variable for the current session."""
if platform.system().lower() != "windows":
return
# Importing here after performing platform detection. Also note that we have to ignore pylance/pyright
# warnings about winreg attributes so that "errors" don't appear on non-Windows platforms.
import winreg
user_key_path = r"Environment"
system_key_path = r"System\CurrentControlSet\Control\Session Manager\Environment"
new_path = ""
# Read user PATH
try:
reg_key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, user_key_path, 0, winreg.KEY_READ) # type: ignore[reportAttributeAccessIssue]
user_path, _ = winreg.QueryValueEx(reg_key, "PATH") # type: ignore[reportAttributeAccessIssue]
winreg.CloseKey(reg_key) # type: ignore[reportAttributeAccessIssue]
except Exception:
user_path = ""
# Read system PATH
try:
reg_key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, system_key_path, 0, winreg.KEY_READ) # type: ignore[reportAttributeAccessIssue]
system_path, _ = winreg.QueryValueEx(reg_key, "PATH") # type: ignore[reportAttributeAccessIssue]
winreg.CloseKey(reg_key) # type: ignore[reportAttributeAccessIssue]
except Exception:
system_path = ""
# Merge user and system PATHs (system first, then user)
if system_path and user_path:
new_path = system_path + ";" + user_path
elif system_path:
new_path = system_path
elif user_path:
new_path = user_path
if new_path:
os.environ["PATH"] = new_path
def get_rclone_version() -> Optional[str]:
"""Get the installed rclone version."""
if not is_rclone_installed():
+2 -38
View File
@@ -10,9 +10,6 @@ from basic_memory.ignore_utils import load_gitignore_patterns, should_ignore_pat
from basic_memory.mcp.async_client import get_client
from basic_memory.mcp.tools.utils import call_put
# Archive file extensions that should be skipped during upload
ARCHIVE_EXTENSIONS = {".zip", ".tar", ".gz", ".bz2", ".xz", ".7z", ".rar", ".tgz", ".tbz2"}
async def upload_path(
local_path: Path,
@@ -64,18 +61,11 @@ async def upload_path(
# Calculate total size
total_bytes = sum(file_path.stat().st_size for file_path, _ in files_to_upload)
skipped_count = 0
# If dry run, just show what would be uploaded
if dry_run:
print("\nFiles that would be uploaded:")
for file_path, relative_path in files_to_upload:
# Skip archive files
if _is_archive_file(file_path):
print(f" [SKIP] {relative_path} (archive file)")
skipped_count += 1
continue
size = file_path.stat().st_size
if size < 1024:
size_str = f"{size} bytes"
@@ -88,14 +78,6 @@ async def upload_path(
# Upload files using httpx
async with get_client() as client:
for i, (file_path, relative_path) in enumerate(files_to_upload, 1):
# Skip archive files (zip, tar, gz, etc.)
if _is_archive_file(file_path):
print(
f"Skipping archive file: {relative_path} ({i}/{len(files_to_upload)})"
)
skipped_count += 1
continue
# Build remote path: /webdav/{project_name}/{relative_path}
remote_path = f"/webdav/{project_name}/{relative_path}"
print(f"Uploading {relative_path} ({i}/{len(files_to_upload)})")
@@ -123,15 +105,10 @@ async def upload_path(
else:
size_str = f"{total_bytes / (1024 * 1024):.1f} MB"
uploaded_count = len(files_to_upload) - skipped_count
if dry_run:
print(f"\nTotal: {uploaded_count} file(s) ({size_str})")
if skipped_count > 0:
print(f" Would skip {skipped_count} archive file(s)")
print(f"\nTotal: {len(files_to_upload)} file(s) ({size_str})")
else:
print(f"✓ Upload complete: {uploaded_count} file(s) ({size_str})")
if skipped_count > 0:
print(f" Skipped {skipped_count} archive file(s)")
print(f"✓ Upload complete: {len(files_to_upload)} file(s) ({size_str})")
return True
@@ -143,19 +120,6 @@ async def upload_path(
return False
def _is_archive_file(file_path: Path) -> bool:
"""
Check if a file is an archive file based on its extension.
Args:
file_path: Path to the file to check
Returns:
True if file is an archive, False otherwise
"""
return file_path.suffix.lower() in ARCHIVE_EXTENSIONS
def _get_files_to_upload(
directory: Path, verbose: bool = False, use_gitignore: bool = True
) -> list[tuple[Path, str]]:
@@ -78,7 +78,7 @@ def upload(
console.print(f"[blue]Creating cloud project '{project}'...[/blue]")
try:
await create_cloud_project(project)
console.print(f"[green]Created project '{project}'[/green]")
console.print(f"[green]Created project '{project}'[/green]")
except Exception as e:
console.print(f"[red]Failed to create project: {e}[/red]")
raise typer.Exit(1)
@@ -109,14 +109,13 @@ def upload(
if dry_run:
console.print("[yellow]DRY RUN complete - no files were uploaded[/yellow]")
else:
console.print(f"[green]Successfully uploaded to '{project}'[/green]")
console.print(f"[green]Successfully uploaded to '{project}'[/green]")
# Sync project if requested (skip on dry run)
# Force full scan after bisync to ensure database is up-to-date with synced files
if sync and not dry_run:
console.print(f"[blue]Syncing project '{project}'...[/blue]")
try:
await sync_project(project, force_full=True)
await sync_project(project)
except Exception as e:
console.print(f"[yellow]Warning: Sync failed: {e}[/yellow]")
console.print("[dim]Files uploaded but may not be indexed yet[/dim]")
+6 -14
View File
@@ -16,25 +16,17 @@ from basic_memory.schemas import ProjectInfoResponse
console = Console()
async def run_sync(project: Optional[str] = None, force_full: bool = False):
"""Run sync operation via API endpoint.
Args:
project: Optional project name
force_full: If True, force a full scan bypassing watermark optimization
"""
async def run_sync(project: Optional[str] = None):
"""Run sync operation via API endpoint."""
try:
async with get_client() as client:
project_item = await get_active_project(client, project, None)
url = f"{project_item.project_url}/project/sync"
if force_full:
url += "?force_full=true"
response = await call_post(client, url)
response = await call_post(client, f"{project_item.project_url}/project/sync")
data = response.json()
console.print(f"[green]{data['message']}[/green]")
console.print(f"[green]{data['message']}[/green]")
except (ToolError, ValueError) as e:
console.print(f"[red]Sync failed: {e}[/red]")
console.print(f"[red]Sync failed: {e}[/red]")
raise typer.Exit(1)
@@ -47,5 +39,5 @@ async def get_project_info(project: str):
response = await call_get(client, f"{project_item.project_url}/project/info")
return ProjectInfoResponse.model_validate(response.json())
except (ToolError, ValueError) as e:
console.print(f"[red]Sync failed: {e}[/red]")
console.print(f"[red]Sync failed: {e}[/red]")
raise typer.Exit(1)
+3 -3
View File
@@ -37,8 +37,8 @@ def reset(
logger.info("Database reset complete")
if reindex:
# Run database sync directly
from basic_memory.cli.commands.command_utils import run_sync
# Import and run sync
from basic_memory.cli.commands.sync import sync
logger.info("Rebuilding search index from filesystem...")
asyncio.run(run_sync(project=None))
sync(watch=False) # pyright: ignore
+23 -538
View File
@@ -22,20 +22,9 @@ from basic_memory.mcp.tools.utils import call_post
from basic_memory.schemas.project_info import ProjectStatusResponse
from basic_memory.mcp.tools.utils import call_delete
from basic_memory.mcp.tools.utils import call_put
from basic_memory.utils import generate_permalink, normalize_project_path
from basic_memory.utils import generate_permalink
from basic_memory.mcp.tools.utils import call_patch
# Import rclone commands for project sync
from basic_memory.cli.commands.cloud.rclone_commands import (
SyncProject,
RcloneError,
project_sync,
project_bisync,
project_check,
project_ls,
)
from basic_memory.cli.commands.cloud.bisync_commands import get_mount_info
console = Console()
# Create a project subcommand
@@ -62,41 +51,15 @@ def list_projects() -> None:
try:
result = asyncio.run(_list_projects())
config = ConfigManager().config
table = Table(title="Basic Memory Projects")
table.add_column("Name", style="cyan")
table.add_column("Path", style="green")
# Add Local Path column if in cloud mode
if config.cloud_mode_enabled:
table.add_column("Local Path", style="yellow", no_wrap=True, overflow="fold")
# Show Default column in local mode or if default_project_mode is enabled in cloud mode
show_default_column = not config.cloud_mode_enabled or config.default_project_mode
if show_default_column:
table.add_column("Default", style="magenta")
table.add_column("Default", style="magenta")
for project in result.projects:
is_default = "[X]" if project.is_default else ""
normalized_path = normalize_project_path(project.path)
# Build row based on mode
row = [project.name, format_path(normalized_path)]
# Add local path if in cloud mode
if config.cloud_mode_enabled:
local_path = ""
if project.name in config.cloud_projects:
local_path = config.cloud_projects[project.name].local_path or ""
local_path = format_path(local_path)
row.append(local_path)
# Add default indicator if showing default column
if show_default_column:
row.append(is_default)
table.add_row(*row)
is_default = "" if project.is_default else ""
table.add_row(project.name, format_path(project.path), is_default)
console.print(table)
except Exception as e:
@@ -110,38 +73,20 @@ def add_project(
path: str = typer.Argument(
None, help="Path to the project directory (required for local mode)"
),
local_path: str = typer.Option(
None, "--local-path", help="Local sync path for cloud mode (optional)"
),
set_default: bool = typer.Option(False, "--default", help="Set as default project"),
) -> None:
"""Add a new project.
Cloud mode examples:\n
bm project add research # No local sync\n
bm project add research --local-path ~/docs # With local sync\n
Local mode example:\n
bm project add research ~/Documents/research
For cloud mode: only name is required
For local mode: both name and path are required
"""
config = ConfigManager().config
# Resolve local sync path early (needed for both cloud and local mode)
local_sync_path: str | None = None
if local_path:
local_sync_path = Path(os.path.abspath(os.path.expanduser(local_path))).as_posix()
if config.cloud_mode_enabled:
# Cloud mode: path auto-generated from name, local sync is optional
# Cloud mode: path not needed (auto-generated from name)
async def _add_project():
async with get_client() as client:
data = {
"name": name,
"path": generate_permalink(name),
"local_sync_path": local_sync_path,
"set_default": set_default,
}
data = {"name": name, "path": generate_permalink(name), "set_default": set_default}
response = await call_post(client, "/projects/projects", json=data)
return ProjectStatusResponse.model_validate(response.json())
else:
@@ -162,85 +107,13 @@ def add_project(
try:
result = asyncio.run(_add_project())
console.print(f"[green]{result.message}[/green]")
# Save local sync path to config if in cloud mode
if config.cloud_mode_enabled and local_sync_path:
from basic_memory.config import CloudProjectConfig
# Create local directory if it doesn't exist
local_dir = Path(local_sync_path)
local_dir.mkdir(parents=True, exist_ok=True)
# Update config with sync path
config.cloud_projects[name] = CloudProjectConfig(
local_path=local_sync_path,
last_sync=None,
bisync_initialized=False,
)
ConfigManager().save_config(config)
console.print(f"\n[green]Local sync path configured: {local_sync_path}[/green]")
console.print("\nNext steps:")
console.print(f" 1. Preview: bm project bisync --name {name} --resync --dry-run")
console.print(f" 2. Sync: bm project bisync --name {name} --resync")
except Exception as e:
console.print(f"[red]Error adding project: {str(e)}[/red]")
raise typer.Exit(1)
@project_app.command("sync-setup")
def setup_project_sync(
name: str = typer.Argument(..., help="Project name"),
local_path: str = typer.Argument(..., help="Local sync directory"),
) -> None:
"""Configure local sync for an existing cloud project.
Example:
bm project sync-setup research ~/Documents/research
"""
config_manager = ConfigManager()
config = config_manager.config
if not config.cloud_mode_enabled:
console.print("[red]Error: sync-setup only available in cloud mode[/red]")
raise typer.Exit(1)
async def _verify_project_exists():
"""Verify the project exists on cloud by listing all projects."""
async with get_client() as client:
response = await call_get(client, "/projects/projects")
project_list = response.json()
project_names = [p["name"] for p in project_list["projects"]]
if name not in project_names:
raise ValueError(f"Project '{name}' not found on cloud")
return True
try:
# Verify project exists on cloud
asyncio.run(_verify_project_exists())
# Resolve and create local path
resolved_path = Path(os.path.abspath(os.path.expanduser(local_path)))
resolved_path.mkdir(parents=True, exist_ok=True)
# Update local config with sync path
from basic_memory.config import CloudProjectConfig
config.cloud_projects[name] = CloudProjectConfig(
local_path=resolved_path.as_posix(),
last_sync=None,
bisync_initialized=False,
)
config_manager.save_config(config)
console.print(f"[green]Sync configured for project '{name}'[/green]")
console.print(f"\nLocal sync path: {resolved_path}")
console.print("\nNext steps:")
console.print(f" 1. Preview: bm project bisync --name {name} --resync --dry-run")
console.print(f" 2. Sync: bm project bisync --name {name} --resync")
except Exception as e:
console.print(f"[red]Error configuring sync: {str(e)}[/red]")
raise typer.Exit(1)
# Display usage hint
console.print("\nTo use this project:")
console.print(f" basic-memory --project={name} <command>")
@project_app.command("remove")
@@ -261,57 +134,16 @@ def remove_project(
return ProjectStatusResponse.model_validate(response.json())
try:
# Get config to check for local sync path and bisync state
config = ConfigManager().config
local_path = None
has_bisync_state = False
if config.cloud_mode_enabled and name in config.cloud_projects:
local_path = config.cloud_projects[name].local_path
# Check for bisync state
from basic_memory.cli.commands.cloud.rclone_commands import get_project_bisync_state
bisync_state_path = get_project_bisync_state(name)
has_bisync_state = bisync_state_path.exists()
# Remove project from cloud/API
result = asyncio.run(_remove_project())
console.print(f"[green]{result.message}[/green]")
# Clean up local sync directory if it exists and delete_notes is True
if delete_notes and local_path:
local_dir = Path(local_path)
if local_dir.exists():
import shutil
shutil.rmtree(local_dir)
console.print(f"[green]Removed local sync directory: {local_path}[/green]")
# Clean up bisync state if it exists
if has_bisync_state:
from basic_memory.cli.commands.cloud.rclone_commands import get_project_bisync_state
import shutil
bisync_state_path = get_project_bisync_state(name)
if bisync_state_path.exists():
shutil.rmtree(bisync_state_path)
console.print("[green]Removed bisync state[/green]")
# Clean up cloud_projects config entry
if config.cloud_mode_enabled and name in config.cloud_projects:
del config.cloud_projects[name]
ConfigManager().save_config(config)
# Show informative message if files were not deleted
if not delete_notes:
if local_path:
console.print(f"[yellow]Note: Local files remain at {local_path}[/yellow]")
except Exception as e:
console.print(f"[red]Error removing project: {str(e)}[/red]")
raise typer.Exit(1)
# Show this message only if files were not deleted
if not delete_notes:
console.print("[yellow]Note: The project files have not been deleted from disk.[/yellow]")
@project_app.command("default")
def set_default_project(
@@ -405,7 +237,7 @@ def move_project(
"[yellow]You must manually move your project files from the old location to:[/yellow]\n"
f"[cyan]{resolved_path}[/cyan]\n\n"
"[dim]Basic Memory has only updated the configuration - your files remain in their original location.[/dim]",
title="Manual File Movement Required",
title="⚠️ Manual File Movement Required",
border_style="yellow",
expand=False,
)
@@ -416,353 +248,6 @@ def move_project(
raise typer.Exit(1)
@project_app.command("sync")
def sync_project_command(
name: str = typer.Option(..., "--name", help="Project name to sync"),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
) -> None:
"""One-way sync: local -> cloud (make cloud identical to local).
Example:
bm project sync --name research
bm project sync --name research --dry-run
"""
config = ConfigManager().config
if not config.cloud_mode_enabled:
console.print("[red]Error: sync only available in cloud mode[/red]")
raise typer.Exit(1)
try:
# Get tenant info for bucket name
tenant_info = asyncio.run(get_mount_info())
bucket_name = tenant_info.bucket_name
# Get project info
async def _get_project():
async with get_client() as client:
response = await call_get(client, "/projects/projects")
projects_list = ProjectList.model_validate(response.json())
for proj in projects_list.projects:
if generate_permalink(proj.name) == generate_permalink(name):
return proj
return None
project_data = asyncio.run(_get_project())
if not project_data:
console.print(f"[red]Error: Project '{name}' not found[/red]")
raise typer.Exit(1)
# Get local_sync_path from cloud_projects config
local_sync_path = None
if name in config.cloud_projects:
local_sync_path = config.cloud_projects[name].local_path
if not local_sync_path:
console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
console.print(f"\nConfigure sync with: bm project sync-setup {name} ~/path/to/local")
raise typer.Exit(1)
# Create SyncProject
sync_project = SyncProject(
name=project_data.name,
path=normalize_project_path(project_data.path),
local_sync_path=local_sync_path,
)
# Run sync
console.print(f"[blue]Syncing {name} (local -> cloud)...[/blue]")
success = project_sync(sync_project, bucket_name, dry_run=dry_run, verbose=verbose)
if success:
console.print(f"[green]{name} synced successfully[/green]")
# Trigger database sync if not a dry run
if not dry_run:
async def _trigger_db_sync():
async with get_client() as client:
permalink = generate_permalink(name)
response = await call_post(
client, f"/{permalink}/project/sync?force_full=true", json={}
)
return response.json()
try:
result = asyncio.run(_trigger_db_sync())
console.print(f"[dim]Database sync initiated: {result.get('message')}[/dim]")
except Exception as e:
console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]")
else:
console.print(f"[red]{name} sync failed[/red]")
raise typer.Exit(1)
except RcloneError as e:
console.print(f"[red]Sync error: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
@project_app.command("bisync")
def bisync_project_command(
name: str = typer.Option(..., "--name", help="Project name to bisync"),
dry_run: bool = typer.Option(False, "--dry-run", help="Preview changes without syncing"),
resync: bool = typer.Option(False, "--resync", help="Force new baseline"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed output"),
) -> None:
"""Two-way sync: local <-> cloud (bidirectional sync).
Examples:
bm project bisync --name research --resync # First time
bm project bisync --name research # Subsequent syncs
bm project bisync --name research --dry-run # Preview changes
"""
config = ConfigManager().config
if not config.cloud_mode_enabled:
console.print("[red]Error: bisync only available in cloud mode[/red]")
raise typer.Exit(1)
try:
# Get tenant info for bucket name
tenant_info = asyncio.run(get_mount_info())
bucket_name = tenant_info.bucket_name
# Get project info
async def _get_project():
async with get_client() as client:
response = await call_get(client, "/projects/projects")
projects_list = ProjectList.model_validate(response.json())
for proj in projects_list.projects:
if generate_permalink(proj.name) == generate_permalink(name):
return proj
return None
project_data = asyncio.run(_get_project())
if not project_data:
console.print(f"[red]Error: Project '{name}' not found[/red]")
raise typer.Exit(1)
# Get local_sync_path from cloud_projects config
local_sync_path = None
if name in config.cloud_projects:
local_sync_path = config.cloud_projects[name].local_path
if not local_sync_path:
console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
console.print(f"\nConfigure sync with: bm project sync-setup {name} ~/path/to/local")
raise typer.Exit(1)
# Create SyncProject
sync_project = SyncProject(
name=project_data.name,
path=normalize_project_path(project_data.path),
local_sync_path=local_sync_path,
)
# Run bisync
console.print(f"[blue]Bisync {name} (local <-> cloud)...[/blue]")
success = project_bisync(
sync_project, bucket_name, dry_run=dry_run, resync=resync, verbose=verbose
)
if success:
console.print(f"[green]{name} bisync completed successfully[/green]")
# Update config
config.cloud_projects[name].last_sync = datetime.now()
config.cloud_projects[name].bisync_initialized = True
ConfigManager().save_config(config)
# Trigger database sync if not a dry run
if not dry_run:
async def _trigger_db_sync():
async with get_client() as client:
permalink = generate_permalink(name)
response = await call_post(
client, f"/{permalink}/project/sync?force_full=true", json={}
)
return response.json()
try:
result = asyncio.run(_trigger_db_sync())
console.print(f"[dim]Database sync initiated: {result.get('message')}[/dim]")
except Exception as e:
console.print(f"[yellow]Warning: Could not trigger database sync: {e}[/yellow]")
else:
console.print(f"[red]{name} bisync failed[/red]")
raise typer.Exit(1)
except RcloneError as e:
console.print(f"[red]Bisync error: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
@project_app.command("check")
def check_project_command(
name: str = typer.Option(..., "--name", help="Project name to check"),
one_way: bool = typer.Option(False, "--one-way", help="Check one direction only (faster)"),
) -> None:
"""Verify file integrity between local and cloud.
Example:
bm project check --name research
"""
config = ConfigManager().config
if not config.cloud_mode_enabled:
console.print("[red]Error: check only available in cloud mode[/red]")
raise typer.Exit(1)
try:
# Get tenant info for bucket name
tenant_info = asyncio.run(get_mount_info())
bucket_name = tenant_info.bucket_name
# Get project info
async def _get_project():
async with get_client() as client:
response = await call_get(client, "/projects/projects")
projects_list = ProjectList.model_validate(response.json())
for proj in projects_list.projects:
if generate_permalink(proj.name) == generate_permalink(name):
return proj
return None
project_data = asyncio.run(_get_project())
if not project_data:
console.print(f"[red]Error: Project '{name}' not found[/red]")
raise typer.Exit(1)
# Get local_sync_path from cloud_projects config
local_sync_path = None
if name in config.cloud_projects:
local_sync_path = config.cloud_projects[name].local_path
if not local_sync_path:
console.print(f"[red]Error: Project '{name}' has no local_sync_path configured[/red]")
console.print(f"\nConfigure sync with: bm project sync-setup {name} ~/path/to/local")
raise typer.Exit(1)
# Create SyncProject
sync_project = SyncProject(
name=project_data.name,
path=normalize_project_path(project_data.path),
local_sync_path=local_sync_path,
)
# Run check
console.print(f"[blue]Checking {name} integrity...[/blue]")
match = project_check(sync_project, bucket_name, one_way=one_way)
if match:
console.print(f"[green]{name} files match[/green]")
else:
console.print(f"[yellow]!{name} has differences[/yellow]")
except RcloneError as e:
console.print(f"[red]Check error: {e}[/red]")
raise typer.Exit(1)
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
@project_app.command("bisync-reset")
def bisync_reset(
name: str = typer.Argument(..., help="Project name to reset bisync state for"),
) -> None:
"""Clear bisync state for a project.
This removes the bisync metadata files, forcing a fresh --resync on next bisync.
Useful when bisync gets into an inconsistent state or when remote path changes.
"""
from basic_memory.cli.commands.cloud.rclone_commands import get_project_bisync_state
import shutil
try:
state_path = get_project_bisync_state(name)
if not state_path.exists():
console.print(f"[yellow]No bisync state found for project '{name}'[/yellow]")
return
# Remove the entire state directory
shutil.rmtree(state_path)
console.print(f"[green]Cleared bisync state for project '{name}'[/green]")
console.print("\nNext steps:")
console.print(f" 1. Preview: bm project bisync --name {name} --resync --dry-run")
console.print(f" 2. Sync: bm project bisync --name {name} --resync")
except Exception as e:
console.print(f"[red]Error clearing bisync state: {str(e)}[/red]")
raise typer.Exit(1)
@project_app.command("ls")
def ls_project_command(
name: str = typer.Option(..., "--name", help="Project name to list files from"),
path: str = typer.Argument(None, help="Path within project (optional)"),
) -> None:
"""List files in remote project.
Examples:
bm project ls --name research
bm project ls --name research subfolder
"""
config = ConfigManager().config
if not config.cloud_mode_enabled:
console.print("[red]Error: ls only available in cloud mode[/red]")
raise typer.Exit(1)
try:
# Get tenant info for bucket name
tenant_info = asyncio.run(get_mount_info())
bucket_name = tenant_info.bucket_name
# Get project info
async def _get_project():
async with get_client() as client:
response = await call_get(client, "/projects/projects")
projects_list = ProjectList.model_validate(response.json())
for proj in projects_list.projects:
if generate_permalink(proj.name) == generate_permalink(name):
return proj
return None
project_data = asyncio.run(_get_project())
if not project_data:
console.print(f"[red]Error: Project '{name}' not found[/red]")
raise typer.Exit(1)
# Create SyncProject (local_sync_path not needed for ls)
sync_project = SyncProject(
name=project_data.name,
path=normalize_project_path(project_data.path),
)
# List files
files = project_ls(sync_project, bucket_name, path=path)
if files:
console.print(f"\n[bold]Files in {name}" + (f"/{path}" if path else "") + ":[/bold]")
for file in files:
console.print(f" {file}")
console.print(f"\n[dim]Total: {len(files)} files[/dim]")
else:
console.print(
f"[yellow]No files found in {name}" + (f"/{path}" if path else "") + "[/yellow]"
)
except Exception as e:
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
@project_app.command("info")
def display_project_info(
name: str = typer.Argument(..., help="Name of the project"),
@@ -784,13 +269,13 @@ def display_project_info(
f"[bold]Project:[/bold] {info.project_name}\n"
f"[bold]Path:[/bold] {info.project_path}\n"
f"[bold]Default Project:[/bold] {info.default_project}\n",
title="Basic Memory Project Info",
title="📊 Basic Memory Project Info",
expand=False,
)
)
# Statistics section
stats_table = Table(title="Statistics")
stats_table = Table(title="📈 Statistics")
stats_table.add_column("Metric", style="cyan")
stats_table.add_column("Count", style="green")
@@ -806,7 +291,7 @@ def display_project_info(
# Entity types
if info.statistics.entity_types:
entity_types_table = Table(title="Entity Types")
entity_types_table = Table(title="📑 Entity Types")
entity_types_table.add_column("Type", style="blue")
entity_types_table.add_column("Count", style="green")
@@ -817,7 +302,7 @@ def display_project_info(
# Most connected entities
if info.statistics.most_connected_entities: # pragma: no cover
connected_table = Table(title="Most Connected Entities")
connected_table = Table(title="🔗 Most Connected Entities")
connected_table.add_column("Title", style="blue")
connected_table.add_column("Permalink", style="cyan")
connected_table.add_column("Relations", style="green")
@@ -831,7 +316,7 @@ def display_project_info(
# Recent activity
if info.activity.recently_updated: # pragma: no cover
recent_table = Table(title="Recent Activity")
recent_table = Table(title="🕒 Recent Activity")
recent_table.add_column("Title", style="blue")
recent_table.add_column("Type", style="cyan")
recent_table.add_column("Last Updated", style="green")
@@ -851,7 +336,7 @@ def display_project_info(
console.print(recent_table)
# Available projects
projects_table = Table(title="Available Projects")
projects_table = Table(title="📁 Available Projects")
projects_table.add_column("Name", style="blue")
projects_table.add_column("Path", style="cyan")
projects_table.add_column("Default", style="green")
@@ -859,7 +344,7 @@ def display_project_info(
for name, proj_info in info.available_projects.items():
is_default = name == info.default_project
project_path = proj_info["path"]
projects_table.add_row(name, project_path, "[X]" if is_default else "")
projects_table.add_row(name, project_path, "" if is_default else "")
console.print(projects_table)
+3 -3
View File
@@ -115,7 +115,7 @@ def display_changes(
del_branch = tree.add("[red]Deleted[/red]")
add_files_to_tree(del_branch, changes.deleted, "red")
if changes.skipped_files:
skip_branch = tree.add("[red]! Skipped (Circuit Breaker)[/red]")
skip_branch = tree.add("[red]⚠️ Skipped (Circuit Breaker)[/red]")
for skipped in sorted(changes.skipped_files, key=lambda x: x.path):
skip_branch.add(
f"[red]{skipped.path}[/red] "
@@ -133,7 +133,7 @@ def display_changes(
if changes.skipped_files:
skip_count = len(changes.skipped_files)
tree.add(
f"[red]! {skip_count} file{'s' if skip_count != 1 else ''} "
f"[red]⚠️ {skip_count} file{'s' if skip_count != 1 else ''} "
f"skipped due to repeated failures[/red]"
)
@@ -152,7 +152,7 @@ async def run_status(project: Optional[str] = None, verbose: bool = False): # p
display_changes(project_item.name, "Status", sync_report, verbose)
except (ValueError, ToolError) as e:
console.print(f"[red]Error: {e}[/red]")
console.print(f"[red]Error: {e}[/red]")
raise typer.Exit(1)
+59
View File
@@ -0,0 +1,59 @@
"""Command module for basic-memory sync operations."""
import asyncio
from typing import Annotated, Optional
import typer
from basic_memory.cli.app import app
from basic_memory.cli.commands.command_utils import run_sync
from basic_memory.config import ConfigManager
@app.command()
def sync(
project: Annotated[
Optional[str],
typer.Option(help="The project name."),
] = None,
watch: Annotated[
bool,
typer.Option("--watch", help="Run continuous sync (cloud mode only)"),
] = False,
interval: Annotated[
int,
typer.Option("--interval", help="Sync interval in seconds for watch mode (default: 60)"),
] = 60,
) -> None:
"""Sync knowledge files with the database.
In local mode: Scans filesystem and updates database.
In cloud mode: Runs bidirectional file sync (bisync) then updates database.
Examples:
bm sync # One-time sync
bm sync --watch # Continuous sync every 60s
bm sync --watch --interval 30 # Continuous sync every 30s
"""
config = ConfigManager().config
if config.cloud_mode_enabled:
# Cloud mode: run bisync which includes database sync
from basic_memory.cli.commands.cloud.bisync_commands import run_bisync, run_bisync_watch
try:
if watch:
run_bisync_watch(interval_seconds=interval)
else:
run_bisync()
except Exception:
raise typer.Exit(1)
else:
# Local mode: just database sync
if watch:
typer.echo(
"Error: --watch is only available in cloud mode. Run 'bm cloud login' first."
)
raise typer.Exit(1)
asyncio.run(run_sync(project))
+1
View File
@@ -13,6 +13,7 @@ from basic_memory.cli.commands import ( # noqa: F401 # pragma: no cover
mcp,
project,
status,
sync,
tool,
)
+19 -63
View File
@@ -3,13 +3,11 @@
import json
import os
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Literal, Optional, List, Tuple
from enum import Enum
from loguru import logger
from pydantic import BaseModel, Field, field_validator
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
import basic_memory
@@ -25,13 +23,6 @@ WATCH_STATUS_JSON = "watch-status.json"
Environment = Literal["test", "dev", "user"]
class DatabaseBackend(str, Enum):
"""Supported database backends."""
SQLITE = "sqlite"
POSTGRES = "postgres"
@dataclass
class ProjectConfig:
"""Configuration for a specific basic-memory project."""
@@ -48,22 +39,6 @@ class ProjectConfig:
return f"/{generate_permalink(self.name)}"
class CloudProjectConfig(BaseModel):
"""Sync configuration for a cloud project.
This tracks the local working directory and sync state for a project
that is synced with Basic Memory Cloud.
"""
local_path: str = Field(description="Local working directory path for this cloud project")
last_sync: Optional[datetime] = Field(
default=None, description="Timestamp of last successful sync operation"
)
bisync_initialized: bool = Field(
default=False, description="Whether rclone bisync baseline has been established"
)
class BasicMemoryConfig(BaseSettings):
"""Pydantic model for Basic Memory global configuration."""
@@ -71,10 +46,8 @@ class BasicMemoryConfig(BaseSettings):
projects: Dict[str, str] = Field(
default_factory=lambda: {
"main": str(Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")))
}
if os.getenv("BASIC_MEMORY_HOME")
else {},
"main": Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory")).as_posix()
},
description="Mapping of project names to their filesystem paths",
)
default_project: str = Field(
@@ -89,17 +62,6 @@ class BasicMemoryConfig(BaseSettings):
# overridden by ~/.basic-memory/config.json
log_level: str = "INFO"
# Database configuration
database_backend: DatabaseBackend = Field(
default=DatabaseBackend.SQLITE,
description="Database backend to use (sqlite or postgres)",
)
database_url: Optional[str] = Field(
default=None,
description="Database connection URL. For Postgres, use postgresql+asyncpg://user:pass@host:port/db. If not set, SQLite will use default path.",
)
# Watch service configuration
sync_delay: int = Field(
default=1000, description="Milliseconds to wait after changes before syncing", gt=0
@@ -132,12 +94,6 @@ class BasicMemoryConfig(BaseSettings):
gt=0,
)
sync_batch_size: int = Field(
default=100,
description="Number of files to process in a single database transaction during sync. Higher values improve performance with remote databases (Postgres) but increase memory usage. Typical values: 100 (conservative), 500 (balanced), 1000 (aggressive).",
gt=0,
)
kebab_filenames: bool = Field(
default=False,
description="Format for generated filenames. False preserves spaces and special chars, True converts them to hyphens for consistency with permalinks",
@@ -182,11 +138,6 @@ class BasicMemoryConfig(BaseSettings):
description="Enable cloud mode - all requests go to cloud instead of local (config file value)",
)
cloud_projects: Dict[str, CloudProjectConfig] = Field(
default_factory=dict,
description="Cloud project sync configuration mapping project names to their local paths and sync state",
)
@property
def cloud_mode_enabled(self) -> bool:
"""Check if cloud mode is enabled.
@@ -203,6 +154,14 @@ class BasicMemoryConfig(BaseSettings):
# Fall back to config file value
return self.cloud_mode
bisync_config: Dict[str, Any] = Field(
default_factory=lambda: {
"profile": "balanced",
"sync_dir": str(Path.home() / "basic-memory-cloud-sync"),
},
description="Bisync configuration for cloud sync",
)
model_config = SettingsConfigDict(
env_prefix="BASIC_MEMORY_",
extra="ignore",
@@ -219,16 +178,15 @@ class BasicMemoryConfig(BaseSettings):
def model_post_init(self, __context: Any) -> None:
"""Ensure configuration is valid after initialization."""
# Ensure at least one project exists; if none exist then create main
if not self.projects: # pragma: no cover
self.projects["main"] = str(
# Ensure main project exists
if "main" not in self.projects: # pragma: no cover
self.projects["main"] = (
Path(os.getenv("BASIC_MEMORY_HOME", Path.home() / "basic-memory"))
)
).as_posix()
# Ensure default project is valid (i.e. points to an existing project)
# Ensure default project is valid
if self.default_project not in self.projects: # pragma: no cover
# Set default to first available project
self.default_project = next(iter(self.projects.keys()))
self.default_project = "main"
@property
def app_database_path(self) -> Path:
@@ -386,7 +344,7 @@ class ConfigManager:
# Load config, modify it, and save it
config = self.load_config()
config.projects[name] = str(project_path)
config.projects[name] = project_path.as_posix()
self.save_config(config)
return ProjectConfig(name=name, home=project_path)
@@ -469,9 +427,7 @@ def get_project_config(project_name: Optional[str] = None) -> ProjectConfig:
def save_basic_memory_config(file_path: Path, config: BasicMemoryConfig) -> None:
"""Save configuration to file."""
try:
# Use model_dump with mode='json' to serialize datetime objects properly
config_dict = config.model_dump(mode="json")
file_path.write_text(json.dumps(config_dict, indent=2))
file_path.write_text(json.dumps(config.model_dump(), indent=2))
except Exception as e: # pragma: no cover
logger.error(f"Failed to save config: {e}")
+73 -118
View File
@@ -5,7 +5,7 @@ from enum import Enum, auto
from pathlib import Path
from typing import AsyncGenerator, Optional
from basic_memory.config import BasicMemoryConfig, ConfigManager, DatabaseBackend
from basic_memory.config import BasicMemoryConfig, ConfigManager
from alembic import command
from alembic.config import Config
@@ -20,12 +20,12 @@ from sqlalchemy.ext.asyncio import (
)
from sqlalchemy.pool import NullPool
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
from basic_memory.repository.search_repository import SearchRepository
# Module level state
_engine: Optional[AsyncEngine] = None
_session_maker: Optional[async_sessionmaker[AsyncSession]] = None
_migrations_completed: bool = False
class DatabaseType(Enum):
@@ -35,33 +35,8 @@ class DatabaseType(Enum):
FILESYSTEM = auto()
@classmethod
def get_db_url(
cls, db_path: Path, db_type: "DatabaseType", config: Optional[BasicMemoryConfig] = None
) -> str:
"""Get SQLAlchemy URL for database path.
Args:
db_path: Path to SQLite database file (ignored for Postgres)
db_type: Type of database (MEMORY or FILESYSTEM)
config: Optional config to check for database backend and URL
Returns:
SQLAlchemy connection URL
"""
# Load config if not provided
if config is None:
config = ConfigManager().config
# Check if Postgres backend is configured
if config.database_backend == DatabaseBackend.POSTGRES:
if not config.database_url:
raise ValueError("DATABASE_URL must be set when using Postgres backend")
logger.info(
f"Using Postgres database: {config.database_url.split('@')[1] if '@' in config.database_url else config.database_url}"
)
return config.database_url
# Default to SQLite
def get_db_url(cls, db_path: Path, db_type: "DatabaseType") -> str:
"""Get SQLAlchemy URL for database path."""
if db_type == cls.MEMORY:
logger.info("Using in-memory SQLite database")
return "sqlite+aiosqlite://"
@@ -89,14 +64,7 @@ async def scoped_session(
factory = get_scoped_session_factory(session_maker)
session = factory()
try:
# Only enable foreign keys for SQLite (Postgres has them enabled by default)
# Detect database type from session's bind (engine) dialect
engine = session.get_bind()
dialect_name = engine.dialect.name
if dialect_name == "sqlite":
await session.execute(text("PRAGMA foreign_keys=ON"))
await session.execute(text("PRAGMA foreign_keys=ON"))
yield session
await session.commit()
except Exception:
@@ -135,16 +103,13 @@ def _configure_sqlite_connection(dbapi_conn, enable_wal: bool = True) -> None:
cursor.close()
def _create_sqlite_engine(db_url: str, db_type: DatabaseType) -> AsyncEngine:
"""Create SQLite async engine with appropriate configuration.
def _create_engine_and_session(
db_path: Path, db_type: DatabaseType = DatabaseType.FILESYSTEM
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
"""Internal helper to create engine and session maker."""
db_url = DatabaseType.get_db_url(db_path, db_type)
logger.debug(f"Creating engine for db_url: {db_url}")
Args:
db_url: SQLite connection URL
db_type: Database type (MEMORY or FILESYSTEM)
Returns:
Configured async engine for SQLite
"""
# Configure connection args with Windows-specific settings
connect_args: dict[str, bool | float | None] = {"check_same_thread": False}
@@ -181,50 +146,6 @@ def _create_sqlite_engine(db_url: str, db_type: DatabaseType) -> AsyncEngine:
"""Enable WAL mode on each connection."""
_configure_sqlite_connection(dbapi_conn, enable_wal=enable_wal)
return engine
def _create_postgres_engine(db_url: str) -> AsyncEngine:
"""Create Postgres async engine with appropriate configuration.
Args:
db_url: Postgres connection URL (postgresql+asyncpg://...)
Returns:
Configured async engine for Postgres
"""
# Postgres with asyncpg - use standard async connection
engine = create_async_engine(
db_url,
echo=False,
pool_pre_ping=True, # Verify connections before using them
)
return engine
def _create_engine_and_session(
db_path: Path, db_type: DatabaseType = DatabaseType.FILESYSTEM
) -> tuple[AsyncEngine, async_sessionmaker[AsyncSession]]:
"""Internal helper to create engine and session maker.
Args:
db_path: Path to database file (used for SQLite, ignored for Postgres)
db_type: Type of database (MEMORY or FILESYSTEM)
Returns:
Tuple of (engine, session_maker)
"""
config = ConfigManager().config
db_url = DatabaseType.get_db_url(db_path, db_type, config)
logger.debug(f"Creating engine for db_url: {db_url}")
# Delegate to backend-specific engine creation
if config.database_backend == DatabaseBackend.POSTGRES:
engine = _create_postgres_engine(db_url)
else:
engine = _create_sqlite_engine(db_url, db_type)
session_maker = async_sessionmaker(engine, expire_on_commit=False)
return engine, session_maker
@@ -260,12 +181,13 @@ async def get_or_create_db(
async def shutdown_db() -> None: # pragma: no cover
"""Clean up database connections."""
global _engine, _session_maker
global _engine, _session_maker, _migrations_completed
if _engine:
await _engine.dispose()
_engine = None
_session_maker = None
_migrations_completed = False
@asynccontextmanager
@@ -279,12 +201,50 @@ async def engine_session_factory(
for each test. For production use, use get_or_create_db() instead.
"""
global _engine, _session_maker
global _engine, _session_maker, _migrations_completed
# Use the same helper function as production code
_engine, _session_maker = _create_engine_and_session(db_path, db_type)
db_url = DatabaseType.get_db_url(db_path, db_type)
logger.debug(f"Creating engine for db_url: {db_url}")
# Configure connection args with Windows-specific settings
connect_args: dict[str, bool | float | None] = {"check_same_thread": False}
# Add Windows-specific parameters to improve reliability
if os.name == "nt": # Windows
connect_args.update(
{
"timeout": 30.0, # Increase timeout to 30 seconds for Windows
"isolation_level": None, # Use autocommit mode
}
)
# Use NullPool for Windows filesystem databases to avoid connection pooling issues
# Important: Do NOT use NullPool for in-memory databases as it will destroy the database
# between connections
if db_type == DatabaseType.FILESYSTEM:
_engine = create_async_engine(
db_url,
connect_args=connect_args,
poolclass=NullPool, # Disable connection pooling on Windows
echo=False,
)
else:
# In-memory databases need connection pooling to maintain state
_engine = create_async_engine(db_url, connect_args=connect_args)
else:
_engine = create_async_engine(db_url, connect_args=connect_args)
# Enable WAL mode for better concurrency and reliability
# Note: WAL mode is not supported for in-memory databases
enable_wal = db_type != DatabaseType.MEMORY
@event.listens_for(_engine.sync_engine, "connect")
def enable_wal_mode(dbapi_conn, connection_record):
"""Enable WAL mode on each connection."""
_configure_sqlite_connection(dbapi_conn, enable_wal=enable_wal)
try:
_session_maker = async_sessionmaker(_engine, expire_on_commit=False)
# Verify that engine and session maker are initialized
if _engine is None: # pragma: no cover
logger.error("Database engine is None in engine_session_factory")
@@ -300,16 +260,20 @@ async def engine_session_factory(
await _engine.dispose()
_engine = None
_session_maker = None
_migrations_completed = False
async def run_migrations(
app_config: BasicMemoryConfig, database_type=DatabaseType.FILESYSTEM
app_config: BasicMemoryConfig, database_type=DatabaseType.FILESYSTEM, force: bool = False
): # pragma: no cover
"""Run any pending alembic migrations.
"""Run any pending alembic migrations."""
global _migrations_completed
# Skip if migrations already completed unless forced
if _migrations_completed and not force:
logger.debug("Migrations already completed in this session, skipping")
return
Note: Alembic tracks which migrations have been applied via the alembic_version table,
so it's safe to call this multiple times - it will only run pending migrations.
"""
logger.info("Running database migrations...")
try:
# Get the absolute path to the alembic directory relative to this file
@@ -324,16 +288,9 @@ async def run_migrations(
)
config.set_main_option("timezone", "UTC")
config.set_main_option("revision_environment", "false")
# Get the correct database URL based on backend configuration
db_url = DatabaseType.get_db_url(app_config.database_path, database_type, app_config)
# For Postgres, Alembic needs synchronous driver (psycopg2), not async (asyncpg)
if app_config.database_backend == DatabaseBackend.POSTGRES:
# Convert asyncpg URL to psycopg2 URL for Alembic
db_url = db_url.replace("postgresql+asyncpg://", "postgresql://")
config.set_main_option("sqlalchemy.url", db_url)
config.set_main_option(
"sqlalchemy.url", DatabaseType.get_db_url(app_config.database_path, database_type)
)
command.upgrade(config, "head")
logger.info("Migrations completed successfully")
@@ -344,14 +301,12 @@ async def run_migrations(
else:
session_maker = _session_maker
# Initialize the search index schema
# For SQLite: Create FTS5 virtual table
# For Postgres: No-op (tsvector column added by migrations)
# The project_id is not used for init_search_index, so we pass a dummy value
if app_config.database_backend == DatabaseBackend.POSTGRES:
await PostgresSearchRepository(session_maker, 1).init_search_index()
else:
await SQLiteSearchRepository(session_maker, 1).init_search_index()
# initialize the search Index schema
# the project_id is not used for init_search_index, so we pass a dummy value
await SearchRepository(session_maker, 1).init_search_index()
# Mark migrations as completed
_migrations_completed = True
except Exception as e: # pragma: no cover
logger.error(f"Error running migrations: {e}")
raise
+3 -7
View File
@@ -25,7 +25,7 @@ from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.observation_repository import ObservationRepository
from basic_memory.repository.project_repository import ProjectRepository
from basic_memory.repository.relation_repository import RelationRepository
from basic_memory.repository.search_repository import SearchRepository, create_search_repository
from basic_memory.repository.search_repository import SearchRepository
from basic_memory.services import EntityService, ProjectService
from basic_memory.services.context_service import ContextService
from basic_memory.services.directory_service import DirectoryService
@@ -214,12 +214,8 @@ async def get_search_repository(
session_maker: SessionMakerDep,
project_id: ProjectIdDep,
) -> SearchRepository:
"""Create a backend-specific SearchRepository instance for the current project.
Uses factory function to return SQLiteSearchRepository or PostgresSearchRepository
based on database backend configuration.
"""
return create_search_repository(session_maker, project_id=project_id)
"""Create a SearchRepository instance for the current project."""
return SearchRepository(session_maker, project_id=project_id)
SearchRepositoryDep = Annotated[SearchRepository, Depends(get_search_repository)]
+3 -12
View File
@@ -177,22 +177,18 @@ def dump_frontmatter(post: frontmatter.Post) -> str:
"""
Serialize frontmatter.Post to markdown with Obsidian-compatible YAML format.
This function ensures that:
1. Tags are formatted as YAML lists instead of JSON arrays
2. String values are properly quoted to handle special characters (colons, etc.)
This function ensures that tags are formatted as YAML lists instead of JSON arrays:
Good (Obsidian compatible):
---
title: "L2 Governance Core (Split: Core)"
tags:
- system
- overview
- reference
---
Bad (causes parsing errors):
Bad (current behavior):
---
title: L2 Governance Core (Split: Core) # Unquoted colon breaks YAML
tags: ["system", "overview", "reference"]
---
@@ -207,13 +203,8 @@ def dump_frontmatter(post: frontmatter.Post) -> str:
return post.content
# Serialize YAML with block style for lists
# SafeDumper automatically quotes values with special characters (colons, etc.)
yaml_str = yaml.dump(
post.metadata,
sort_keys=False,
allow_unicode=True,
default_flow_style=False,
Dumper=yaml.SafeDumper,
post.metadata, sort_keys=False, allow_unicode=True, default_flow_style=False
)
# Construct the final markdown with frontmatter
+5 -86
View File
@@ -4,7 +4,7 @@ Uses markdown-it with plugins to parse structured data from markdown content.
"""
from dataclasses import dataclass, field
from datetime import date, datetime
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
@@ -26,82 +26,6 @@ from basic_memory.utils import parse_tags
md = MarkdownIt().use(observation_plugin).use(relation_plugin)
def normalize_frontmatter_value(value: Any) -> Any:
"""Normalize frontmatter values to safe types for processing.
PyYAML automatically converts various string-like values into native Python types:
- Date strings ("2025-10-24") datetime.date objects
- Numbers ("1.0") int or float
- Booleans ("true") bool
- Lists list objects
This can cause AttributeError when code expects strings and calls string methods
like .strip() on these values (see GitHub issue #236).
This function normalizes all frontmatter values to safe types:
- Dates/datetimes ISO format strings
- Numbers (int/float) strings
- Booleans strings ("True"/"False")
- Lists preserved as lists, but items are recursively normalized
- Dicts preserved as dicts, but values are recursively normalized
- Strings kept as-is
- None kept as None
Args:
value: The frontmatter value to normalize
Returns:
The normalized value safe for string operations
Example:
>>> normalize_frontmatter_value(datetime.date(2025, 10, 24))
'2025-10-24'
>>> normalize_frontmatter_value([datetime.date(2025, 10, 24), "tag", 123])
['2025-10-24', 'tag', '123']
>>> normalize_frontmatter_value(True)
'True'
"""
# Convert date/datetime objects to ISO format strings
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, date):
return value.isoformat()
# Convert boolean to string (must come before int check since bool is subclass of int)
if isinstance(value, bool):
return str(value)
# Convert numbers to strings
if isinstance(value, (int, float)):
return str(value)
# Recursively process lists (preserve as list, normalize items)
if isinstance(value, list):
return [normalize_frontmatter_value(item) for item in value]
# Recursively process dicts (preserve as dict, normalize values)
if isinstance(value, dict):
return {key: normalize_frontmatter_value(val) for key, val in value.items()}
# Keep strings and None as-is
return value
def normalize_frontmatter_metadata(metadata: dict) -> dict:
"""Normalize all values in frontmatter metadata dict.
Converts date/datetime objects to ISO format strings to prevent
AttributeError when code expects strings (GitHub issue #236).
Args:
metadata: The frontmatter metadata dictionary
Returns:
A new dictionary with all values normalized
"""
return {key: normalize_frontmatter_value(value) for key, value in metadata.items()}
@dataclass
class EntityContent:
content: str
@@ -203,25 +127,20 @@ class EntityParser:
# Extract file stat info
file_stats = absolute_path.stat()
# Normalize frontmatter values to prevent AttributeError on date objects (issue #236)
# PyYAML automatically converts date strings like "2025-10-24" to datetime.date objects
# This normalization converts them back to ISO format strings to ensure compatibility
# with code that expects string values
metadata = normalize_frontmatter_metadata(post.metadata)
metadata = post.metadata
# Ensure required fields have defaults (issue #184, #387)
# Handle title - use default if missing, None/null, empty, or string "None"
title = metadata.get("title")
title = post.metadata.get("title")
if not title or title == "None":
metadata["title"] = absolute_path.stem
else:
metadata["title"] = title
# Handle type - use default if missing OR explicitly set to None/null
entity_type = metadata.get("type")
entity_type = post.metadata.get("type")
metadata["type"] = entity_type if entity_type is not None else "note"
tags = parse_tags(metadata.get("tags", [])) # pyright: ignore
tags = parse_tags(post.metadata.get("tags", [])) # pyright: ignore
if tags:
metadata["tags"] = tags
+3 -1
View File
@@ -16,6 +16,8 @@ from basic_memory.schemas.memory import (
memory_url_path,
)
type StringOrInt = str | int
@mcp.tool(
description="""Build context from a memory:// URI to continue conversations naturally.
@@ -37,7 +39,7 @@ from basic_memory.schemas.memory import (
async def build_context(
url: MemoryUrl,
project: Optional[str] = None,
depth: str | int | None = 1,
depth: Optional[StringOrInt] = 1,
timeframe: Optional[TimeFrame] = "7d",
page: int = 1,
page_size: int = 10,
+1 -8
View File
@@ -110,14 +110,7 @@ async def canvas(
# Write the file using the resource API
logger.info(f"Creating canvas file: {file_path} in project {project}")
# Send canvas_json as content string, not as json parameter
# The resource endpoint expects Body() string content, not JSON-encoded data
response = await call_put(
client,
f"{project_url}/resource/{file_path}",
content=canvas_json,
headers={"Content-Type": "text/plain"},
)
response = await call_put(client, f"{project_url}/resource/{file_path}", json=canvas_json)
# Parse response
result = response.json()
+3 -3
View File
@@ -205,8 +205,8 @@ async def search_notes(
page: int = 1,
page_size: int = 10,
search_type: str = "text",
types: List[str] = [],
entity_types: List[str] = [],
types: Optional[List[str]] = None,
entity_types: Optional[List[str]] = None,
after_date: Optional[str] = None,
context: Context | None = None,
) -> SearchResponse | str:
@@ -345,7 +345,7 @@ async def search_notes(
else:
search_query.text = query # Default to text search
# Add optional filters if provided (empty lists are treated as no filter)
# Add optional filters if provided
if entity_types:
search_query.entity_types = [SearchItemType(t) for t in entity_types]
if types:
+9 -7
View File
@@ -16,6 +16,9 @@ from basic_memory.utils import parse_tags, validate_project_path
# Define TagType as a Union that can accept either a string or a list of strings or None
TagType = Union[List[str], str, None]
# Define TagType as a Union that can accept either a string or a list of strings or None
TagType = Union[List[str], str, None]
@mcp.tool(
description="Create or update a markdown note. Returns a markdown formatted summary of the semantic content.",
@@ -25,8 +28,8 @@ async def write_note(
content: str,
folder: str,
project: Optional[str] = None,
tags: list[str] | str | None = None,
note_type: str = "note",
tags=None,
entity_type: str = "note",
context: Context | None = None,
) -> str:
"""Write a markdown note to the knowledge base.
@@ -67,8 +70,7 @@ async def write_note(
available projects.
tags: Tags to categorize the note. Can be a list of strings, a comma-separated string, or None.
Note: If passing from external MCP clients, use a string format (e.g. "tag1,tag2,tag3")
note_type: Type of note to create (stored in frontmatter). Defaults to "note".
Can be "guide", "report", "config", "person", etc.
entity_type: Type of entity to create. Defaults to "note". Can be "guide", "report", "config", etc.
context: Optional FastMCP context for performance caching.
Returns:
@@ -94,14 +96,14 @@ async def write_note(
content="# Weekly Standup\\n\\n- [decision] Use SQLite for storage #tech"
)
# Create a note with tags and note type
# Create a note with tags and entity type
write_note(
project="work-project",
title="API Design",
folder="specs",
content="# REST API Specification\\n\\n- implements [[Authentication]]",
tags=["api", "design"],
note_type="guide"
entity_type="guide"
)
# Update existing note (same title/folder)
@@ -145,7 +147,7 @@ async def write_note(
entity = Entity(
title=title,
folder=folder,
entity_type=note_type,
entity_type=entity_type,
content_type="text/markdown",
content=content,
entity_metadata=metadata,
-2
View File
@@ -4,7 +4,6 @@ import basic_memory
from basic_memory.models.base import Base
from basic_memory.models.knowledge import Entity, Observation, Relation
from basic_memory.models.project import Project
from basic_memory.models.search import SearchIndex
__all__ = [
"Base",
@@ -12,6 +11,5 @@ __all__ = [
"Observation",
"Relation",
"Project",
"SearchIndex",
"basic_memory",
]
+2 -50
View File
@@ -1,56 +1,8 @@
"""Search models and tables."""
from sqlalchemy import DDL, Column, Integer, String, DateTime, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.types import JSON
from sqlalchemy import DDL
from basic_memory.models.base import Base
class SearchIndex(Base):
"""Search index table for Postgres only.
For SQLite: This model is skipped; FTS5 virtual table is created via DDL instead.
For Postgres: This is the actual table structure with tsvector support.
"""
__tablename__ = "search_index"
# Primary key (rowid in SQLite FTS5, explicit id in Postgres)
id = Column(Integer, primary_key=True, autoincrement=True)
# Core searchable fields
title = Column(Text, nullable=True)
content_stems = Column(Text, nullable=True)
content_snippet = Column(Text, nullable=True)
permalink = Column(String(255), nullable=True, index=True)
file_path = Column(Text, nullable=True)
type = Column(String(50), nullable=True)
# Project context
project_id = Column(Integer, nullable=True, index=True)
# Relation fields
from_id = Column(Integer, nullable=True)
to_id = Column(Integer, nullable=True)
relation_type = Column(String(100), nullable=True)
# Observation fields
entity_id = Column(Integer, nullable=True)
category = Column(String(100), nullable=True)
# Common fields
# Use JSONB for Postgres, JSON for SQLite
# Note: 'metadata' is a reserved name in SQLAlchemy, so we use 'metadata_' and map to 'metadata'
metadata_ = Column("metadata", JSON().with_variant(JSONB(), "postgresql"), nullable=True)
created_at = Column(DateTime(timezone=True), nullable=True)
updated_at = Column(DateTime(timezone=True), nullable=True)
# Note: textsearchable_index_col (tsvector) will be added by migration for Postgres only
# Define FTS5 virtual table creation for SQLite only
# This DDL is executed separately for SQLite databases
# Define FTS5 virtual table creation
CREATE_SEARCH_INDEX = DDL("""
CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
-- Core entity fields
@@ -63,38 +63,6 @@ class EntityRepository(Repository[Entity]):
)
return await self.find_one(query)
async def get_by_file_paths_batch(
self, file_paths: Sequence[Union[Path, str]]
) -> dict[str, Entity]:
"""Batch fetch entities by file paths with eager-loaded relationships.
Optimized for scan operations - reduces N queries to 1 batched query.
Returns entities with relationships already loaded via selectinload.
Args:
file_paths: List of file paths to fetch entities for
Returns:
Dict mapping file_path (as posix string) -> Entity
Only includes entities that exist; missing files are not in dict
"""
if not file_paths:
return {}
# Convert all paths to posix strings
posix_paths = [Path(p).as_posix() for p in file_paths]
# Batch query with eager loading
query = (
self.select().where(Entity.file_path.in_(posix_paths)).options(*self.get_load_options())
)
result = await self.execute_query(query)
entities = list(result.scalars().all())
# Return as dict for O(1) lookup
return {e.file_path: e for e in entities}
async def find_by_checksum(self, checksum: str) -> Sequence[Entity]:
"""Find entities with the given checksum.
@@ -187,13 +155,8 @@ class EntityRepository(Repository[Entity]):
except IntegrityError as e:
# Check if this is a FOREIGN KEY constraint failure
# SQLite: "FOREIGN KEY constraint failed"
# Postgres: "violates foreign key constraint"
error_str = str(e)
if (
"FOREIGN KEY constraint failed" in error_str
or "violates foreign key constraint" in error_str
):
if "FOREIGN KEY constraint failed" in error_str:
# Import locally to avoid circular dependency (repository -> services -> repository)
from basic_memory.services.exceptions import SyncFatalError
@@ -347,103 +310,5 @@ class EntityRepository(Repository[Entity]):
# Insert with unique permalink
session.add(entity)
try:
await session.flush()
except IntegrityError as e:
# Check if this is a FOREIGN KEY constraint failure
# SQLite: "FOREIGN KEY constraint failed"
# Postgres: "violates foreign key constraint"
error_str = str(e)
if (
"FOREIGN KEY constraint failed" in error_str
or "violates foreign key constraint" in error_str
):
# Import locally to avoid circular dependency (repository -> services -> repository)
from basic_memory.services.exceptions import SyncFatalError
# Project doesn't exist in database - this is a fatal sync error
raise SyncFatalError(
f"Cannot sync file '{entity.file_path}': "
f"project_id={entity.project_id} does not exist in database. "
f"The project may have been deleted. This sync will be terminated."
) from e
# Re-raise if not a foreign key error
raise
await session.flush()
return entity
async def upsert_entities(self, entities: List[Entity]) -> List[Entity]:
"""Bulk insert or update multiple entities in a single transaction.
Optimized for batch operations with remote databases (Postgres).
Handles conflicts the same way as upsert_entity() but processes
all entities in one transaction.
Args:
entities: List of entities to upsert
Returns:
List of upserted entities with relationships loaded
Raises:
SyncFatalError: If any entity references a non-existent project_id
"""
if not entities:
return []
async with db.scoped_session(self.session_maker) as session:
# Set project_id on all entities if needed
for entity in entities:
self._set_project_id_if_needed(entity)
# Try to add all entities
for entity in entities:
session.add(entity)
try:
await session.flush()
# Fetch all entities with relationships loaded
file_paths = [e.file_path for e in entities]
query = (
self.select()
.where(Entity.file_path.in_(file_paths))
.options(*self.get_load_options())
)
result = await session.execute(query)
return list(result.scalars().all())
except IntegrityError as e:
# Check for foreign key constraint failures
error_str = str(e)
if (
"FOREIGN KEY constraint failed" in error_str
or "violates foreign key constraint" in error_str
):
from basic_memory.services.exceptions import SyncFatalError
raise SyncFatalError(
"Cannot sync entities: project_id does not exist in database. "
"The project may have been deleted. This sync will be terminated."
) from e
# For other integrity errors (file_path or permalink conflicts),
# rollback and fall back to individual processing
await session.rollback()
# Process each entity individually to handle conflicts properly
logger.debug(
f"Batch upsert failed with IntegrityError, falling back to individual upserts for {len(entities)} entities"
)
result_entities = []
for entity in entities:
try:
upserted = await self.upsert_entity(entity)
result_entities.append(upserted)
except Exception as individual_error:
logger.error(
f"Failed to upsert entity {entity.file_path}: {individual_error}"
)
# Continue with other entities
return result_entities
@@ -70,33 +70,3 @@ class ObservationRepository(Repository[Observation]):
observations_by_entity[obs.entity_id].append(obs)
return observations_by_entity
async def delete_by_entity_ids(self, entity_ids: List[int]) -> int:
"""Delete all observations for multiple entities in a single query.
Optimized for batch operations - deletes observations for many entities
in one database transaction.
Args:
entity_ids: List of entity IDs whose observations should be deleted
Returns:
Number of observations deleted
"""
if not entity_ids:
return 0
from basic_memory import db
async with db.scoped_session(self.session_maker) as session:
# Use bulk delete with IN clause
query = select(Observation).where(Observation.entity_id.in_(entity_ids))
result = await session.execute(query)
observations_to_delete = result.scalars().all()
# Delete all observations
for obs in observations_to_delete:
await session.delete(obs)
await session.flush()
return len(observations_to_delete)
@@ -1,313 +0,0 @@
"""PostgreSQL tsvector-based search repository implementation."""
import json
import re
from datetime import datetime
from typing import List, Optional
from loguru import logger
from sqlalchemy import text
from basic_memory import db
from basic_memory.repository.search_index_row import SearchIndexRow
from basic_memory.repository.search_repository_base import SearchRepositoryBase
from basic_memory.schemas.search import SearchItemType
class PostgresSearchRepository(SearchRepositoryBase):
"""PostgreSQL tsvector implementation of search repository.
Uses PostgreSQL's full-text search capabilities with:
- tsvector for document representation
- tsquery for query representation
- GIN indexes for performance
- ts_rank() function for relevance scoring
- JSONB containment operators for metadata search
"""
async def init_search_index(self):
"""Create Postgres table with tsvector column and GIN indexes.
Note: This is handled by Alembic migrations. This method is a no-op
for Postgres as the schema is created via migrations.
"""
logger.info("PostgreSQL search index initialization handled by migrations")
# Table creation is done via Alembic migrations
# This includes:
# - CREATE TABLE search_index (...)
# - ADD COLUMN textsearchable_index_col tsvector GENERATED ALWAYS AS (...)
# - CREATE INDEX USING GIN on textsearchable_index_col
# - CREATE INDEX USING GIN on metadata jsonb_path_ops
pass
def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
"""Prepare a search term for tsquery format.
Args:
term: The search term to prepare
is_prefix: Whether to add prefix search capability (:* operator)
Returns:
Formatted search term for tsquery
For Postgres:
- Boolean operators are converted to tsquery format (&, |, !)
- Prefix matching uses the :* operator
- Terms are sanitized to prevent tsquery syntax errors
"""
# Check for explicit boolean operators
boolean_operators = [" AND ", " OR ", " NOT "]
if any(op in f" {term} " for op in boolean_operators):
return self._prepare_boolean_query(term)
# For non-Boolean queries, prepare single term
return self._prepare_single_term(term, is_prefix)
def _prepare_boolean_query(self, query: str) -> str:
"""Convert Boolean query to tsquery format.
Args:
query: A Boolean query like "coffee AND brewing" or "(pour OR french) AND press"
Returns:
tsquery-formatted string with & (AND), | (OR), ! (NOT) operators
Examples:
"coffee AND brewing" -> "coffee & brewing"
"(pour OR french) AND press" -> "(pour | french) & press"
"coffee NOT decaf" -> "coffee & !decaf"
"""
# Replace Boolean operators with tsquery operators
# Keep parentheses for grouping
result = query
result = re.sub(r"\bAND\b", "&", result)
result = re.sub(r"\bOR\b", "|", result)
# NOT must be converted to "& !" and the ! must be attached to the following term
# "Python NOT Django" -> "Python & !Django"
result = re.sub(r"\bNOT\s+", "& !", result)
return result
def _prepare_single_term(self, term: str, is_prefix: bool = True) -> str:
"""Prepare a single search term for tsquery.
Args:
term: A single search term
is_prefix: Whether to add prefix search capability (:* suffix)
Returns:
A properly formatted single term for tsquery
For Postgres tsquery:
- Multi-word queries become "word1 & word2"
- Prefix matching uses ":*" suffix (e.g., "coff:*")
- Special characters that need escaping: & | ! ( ) :
"""
if not term or not term.strip():
return term
term = term.strip()
# Check if term is already a wildcard pattern
if "*" in term:
# Replace * with :* for Postgres prefix matching
return term.replace("*", ":*")
# Remove tsquery special characters from the search term
# These characters have special meaning in tsquery and cause syntax errors
# if not used as operators
special_chars = ["&", "|", "!", "(", ")", ":"]
cleaned_term = term
for char in special_chars:
cleaned_term = cleaned_term.replace(char, " ")
# Handle multi-word queries
if " " in cleaned_term:
words = [w for w in cleaned_term.split() if w.strip()]
if not words:
# All characters were special chars, search won't match anything
# Return a safe search term that won't cause syntax errors
return "NOSPECIALCHARS:*"
if is_prefix:
# Add prefix matching to each word
prepared_words = [f"{word}:*" for word in words]
else:
prepared_words = words
# Join with AND operator
return " & ".join(prepared_words)
# Single word
cleaned_term = cleaned_term.strip()
if not cleaned_term:
return "NOSPECIALCHARS:*"
if is_prefix:
return f"{cleaned_term}:*"
else:
return cleaned_term
async def search(
self,
search_text: Optional[str] = None,
permalink: Optional[str] = None,
permalink_match: Optional[str] = None,
title: Optional[str] = None,
types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
limit: int = 10,
offset: int = 0,
) -> List[SearchIndexRow]:
"""Search across all indexed content using PostgreSQL tsvector."""
conditions = []
params = {}
order_by_clause = ""
# Handle text search for title and content using tsvector
if search_text:
if search_text.strip() == "*" or search_text.strip() == "":
# For wildcard searches, don't add any text conditions
pass
else:
# Prepare search term for tsquery
processed_text = self._prepare_search_term(search_text.strip())
params["text"] = processed_text
# Use @@ operator for tsvector matching
conditions.append("textsearchable_index_col @@ to_tsquery('english', :text)")
# Handle title search
if title:
title_text = self._prepare_search_term(title.strip(), is_prefix=False)
params["title_text"] = title_text
conditions.append("to_tsvector('english', title) @@ to_tsquery('english', :title_text)")
# Handle permalink exact search
if permalink:
params["permalink"] = permalink
conditions.append("permalink = :permalink")
# Handle permalink pattern match
if permalink_match:
permalink_text = permalink_match.lower().strip()
params["permalink"] = permalink_text
if "*" in permalink_match:
# Use LIKE for pattern matching in Postgres
# Convert * to % for SQL LIKE
permalink_pattern = permalink_text.replace("*", "%")
params["permalink"] = permalink_pattern
conditions.append("permalink LIKE :permalink")
else:
conditions.append("permalink = :permalink")
# Handle search item type filter
if search_item_types:
type_list = ", ".join(f"'{t.value}'" for t in search_item_types)
conditions.append(f"type IN ({type_list})")
# Handle entity type filter using JSONB containment
if types:
# Use JSONB @> operator for efficient containment queries
type_conditions = []
for entity_type in types:
# Create JSONB containment condition for each type
type_conditions.append(f'metadata @> \'{{"entity_type": "{entity_type}"}}\'')
conditions.append(f"({' OR '.join(type_conditions)})")
# Handle date filter
if after_date:
params["after_date"] = after_date
conditions.append("created_at > :after_date")
# order by most recent first
order_by_clause = ", updated_at DESC"
# Always filter by project_id
params["project_id"] = self.project_id
conditions.append("project_id = :project_id")
# set limit and offset
params["limit"] = limit
params["offset"] = offset
# Build WHERE clause
where_clause = " AND ".join(conditions) if conditions else "1=1"
# Build SQL with ts_rank() for scoring
# Note: If no text search, score will be NULL, so we use COALESCE to default to 0
if search_text and search_text.strip() and search_text.strip() != "*":
score_expr = "ts_rank(textsearchable_index_col, to_tsquery('english', :text))"
else:
score_expr = "0"
sql = f"""
SELECT
project_id,
id,
title,
permalink,
file_path,
type,
metadata,
from_id,
to_id,
relation_type,
entity_id,
content_snippet,
category,
created_at,
updated_at,
{score_expr} as score
FROM search_index
WHERE {where_clause}
ORDER BY score DESC {order_by_clause}
LIMIT :limit
OFFSET :offset
"""
logger.trace(f"Search {sql} params: {params}")
try:
async with db.scoped_session(self.session_maker) as session:
result = await session.execute(text(sql), params)
rows = result.fetchall()
except Exception as e:
# Handle tsquery syntax errors
if "tsquery" in str(e).lower() or "syntax error" in str(e).lower(): # pragma: no cover
logger.warning(f"tsquery syntax error for search term: {search_text}, error: {e}")
# Return empty results rather than crashing
return []
else:
# Re-raise other database errors
logger.error(f"Database error during search: {e}")
raise
results = [
SearchIndexRow(
project_id=self.project_id,
id=row.id,
title=row.title,
permalink=row.permalink,
file_path=row.file_path,
type=row.type,
score=float(row.score) if row.score else 0.0,
metadata=(
row.metadata
if isinstance(row.metadata, dict)
else (json.loads(row.metadata) if row.metadata else {})
),
from_id=row.from_id,
to_id=row.to_id,
relation_type=row.relation_type,
entity_id=row.entity_id,
content_snippet=row.content_snippet,
category=row.category,
created_at=row.created_at,
updated_at=row.updated_at,
)
for row in rows
]
logger.trace(f"Found {len(results)} search results")
for r in results:
logger.trace(
f"Search result: project_id: {r.project_id} type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}"
)
return results
@@ -67,27 +67,6 @@ class RelationRepository(Repository[Relation]):
async with db.scoped_session(self.session_maker) as session:
await session.execute(delete(Relation).where(Relation.from_id == entity_id))
async def delete_outgoing_relations_from_entities(self, entity_ids: List[int]) -> int:
"""Delete outgoing relations for multiple entities in a single query.
Optimized for batch operations - deletes relations for many entities
in one database transaction. Only deletes relations where these entities
are the source (from_id).
Args:
entity_ids: List of entity IDs whose outgoing relations should be deleted
Returns:
Number of relations deleted
"""
if not entity_ids:
return 0
async with db.scoped_session(self.session_maker) as session:
# Use bulk delete with IN clause
result = await session.execute(delete(Relation).where(Relation.from_id.in_(entity_ids)))
return result.rowcount or 0
async def find_unresolved_relations(self) -> Sequence[Relation]:
"""Find all unresolved relations, where to_id is null."""
query = select(Relation).filter(Relation.to_id.is_(None))
@@ -1,95 +0,0 @@
"""Search index data structures."""
import json
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from pathlib import Path
from basic_memory.schemas.search import SearchItemType
@dataclass
class SearchIndexRow:
"""Search result with score and metadata."""
project_id: int
id: int
type: str
file_path: str
# date values
created_at: datetime
updated_at: datetime
permalink: Optional[str] = None
metadata: Optional[dict] = None
# assigned in result
score: Optional[float] = None
# Type-specific fields
title: Optional[str] = None # entity
content_stems: Optional[str] = None # entity, observation
content_snippet: Optional[str] = None # entity, observation
entity_id: Optional[int] = None # observations
category: Optional[str] = None # observations
from_id: Optional[int] = None # relations
to_id: Optional[int] = None # relations
relation_type: Optional[str] = None # relations
@property
def content(self):
return self.content_snippet
@property
def directory(self) -> str:
"""Extract directory part from file_path.
For a file at "projects/notes/ideas.md", returns "/projects/notes"
For a file at root level "README.md", returns "/"
"""
if not self.type == SearchItemType.ENTITY.value and not self.file_path:
return ""
# Normalize path separators to handle both Windows (\) and Unix (/) paths
normalized_path = Path(self.file_path).as_posix()
# Split the path by slashes
parts = normalized_path.split("/")
# If there's only one part (e.g., "README.md"), it's at the root
if len(parts) <= 1:
return "/"
# Join all parts except the last one (filename)
directory_path = "/".join(parts[:-1])
return f"/{directory_path}"
def to_insert(self, serialize_json: bool = True):
"""Convert to dict for database insertion.
Args:
serialize_json: If True, converts metadata dict to JSON string (for SQLite).
If False, keeps metadata as dict (for Postgres JSONB).
"""
return {
"id": self.id,
"title": self.title,
"content_stems": self.content_stems,
"content_snippet": self.content_snippet,
"permalink": self.permalink,
"file_path": self.file_path,
"type": self.type,
"metadata": json.dumps(self.metadata)
if serialize_json and self.metadata
else self.metadata,
"from_id": self.from_id,
"to_id": self.to_id,
"relation_type": self.relation_type,
"entity_id": self.entity_id,
"category": self.category,
"created_at": self.created_at if self.created_at else None,
"updated_at": self.updated_at if self.updated_at else None,
"project_id": self.project_id,
}
+603 -58
View File
@@ -1,35 +1,365 @@
"""Repository for search operations.
This module provides the search repository interface.
The actual repository implementations are backend-specific:
- SQLiteSearchRepository: Uses FTS5 virtual tables
- PostgresSearchRepository: Uses tsvector/tsquery with GIN indexes
"""
"""Repository for search operations."""
import json
import re
import time
from dataclasses import dataclass
from datetime import datetime
from typing import List, Optional, Protocol
from typing import Any, Dict, List, Optional
from pathlib import Path
from sqlalchemy import Result
from loguru import logger
from sqlalchemy import Executable, Result, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from basic_memory.config import ConfigManager, DatabaseBackend
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
from basic_memory.repository.search_index_row import SearchIndexRow
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
from basic_memory import db
from basic_memory.models.search import CREATE_SEARCH_INDEX
from basic_memory.schemas.search import SearchItemType
class SearchRepository(Protocol):
"""Protocol defining the search repository interface.
Both SQLite and Postgres implementations must satisfy this protocol.
"""
@dataclass
class SearchIndexRow:
"""Search result with score and metadata."""
project_id: int
id: int
type: str
file_path: str
async def init_search_index(self) -> None:
"""Initialize the search index schema."""
...
# date values
created_at: datetime
updated_at: datetime
permalink: Optional[str] = None
metadata: Optional[dict] = None
# assigned in result
score: Optional[float] = None
# Type-specific fields
title: Optional[str] = None # entity
content_stems: Optional[str] = None # entity, observation
content_snippet: Optional[str] = None # entity, observation
entity_id: Optional[int] = None # observations
category: Optional[str] = None # observations
from_id: Optional[int] = None # relations
to_id: Optional[int] = None # relations
relation_type: Optional[str] = None # relations
@property
def content(self):
return self.content_snippet
@property
def directory(self) -> str:
"""Extract directory part from file_path.
For a file at "projects/notes/ideas.md", returns "/projects/notes"
For a file at root level "README.md", returns "/"
"""
if not self.type == SearchItemType.ENTITY.value and not self.file_path:
return ""
# Normalize path separators to handle both Windows (\) and Unix (/) paths
normalized_path = Path(self.file_path).as_posix()
# Split the path by slashes
parts = normalized_path.split("/")
# If there's only one part (e.g., "README.md"), it's at the root
if len(parts) <= 1:
return "/"
# Join all parts except the last one (filename)
directory_path = "/".join(parts[:-1])
return f"/{directory_path}"
def to_insert(self):
return {
"id": self.id,
"title": self.title,
"content_stems": self.content_stems,
"content_snippet": self.content_snippet,
"permalink": self.permalink,
"file_path": self.file_path,
"type": self.type,
"metadata": json.dumps(self.metadata),
"from_id": self.from_id,
"to_id": self.to_id,
"relation_type": self.relation_type,
"entity_id": self.entity_id,
"category": self.category,
"created_at": self.created_at if self.created_at else None,
"updated_at": self.updated_at if self.updated_at else None,
"project_id": self.project_id,
}
class SearchRepository:
"""Repository for search index operations."""
def __init__(self, session_maker: async_sessionmaker[AsyncSession], project_id: int):
"""Initialize with session maker and project_id filter.
Args:
session_maker: SQLAlchemy session maker
project_id: Project ID to filter all operations by
Raises:
ValueError: If project_id is None or invalid
"""
if project_id is None or project_id <= 0: # pragma: no cover
raise ValueError("A valid project_id is required for SearchRepository")
self.session_maker = session_maker
self.project_id = project_id
async def init_search_index(self):
"""Create or recreate the search index."""
logger.info("Initializing search index")
try:
async with db.scoped_session(self.session_maker) as session:
await session.execute(CREATE_SEARCH_INDEX)
await session.commit()
except Exception as e: # pragma: no cover
logger.error(f"Error initializing search index: {e}")
raise e
def _prepare_boolean_query(self, query: str) -> str:
"""Prepare a Boolean query by quoting individual terms while preserving operators.
Args:
query: A Boolean query like "tier1-test AND unicode" or "(hello OR world) NOT test"
Returns:
A properly formatted Boolean query with quoted terms that need quoting
"""
# Define Boolean operators and their boundaries
boolean_pattern = r"(\bAND\b|\bOR\b|\bNOT\b)"
# Split the query by Boolean operators, keeping the operators
parts = re.split(boolean_pattern, query)
processed_parts = []
for part in parts:
part = part.strip()
if not part:
continue
# If it's a Boolean operator, keep it as is
if part in ["AND", "OR", "NOT"]:
processed_parts.append(part)
else:
# Handle parentheses specially - they should be preserved for grouping
if "(" in part or ")" in part:
# Parse parenthetical expressions carefully
processed_part = self._prepare_parenthetical_term(part)
processed_parts.append(processed_part)
else:
# This is a search term - for Boolean queries, don't add prefix wildcards
prepared_term = self._prepare_single_term(part, is_prefix=False)
processed_parts.append(prepared_term)
return " ".join(processed_parts)
def _prepare_parenthetical_term(self, term: str) -> str:
"""Prepare a term that contains parentheses, preserving the parentheses for grouping.
Args:
term: A term that may contain parentheses like "(hello" or "world)" or "(hello OR world)"
Returns:
A properly formatted term with parentheses preserved
"""
# Handle terms that start/end with parentheses but may contain quotable content
result = ""
i = 0
while i < len(term):
if term[i] in "()":
# Preserve parentheses as-is
result += term[i]
i += 1
else:
# Find the next parenthesis or end of string
start = i
while i < len(term) and term[i] not in "()":
i += 1
# Extract the content between parentheses
content = term[start:i].strip()
if content:
# Only quote if it actually needs quoting (has hyphens, special chars, etc)
# but don't quote if it's just simple words
if self._needs_quoting(content):
escaped_content = content.replace('"', '""')
result += f'"{escaped_content}"'
else:
result += content
return result
def _needs_quoting(self, term: str) -> bool:
"""Check if a term needs to be quoted for FTS5 safety.
Args:
term: The term to check
Returns:
True if the term should be quoted
"""
if not term or not term.strip():
return False
# Characters that indicate we should quote (excluding parentheses which are valid syntax)
needs_quoting_chars = [
" ",
".",
":",
";",
",",
"<",
">",
"?",
"/",
"-",
"'",
'"',
"[",
"]",
"{",
"}",
"+",
"!",
"@",
"#",
"$",
"%",
"^",
"&",
"=",
"|",
"\\",
"~",
"`",
]
return any(c in term for c in needs_quoting_chars)
def _prepare_single_term(self, term: str, is_prefix: bool = True) -> str:
"""Prepare a single search term (no Boolean operators).
Args:
term: A single search term
is_prefix: Whether to add prefix search capability (* suffix)
Returns:
A properly formatted single term
"""
if not term or not term.strip():
return term
term = term.strip()
# Check if term is already a proper wildcard pattern (alphanumeric + *)
# e.g., "hello*", "test*world" - these should be left alone
if "*" in term and all(c.isalnum() or c in "*_-" for c in term):
return term
# Characters that can cause FTS5 syntax errors when used as operators
# We're more conservative here - only quote when we detect problematic patterns
problematic_chars = [
'"',
"'",
"(",
")",
"[",
"]",
"{",
"}",
"+",
"!",
"@",
"#",
"$",
"%",
"^",
"&",
"=",
"|",
"\\",
"~",
"`",
]
# Characters that indicate we should quote (spaces, dots, colons, etc.)
# Adding hyphens here because FTS5 can have issues with hyphens followed by wildcards
needs_quoting_chars = [" ", ".", ":", ";", ",", "<", ">", "?", "/", "-"]
# Check if term needs quoting
has_problematic = any(c in term for c in problematic_chars)
has_spaces_or_special = any(c in term for c in needs_quoting_chars)
if has_problematic or has_spaces_or_special:
# Handle multi-word queries differently from special character queries
if " " in term and not any(c in term for c in problematic_chars):
# Check if any individual word contains special characters that need quoting
words = term.strip().split()
has_special_in_words = any(
any(c in word for c in needs_quoting_chars if c != " ") for word in words
)
if not has_special_in_words:
# For multi-word queries with simple words (like "emoji unicode"),
# use boolean AND to handle word order variations
if is_prefix:
# Add prefix wildcard to each word for better matching
prepared_words = [f"{word}*" for word in words if word]
else:
prepared_words = words
term = " AND ".join(prepared_words)
else:
# If any word has special characters, quote the entire phrase
escaped_term = term.replace('"', '""')
if is_prefix and not ("/" in term and term.endswith(".md")):
term = f'"{escaped_term}"*'
else:
term = f'"{escaped_term}"'
else:
# For terms with problematic characters or file paths, use exact phrase matching
# Escape any existing quotes by doubling them
escaped_term = term.replace('"', '""')
# Quote the entire term to handle special characters safely
if is_prefix and not ("/" in term and term.endswith(".md")):
# For search terms (not file paths), add prefix matching
term = f'"{escaped_term}"*'
else:
# For file paths, use exact matching
term = f'"{escaped_term}"'
elif is_prefix:
# Only add wildcard for simple terms without special characters
term = f"{term}*"
return term
def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
"""Prepare a search term for FTS5 query.
Args:
term: The search term to prepare
is_prefix: Whether to add prefix search capability (* suffix)
For FTS5:
- Boolean operators (AND, OR, NOT) are preserved for complex queries
- Terms with FTS5 special characters are quoted to prevent syntax errors
- Simple terms get prefix wildcards for better matching
"""
# Check for explicit boolean operators - if present, process as Boolean query
boolean_operators = [" AND ", " OR ", " NOT "]
if any(op in f" {term} " for op in boolean_operators):
return self._prepare_boolean_query(term)
# For non-Boolean queries, use the single term preparation logic
return self._prepare_single_term(term, is_prefix)
async def search(
self,
@@ -43,52 +373,267 @@ class SearchRepository(Protocol):
limit: int = 10,
offset: int = 0,
) -> List[SearchIndexRow]:
"""Search across indexed content."""
...
"""Search across all indexed content with fuzzy matching."""
conditions = []
params = {}
order_by_clause = ""
async def index_item(self, search_index_row: SearchIndexRow) -> None:
"""Index a single item."""
...
# Handle text search for title and content
if search_text:
# Skip FTS for wildcard-only queries that would cause "unknown special query" errors
if search_text.strip() == "*" or search_text.strip() == "":
# For wildcard searches, don't add any text conditions - return all results
pass
else:
# Use _prepare_search_term to handle both Boolean and non-Boolean queries
processed_text = self._prepare_search_term(search_text.strip())
params["text"] = processed_text
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
async def bulk_index_items(self, search_index_rows: List[SearchIndexRow]) -> None:
"""Index multiple items in a batch."""
...
# Handle title match search
if title:
title_text = self._prepare_search_term(title.strip(), is_prefix=False)
params["title_text"] = title_text
conditions.append("title MATCH :title_text")
async def delete_by_permalink(self, permalink: str) -> None:
"""Delete item by permalink."""
...
# Handle permalink exact search
if permalink:
params["permalink"] = permalink
conditions.append("permalink = :permalink")
async def delete_by_entity_id(self, entity_id: int) -> None:
"""Delete items by entity ID."""
...
# Handle permalink match search, supports *
if permalink_match:
# For GLOB patterns, don't use _prepare_search_term as it will quote slashes
# GLOB patterns need to preserve their syntax
permalink_text = permalink_match.lower().strip()
params["permalink"] = permalink_text
if "*" in permalink_match:
conditions.append("permalink GLOB :permalink")
else:
# For exact matches without *, we can use FTS5 MATCH
# but only prepare the term if it doesn't look like a path
if "/" in permalink_text:
conditions.append("permalink = :permalink")
else:
permalink_text = self._prepare_search_term(permalink_text, is_prefix=False)
params["permalink"] = permalink_text
conditions.append("permalink MATCH :permalink")
async def execute_query(self, query, params: dict) -> Result:
"""Execute a raw SQL query."""
...
# Handle entity type filter
if search_item_types:
type_list = ", ".join(f"'{t.value}'" for t in search_item_types)
conditions.append(f"type IN ({type_list})")
# Handle type filter
if types:
type_list = ", ".join(f"'{t}'" for t in types)
conditions.append(f"json_extract(metadata, '$.entity_type') IN ({type_list})")
def create_search_repository(
session_maker: async_sessionmaker[AsyncSession], project_id: int
) -> SearchRepository:
"""Factory function to create the appropriate search repository based on database backend.
# Handle date filter using datetime() for proper comparison
if after_date:
params["after_date"] = after_date
conditions.append("datetime(created_at) > datetime(:after_date)")
Args:
session_maker: SQLAlchemy async session maker
project_id: Project ID for the repository
# order by most recent first
order_by_clause = ", updated_at DESC"
Returns:
SearchRepository: Backend-appropriate search repository instance
"""
config = ConfigManager().config
# Always filter by project_id
params["project_id"] = self.project_id
conditions.append("project_id = :project_id")
if config.database_backend == DatabaseBackend.POSTGRES:
return PostgresSearchRepository(session_maker, project_id=project_id)
else:
return SQLiteSearchRepository(session_maker, project_id=project_id)
# set limit on search query
params["limit"] = limit
params["offset"] = offset
# Build WHERE clause
where_clause = " AND ".join(conditions) if conditions else "1=1"
__all__ = [
"SearchRepository",
"SearchIndexRow",
"create_search_repository",
]
sql = f"""
SELECT
project_id,
id,
title,
permalink,
file_path,
type,
metadata,
from_id,
to_id,
relation_type,
entity_id,
content_snippet,
category,
created_at,
updated_at,
bm25(search_index) as score
FROM search_index
WHERE {where_clause}
ORDER BY score ASC {order_by_clause}
LIMIT :limit
OFFSET :offset
"""
logger.trace(f"Search {sql} params: {params}")
try:
async with db.scoped_session(self.session_maker) as session:
result = await session.execute(text(sql), params)
rows = result.fetchall()
except Exception as e:
# Handle FTS5 syntax errors and provide user-friendly feedback
if "fts5: syntax error" in str(e).lower(): # pragma: no cover
logger.warning(f"FTS5 syntax error for search term: {search_text}, error: {e}")
# Return empty results rather than crashing
return []
else:
# Re-raise other database errors
logger.error(f"Database error during search: {e}")
raise
results = [
SearchIndexRow(
project_id=self.project_id,
id=row.id,
title=row.title,
permalink=row.permalink,
file_path=row.file_path,
type=row.type,
score=row.score,
metadata=json.loads(row.metadata),
from_id=row.from_id,
to_id=row.to_id,
relation_type=row.relation_type,
entity_id=row.entity_id,
content_snippet=row.content_snippet,
category=row.category,
created_at=row.created_at,
updated_at=row.updated_at,
)
for row in rows
]
logger.trace(f"Found {len(results)} search results")
for r in results:
logger.trace(
f"Search result: project_id: {r.project_id} type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}"
)
return results
async def index_item(
self,
search_index_row: SearchIndexRow,
):
"""Index or update a single item."""
async with db.scoped_session(self.session_maker) as session:
# Delete existing record if any
await session.execute(
text(
"DELETE FROM search_index WHERE permalink = :permalink AND project_id = :project_id"
),
{"permalink": search_index_row.permalink, "project_id": self.project_id},
)
# Prepare data for insert with project_id
insert_data = search_index_row.to_insert()
insert_data["project_id"] = self.project_id
# Insert new record
await session.execute(
text("""
INSERT INTO search_index (
id, title, content_stems, content_snippet, permalink, file_path, type, metadata,
from_id, to_id, relation_type,
entity_id, category,
created_at, updated_at,
project_id
) VALUES (
:id, :title, :content_stems, :content_snippet, :permalink, :file_path, :type, :metadata,
:from_id, :to_id, :relation_type,
:entity_id, :category,
:created_at, :updated_at,
:project_id
)
"""),
insert_data,
)
logger.debug(f"indexed row {search_index_row}")
await session.commit()
async def bulk_index_items(self, search_index_rows: List[SearchIndexRow]):
"""Index multiple items in a single batch operation.
Note: This method assumes that any existing records for the entity_id
have already been deleted (typically via delete_by_entity_id).
Args:
search_index_rows: List of SearchIndexRow objects to index
"""
if not search_index_rows:
return
async with db.scoped_session(self.session_maker) as session:
# Prepare all insert data with project_id
insert_data_list = []
for row in search_index_rows:
insert_data = row.to_insert()
insert_data["project_id"] = self.project_id
insert_data_list.append(insert_data)
# Batch insert all records using executemany
await session.execute(
text("""
INSERT INTO search_index (
id, title, content_stems, content_snippet, permalink, file_path, type, metadata,
from_id, to_id, relation_type,
entity_id, category,
created_at, updated_at,
project_id
) VALUES (
:id, :title, :content_stems, :content_snippet, :permalink, :file_path, :type, :metadata,
:from_id, :to_id, :relation_type,
:entity_id, :category,
:created_at, :updated_at,
:project_id
)
"""),
insert_data_list,
)
logger.debug(f"Bulk indexed {len(search_index_rows)} rows")
await session.commit()
async def delete_by_entity_id(self, entity_id: int):
"""Delete an item from the search index by entity_id."""
async with db.scoped_session(self.session_maker) as session:
await session.execute(
text(
"DELETE FROM search_index WHERE entity_id = :entity_id AND project_id = :project_id"
),
{"entity_id": entity_id, "project_id": self.project_id},
)
await session.commit()
async def delete_by_permalink(self, permalink: str):
"""Delete an item from the search index."""
async with db.scoped_session(self.session_maker) as session:
await session.execute(
text(
"DELETE FROM search_index WHERE permalink = :permalink AND project_id = :project_id"
),
{"permalink": permalink, "project_id": self.project_id},
)
await session.commit()
async def execute_query(
self,
query: Executable,
params: Dict[str, Any],
) -> Result[Any]:
"""Execute a query asynchronously."""
# logger.debug(f"Executing query: {query}, params: {params}")
async with db.scoped_session(self.session_maker) as session:
start_time = time.perf_counter()
result = await session.execute(query, params)
end_time = time.perf_counter()
elapsed_time = end_time - start_time
logger.debug(f"Query executed successfully in {elapsed_time:.2f}s.")
return result
@@ -1,240 +0,0 @@
"""Abstract base class for search repository implementations."""
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Any, Dict, List, Optional
from loguru import logger
from sqlalchemy import Executable, Result, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from basic_memory import db
from basic_memory.schemas.search import SearchItemType
from basic_memory.repository.search_index_row import SearchIndexRow
class SearchRepositoryBase(ABC):
"""Abstract base class for backend-specific search repository implementations.
This class defines the common interface that all search repositories must implement,
regardless of whether they use SQLite FTS5 or Postgres tsvector for full-text search.
Concrete implementations:
- SQLiteSearchRepository: Uses FTS5 virtual tables with MATCH queries
- PostgresSearchRepository: Uses tsvector/tsquery with GIN indexes
"""
def __init__(self, session_maker: async_sessionmaker[AsyncSession], project_id: int):
"""Initialize with session maker and project_id filter.
Args:
session_maker: SQLAlchemy session maker
project_id: Project ID to filter all operations by
Raises:
ValueError: If project_id is None or invalid
"""
if project_id is None or project_id <= 0: # pragma: no cover
raise ValueError("A valid project_id is required for SearchRepository")
self.session_maker = session_maker
self.project_id = project_id
@abstractmethod
async def init_search_index(self) -> None:
"""Create or recreate the search index.
Backend-specific implementations:
- SQLite: CREATE VIRTUAL TABLE using FTS5
- Postgres: CREATE TABLE with tsvector column and GIN indexes
"""
pass
@abstractmethod
def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
"""Prepare a search term for backend-specific query syntax.
Args:
term: The search term to prepare
is_prefix: Whether to add prefix search capability
Returns:
Formatted search term for the backend
Backend-specific implementations:
- SQLite: Quotes FTS5 special characters, adds * wildcards
- Postgres: Converts to tsquery syntax with :* prefix operator
"""
pass
@abstractmethod
async def search(
self,
search_text: Optional[str] = None,
permalink: Optional[str] = None,
permalink_match: Optional[str] = None,
title: Optional[str] = None,
types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
limit: int = 10,
offset: int = 0,
) -> List[SearchIndexRow]:
"""Search across all indexed content.
Args:
search_text: Full-text search across title and content
permalink: Exact permalink match
permalink_match: Permalink pattern match (supports *)
title: Title search
types: Filter by entity types (from metadata.entity_type)
after_date: Filter by created_at > after_date
search_item_types: Filter by SearchItemType (ENTITY, OBSERVATION, RELATION)
limit: Maximum results to return
offset: Number of results to skip
Returns:
List of SearchIndexRow results with relevance scores
Backend-specific implementations:
- SQLite: Uses MATCH operator and bm25() for scoring
- Postgres: Uses @@ operator and ts_rank() for scoring
"""
pass
async def index_item(self, search_index_row: SearchIndexRow) -> None:
"""Index or update a single item.
This implementation is shared across backends as it uses standard SQL INSERT.
"""
async with db.scoped_session(self.session_maker) as session:
# Delete existing record if any
await session.execute(
text(
"DELETE FROM search_index WHERE permalink = :permalink AND project_id = :project_id"
),
{"permalink": search_index_row.permalink, "project_id": self.project_id},
)
# When using text() raw SQL, always serialize JSON to string
# Both SQLite (TEXT) and Postgres (JSONB) accept JSON strings in raw SQL
# The database driver/column type will handle conversion
insert_data = search_index_row.to_insert(serialize_json=True)
insert_data["project_id"] = self.project_id
# Insert new record
await session.execute(
text("""
INSERT INTO search_index (
id, title, content_stems, content_snippet, permalink, file_path, type, metadata,
from_id, to_id, relation_type,
entity_id, category,
created_at, updated_at,
project_id
) VALUES (
:id, :title, :content_stems, :content_snippet, :permalink, :file_path, :type, :metadata,
:from_id, :to_id, :relation_type,
:entity_id, :category,
:created_at, :updated_at,
:project_id
)
"""),
insert_data,
)
logger.debug(f"indexed row {search_index_row}")
await session.commit()
async def bulk_index_items(self, search_index_rows: List[SearchIndexRow]) -> None:
"""Index multiple items in a single batch operation.
This implementation is shared across backends as it uses standard SQL INSERT.
Note: This method assumes that any existing records for the entity_id
have already been deleted (typically via delete_by_entity_id).
Args:
search_index_rows: List of SearchIndexRow objects to index
"""
if not search_index_rows:
return
async with db.scoped_session(self.session_maker) as session:
# When using text() raw SQL, always serialize JSON to string
# Both SQLite (TEXT) and Postgres (JSONB) accept JSON strings in raw SQL
# The database driver/column type will handle conversion
insert_data_list = []
for row in search_index_rows:
insert_data = row.to_insert(serialize_json=True)
insert_data["project_id"] = self.project_id
insert_data_list.append(insert_data)
# Batch insert all records using executemany
await session.execute(
text("""
INSERT INTO search_index (
id, title, content_stems, content_snippet, permalink, file_path, type, metadata,
from_id, to_id, relation_type,
entity_id, category,
created_at, updated_at,
project_id
) VALUES (
:id, :title, :content_stems, :content_snippet, :permalink, :file_path, :type, :metadata,
:from_id, :to_id, :relation_type,
:entity_id, :category,
:created_at, :updated_at,
:project_id
)
"""),
insert_data_list,
)
logger.debug(f"Bulk indexed {len(search_index_rows)} rows")
await session.commit()
async def delete_by_entity_id(self, entity_id: int) -> None:
"""Delete all search index entries for an entity.
This implementation is shared across backends as it uses standard SQL DELETE.
"""
async with db.scoped_session(self.session_maker) as session:
await session.execute(
text(
"DELETE FROM search_index WHERE entity_id = :entity_id AND project_id = :project_id"
),
{"entity_id": entity_id, "project_id": self.project_id},
)
await session.commit()
async def delete_by_permalink(self, permalink: str) -> None:
"""Delete a search index entry by permalink.
This implementation is shared across backends as it uses standard SQL DELETE.
"""
async with db.scoped_session(self.session_maker) as session:
await session.execute(
text(
"DELETE FROM search_index WHERE permalink = :permalink AND project_id = :project_id"
),
{"permalink": permalink, "project_id": self.project_id},
)
await session.commit()
async def execute_query(
self,
query: Executable,
params: Dict[str, Any],
) -> Result[Any]:
"""Execute a query asynchronously.
This implementation is shared across backends for utility query execution.
"""
import time
async with db.scoped_session(self.session_maker) as session:
start_time = time.perf_counter()
result = await session.execute(query, params)
end_time = time.perf_counter()
elapsed_time = end_time - start_time
logger.debug(f"Query executed successfully in {elapsed_time:.2f}s.")
return result
@@ -1,438 +0,0 @@
"""SQLite FTS5-based search repository implementation."""
import json
import re
from datetime import datetime
from typing import List, Optional
from loguru import logger
from sqlalchemy import text
from basic_memory import db
from basic_memory.models.search import CREATE_SEARCH_INDEX
from basic_memory.repository.search_index_row import SearchIndexRow
from basic_memory.repository.search_repository_base import SearchRepositoryBase
from basic_memory.schemas.search import SearchItemType
class SQLiteSearchRepository(SearchRepositoryBase):
"""SQLite FTS5 implementation of search repository.
Uses SQLite's FTS5 virtual tables for full-text search with:
- MATCH operator for queries
- bm25() function for relevance scoring
- Special character quoting for syntax safety
- Prefix wildcard matching with *
"""
async def init_search_index(self):
"""Create FTS5 virtual table for search.
Note: Drops any existing search_index table first to ensure FTS5 virtual table creation.
This is necessary because Base.metadata.create_all() might create a regular table.
"""
logger.info("Initializing SQLite FTS5 search index")
try:
async with db.scoped_session(self.session_maker) as session:
# Drop any existing regular or virtual table first
await session.execute(text("DROP TABLE IF EXISTS search_index"))
# Create FTS5 virtual table
await session.execute(CREATE_SEARCH_INDEX)
await session.commit()
except Exception as e: # pragma: no cover
logger.error(f"Error initializing search index: {e}")
raise e
def _prepare_boolean_query(self, query: str) -> str:
"""Prepare a Boolean query by quoting individual terms while preserving operators.
Args:
query: A Boolean query like "tier1-test AND unicode" or "(hello OR world) NOT test"
Returns:
A properly formatted Boolean query with quoted terms that need quoting
"""
# Define Boolean operators and their boundaries
boolean_pattern = r"(\bAND\b|\bOR\b|\bNOT\b)"
# Split the query by Boolean operators, keeping the operators
parts = re.split(boolean_pattern, query)
processed_parts = []
for part in parts:
part = part.strip()
if not part:
continue
# If it's a Boolean operator, keep it as is
if part in ["AND", "OR", "NOT"]:
processed_parts.append(part)
else:
# Handle parentheses specially - they should be preserved for grouping
if "(" in part or ")" in part:
# Parse parenthetical expressions carefully
processed_part = self._prepare_parenthetical_term(part)
processed_parts.append(processed_part)
else:
# This is a search term - for Boolean queries, don't add prefix wildcards
prepared_term = self._prepare_single_term(part, is_prefix=False)
processed_parts.append(prepared_term)
return " ".join(processed_parts)
def _prepare_parenthetical_term(self, term: str) -> str:
"""Prepare a term that contains parentheses, preserving the parentheses for grouping.
Args:
term: A term that may contain parentheses like "(hello" or "world)" or "(hello OR world)"
Returns:
A properly formatted term with parentheses preserved
"""
# Handle terms that start/end with parentheses but may contain quotable content
result = ""
i = 0
while i < len(term):
if term[i] in "()":
# Preserve parentheses as-is
result += term[i]
i += 1
else:
# Find the next parenthesis or end of string
start = i
while i < len(term) and term[i] not in "()":
i += 1
# Extract the content between parentheses
content = term[start:i].strip()
if content:
# Only quote if it actually needs quoting (has hyphens, special chars, etc)
# but don't quote if it's just simple words
if self._needs_quoting(content):
escaped_content = content.replace('"', '""')
result += f'"{escaped_content}"'
else:
result += content
return result
def _needs_quoting(self, term: str) -> bool:
"""Check if a term needs to be quoted for FTS5 safety.
Args:
term: The term to check
Returns:
True if the term should be quoted
"""
if not term or not term.strip():
return False
# Characters that indicate we should quote (excluding parentheses which are valid syntax)
needs_quoting_chars = [
" ",
".",
":",
";",
",",
"<",
">",
"?",
"/",
"-",
"'",
'"',
"[",
"]",
"{",
"}",
"+",
"!",
"@",
"#",
"$",
"%",
"^",
"&",
"=",
"|",
"\\",
"~",
"`",
]
return any(c in term for c in needs_quoting_chars)
def _prepare_single_term(self, term: str, is_prefix: bool = True) -> str:
"""Prepare a single search term (no Boolean operators).
Args:
term: A single search term
is_prefix: Whether to add prefix search capability (* suffix)
Returns:
A properly formatted single term
"""
if not term or not term.strip():
return term
term = term.strip()
# Check if term is already a proper wildcard pattern (alphanumeric + *)
# e.g., "hello*", "test*world" - these should be left alone
if "*" in term and all(c.isalnum() or c in "*_-" for c in term):
return term
# Characters that can cause FTS5 syntax errors when used as operators
# We're more conservative here - only quote when we detect problematic patterns
problematic_chars = [
'"',
"'",
"(",
")",
"[",
"]",
"{",
"}",
"+",
"!",
"@",
"#",
"$",
"%",
"^",
"&",
"=",
"|",
"\\",
"~",
"`",
]
# Characters that indicate we should quote (spaces, dots, colons, etc.)
# Adding hyphens here because FTS5 can have issues with hyphens followed by wildcards
needs_quoting_chars = [" ", ".", ":", ";", ",", "<", ">", "?", "/", "-"]
# Check if term needs quoting
has_problematic = any(c in term for c in problematic_chars)
has_spaces_or_special = any(c in term for c in needs_quoting_chars)
if has_problematic or has_spaces_or_special:
# Handle multi-word queries differently from special character queries
if " " in term and not any(c in term for c in problematic_chars):
# Check if any individual word contains special characters that need quoting
words = term.strip().split()
has_special_in_words = any(
any(c in word for c in needs_quoting_chars if c != " ") for word in words
)
if not has_special_in_words:
# For multi-word queries with simple words (like "emoji unicode"),
# use boolean AND to handle word order variations
if is_prefix:
# Add prefix wildcard to each word for better matching
prepared_words = [f"{word}*" for word in words if word]
else:
prepared_words = words
term = " AND ".join(prepared_words)
else:
# If any word has special characters, quote the entire phrase
escaped_term = term.replace('"', '""')
if is_prefix and not ("/" in term and term.endswith(".md")):
term = f'"{escaped_term}"*'
else:
term = f'"{escaped_term}"'
else:
# For terms with problematic characters or file paths, use exact phrase matching
# Escape any existing quotes by doubling them
escaped_term = term.replace('"', '""')
# Quote the entire term to handle special characters safely
if is_prefix and not ("/" in term and term.endswith(".md")):
# For search terms (not file paths), add prefix matching
term = f'"{escaped_term}"*'
else:
# For file paths, use exact matching
term = f'"{escaped_term}"'
elif is_prefix:
# Only add wildcard for simple terms without special characters
term = f"{term}*"
return term
def _prepare_search_term(self, term: str, is_prefix: bool = True) -> str:
"""Prepare a search term for FTS5 query.
Args:
term: The search term to prepare
is_prefix: Whether to add prefix search capability (* suffix)
For FTS5:
- Boolean operators (AND, OR, NOT) are preserved for complex queries
- Terms with FTS5 special characters are quoted to prevent syntax errors
- Simple terms get prefix wildcards for better matching
"""
# Check for explicit boolean operators - if present, process as Boolean query
boolean_operators = [" AND ", " OR ", " NOT "]
if any(op in f" {term} " for op in boolean_operators):
return self._prepare_boolean_query(term)
# For non-Boolean queries, use the single term preparation logic
return self._prepare_single_term(term, is_prefix)
async def search(
self,
search_text: Optional[str] = None,
permalink: Optional[str] = None,
permalink_match: Optional[str] = None,
title: Optional[str] = None,
types: Optional[List[str]] = None,
after_date: Optional[datetime] = None,
search_item_types: Optional[List[SearchItemType]] = None,
limit: int = 10,
offset: int = 0,
) -> List[SearchIndexRow]:
"""Search across all indexed content using SQLite FTS5."""
conditions = []
params = {}
order_by_clause = ""
# Handle text search for title and content
if search_text:
# Skip FTS for wildcard-only queries that would cause "unknown special query" errors
if search_text.strip() == "*" or search_text.strip() == "":
# For wildcard searches, don't add any text conditions - return all results
pass
else:
# Use _prepare_search_term to handle both Boolean and non-Boolean queries
processed_text = self._prepare_search_term(search_text.strip())
params["text"] = processed_text
conditions.append("(title MATCH :text OR content_stems MATCH :text)")
# Handle title match search
if title:
title_text = self._prepare_search_term(title.strip(), is_prefix=False)
params["title_text"] = title_text
conditions.append("title MATCH :title_text")
# Handle permalink exact search
if permalink:
params["permalink"] = permalink
conditions.append("permalink = :permalink")
# Handle permalink match search, supports *
if permalink_match:
# For GLOB patterns, don't use _prepare_search_term as it will quote slashes
# GLOB patterns need to preserve their syntax
permalink_text = permalink_match.lower().strip()
params["permalink"] = permalink_text
if "*" in permalink_match:
conditions.append("permalink GLOB :permalink")
else:
# For exact matches without *, we can use FTS5 MATCH
# but only prepare the term if it doesn't look like a path
if "/" in permalink_text:
conditions.append("permalink = :permalink")
else:
permalink_text = self._prepare_search_term(permalink_text, is_prefix=False)
params["permalink"] = permalink_text
conditions.append("permalink MATCH :permalink")
# Handle entity type filter
if search_item_types:
type_list = ", ".join(f"'{t.value}'" for t in search_item_types)
conditions.append(f"type IN ({type_list})")
# Handle type filter
if types:
type_list = ", ".join(f"'{t}'" for t in types)
conditions.append(f"json_extract(metadata, '$.entity_type') IN ({type_list})")
# Handle date filter using datetime() for proper comparison
if after_date:
params["after_date"] = after_date
conditions.append("datetime(created_at) > datetime(:after_date)")
# order by most recent first
order_by_clause = ", updated_at DESC"
# Always filter by project_id
params["project_id"] = self.project_id
conditions.append("project_id = :project_id")
# set limit on search query
params["limit"] = limit
params["offset"] = offset
# Build WHERE clause
where_clause = " AND ".join(conditions) if conditions else "1=1"
sql = f"""
SELECT
project_id,
id,
title,
permalink,
file_path,
type,
metadata,
from_id,
to_id,
relation_type,
entity_id,
content_snippet,
category,
created_at,
updated_at,
bm25(search_index) as score
FROM search_index
WHERE {where_clause}
ORDER BY score ASC {order_by_clause}
LIMIT :limit
OFFSET :offset
"""
logger.trace(f"Search {sql} params: {params}")
try:
async with db.scoped_session(self.session_maker) as session:
result = await session.execute(text(sql), params)
rows = result.fetchall()
except Exception as e:
# Handle FTS5 syntax errors and provide user-friendly feedback
if "fts5: syntax error" in str(e).lower(): # pragma: no cover
logger.warning(f"FTS5 syntax error for search term: {search_text}, error: {e}")
# Return empty results rather than crashing
return []
else:
# Re-raise other database errors
logger.error(f"Database error during search: {e}")
raise
results = [
SearchIndexRow(
project_id=self.project_id,
id=row.id,
title=row.title,
permalink=row.permalink,
file_path=row.file_path,
type=row.type,
score=row.score,
metadata=json.loads(row.metadata) if row.metadata else {},
from_id=row.from_id,
to_id=row.to_id,
relation_type=row.relation_type,
entity_id=row.entity_id,
content_snippet=row.content_snippet,
category=row.category,
created_at=row.created_at,
updated_at=row.updated_at,
)
for row in rows
]
logger.trace(f"Found {len(results)} search results")
for r in results:
logger.trace(
f"Search result: project_id: {r.project_id} type:{r.type} title: {r.title} permalink: {r.permalink} score: {r.score}"
)
return results
+3 -33
View File
@@ -21,38 +21,13 @@ from typing import List, Optional, Annotated, Dict
from annotated_types import MinLen, MaxLen
from dateparser import parse
from pydantic import BaseModel, BeforeValidator, Field, model_validator, computed_field
from pydantic import BaseModel, BeforeValidator, Field, model_validator
from basic_memory.config import ConfigManager
from basic_memory.file_utils import sanitize_for_filename, sanitize_for_folder
from basic_memory.utils import generate_permalink
def has_valid_file_extension(filename: str) -> bool:
"""Check if a filename has a valid file extension recognized by mimetypes.
This is used to determine whether to split the extension when processing
titles in kebab_filenames mode. Prevents treating periods in version numbers
or decimals as file extensions.
Args:
filename: The filename to check
Returns:
True if the filename has a recognized file extension, False otherwise
Examples:
>>> has_valid_file_extension("document.md")
True
>>> has_valid_file_extension("Version 2.0.0")
False
>>> has_valid_file_extension("image.png")
True
"""
mime_type, _ = mimetypes.guess_type(filename)
return mime_type is not None
def to_snake_case(name: str) -> str:
"""Convert a string to snake_case.
@@ -257,17 +232,12 @@ class Entity(BaseModel):
use_kebab_case = app_config.kebab_filenames
if use_kebab_case:
# Convert to kebab-case: lowercase with hyphens, preserving periods in version numbers
# generate_permalink() uses mimetypes to detect real file extensions and only splits
# them off, avoiding misinterpreting periods in version numbers as extensions
has_extension = has_valid_file_extension(fixed_title)
fixed_title = generate_permalink(file_path=fixed_title, split_extension=has_extension)
fixed_title = generate_permalink(file_path=fixed_title, split_extension=False)
return fixed_title
@computed_field
@property
def file_path(self) -> str:
def file_path(self):
"""Get the file path for this entity based on its permalink."""
safe_title = self.safe_title
if self.content_type == "text/markdown":
+43 -218
View File
@@ -9,7 +9,6 @@ from sqlalchemy import text
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.observation_repository import ObservationRepository
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
from basic_memory.repository.search_repository import SearchRepository, SearchIndexRow
from basic_memory.schemas.memory import MemoryUrl, memory_url_path
from basic_memory.schemas.search import SearchItemType
@@ -253,6 +252,9 @@ class ContextService:
# Build the VALUES clause for entity IDs
entity_id_values = ", ".join([str(i) for i in entity_ids])
# For compatibility with the old query, we still need this for filtering
values = ", ".join([f"('{t}', {i})" for t, i in type_id_pairs])
# Parameters for bindings - include project_id for security filtering
params = {
"max_depth": max_depth,
@@ -262,14 +264,7 @@ class ContextService:
# Build date and timeframe filters conditionally based on since parameter
if since:
# SQLite accepts ISO strings, but Postgres/asyncpg requires datetime objects
if isinstance(self.search_repository, PostgresSearchRepository):
# asyncpg expects timezone-NAIVE datetime in UTC for DateTime(timezone=True) columns
# even though the column stores timezone-aware values
since_utc = since.astimezone(timezone.utc) if since.tzinfo else since
params["since_date"] = since_utc.replace(tzinfo=None) # pyright: ignore
else:
params["since_date"] = since.isoformat() # pyright: ignore
params["since_date"] = since.isoformat() # pyright: ignore
date_filter = "AND e.created_at >= :since_date"
relation_date_filter = "AND e_from.created_at >= :since_date"
timeframe_condition = "AND eg.relation_date >= :since_date"
@@ -284,210 +279,13 @@ class ContextService:
# Use a CTE that operates directly on entity and relation tables
# This avoids the overhead of the search_index virtual table
# Note: Postgres and SQLite have different CTE limitations:
# - Postgres: doesn't allow multiple UNION ALL branches referencing the CTE
# - SQLite: doesn't support LATERAL joins
# So we need different queries for each database backend
# Detect database backend
is_postgres = isinstance(self.search_repository, PostgresSearchRepository)
if is_postgres:
query = self._build_postgres_query(
entity_id_values,
date_filter,
project_filter,
relation_date_filter,
relation_project_filter,
timeframe_condition,
)
else:
# SQLite needs VALUES clause for exclusion (not needed for Postgres)
values = ", ".join([f"('{t}', {i})" for t, i in type_id_pairs])
query = self._build_sqlite_query(
entity_id_values,
date_filter,
project_filter,
relation_date_filter,
relation_project_filter,
timeframe_condition,
values,
)
result = await self.search_repository.execute_query(query, params=params)
rows = result.all()
context_rows = [
ContextResultRow(
type=row.type,
id=row.id,
title=row.title,
permalink=row.permalink,
file_path=row.file_path,
from_id=row.from_id,
to_id=row.to_id,
relation_type=row.relation_type,
content=row.content,
category=row.category,
entity_id=row.entity_id,
depth=row.depth,
root_id=row.root_id,
created_at=row.created_at,
)
for row in rows
]
return context_rows
def _build_postgres_query(
self,
entity_id_values: str,
date_filter: str,
project_filter: str,
relation_date_filter: str,
relation_project_filter: str,
timeframe_condition: str,
):
"""Build Postgres-specific CTE query using LATERAL joins."""
return text(f"""
query = text(f"""
WITH RECURSIVE entity_graph AS (
-- Base case: seed entities
SELECT
SELECT
e.id,
'entity' as type,
e.title,
e.permalink,
e.file_path,
CAST(NULL AS INTEGER) as from_id,
CAST(NULL AS INTEGER) as to_id,
CAST(NULL AS TEXT) as relation_type,
CAST(NULL AS TEXT) as content,
CAST(NULL AS TEXT) as category,
CAST(NULL AS INTEGER) as entity_id,
0 as depth,
e.id as root_id,
e.created_at,
e.created_at as relation_date
FROM entity e
WHERE e.id IN ({entity_id_values})
{date_filter}
{project_filter}
UNION ALL
-- Fetch BOTH relations AND connected entities in a single recursive step
-- Postgres only allows ONE reference to the recursive CTE in the recursive term
-- We use CROSS JOIN LATERAL to generate two rows (relation + entity) from each traversal
SELECT
CASE
WHEN step_type = 1 THEN r.id
ELSE e.id
END as id,
CASE
WHEN step_type = 1 THEN 'relation'
ELSE 'entity'
END as type,
CASE
WHEN step_type = 1 THEN r.relation_type || ': ' || r.to_name
ELSE e.title
END as title,
CASE
WHEN step_type = 1 THEN ''
ELSE COALESCE(e.permalink, '')
END as permalink,
CASE
WHEN step_type = 1 THEN e_from.file_path
ELSE e.file_path
END as file_path,
CASE
WHEN step_type = 1 THEN r.from_id
ELSE NULL
END as from_id,
CASE
WHEN step_type = 1 THEN r.to_id
ELSE NULL
END as to_id,
CASE
WHEN step_type = 1 THEN r.relation_type
ELSE NULL
END as relation_type,
CAST(NULL AS TEXT) as content,
CAST(NULL AS TEXT) as category,
CAST(NULL AS INTEGER) as entity_id,
eg.depth + step_type as depth,
eg.root_id,
CASE
WHEN step_type = 1 THEN e_from.created_at
ELSE e.created_at
END as created_at,
CASE
WHEN step_type = 1 THEN e_from.created_at
ELSE eg.relation_date
END as relation_date
FROM entity_graph eg
CROSS JOIN LATERAL (VALUES (1), (2)) AS steps(step_type)
JOIN relation r ON (
eg.type = 'entity' AND
(r.from_id = eg.id OR r.to_id = eg.id)
)
JOIN entity e_from ON (
r.from_id = e_from.id
{relation_project_filter}
)
LEFT JOIN entity e ON (
step_type = 2 AND
e.id = CASE
WHEN r.from_id = eg.id THEN r.to_id
ELSE r.from_id
END
{date_filter}
{project_filter}
)
WHERE eg.depth < :max_depth
AND (step_type = 1 OR (step_type = 2 AND e.id IS NOT NULL AND e.id != eg.id))
{timeframe_condition}
)
-- Materialize and filter
SELECT DISTINCT
type,
id,
title,
permalink,
file_path,
from_id,
to_id,
relation_type,
content,
category,
entity_id,
MIN(depth) as depth,
root_id,
created_at
FROM entity_graph
WHERE depth > 0
GROUP BY type, id, title, permalink, file_path, from_id, to_id,
relation_type, content, category, entity_id, root_id, created_at
ORDER BY depth, type, id
LIMIT :max_results
""")
def _build_sqlite_query(
self,
entity_id_values: str,
date_filter: str,
project_filter: str,
relation_date_filter: str,
relation_project_filter: str,
timeframe_condition: str,
values: str,
):
"""Build SQLite-specific CTE query using multiple UNION ALL branches."""
return text(f"""
WITH RECURSIVE entity_graph AS (
-- Base case: seed entities
SELECT
e.id,
'entity' as type,
e.title,
e.title,
e.permalink,
e.file_path,
NULL as from_id,
@@ -513,6 +311,7 @@ class ContextService:
r.id,
'relation' as type,
r.relation_type || ': ' || r.to_name as title,
-- Relation model doesn't have permalink column - we'll generate it at runtime
'' as permalink,
e_from.file_path,
r.from_id,
@@ -523,7 +322,7 @@ class ContextService:
NULL as entity_id,
eg.depth + 1,
eg.root_id,
e_from.created_at,
e_from.created_at, -- Use the from_entity's created_at since relation has no timestamp
e_from.created_at as relation_date,
CASE WHEN r.from_id = eg.id THEN 0 ELSE 1 END as is_incoming
FROM entity_graph eg
@@ -538,6 +337,7 @@ class ContextService:
)
LEFT JOIN entity e_to ON (r.to_id = e_to.id)
WHERE eg.depth < :max_depth
-- Ensure to_entity (if exists) also belongs to same project
AND (r.to_id IS NULL OR e_to.project_id = :project_id)
UNION ALL
@@ -547,9 +347,9 @@ class ContextService:
e.id,
'entity' as type,
e.title,
CASE
WHEN e.permalink IS NULL THEN ''
ELSE e.permalink
CASE
WHEN e.permalink IS NULL THEN ''
ELSE e.permalink
END as permalink,
e.file_path,
NULL as from_id,
@@ -566,7 +366,7 @@ class ContextService:
FROM entity_graph eg
JOIN entity e ON (
eg.type = 'relation' AND
e.id = CASE
e.id = CASE
WHEN eg.is_incoming = 0 THEN eg.to_id
ELSE eg.from_id
END
@@ -574,9 +374,10 @@ class ContextService:
{project_filter}
)
WHERE eg.depth < :max_depth
-- Only include entities connected by relations within timeframe if specified
{timeframe_condition}
)
SELECT DISTINCT
SELECT DISTINCT
type,
id,
title,
@@ -592,9 +393,33 @@ class ContextService:
root_id,
created_at
FROM entity_graph
WHERE depth > 0
GROUP BY type, id, title, permalink, file_path, from_id, to_id,
relation_type, content, category, entity_id, root_id, created_at
WHERE (type, id) NOT IN ({values})
GROUP BY
type, id
ORDER BY depth, type, id
LIMIT :max_results
""")
result = await self.search_repository.execute_query(query, params=params)
rows = result.all()
context_rows = [
ContextResultRow(
type=row.type,
id=row.id,
title=row.title,
permalink=row.permalink,
file_path=row.file_path,
from_id=row.from_id,
to_id=row.to_id,
relation_type=row.relation_type,
content=row.content,
category=row.category,
entity_id=row.entity_id,
depth=row.depth,
root_id=row.root_id,
created_at=row.created_at,
)
for row in rows
]
return context_rows
+1 -8
View File
@@ -623,7 +623,7 @@ class EntityService(BaseService[EntityModel]):
Args:
current_content: The current markdown content
section_header: The section header to find and replace (e.g., "## Section Name")
new_content: The new content to replace the section with (should not include the header itself)
new_content: The new content to replace the section with
Returns:
The updated content with the section replaced
@@ -635,13 +635,6 @@ class EntityService(BaseService[EntityModel]):
if not section_header.startswith("#"):
section_header = "## " + section_header
# Strip duplicate header from new_content if present (fix for issue #390)
# LLMs sometimes include the section header in their content, which would create duplicates
new_content_lines = new_content.lstrip().split("\n")
if new_content_lines and new_content_lines[0].strip() == section_header.strip():
# Remove the duplicate header line
new_content = "\n".join(new_content_lines[1:]).lstrip()
# First pass: count matching sections to check for duplicates
lines = current_content.split("\n")
matching_sections = []
+19 -46
View File
@@ -234,26 +234,16 @@ class ProjectService:
if not self.repository: # pragma: no cover
raise ValueError("Repository is required for remove_project")
# Get project from database first
# Get project path before removing from config
project = await self.get_project(name)
if not project:
raise ValueError(f"Project '{name}' not found")
project_path = project.path if project else None
project_path = project.path
# First remove from config (this will validate the project exists and is not default)
self.config_manager.remove_project(name)
# Check if project is default (in cloud mode, check database; in local mode, check config)
if project.is_default or name == self.config_manager.config.default_project:
raise ValueError(f"Cannot remove the default project '{name}'")
# Remove from config if it exists there (may not exist in cloud mode)
try:
self.config_manager.remove_project(name)
except ValueError:
# Project not in config - that's OK in cloud mode, continue with database deletion
logger.debug(f"Project '{name}' not found in config, removing from database only")
# Remove from database
await self.repository.delete(project.id)
# Then remove from database using robust lookup
if project:
await self.repository.delete(project.id)
logger.info(f"Project '{name}' removed from configuration and database")
@@ -766,42 +756,25 @@ class ProjectService:
)
# Query for monthly entity creation (project filtered)
# Use different date formatting for SQLite vs Postgres
from basic_memory.config import DatabaseBackend
is_postgres = self.config_manager.config.database_backend == DatabaseBackend.POSTGRES
date_format = (
"to_char(created_at, 'YYYY-MM')" if is_postgres else "strftime('%Y-%m', created_at)"
)
# Postgres needs datetime objects, SQLite needs ISO strings
six_months_param = six_months_ago if is_postgres else six_months_ago.isoformat()
entity_growth_result = await self.repository.execute_query(
text(f"""
SELECT
{date_format} AS month,
text("""
SELECT
strftime('%Y-%m', created_at) AS month,
COUNT(*) AS count
FROM entity
WHERE created_at >= :six_months_ago AND project_id = :project_id
GROUP BY month
ORDER BY month
"""),
{"six_months_ago": six_months_param, "project_id": project_id},
{"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
)
entity_growth = {row[0]: row[1] for row in entity_growth_result.fetchall()}
# Query for monthly observation creation (project filtered)
date_format_entity = (
"to_char(entity.created_at, 'YYYY-MM')"
if is_postgres
else "strftime('%Y-%m', entity.created_at)"
)
observation_growth_result = await self.repository.execute_query(
text(f"""
SELECT
{date_format_entity} AS month,
text("""
SELECT
strftime('%Y-%m', entity.created_at) AS month,
COUNT(*) AS count
FROM observation
INNER JOIN entity ON observation.entity_id = entity.id
@@ -809,15 +782,15 @@ class ProjectService:
GROUP BY month
ORDER BY month
"""),
{"six_months_ago": six_months_param, "project_id": project_id},
{"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
)
observation_growth = {row[0]: row[1] for row in observation_growth_result.fetchall()}
# Query for monthly relation creation (project filtered)
relation_growth_result = await self.repository.execute_query(
text(f"""
SELECT
{date_format_entity} AS month,
text("""
SELECT
strftime('%Y-%m', entity.created_at) AS month,
COUNT(*) AS count
FROM relation
INNER JOIN entity ON relation.from_id = entity.id
@@ -825,7 +798,7 @@ class ProjectService:
GROUP BY month
ORDER BY month
"""),
{"six_months_ago": six_months_param, "project_id": project_id},
{"six_months_ago": six_months_ago.isoformat(), "project_id": project_id},
)
relation_growth = {row[0]: row[1] for row in relation_growth_result.fetchall()}
@@ -185,7 +185,6 @@ class SearchService:
entity_id=entity.id,
type=SearchItemType.ENTITY.value,
title=entity.title,
permalink=entity.permalink, # Required for Postgres NOT NULL constraint
file_path=entity.file_path,
metadata={
"entity_type": entity.entity_type,
+40 -366
View File
@@ -26,12 +26,11 @@ from basic_memory.repository import (
ObservationRepository,
ProjectRepository,
)
from basic_memory.repository.search_repository import create_search_repository
from basic_memory.repository.search_repository import SearchRepository
from basic_memory.services import EntityService, FileService
from basic_memory.services.exceptions import SyncFatalError
from basic_memory.services.link_resolver import LinkResolver
from basic_memory.services.search_service import SearchService
from basic_memory.sync.utils import chunks
# Circuit breaker configuration
MAX_CONSECUTIVE_FAILURES = 3
@@ -257,24 +256,16 @@ class SyncService:
del self._file_failures[path]
@logfire.instrument()
async def sync(
self, directory: Path, project_name: Optional[str] = None, force_full: bool = False
) -> SyncReport:
"""Sync all files with database and update scan watermark.
Args:
directory: Directory to sync
project_name: Optional project name
force_full: If True, force a full scan bypassing watermark optimization
"""
async def sync(self, directory: Path, project_name: Optional[str] = None) -> SyncReport:
"""Sync all files with database and update scan watermark."""
start_time = time.time()
sync_start_timestamp = time.time() # Capture at start for watermark
logger.info(f"Sync operation started for directory: {directory} (force_full={force_full})")
logger.info(f"Sync operation started for directory: {directory}")
# initial paths from db to sync
# path -> checksum
report = await self.scan(directory, force_full=force_full)
report = await self.scan(directory)
# order of sync matters to resolve relations effectively
logger.info(
@@ -300,139 +291,38 @@ class SyncService:
for path in report.deleted:
await self.handle_delete(path)
# then new and modified - process in batches for better performance
batch_size = self.app_config.sync_batch_size
logger.debug(f"Using batch size of {batch_size} for file processing")
# then new and modified
with logfire.span("process_new_files", new_count=len(report.new)):
# Convert set to list for batching
new_files_list = list(report.new)
for path in report.new:
entity, _ = await self.sync_file(path, new=True)
for batch in chunks(new_files_list, batch_size):
logger.debug(f"Processing batch of {len(batch)} new files")
# Separate markdown and non-markdown files
markdown_files = [p for p in batch if self.file_service.is_markdown(p)]
regular_files = [p for p in batch if not self.file_service.is_markdown(p)]
# Batch process markdown files
if markdown_files:
try:
batch_results = await self.sync_markdown_batch(markdown_files, new=True)
# Track skipped files
for path, (entity, _) in zip(markdown_files, batch_results):
if entity is None and await self._should_skip_file(path):
failure_info = self._file_failures[path]
report.skipped_files.append(
SkippedFile(
path=path,
reason=failure_info.last_error,
failure_count=failure_info.count,
first_failed=failure_info.first_failure,
)
)
except SyncFatalError:
# Re-raise fatal errors immediately
raise
except Exception as e:
# Batch method raised an exception - record failure for all files in batch
logger.error(f"Batch sync failed for {len(markdown_files)} files: {e}")
for path in markdown_files:
await self._record_failure(path, str(e))
# Track skipped files
if await self._should_skip_file(path):
failure_info = self._file_failures[path]
report.skipped_files.append(
SkippedFile(
path=path,
reason=failure_info.last_error,
failure_count=failure_info.count,
first_failed=failure_info.first_failure,
)
)
# Process regular files individually (they're already fast)
for path in regular_files:
entity, _ = await self.sync_file(path, new=True)
# Track if file was skipped
if entity is None and await self._should_skip_file(path):
failure_info = self._file_failures[path]
report.skipped_files.append(
SkippedFile(
path=path,
reason=failure_info.last_error,
failure_count=failure_info.count,
first_failed=failure_info.first_failure,
)
# Track if file was skipped
if entity is None and await self._should_skip_file(path):
failure_info = self._file_failures[path]
report.skipped_files.append(
SkippedFile(
path=path,
reason=failure_info.last_error,
failure_count=failure_info.count,
first_failed=failure_info.first_failure,
)
)
with logfire.span("process_modified_files", modified_count=len(report.modified)):
# Convert set to list for batching
modified_files_list = list(report.modified)
for path in report.modified:
entity, _ = await self.sync_file(path, new=False)
for batch in chunks(modified_files_list, batch_size):
logger.debug(f"Processing batch of {len(batch)} modified files")
# Separate markdown and non-markdown files
markdown_files = [p for p in batch if self.file_service.is_markdown(p)]
regular_files = [p for p in batch if not self.file_service.is_markdown(p)]
# Batch process markdown files
if markdown_files:
try:
batch_results = await self.sync_markdown_batch(markdown_files, new=False)
# Track skipped files
for path, (entity, _) in zip(markdown_files, batch_results):
if entity is None and await self._should_skip_file(path):
failure_info = self._file_failures[path]
report.skipped_files.append(
SkippedFile(
path=path,
reason=failure_info.last_error,
failure_count=failure_info.count,
first_failed=failure_info.first_failure,
)
)
except SyncFatalError:
# Re-raise fatal errors immediately
raise
except Exception as e:
# Batch method raised an exception - record failure for all files in batch
logger.error(f"Batch sync failed for {len(markdown_files)} files: {e}")
for path in markdown_files:
await self._record_failure(path, str(e))
# Track skipped files
if await self._should_skip_file(path):
failure_info = self._file_failures[path]
report.skipped_files.append(
SkippedFile(
path=path,
reason=failure_info.last_error,
failure_count=failure_info.count,
first_failed=failure_info.first_failure,
)
)
# Process regular files individually (they're already fast)
for path in regular_files:
entity, _ = await self.sync_file(path, new=False)
# Track if file was skipped
if entity is None and await self._should_skip_file(path):
failure_info = self._file_failures[path]
report.skipped_files.append(
SkippedFile(
path=path,
reason=failure_info.last_error,
failure_count=failure_info.count,
first_failed=failure_info.first_failure,
)
# Track if file was skipped
if entity is None and await self._should_skip_file(path):
failure_info = self._file_failures[path]
report.skipped_files.append(
SkippedFile(
path=path,
reason=failure_info.last_error,
failure_count=failure_info.count,
first_failed=failure_info.first_failure,
)
)
# Only resolve relations if there were actual changes
# If no files changed, no new unresolved relations could have been created
@@ -493,7 +383,7 @@ class SyncService:
return report
@logfire.instrument()
async def scan(self, directory, force_full: bool = False):
async def scan(self, directory):
"""Smart scan using watermark and file count for large project optimization.
Uses scan watermark tracking to dramatically reduce scan time for large projects:
@@ -511,10 +401,6 @@ class SyncService:
- Compare with last_file_count to detect deletions
- If no deletions: incremental scan with find -newermt (0.2s)
- Process changed files with mtime-based comparison
Args:
directory: Directory to scan
force_full: If True, bypass watermark optimization and force full scan
"""
scan_start_time = time.time()
@@ -534,13 +420,7 @@ class SyncService:
logger.debug(f"Found {current_count} files in directory")
# Step 2: Determine scan strategy based on watermark and file count
if force_full:
# User explicitly requested full scan → bypass watermark optimization
scan_type = "full_forced"
logger.info("Force full scan requested, bypassing watermark optimization")
file_paths_to_scan = await self._scan_directory_full(directory)
elif project.last_file_count is None:
if project.last_file_count is None:
# First sync ever → full scan
scan_type = "full_initial"
logger.info("First sync for this project, performing full scan")
@@ -586,12 +466,6 @@ class SyncService:
logger.debug(f"Processing {len(file_paths_to_scan)} files with mtime-based comparison")
# Optimization: Batch fetch all entities for files being scanned
# This reduces N queries to 1 batch query (massive performance win for remote DBs)
logger.debug(f"Batch fetching entities for {len(file_paths_to_scan)} files")
entities_by_path = await self.entity_repository.get_by_file_paths_batch(file_paths_to_scan)
logger.debug(f"Found {len(entities_by_path)} existing entities in database")
for rel_path in file_paths_to_scan:
scanned_paths.add(rel_path)
@@ -603,8 +477,8 @@ class SyncService:
stat_info = abs_path.stat()
# O(1) dict lookup instead of database query
db_entity = entities_by_path.get(rel_path)
# Indexed lookup - single file query (not full table scan)
db_entity = await self.entity_repository.get_by_file_path(rel_path)
if db_entity is None:
# New file - need checksum for move detection
@@ -676,7 +550,7 @@ class SyncService:
# Step 5: Detect deletions (only for full scans)
# Incremental scans can't reliably detect deletions since they only see modified files
if scan_type in ("full_initial", "full_deletions", "full_fallback", "full_forced"):
if scan_type in ("full_initial", "full_deletions", "full_fallback"):
# Use optimized query for just file paths (not full entities)
db_file_paths = await self.entity_repository.get_all_file_paths()
logger.debug(f"Found {len(db_file_paths)} db paths for deletion detection")
@@ -845,206 +719,6 @@ class SyncService:
# Return the final checksum to ensure everything is consistent
return entity, final_checksum
@logfire.instrument()
async def sync_markdown_batch(
self, paths: List[str], new: bool = True
) -> List[Tuple[Optional[Entity], str]]:
"""Sync multiple markdown files in a single batch operation.
Optimized for remote databases (Postgres) - reduces N queries to 1 batch query.
Parses all files first, then does all database operations in one transaction.
Args:
paths: List of paths to markdown files
new: Whether these are new files
Returns:
List of tuples (entity, checksum) for each file
"""
from basic_memory.markdown.utils import entity_model_from_markdown
if not paths:
return []
logger.debug(f"Batch syncing {len(paths)} markdown files (new={new})")
# Phase 1: Parse all files (no DB operations)
parsed_files = []
for path in paths:
# Check if file should be skipped due to repeated failures (circuit breaker)
if await self._should_skip_file(path):
logger.warning(f"Skipping file in batch due to repeated failures: {path}")
parsed_files.append(None)
continue
try:
file_content = await self.file_service.read_file_content(path)
file_contains_frontmatter = has_frontmatter(file_content)
# Get file timestamps for tracking modification times
file_stats = self.file_service.file_stats(path)
created = datetime.fromtimestamp(file_stats.st_ctime).astimezone()
modified = datetime.fromtimestamp(file_stats.st_mtime).astimezone()
# Parse markdown to get entity structure
entity_markdown = await self.entity_parser.parse_file(path)
# Resolve permalink if needed (skip conflict checks during batch)
permalink = entity_markdown.frontmatter.permalink
if file_contains_frontmatter and not self.app_config.disable_permalinks:
permalink = await self.entity_service.resolve_permalink(
path, markdown=entity_markdown, skip_conflict_check=True
)
# If permalink changed, update the file
if permalink != entity_markdown.frontmatter.permalink:
logger.info(
f"Updating permalink for path: {path}, "
f"old_permalink: {entity_markdown.frontmatter.permalink}, "
f"new_permalink: {permalink}"
)
entity_markdown.frontmatter.metadata["permalink"] = permalink
await self.file_service.update_frontmatter(path, {"permalink": permalink})
# Convert to entity model (without saving to DB yet)
entity_model = entity_model_from_markdown(Path(path), entity_markdown)
entity_model.checksum = None # Will be set after relations are resolved
parsed_files.append(
{
"path": path,
"entity_model": entity_model,
"entity_markdown": entity_markdown,
"created": created,
"modified": modified,
"mtime": file_stats.st_mtime,
"size": file_stats.st_size,
}
)
except Exception as e:
# Check if this is a fatal error (or caused by one)
# Fatal errors like project deletion should terminate sync immediately
if isinstance(e, SyncFatalError) or isinstance(e.__cause__, SyncFatalError):
logger.error(
f"Fatal sync error encountered during batch parse, terminating sync: path={path}"
)
raise
# Otherwise treat as recoverable file-level error
logger.error(f"Failed to parse file in batch: path={path}, error={e}")
# Track failure for circuit breaker
await self._record_failure(path, str(e))
parsed_files.append(None) # Mark as failed
# Phase 2: Batch database operations
# Filter out failed parses
valid_files = [f for f in parsed_files if f is not None]
if not valid_files:
logger.warning("No valid files to sync in batch")
return [(None, "") for _ in paths]
# If this is a new batch, upsert all entities at once
if new:
entities_to_upsert = [f["entity_model"] for f in valid_files]
logger.debug(f"Batch upserting {len(entities_to_upsert)} new entities")
upserted_entities = await self.entity_repository.upsert_entities(entities_to_upsert)
# Create lookup by file_path for O(1) access
entities_by_path = {e.file_path: e for e in upserted_entities}
# If updating existing entities, we need to handle observations/relations differently
else:
logger.debug(f"Batch updating {len(valid_files)} existing entities")
# For updates, we need to:
# 1. Get existing entities
# 2. Delete old observations/relations
# 3. Upsert updated entities
file_paths = [f["path"] for f in valid_files]
existing_entities = await self.entity_repository.get_by_file_paths_batch(file_paths)
# Delete old observations and relations in batch
entity_ids = [e.id for e in existing_entities.values() if e.id]
if entity_ids:
await self.entity_service.observation_repository.delete_by_entity_ids(entity_ids)
await self.relation_repository.delete_outgoing_relations_from_entities(entity_ids)
# Upsert all updated entities
entities_to_upsert = [f["entity_model"] for f in valid_files]
upserted_entities = await self.entity_repository.upsert_entities(entities_to_upsert)
# Create lookup by file_path
entities_by_path = {e.file_path: e for e in upserted_entities}
# Phase 3: Post-processing (relations, checksums, search index)
results = []
for i, path in enumerate(paths):
parsed_file = parsed_files[i]
# Skip failed files
if parsed_file is None:
results.append((None, ""))
continue
entity = entities_by_path.get(parsed_file["path"])
if entity is None:
logger.error(f"Entity not found after upsert: {parsed_file['path']}")
results.append((None, ""))
continue
try:
# Update relations for this entity
entity_with_relations = await self.entity_service.update_entity_relations(
parsed_file["path"], parsed_file["entity_markdown"]
)
# Compute final checksum after relations are resolved
final_checksum = await self.file_service.compute_checksum(parsed_file["path"])
# Update checksum, timestamps, and file metadata
await self.entity_repository.update(
entity.id,
{
"checksum": final_checksum,
"created_at": parsed_file["created"],
"updated_at": parsed_file["modified"],
"mtime": parsed_file["mtime"],
"size": parsed_file["size"],
},
)
# Index for search
await self.search_service.index_entity(entity_with_relations)
# Clear failure tracking on successful sync
self._clear_failure(parsed_file["path"])
results.append((entity_with_relations, final_checksum))
logger.debug(
f"Batch sync completed for file: path={parsed_file['path']}, "
f"entity_id={entity.id}, checksum={final_checksum[:8]}"
)
except Exception as e:
# Check if this is a fatal error
if isinstance(e, SyncFatalError) or isinstance(e.__cause__, SyncFatalError):
logger.error(
f"Fatal sync error during post-processing, terminating sync: path={parsed_file['path']}"
)
raise
# Otherwise treat as recoverable file-level error
logger.error(
f"Failed to complete post-processing for file: path={parsed_file['path']}, error={e}"
)
await self._record_failure(parsed_file["path"], str(e))
results.append((None, ""))
continue
return results
@logfire.instrument()
async def sync_regular_file(self, path: str, new: bool = True) -> Tuple[Optional[Entity], str]:
"""Sync a non-markdown file with basic tracking.
@@ -1442,13 +1116,13 @@ class SyncService:
async def _scan_directory_full(self, directory: Path) -> List[str]:
"""Full directory scan returning all file paths.
Uses scan_directory() which respects .bmignore patterns.
Uses scan_directory() which respects .bmignore patterns.
Args:
directory: Directory to scan
Args:
directory: Directory to scan
Returns:
List of relative file paths (respects .bmignore)
List of relative file paths (respects .bmignore)
"""
file_paths = []
async for file_path_str, _ in self.scan_directory(directory):
@@ -1521,7 +1195,7 @@ async def get_sync_service(project: Project) -> SyncService: # pragma: no cover
entity_repository = EntityRepository(session_maker, project_id=project.id)
observation_repository = ObservationRepository(session_maker, project_id=project.id)
relation_repository = RelationRepository(session_maker, project_id=project.id)
search_repository = create_search_repository(session_maker, project_id=project.id)
search_repository = SearchRepository(session_maker, project_id=project.id)
project_repository = ProjectRepository(session_maker)
# Initialize services
-23
View File
@@ -1,23 +0,0 @@
"""Utilities for sync operations."""
from typing import Iterator, List, TypeVar
T = TypeVar("T")
def chunks(items: List[T], size: int) -> Iterator[List[T]]:
"""Split a list into chunks of specified size.
Args:
items: List of items to chunk
size: Size of each chunk
Yields:
Lists of items, each of specified size (last chunk may be smaller)
Example:
>>> list(chunks([1, 2, 3, 4, 5], 2))
[[1, 2], [3, 4], [5]]
"""
for i in range(0, len(items), size):
yield items[i : i + size]
+6 -67
View File
@@ -13,49 +13,6 @@ from loguru import logger
from unidecode import unidecode
def normalize_project_path(path: str) -> str:
"""Normalize project path by stripping mount point prefix.
In cloud deployments, the S3 bucket is mounted at /app/data. We strip this
prefix from project paths to avoid leaking implementation details and to
ensure paths match the actual S3 bucket structure.
For local paths (including Windows paths), returns the path unchanged.
Args:
path: Project path (e.g., "/app/data/basic-memory-llc" or "C:\\Users\\...")
Returns:
Normalized path (e.g., "/basic-memory-llc" or "C:\\Users\\...")
Examples:
>>> normalize_project_path("/app/data/my-project")
'/my-project'
>>> normalize_project_path("/my-project")
'/my-project'
>>> normalize_project_path("app/data/my-project")
'/my-project'
>>> normalize_project_path("C:\\\\Users\\\\project")
'C:\\\\Users\\\\project'
"""
# Check if this is a Windows absolute path (e.g., C:\Users\...)
# Windows paths have a drive letter followed by a colon
if len(path) >= 2 and path[1] == ":":
# Windows absolute path - return unchanged
return path
# Handle both absolute and relative Unix paths
normalized = path.lstrip("/")
if normalized.startswith("app/data/"):
normalized = normalized.removeprefix("app/data/")
# Ensure leading slash for Unix absolute paths
if not normalized.startswith("/"):
normalized = "/" + normalized
return normalized
@runtime_checkable
class PathLike(Protocol):
"""Protocol for objects that can be used as paths."""
@@ -76,14 +33,10 @@ def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: b
Args:
file_path: Original file path (str, Path, or PathLike)
split_extension: Whether to split off and discard file extensions.
When True, uses mimetypes to detect real extensions.
When False, preserves all content including periods.
Returns:
Normalized permalink that matches validation rules. Converts spaces and underscores
to hyphens for consistency. Preserves non-ASCII characters like Chinese.
Preserves periods in version numbers (e.g., "2.0.0") when they're not real file extensions.
Examples:
>>> generate_permalink("docs/My Feature.md")
@@ -94,26 +47,12 @@ def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: b
'design/unified-model-refactor'
>>> generate_permalink("中文/测试文档.md")
'中文/测试文档'
>>> generate_permalink("Version 2.0.0")
'version-2.0.0'
"""
# Convert Path to string if needed
path_str = Path(str(file_path)).as_posix()
# Only split extension if there's a real file extension
# Use mimetypes to detect real extensions, avoiding misinterpreting periods in version numbers
import mimetypes
mime_type, _ = mimetypes.guess_type(path_str)
has_real_extension = mime_type is not None
if has_real_extension and split_extension:
# Real file extension detected - split it off
(base, extension) = os.path.splitext(path_str)
else:
# No real extension or split_extension=False - process the whole string
base = path_str
extension = ""
# Remove extension (for now, possibly)
(base, extension) = os.path.splitext(path_str)
# Check if we have CJK characters that should be preserved
# CJK ranges: \u4e00-\u9fff (CJK Unified Ideographs), \u3000-\u303f (CJK symbols),
@@ -165,9 +104,9 @@ def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: b
# Remove apostrophes entirely (don't replace with hyphens)
text_no_apostrophes = text_with_hyphens.replace("'", "")
# Replace unsafe chars with hyphens, but preserve CJK characters and periods
# Replace unsafe chars with hyphens, but preserve CJK characters
clean_text = re.sub(
r"[^a-z0-9\u4e00-\u9fff\u3000-\u303f\u3400-\u4dbf/\-\.]", "-", text_no_apostrophes
r"[^a-z0-9\u4e00-\u9fff\u3000-\u303f\u3400-\u4dbf/\-]", "-", text_no_apostrophes
)
else:
# Original ASCII-only processing for backward compatibility
@@ -186,8 +125,8 @@ def generate_permalink(file_path: Union[Path, str, PathLike], split_extension: b
# Remove apostrophes entirely (don't replace with hyphens)
text_no_apostrophes = text_with_hyphens.replace("'", "")
# Replace remaining invalid chars with hyphens, preserving periods
clean_text = re.sub(r"[^a-z0-9/\-\.]", "-", text_no_apostrophes)
# Replace remaining invalid chars with hyphens
clean_text = re.sub(r"[^a-z0-9/\-]", "-", text_no_apostrophes)
# Collapse multiple hyphens
clean_text = re.sub(r"-+", "-", clean_text)
@@ -5,13 +5,13 @@ from pathlib import Path
from typer.testing import CliRunner
from basic_memory.cli.main import app as cli_app
from basic_memory.cli.main import app
def test_project_list(app, app_config, test_project, config_manager):
def test_project_list(app_config, test_project, config_manager):
"""Test 'bm project list' command shows projects."""
runner = CliRunner()
result = runner.invoke(cli_app, ["project", "list"])
result = runner.invoke(app, ["project", "list"])
if result.exit_code != 0:
print(f"STDOUT: {result.stdout}")
@@ -19,13 +19,13 @@ def test_project_list(app, app_config, test_project, config_manager):
print(f"Exception: {result.exception}")
assert result.exit_code == 0
assert "test-project" in result.stdout
assert "[X]" in result.stdout # default marker
assert "" in result.stdout # default marker
def test_project_info(app, app_config, test_project, config_manager):
def test_project_info(app_config, test_project, config_manager):
"""Test 'bm project info' command shows project details."""
runner = CliRunner()
result = runner.invoke(cli_app, ["project", "info", "test-project"])
result = runner.invoke(app, ["project", "info", "test-project"])
if result.exit_code != 0:
print(f"STDOUT: {result.stdout}")
@@ -36,12 +36,12 @@ def test_project_info(app, app_config, test_project, config_manager):
assert "Statistics" in result.stdout
def test_project_info_json(app, app_config, test_project, config_manager):
def test_project_info_json(app_config, test_project, config_manager):
"""Test 'bm project info --json' command outputs valid JSON."""
import json
runner = CliRunner()
result = runner.invoke(cli_app, ["project", "info", "test-project", "--json"])
result = runner.invoke(app, ["project", "info", "test-project", "--json"])
if result.exit_code != 0:
print(f"STDOUT: {result.stdout}")
@@ -55,7 +55,7 @@ def test_project_info_json(app, app_config, test_project, config_manager):
assert "system" in data
def test_project_add_and_remove(app, app_config, config_manager):
def test_project_add_and_remove(app_config, config_manager):
"""Test adding and removing a project."""
runner = CliRunner()
@@ -65,7 +65,7 @@ def test_project_add_and_remove(app, app_config, config_manager):
new_project_path.mkdir()
# Add project
result = runner.invoke(cli_app, ["project", "add", "new-project", str(new_project_path)])
result = runner.invoke(app, ["project", "add", "new-project", str(new_project_path)])
if result.exit_code != 0:
print(f"STDOUT: {result.stdout}")
@@ -77,17 +77,17 @@ def test_project_add_and_remove(app, app_config, config_manager):
)
# Verify it shows up in list
result = runner.invoke(cli_app, ["project", "list"])
result = runner.invoke(app, ["project", "list"])
assert result.exit_code == 0
assert "new-project" in result.stdout
# Remove project
result = runner.invoke(cli_app, ["project", "remove", "new-project"])
result = runner.invoke(app, ["project", "remove", "new-project"])
assert result.exit_code == 0
assert "removed" in result.stdout.lower() or "deleted" in result.stdout.lower()
def test_project_set_default(app, app_config, config_manager):
def test_project_set_default(app_config, config_manager):
"""Test setting default project."""
runner = CliRunner()
@@ -97,16 +97,14 @@ def test_project_set_default(app, app_config, config_manager):
new_project_path.mkdir()
# Add a second project
result = runner.invoke(
cli_app, ["project", "add", "another-project", str(new_project_path)]
)
result = runner.invoke(app, ["project", "add", "another-project", str(new_project_path)])
if result.exit_code != 0:
print(f"STDOUT: {result.stdout}")
print(f"STDERR: {result.stderr}")
assert result.exit_code == 0
# Set as default
result = runner.invoke(cli_app, ["project", "default", "another-project"])
result = runner.invoke(app, ["project", "default", "another-project"])
if result.exit_code != 0:
print(f"STDOUT: {result.stdout}")
print(f"STDERR: {result.stderr}")
@@ -114,52 +112,59 @@ def test_project_set_default(app, app_config, config_manager):
assert "default" in result.stdout.lower()
# Verify in list
result = runner.invoke(cli_app, ["project", "list"])
result = runner.invoke(app, ["project", "list"])
assert result.exit_code == 0
# The new project should have the [X] marker now
# The new project should have the checkmark now
lines = result.stdout.split("\n")
for line in lines:
if "another-project" in line:
assert "[X]" in line
assert "" in line
def test_remove_main_project(app, app_config, config_manager):
"""Test that removing main project then listing projects prevents main from reappearing (issue #397)."""
def test_remove_default_project_issue_397(app_config, config_manager):
"""Test that removed default project doesn't appear in list (issue #397).
Reproduces bug where 'main' project still shows in 'bm project list'
after being removed.
"""
runner = CliRunner()
# Create separate temp dirs for each project
with (
tempfile.TemporaryDirectory() as main_dir,
tempfile.TemporaryDirectory() as new_default_dir,
):
main_path = Path(main_dir)
new_default_path = Path(new_default_dir)
# Use a separate temporary directory to avoid nested path conflicts
with tempfile.TemporaryDirectory() as temp_dir:
new_project_path = Path(temp_dir) / "newmain"
new_project_path.mkdir()
# Ensure main exists
result = runner.invoke(cli_app, ["project", "list"])
if "main" not in result.stdout:
result = runner.invoke(cli_app, ["project", "add", "main", str(main_path)])
print(result.stdout)
assert result.exit_code == 0
# Confirm main is present
result = runner.invoke(cli_app, ["project", "list"])
assert "main" in result.stdout
# Add a second project
result = runner.invoke(cli_app, ["project", "add", "new_default", str(new_default_path)])
# Step 1: Add a new project
result = runner.invoke(app, ["project", "add", "newmain", str(new_project_path)])
if result.exit_code != 0:
print(f"STDOUT: {result.stdout}")
print(f"STDERR: {result.stderr}")
assert result.exit_code == 0
# Set new_default as default (if needed)
result = runner.invoke(cli_app, ["project", "default", "new_default"])
# Step 2: Set the new project as default
result = runner.invoke(app, ["project", "default", "newmain"])
if result.exit_code != 0:
print(f"STDOUT: {result.stdout}")
print(f"STDERR: {result.stderr}")
assert result.exit_code == 0
# Remove main
result = runner.invoke(cli_app, ["project", "remove", "main"])
# Step 3: Remove the original default project (test-project acts as "main")
result = runner.invoke(app, ["project", "remove", "test-project"])
if result.exit_code != 0:
print(f"STDOUT: {result.stdout}")
print(f"STDERR: {result.stderr}")
assert result.exit_code == 0
# Confirm only new_default exists and main does not
result = runner.invoke(cli_app, ["project", "list"])
# Step 4: Verify test-project is NOT in the list
result = runner.invoke(app, ["project", "list"])
if result.exit_code != 0:
print(f"STDOUT: {result.stdout}")
print(f"STDERR: {result.stderr}")
assert result.exit_code == 0
assert "main" not in result.stdout
assert "new_default" in result.stdout
# Bug: test-project still appears in list after removal
# Expected: test-project should NOT be in the output
assert "test-project" not in result.stdout, (
f"Bug reproduced: 'test-project' still appears in list after removal.\n"
f"Output:\n{result.stdout}"
)
@@ -0,0 +1,61 @@
"""Integration tests for sync CLI commands."""
from pathlib import Path
from typer.testing import CliRunner
from basic_memory.cli.main import app
def test_sync_command(app_config, test_project, config_manager, config_home):
"""Test 'bm sync' command successfully syncs files."""
runner = CliRunner()
# Create a test file
test_file = Path(config_home) / "test-note.md"
test_file.write_text("# Test Note\n\nThis is a test.")
# Run sync
result = runner.invoke(app, ["sync", "--project", "test-project"])
if result.exit_code != 0:
print(f"STDOUT: {result.stdout}")
print(f"STDERR: {result.stderr}")
assert result.exit_code == 0
assert "sync" in result.stdout.lower() or "initiated" in result.stdout.lower()
def test_status_command(app_config, test_project, config_manager, config_home):
"""Test 'bm status' command shows sync status."""
runner = CliRunner()
# Create a test file
test_file = Path(config_home) / "unsynced.md"
test_file.write_text("# Unsynced Note\n\nThis file hasn't been synced yet.")
# Run status
result = runner.invoke(app, ["status", "--project", "test-project"])
if result.exit_code != 0:
print(f"STDOUT: {result.stdout}")
print(f"STDERR: {result.stderr}")
assert result.exit_code == 0
# Should show some status output
assert len(result.stdout) > 0
def test_status_verbose(app_config, test_project, config_manager, config_home):
"""Test 'bm status --verbose' shows detailed status."""
runner = CliRunner()
# Create a test file
test_file = Path(config_home) / "test.md"
test_file.write_text("# Test\n\nContent.")
# Run status with verbose
result = runner.invoke(app, ["status", "--project", "test-project", "--verbose"])
if result.exit_code != 0:
print(f"STDOUT: {result.stdout}")
print(f"STDERR: {result.stderr}")
assert result.exit_code == 0
assert len(result.stdout) > 0
+29 -120
View File
@@ -50,16 +50,15 @@ The `app` fixture ensures FastAPI dependency overrides are active, and
`mcp_server` provides the MCP server with proper project session initialization.
"""
from typing import AsyncGenerator, Literal
from typing import AsyncGenerator
import pytest
import pytest_asyncio
from pathlib import Path
from sqlalchemy import text
from httpx import AsyncClient, ASGITransport
from basic_memory.config import BasicMemoryConfig, ProjectConfig, ConfigManager, DatabaseBackend
from basic_memory.config import BasicMemoryConfig, ProjectConfig, ConfigManager
from basic_memory.db import engine_session_factory, DatabaseType
from basic_memory.models import Project
from basic_memory.repository.project_repository import ProjectRepository
@@ -72,89 +71,24 @@ from basic_memory.deps import get_project_config, get_engine_factory, get_app_co
from basic_memory.mcp import tools # noqa: F401
@pytest.fixture(
params=[
pytest.param("sqlite", id="sqlite"),
pytest.param("postgres", id="postgres", marks=pytest.mark.postgres),
]
)
def db_backend(request) -> Literal["sqlite", "postgres"]:
"""Parametrize tests to run against both SQLite and Postgres.
@pytest_asyncio.fixture(scope="function")
async def engine_factory(tmp_path):
"""Create a SQLite file engine factory for integration testing."""
db_path = tmp_path / "test.db"
async with engine_session_factory(db_path, DatabaseType.FILESYSTEM) as (
engine,
session_maker,
):
# Initialize database schema
from basic_memory.models.base import Base
Usage:
pytest # Runs tests against SQLite only (default)
pytest -m postgres # Runs tests against Postgres only
pytest -m "not postgres" # Runs tests against SQLite only
pytest --run-all-backends # Runs tests against both backends
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
Note: Only tests that use database fixtures (engine_factory, session_maker, etc.)
will be parametrized. Tests that don't use the database won't be affected.
"""
return request.param
yield engine, session_maker
@pytest_asyncio.fixture
async def engine_factory(
app_config,
config_manager,
db_backend: Literal["sqlite", "postgres"],
tmp_path,
) -> AsyncGenerator[tuple, None]:
"""Create engine and session factory for the configured database backend."""
from basic_memory.models.search import CREATE_SEARCH_INDEX
from basic_memory import db
# Determine database type based on backend
if db_backend == "postgres":
db_type = DatabaseType.FILESYSTEM
else:
db_type = DatabaseType.FILESYSTEM # Integration tests use file-based SQLite
# Use tmp_path for SQLite, use config database_path for Postgres
if db_backend == "sqlite":
db_path = tmp_path / "test.db"
else:
db_path = app_config.database_path
if db_backend == "postgres":
# Postgres: Create fresh engine for each test with full schema reset
config_manager._config = app_config
# Use context manager to handle engine disposal properly
async with engine_session_factory(db_path, db_type) as (engine, session_maker):
# Drop and recreate schema for complete isolation
async with engine.begin() as conn:
await conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE"))
await conn.execute(text("CREATE SCHEMA public"))
await conn.execute(text("GRANT ALL ON SCHEMA public TO basic_memory_user"))
await conn.execute(text("GRANT ALL ON SCHEMA public TO public"))
# Run migrations to create production tables
from basic_memory.db import run_migrations
await run_migrations(app_config, db_type)
yield engine, session_maker
else:
# SQLite: Create fresh database (fast with tmp files)
async with engine_session_factory(db_path, db_type) as (engine, session_maker):
# Create all tables via ORM
from basic_memory.models.base import Base
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Drop any SearchIndex ORM table, then create FTS5 virtual table
async with db.scoped_session(session_maker) as session:
await session.execute(text("DROP TABLE IF EXISTS search_index"))
await session.execute(CREATE_SEARCH_INDEX)
await session.commit()
yield engine, session_maker
@pytest_asyncio.fixture
@pytest_asyncio.fixture(scope="function")
async def test_project(config_home, engine_factory) -> Project:
"""Create a test project."""
project_data = {
@@ -179,27 +113,14 @@ def config_home(tmp_path, monkeypatch) -> Path:
return tmp_path
@pytest.fixture
def app_config(
config_home, db_backend: Literal["sqlite", "postgres"], tmp_path, monkeypatch
) -> BasicMemoryConfig:
@pytest.fixture(scope="function", autouse=True)
def app_config(config_home, tmp_path, monkeypatch) -> BasicMemoryConfig:
"""Create test app configuration."""
# Disable cloud mode for CLI tests
monkeypatch.setenv("BASIC_MEMORY_CLOUD_MODE", "false")
# Create a basic config with test-project like unit tests do
projects = {"test-project": str(config_home)}
# Configure database backend based on test parameter
if db_backend == "postgres":
database_backend = DatabaseBackend.POSTGRES
database_url = (
"postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory_test"
)
else:
database_backend = DatabaseBackend.SQLITE
database_url = None
app_config = BasicMemoryConfig(
env="test",
projects=projects,
@@ -207,19 +128,12 @@ def app_config(
default_project_mode=False, # Match real-world usage - tools must pass explicit project
update_permalinks_on_move=True,
cloud_mode=False, # Explicitly disable cloud mode
database_backend=database_backend,
database_url=database_url,
)
return app_config
@pytest.fixture
@pytest.fixture(scope="function", autouse=True)
def config_manager(app_config: BasicMemoryConfig, config_home) -> ConfigManager:
# Invalidate config cache to ensure clean state for each test
from basic_memory import config as config_module
config_module._CONFIG_CACHE = None
config_manager = ConfigManager()
# Update its paths to use the test directory
config_manager.config_dir = config_home / ".basic-memory"
@@ -231,7 +145,7 @@ def config_manager(app_config: BasicMemoryConfig, config_home) -> ConfigManager:
return config_manager
@pytest.fixture
@pytest.fixture(scope="function", autouse=True)
def project_config(test_project):
"""Create test project configuration."""
@@ -243,7 +157,7 @@ def project_config(test_project):
return project_config
@pytest.fixture
@pytest.fixture(scope="function")
def app(app_config, project_config, engine_factory, test_project, config_manager) -> FastAPI:
"""Create test FastAPI application with single project."""
@@ -258,25 +172,20 @@ def app(app_config, project_config, engine_factory, test_project, config_manager
return app
@pytest_asyncio.fixture
async def search_service(engine_factory, test_project, app_config):
"""Create and initialize search service for integration tests.
Uses app_config fixture to determine database backend - no patching needed.
"""
@pytest_asyncio.fixture(scope="function")
async def search_service(engine_factory, test_project):
"""Create and initialize search service for integration tests."""
from basic_memory.repository.search_repository import SearchRepository
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.services.file_service import FileService
from basic_memory.services.search_service import SearchService
from basic_memory.markdown.markdown_processor import MarkdownProcessor
from basic_memory.markdown import EntityParser
from basic_memory.repository.search_repository import create_search_repository
engine, session_maker = engine_factory
# Use factory function to create appropriate search repository
search_repository = create_search_repository(session_maker, project_id=test_project.id)
# Create repositories
search_repository = SearchRepository(session_maker, project_id=test_project.id)
entity_repository = EntityRepository(session_maker, project_id=test_project.id)
# Create file service
@@ -290,7 +199,7 @@ async def search_service(engine_factory, test_project, app_config):
return service
@pytest.fixture
@pytest.fixture(scope="function")
def mcp_server(config_manager, search_service):
# Import mcp instance
from basic_memory.mcp.server import mcp as server
@@ -304,7 +213,7 @@ def mcp_server(config_manager, search_service):
return server
@pytest_asyncio.fixture
@pytest_asyncio.fixture(scope="function")
async def client(app: FastAPI) -> AsyncGenerator[AsyncClient, None]:
"""Create test client that both MCP and tests will use."""
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
+82 -66
View File
@@ -9,10 +9,9 @@ from textwrap import dedent
import pytest
from fastmcp import Client
from unittest.mock import patch
from basic_memory.config import ConfigManager
from basic_memory.schemas.project_info import ProjectItem
from pathlib import Path
@pytest.mark.asyncio
@@ -314,68 +313,79 @@ async def test_write_note_preserve_frontmatter(mcp_server, app, test_project):
@pytest.mark.asyncio
async def test_write_note_kebab_filenames_basic(mcp_server, app, test_project, app_config):
async def test_write_note_kebab_filenames_basic(mcp_server, test_project):
"""Test note creation with kebab_filenames=True and invalid filename characters."""
app_config.kebab_filenames = True
ConfigManager().save_config(app_config)
config = ConfigManager().config
curr_config_val = config.kebab_filenames
config.kebab_filenames = True
async with Client(mcp_server) as client:
result = await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "My Note: With/Invalid|Chars?",
"folder": "my-folder",
"content": "Testing kebab-case and invalid characters.",
"tags": "kebab,invalid,filename",
},
)
with patch.object(ConfigManager, "config", config):
async with Client(mcp_server) as client:
result = await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": "My Note: With/Invalid|Chars?",
"folder": "my-folder",
"content": "Testing kebab-case and invalid characters.",
"tags": "kebab,invalid,filename",
},
)
assert len(result.content) == 1
response_text = result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
assert len(result.content) == 1
response_text = result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
# File path and permalink should be kebab-case and sanitized
assert f"project: {test_project.name}" in response_text
assert "file_path: my-folder/my-note-with-invalid-chars.md" in response_text
assert "permalink: my-folder/my-note-with-invalid-chars" in response_text
assert f"[Session: Using project '{test_project.name}']" in response_text
# File path and permalink should be kebab-case and sanitized
assert f"project: {test_project.name}" in response_text
assert "file_path: my-folder/my-note-with-invalid-chars.md" in response_text
assert "permalink: my-folder/my-note-with-invalid-chars" in response_text
assert f"[Session: Using project '{test_project.name}']" in response_text
# Restore original config value
config.kebab_filenames = curr_config_val
@pytest.mark.asyncio
async def test_write_note_kebab_filenames_repeat_invalid(mcp_server, app, test_project, app_config):
async def test_write_note_kebab_filenames_repeat_invalid(mcp_server, test_project):
"""Test note creation with multiple invalid and repeated characters."""
app_config.kebab_filenames = True
ConfigManager().save_config(app_config)
config = ConfigManager().config
curr_config_val = config.kebab_filenames
config.kebab_filenames = True
async with Client(mcp_server) as client:
result = await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": 'Crazy<>:"|?*Note/Name',
"folder": "my-folder",
"content": "Should be fully kebab-case and safe.",
"tags": "crazy,filename,test",
},
)
with patch.object(ConfigManager, "config", config):
async with Client(mcp_server) as client:
result = await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": 'Crazy<>:"|?*Note/Name',
"folder": "my-folder",
"content": "Should be fully kebab-case and safe.",
"tags": "crazy,filename,test",
},
)
assert len(result.content) == 1
response_text = result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
assert len(result.content) == 1
response_text = result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
assert f"project: {test_project.name}" in response_text
assert "file_path: my-folder/crazy-note-name.md" in response_text
assert "permalink: my-folder/crazy-note-name" in response_text
assert f"[Session: Using project '{test_project.name}']" in response_text
assert f"project: {test_project.name}" in response_text
assert "file_path: my-folder/crazy-note-name.md" in response_text
assert "permalink: my-folder/crazy-note-name" in response_text
assert f"[Session: Using project '{test_project.name}']" in response_text
# Restore original config value
config.kebab_filenames = curr_config_val
@pytest.mark.asyncio
async def test_write_note_file_path_os_path_join(mcp_server, app, test_project, app_config):
async def test_write_note_file_path_os_path_join(mcp_server, test_project):
"""Test that os.path.join logic in Entity.file_path works for various folder/title combinations."""
app_config.kebab_filenames = True
ConfigManager().save_config(app_config)
config = ConfigManager().config
curr_config_val = config.kebab_filenames
config.kebab_filenames = True
test_cases = [
# (folder, title, expected file_path, expected permalink)
@@ -397,31 +407,35 @@ async def test_write_note_file_path_os_path_join(mcp_server, app, test_project,
("folder//subfolder", "Note", "folder/subfolder/note.md", "folder/subfolder/note"),
]
async with Client(mcp_server) as client:
for folder, title, expected_path, expected_permalink in test_cases:
result = await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": title,
"folder": folder,
"content": "Testing os.path.join logic.",
"tags": "integration,ospath",
},
)
with patch.object(ConfigManager, "config", config):
async with Client(mcp_server) as client:
for folder, title, expected_path, expected_permalink in test_cases:
result = await client.call_tool(
"write_note",
{
"project": test_project.name,
"title": title,
"folder": folder,
"content": "Testing os.path.join logic.",
"tags": "integration,ospath",
},
)
assert len(result.content) == 1
response_text = result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
print(response_text)
assert len(result.content) == 1
response_text = result.content[0].text # pyright: ignore [reportAttributeAccessIssue]
print(response_text)
assert f"project: {test_project.name}" in response_text
assert f"file_path: {expected_path}" in response_text
assert f"permalink: {expected_permalink}" in response_text
assert f"[Session: Using project '{test_project.name}']" in response_text
assert f"project: {test_project.name}" in response_text
assert f"file_path: {expected_path}" in response_text
assert f"permalink: {expected_permalink}" in response_text
assert f"[Session: Using project '{test_project.name}']" in response_text
# Restore original config value
config.kebab_filenames = curr_config_val
@pytest.mark.asyncio
async def test_write_note_project_path_validation(mcp_server, app, test_project):
async def test_write_note_project_path_validation(mcp_server, test_project):
"""Test that ProjectItem.home uses expanded path, not name (Issue #340).
Regression test verifying that:
@@ -432,6 +446,8 @@ async def test_write_note_project_path_validation(mcp_server, app, test_project)
the project name and path happen to be the same. The fix in src/basic_memory/schemas/project_info.py:186
ensures .expanduser() is called, which is critical for paths with ~ like "~/Documents/Test BiSync".
"""
from basic_memory.schemas.project_info import ProjectItem
from pathlib import Path
# Test the fix directly: ProjectItem.home should expand tilde paths
project_with_tilde = ProjectItem(
+28 -69
View File
@@ -10,11 +10,8 @@ from sqlalchemy import text
@pytest.mark.asyncio
async def test_wal_mode_enabled(engine_factory, db_backend):
async def test_wal_mode_enabled(engine_factory):
"""Test that WAL mode is enabled on filesystem database connections."""
if db_backend == "postgres":
pytest.skip("SQLite-specific test - PRAGMA commands not supported in Postgres")
engine, _ = engine_factory
# Execute a query to verify WAL mode is enabled
@@ -27,11 +24,8 @@ async def test_wal_mode_enabled(engine_factory, db_backend):
@pytest.mark.asyncio
async def test_busy_timeout_configured(engine_factory, db_backend):
async def test_busy_timeout_configured(engine_factory):
"""Test that busy timeout is configured for database connections."""
if db_backend == "postgres":
pytest.skip("SQLite-specific test - PRAGMA commands not supported in Postgres")
engine, _ = engine_factory
async with engine.connect() as conn:
@@ -43,11 +37,8 @@ async def test_busy_timeout_configured(engine_factory, db_backend):
@pytest.mark.asyncio
async def test_synchronous_mode_configured(engine_factory, db_backend):
async def test_synchronous_mode_configured(engine_factory):
"""Test that synchronous mode is set to NORMAL for performance."""
if db_backend == "postgres":
pytest.skip("SQLite-specific test - PRAGMA commands not supported in Postgres")
engine, _ = engine_factory
async with engine.connect() as conn:
@@ -59,11 +50,8 @@ async def test_synchronous_mode_configured(engine_factory, db_backend):
@pytest.mark.asyncio
async def test_cache_size_configured(engine_factory, db_backend):
async def test_cache_size_configured(engine_factory):
"""Test that cache size is configured for performance."""
if db_backend == "postgres":
pytest.skip("SQLite-specific test - PRAGMA commands not supported in Postgres")
engine, _ = engine_factory
async with engine.connect() as conn:
@@ -75,11 +63,8 @@ async def test_cache_size_configured(engine_factory, db_backend):
@pytest.mark.asyncio
async def test_temp_store_configured(engine_factory, db_backend):
async def test_temp_store_configured(engine_factory):
"""Test that temp_store is set to MEMORY."""
if db_backend == "postgres":
pytest.skip("SQLite-specific test - PRAGMA commands not supported in Postgres")
engine, _ = engine_factory
async with engine.connect() as conn:
@@ -91,61 +76,42 @@ async def test_temp_store_configured(engine_factory, db_backend):
@pytest.mark.asyncio
@pytest.mark.windows
@pytest.mark.skipif(
__import__("os").name != "nt", reason="Windows-specific test - only runs on Windows platform"
)
async def test_windows_locking_mode_when_on_windows(tmp_path, monkeypatch, config_manager):
async def test_windows_locking_mode_when_on_windows(tmp_path):
"""Test that Windows-specific locking mode is set when running on Windows."""
from basic_memory.db import engine_session_factory, DatabaseType
from basic_memory.config import DatabaseBackend
# Force SQLite backend for this SQLite-specific test
config_manager.config.database_backend = DatabaseBackend.SQLITE
# Set HOME environment variable
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("BASIC_MEMORY_HOME", str(tmp_path / "basic-memory"))
db_path = tmp_path / "test_windows.db"
async with engine_session_factory(db_path, DatabaseType.FILESYSTEM) as (
engine,
_,
):
async with engine.connect() as conn:
result = await conn.execute(text("PRAGMA locking_mode"))
locking_mode = result.fetchone()[0]
with patch("os.name", "nt"):
# Need to patch at module level where it's imported
with patch("basic_memory.db.os.name", "nt"):
async with engine_session_factory(db_path, DatabaseType.FILESYSTEM) as (
engine,
_,
):
async with engine.connect() as conn:
result = await conn.execute(text("PRAGMA locking_mode"))
locking_mode = result.fetchone()[0]
# Locking mode should be NORMAL on Windows
assert locking_mode.upper() == "NORMAL"
# Locking mode should be NORMAL on Windows
assert locking_mode.upper() == "NORMAL"
@pytest.mark.asyncio
@pytest.mark.windows
@pytest.mark.skipif(
__import__("os").name != "nt", reason="Windows-specific test - only runs on Windows platform"
)
async def test_null_pool_on_windows(tmp_path, monkeypatch):
async def test_null_pool_on_windows(tmp_path):
"""Test that NullPool is used on Windows to avoid connection pooling issues."""
from basic_memory.db import engine_session_factory, DatabaseType
from sqlalchemy.pool import NullPool
# Set HOME environment variable
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("BASIC_MEMORY_HOME", str(tmp_path / "basic-memory"))
db_path = tmp_path / "test_windows_pool.db"
async with engine_session_factory(db_path, DatabaseType.FILESYSTEM) as (engine, _):
# Engine should be using NullPool on Windows
assert isinstance(engine.pool, NullPool)
with patch("basic_memory.db.os.name", "nt"):
async with engine_session_factory(db_path, DatabaseType.FILESYSTEM) as (engine, _):
# Engine should be using NullPool on Windows
assert isinstance(engine.pool, NullPool)
@pytest.mark.asyncio
@pytest.mark.skipif(
__import__("os").name == "nt", reason="Non-Windows test - cannot mock POSIX paths on Windows"
)
async def test_regular_pool_on_non_windows(tmp_path):
"""Test that regular pooling is used on non-Windows platforms."""
from basic_memory.db import engine_session_factory, DatabaseType
@@ -160,11 +126,7 @@ async def test_regular_pool_on_non_windows(tmp_path):
@pytest.mark.asyncio
@pytest.mark.windows
@pytest.mark.skipif(
__import__("os").name != "nt", reason="Windows-specific test - only runs on Windows platform"
)
async def test_memory_database_no_null_pool_on_windows(tmp_path, monkeypatch):
async def test_memory_database_no_null_pool_on_windows(tmp_path):
"""Test that in-memory databases do NOT use NullPool even on Windows.
NullPool closes connections immediately, which destroys in-memory databases.
@@ -173,12 +135,9 @@ async def test_memory_database_no_null_pool_on_windows(tmp_path, monkeypatch):
from basic_memory.db import engine_session_factory, DatabaseType
from sqlalchemy.pool import NullPool
# Set HOME environment variable
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("BASIC_MEMORY_HOME", str(tmp_path / "basic-memory"))
db_path = tmp_path / "test_memory.db"
async with engine_session_factory(db_path, DatabaseType.MEMORY) as (engine, _):
# In-memory databases should NOT use NullPool on Windows
assert not isinstance(engine.pool, NullPool)
with patch("basic_memory.db.os.name", "nt"):
async with engine_session_factory(db_path, DatabaseType.MEMORY) as (engine, _):
# In-memory databases should NOT use NullPool on Windows
assert not isinstance(engine.pool, NullPool)
+16 -31
View File
@@ -2,6 +2,7 @@
import pytest
from basic_memory.config import BasicMemoryConfig
from basic_memory.markdown import EntityParser, MarkdownProcessor
from basic_memory.repository import (
EntityRepository,
@@ -9,8 +10,7 @@ from basic_memory.repository import (
RelationRepository,
ProjectRepository,
)
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
from basic_memory.repository.search_repository import SearchRepository
from basic_memory.schemas import Entity as EntitySchema
from basic_memory.services import FileService
from basic_memory.services.entity_service import EntityService
@@ -20,25 +20,18 @@ from basic_memory.sync.sync_service import SyncService
@pytest.mark.asyncio
async def test_disable_permalinks_create_entity(tmp_path, engine_factory, app_config, test_project):
async def test_disable_permalinks_create_entity(tmp_path, engine_factory):
"""Test that entities created with disable_permalinks=True don't have permalinks."""
from basic_memory.config import DatabaseBackend
engine, session_maker = engine_factory
# Override app config to enable disable_permalinks
app_config.disable_permalinks = True
# Create app config with disable_permalinks=True
app_config = BasicMemoryConfig(disable_permalinks=True)
# Setup repositories
entity_repository = EntityRepository(session_maker, project_id=test_project.id)
observation_repository = ObservationRepository(session_maker, project_id=test_project.id)
relation_repository = RelationRepository(session_maker, project_id=test_project.id)
# Use database-specific search repository
if app_config.database_backend == DatabaseBackend.POSTGRES:
search_repository = PostgresSearchRepository(session_maker, project_id=test_project.id)
else:
search_repository = SQLiteSearchRepository(session_maker, project_id=test_project.id)
entity_repository = EntityRepository(session_maker, project_id=1)
observation_repository = ObservationRepository(session_maker, project_id=1)
relation_repository = RelationRepository(session_maker, project_id=1)
search_repository = SearchRepository(session_maker, project_id=1)
# Setup services
entity_parser = EntityParser(tmp_path)
@@ -80,30 +73,22 @@ async def test_disable_permalinks_create_entity(tmp_path, engine_factory, app_co
@pytest.mark.asyncio
async def test_disable_permalinks_sync_workflow(tmp_path, engine_factory, app_config, test_project):
async def test_disable_permalinks_sync_workflow(tmp_path, engine_factory):
"""Test full sync workflow with disable_permalinks enabled."""
from basic_memory.config import DatabaseBackend
engine, session_maker = engine_factory
# Override app config to enable disable_permalinks
app_config.disable_permalinks = True
# Create app config with disable_permalinks=True
app_config = BasicMemoryConfig(disable_permalinks=True)
# Create a test markdown file without frontmatter
test_file = tmp_path / "test_note.md"
test_file.write_text("# Test Note\nThis is test content.")
# Setup repositories
entity_repository = EntityRepository(session_maker, project_id=test_project.id)
observation_repository = ObservationRepository(session_maker, project_id=test_project.id)
relation_repository = RelationRepository(session_maker, project_id=test_project.id)
# Use database-specific search repository
if app_config.database_backend == DatabaseBackend.POSTGRES:
search_repository = PostgresSearchRepository(session_maker, project_id=test_project.id)
else:
search_repository = SQLiteSearchRepository(session_maker, project_id=test_project.id)
entity_repository = EntityRepository(session_maker, project_id=1)
observation_repository = ObservationRepository(session_maker, project_id=1)
relation_repository = RelationRepository(session_maker, project_id=1)
search_repository = SearchRepository(session_maker, project_id=1)
project_repository = ProjectRepository(session_maker)
# Setup services
-172
View File
@@ -1,172 +0,0 @@
# Dual-Backend Testing
Basic Memory tests run against both SQLite and Postgres backends to ensure compatibility.
## Quick Start
```bash
# Run tests against SQLite only (default, no setup needed)
pytest
# Run tests against Postgres only (requires docker-compose)
docker-compose -f docker-compose-postgres.yml up -d
pytest -m postgres
# Run tests against BOTH backends
docker-compose -f docker-compose-postgres.yml up -d
pytest --run-all-backends # Not yet implemented - run both commands above
```
## How It Works
### Parametrized Backend Fixture
The `db_backend` fixture is parametrized to run tests against both `sqlite` and `postgres`:
```python
@pytest.fixture(
params=[
pytest.param("sqlite", id="sqlite"),
pytest.param("postgres", id="postgres", marks=pytest.mark.postgres),
]
)
def db_backend(request) -> Literal["sqlite", "postgres"]:
return request.param
```
### Backend-Specific Engine Factories
Each backend has its own engine factory implementation:
- **`sqlite_engine_factory`** - Uses in-memory SQLite (fast, isolated)
- **`postgres_engine_factory`** - Uses Postgres test database (realistic, requires Docker)
The main `engine_factory` fixture delegates to the appropriate implementation based on `db_backend`.
### Configuration
The `app_config` fixture automatically configures the correct backend:
```python
# SQLite config
database_backend = DatabaseBackend.SQLITE
database_url = None # Uses default SQLite path
# Postgres config
database_backend = DatabaseBackend.POSTGRES
database_url = "postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory_test"
```
## Running Postgres Tests
### 1. Start Postgres Docker Container
```bash
docker-compose -f docker-compose-postgres.yml up -d
```
This starts:
- Postgres 17 on port **5433** (not 5432 to avoid conflicts)
- Test database: `basic_memory_test`
- Credentials: `basic_memory_user` / `dev_password`
### 2. Run Postgres Tests
```bash
# Run only Postgres tests
pytest -m postgres
# Run specific test with Postgres
pytest tests/test_entity_repository.py::test_create -m postgres
# Skip Postgres tests (default behavior)
pytest -m "not postgres"
```
### 3. Stop Docker Container
```bash
docker-compose -f docker-compose-postgres.yml down
```
## Test Isolation
### SQLite Tests
- Each test gets a fresh in-memory database
- Automatic cleanup (database destroyed after test)
- No setup required
### Postgres Tests
- Database is **cleaned before each test** (drop all tables, recreate)
- Tests share the same Postgres instance but get isolated schemas
- Requires Docker Compose to be running
## Markers
- `postgres` - Marks tests that run against Postgres backend
- Use `-m postgres` to run only Postgres tests
- Use `-m "not postgres"` to skip Postgres tests (default)
## CI Integration
### GitHub Actions
Use service containers for Postgres (no Docker Compose needed):
```yaml
jobs:
test:
runs-on: ubuntu-latest
# Postgres service container
services:
postgres:
image: postgres:17
env:
POSTGRES_DB: basic_memory_test
POSTGRES_USER: basic_memory_user
POSTGRES_PASSWORD: dev_password
ports:
- 5433:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Run SQLite tests
run: pytest -m "not postgres"
- name: Run Postgres tests
run: pytest -m postgres
```
## Troubleshooting
### Postgres tests fail with "connection refused"
Make sure Docker Compose is running:
```bash
docker-compose -f docker-compose-postgres.yml ps
docker-compose -f docker-compose-postgres.yml logs postgres
```
### Port 5433 already in use
Either:
- Stop the conflicting service
- Change the port in `docker-compose-postgres.yml` and `tests/conftest.py`
### Tests hang or timeout
Check Postgres health:
```bash
docker-compose -f docker-compose-postgres.yml exec postgres pg_isready -U basic_memory_user
```
## Future Enhancements
- [ ] Add `--run-all-backends` CLI flag to run both backends in sequence
- [ ] Implement test fixtures for backend-specific features (e.g., Postgres full-text search vs SQLite FTS5)
- [ ] Add performance comparison benchmarks between backends
+8 -129
View File
@@ -219,20 +219,20 @@ async def test_update_project_path_endpoint(test_config, client, project_service
test_project_name = "test-update-project"
with tempfile.TemporaryDirectory() as temp_dir:
test_root = Path(temp_dir)
old_path = test_root / "old-location"
new_path = test_root / "new-location"
old_path = (test_root / "old-location").as_posix()
new_path = (test_root / "new-location").as_posix()
await project_service.add_project(test_project_name, str(old_path))
await project_service.add_project(test_project_name, old_path)
try:
# Verify initial state
project = await project_service.get_project(test_project_name)
assert project is not None
assert Path(project.path) == old_path
assert project.path == old_path
# Update the project path
response = await client.patch(
f"{project_url}/project/{test_project_name}", json={"path": str(new_path)}
f"{project_url}/project/{test_project_name}", json={"path": new_path}
)
# Verify response
@@ -248,16 +248,16 @@ async def test_update_project_path_endpoint(test_config, client, project_service
# Check old project data
assert data["old_project"]["name"] == test_project_name
assert Path(data["old_project"]["path"]) == old_path
assert data["old_project"]["path"] == old_path
# Check new project data
assert data["new_project"]["name"] == test_project_name
assert Path(data["new_project"]["path"]) == new_path
assert data["new_project"]["path"] == new_path
# Verify project was actually updated in database
updated_project = await project_service.get_project(test_project_name)
assert updated_project is not None
assert Path(updated_project.path) == new_path
assert updated_project.path == new_path
finally:
# Clean up
@@ -466,40 +466,6 @@ async def test_sync_project_endpoint(test_graph, client, project_url):
assert "Filesystem sync initiated" in data["message"]
@pytest.mark.asyncio
async def test_sync_project_endpoint_with_force_full(test_graph, client, project_url):
"""Test the project sync endpoint with force_full parameter."""
# Call the sync endpoint with force_full=true
response = await client.post(f"{project_url}/project/sync?force_full=true")
# Verify response
assert response.status_code == 200
data = response.json()
# Check response structure
assert "status" in data
assert "message" in data
assert data["status"] == "sync_started"
assert "Filesystem sync initiated" in data["message"]
@pytest.mark.asyncio
async def test_sync_project_endpoint_with_force_full_false(test_graph, client, project_url):
"""Test the project sync endpoint with force_full=false."""
# Call the sync endpoint with force_full=false
response = await client.post(f"{project_url}/project/sync?force_full=false")
# Verify response
assert response.status_code == 200
data = response.json()
# Check response structure
assert "status" in data
assert "message" in data
assert data["status"] == "sync_started"
assert "Filesystem sync initiated" in data["message"]
@pytest.mark.asyncio
async def test_sync_project_endpoint_not_found(client):
"""Test the project sync endpoint with nonexistent project."""
@@ -510,93 +476,6 @@ async def test_sync_project_endpoint_not_found(client):
assert response.status_code == 404
@pytest.mark.asyncio
async def test_sync_project_endpoint_foreground(test_graph, client, project_url):
"""Test the project sync endpoint with run_in_background=false returns sync report."""
# Call the sync endpoint with run_in_background=false
response = await client.post(f"{project_url}/project/sync?run_in_background=false")
# Verify response
assert response.status_code == 200
data = response.json()
# Check that we get a sync report instead of status message
assert "new" in data
assert "modified" in data
assert "deleted" in data
assert "moves" in data
assert "checksums" in data
assert "skipped_files" in data
assert "total" in data
# Verify these are the right types
assert isinstance(data["new"], list)
assert isinstance(data["modified"], list)
assert isinstance(data["deleted"], list)
assert isinstance(data["moves"], dict)
assert isinstance(data["checksums"], dict)
assert isinstance(data["skipped_files"], list)
assert isinstance(data["total"], int)
@pytest.mark.asyncio
async def test_sync_project_endpoint_foreground_with_force_full(test_graph, client, project_url):
"""Test the project sync endpoint with run_in_background=false and force_full=true."""
# Call the sync endpoint with both parameters
response = await client.post(
f"{project_url}/project/sync?run_in_background=false&force_full=true"
)
# Verify response
assert response.status_code == 200
data = response.json()
# Check that we get a sync report with all expected fields
assert "new" in data
assert "modified" in data
assert "deleted" in data
assert "moves" in data
assert "checksums" in data
assert "skipped_files" in data
assert "total" in data
@pytest.mark.asyncio
async def test_sync_project_endpoint_foreground_with_changes(
test_graph, client, project_config, project_url, tmpdir
):
"""Test foreground sync detects actual file changes."""
# Create a new file in the project directory
import os
from pathlib import Path
test_file = Path(project_config.home) / "new_test_file.md"
test_file.write_text("# New Test File\n\nThis is a test file for sync detection.")
try:
# Call the sync endpoint with run_in_background=false
response = await client.post(f"{project_url}/project/sync?run_in_background=false")
# Verify response
assert response.status_code == 200
data = response.json()
# The sync report should show changes (the new file we created)
assert data["total"] >= 0 # Should have at least detected changes
assert "new" in data
assert "modified" in data
assert "deleted" in data
# At least one of these should have changes
has_changes = len(data["new"]) > 0 or len(data["modified"]) > 0 or len(data["deleted"]) > 0
assert has_changes or data["total"] >= 0 # Either changes detected or empty sync is valid
finally:
# Clean up the test file
if test_file.exists():
os.remove(test_file)
@pytest.mark.asyncio
async def test_remove_default_project_fails(test_config, client, project_service):
"""Test that removing the default project returns an error."""
+2 -10
View File
@@ -12,7 +12,7 @@ from basic_memory.schemas.search import SearchItemType, SearchResponse
@pytest_asyncio.fixture
async def indexed_entity(full_entity, search_service):
async def indexed_entity(init_search_index, full_entity, search_service):
"""Create an entity and index it."""
await search_service.index_entity(full_entity)
return full_entity
@@ -118,16 +118,8 @@ async def test_search_empty(search_service, client, project_url):
@pytest.mark.asyncio
async def test_reindex(
client, search_service, entity_service, session_maker, project_url, app_config
):
async def test_reindex(client, search_service, entity_service, session_maker, project_url):
"""Test reindex endpoint."""
# Skip for Postgres - needs investigation of database connection isolation
from basic_memory.config import DatabaseBackend
if app_config.database_backend == DatabaseBackend.POSTGRES:
pytest.skip("Not yet supported for Postgres - database connection isolation issue")
# Create test entity and document
await entity_service.create_entity(
EntitySchema(
+3 -2
View File
@@ -1,5 +1,6 @@
from typing import AsyncGenerator
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import AsyncClient, ASGITransport
@@ -25,7 +26,7 @@ async def client(app: FastAPI, aiolib) -> AsyncGenerator[AsyncClient, None]:
yield client
@pytest_asyncio.fixture
async def cli_env(project_config, client, test_config):
@pytest.fixture
def cli_env(project_config, client, test_config):
"""Set up CLI environment with correct project session."""
return {"project_config": project_config, "client": client}
+463
View File
@@ -0,0 +1,463 @@
"""Tests for bisync_commands module."""
from pathlib import Path
from unittest.mock import Mock, patch
import pytest
from basic_memory.cli.commands.cloud.bisync_commands import (
BisyncError,
convert_bmignore_to_rclone_filters,
scan_local_directories,
validate_bisync_directory,
build_bisync_command,
get_bisync_directory,
get_bisync_state_path,
bisync_state_exists,
BISYNC_PROFILES,
)
class TestConvertBmignoreToRcloneFilters:
"""Tests for convert_bmignore_to_rclone_filters()."""
def test_converts_basic_patterns(self, tmp_path):
"""Test conversion of basic gitignore patterns to rclone format."""
bmignore_dir = tmp_path / ".basic-memory"
bmignore_dir.mkdir(exist_ok=True)
bmignore_file = bmignore_dir / ".bmignore"
# Write test patterns
bmignore_file.write_text("# Comment line\nnode_modules\n*.pyc\n.git\n**/*.log\n")
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bmignore_path",
return_value=bmignore_file,
):
convert_bmignore_to_rclone_filters()
# Read the generated rclone filter file
rclone_filter = bmignore_dir / ".bmignore.rclone"
assert rclone_filter.exists()
content = rclone_filter.read_text()
lines = content.strip().split("\n")
# Check comment preserved
assert "# Comment line" in lines
# Check patterns converted correctly
assert "- node_modules/**" in lines # Directory without wildcard
assert "- *.pyc" in lines # Wildcard pattern unchanged
assert "- .git/**" in lines # Directory pattern
assert "- **/*.log" in lines # Wildcard pattern unchanged
def test_handles_empty_bmignore(self, tmp_path):
"""Test handling of empty .bmignore file."""
bmignore_dir = tmp_path / ".basic-memory"
bmignore_dir.mkdir(exist_ok=True)
bmignore_file = bmignore_dir / ".bmignore"
bmignore_file.write_text("")
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bmignore_path",
return_value=bmignore_file,
):
convert_bmignore_to_rclone_filters()
rclone_filter = bmignore_dir / ".bmignore.rclone"
assert rclone_filter.exists()
def test_handles_missing_bmignore(self, tmp_path):
"""Test handling when .bmignore doesn't exist."""
bmignore_dir = tmp_path / ".basic-memory"
bmignore_dir.mkdir(exist_ok=True)
bmignore_file = bmignore_dir / ".bmignore"
# Ensure file doesn't exist
if bmignore_file.exists():
bmignore_file.unlink()
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bmignore_path",
return_value=bmignore_file,
):
with patch("basic_memory.cli.commands.cloud.bisync_commands.create_default_bmignore"):
convert_bmignore_to_rclone_filters()
# Should create minimal filter with .git
rclone_filter = bmignore_dir / ".bmignore.rclone"
assert rclone_filter.exists()
content = rclone_filter.read_text()
assert "- .git/**" in content
class TestScanLocalDirectories:
"""Tests for scan_local_directories()."""
def test_scans_existing_directories(self, tmp_path):
"""Test scanning existing project directories."""
# Use a subdirectory to avoid interference from test fixtures
scan_dir = tmp_path / "scan_test"
scan_dir.mkdir()
# Create test directories
(scan_dir / "project1").mkdir()
(scan_dir / "project2").mkdir()
(scan_dir / "project3").mkdir()
# Create a hidden directory (should be ignored)
(scan_dir / ".hidden").mkdir()
# Create a file (should be ignored)
(scan_dir / "file.txt").write_text("test")
result = scan_local_directories(scan_dir)
assert len(result) == 3
assert "project1" in result
assert "project2" in result
assert "project3" in result
assert ".hidden" not in result
def test_handles_empty_directory(self, tmp_path):
"""Test scanning empty directory."""
scan_dir = tmp_path / "empty_test"
scan_dir.mkdir()
result = scan_local_directories(scan_dir)
assert result == []
def test_handles_nonexistent_directory(self, tmp_path):
"""Test scanning nonexistent directory."""
nonexistent = tmp_path / "does-not-exist"
result = scan_local_directories(nonexistent)
assert result == []
def test_ignores_hidden_directories(self, tmp_path):
"""Test that hidden directories are ignored."""
scan_dir = tmp_path / "hidden_test"
scan_dir.mkdir()
(scan_dir / ".git").mkdir()
(scan_dir / ".cache").mkdir()
(scan_dir / "visible").mkdir()
result = scan_local_directories(scan_dir)
assert len(result) == 1
assert "visible" in result
assert ".git" not in result
assert ".cache" not in result
class TestValidateBisyncDirectory:
"""Tests for validate_bisync_directory()."""
def test_allows_valid_directory(self, tmp_path):
"""Test that valid directory passes validation."""
bisync_dir = tmp_path / "sync"
bisync_dir.mkdir()
# Should not raise
validate_bisync_directory(bisync_dir)
def test_rejects_mount_directory(self, tmp_path):
"""Test that mount directory is rejected."""
mount_dir = Path.home() / "basic-memory-cloud"
with pytest.raises(BisyncError) as exc_info:
validate_bisync_directory(mount_dir)
assert "mount directory" in str(exc_info.value).lower()
@patch("subprocess.run")
def test_rejects_mounted_directory(self, mock_run, tmp_path):
"""Test that currently mounted directory is rejected."""
bisync_dir = tmp_path / "sync"
bisync_dir.mkdir()
# Mock mount command showing this directory is mounted
mock_run.return_value = Mock(
stdout=f"rclone on {bisync_dir} type fuse.rclone",
stderr="",
returncode=0,
)
with pytest.raises(BisyncError) as exc_info:
validate_bisync_directory(bisync_dir)
assert "currently mounted" in str(exc_info.value).lower()
class TestBuildBisyncCommand:
"""Tests for build_bisync_command()."""
def test_builds_basic_command(self, tmp_path):
"""Test building basic bisync command."""
tenant_id = "test-tenant"
bucket_name = "test-bucket"
local_path = tmp_path / "sync"
local_path.mkdir()
profile = BISYNC_PROFILES["balanced"]
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bisync_filter_path"
) as mock_filter:
mock_filter.return_value = Path("/test/filter")
cmd = build_bisync_command(
tenant_id=tenant_id,
bucket_name=bucket_name,
local_path=local_path,
profile=profile,
)
assert cmd[0] == "rclone"
assert cmd[1] == "bisync"
assert str(local_path) in cmd
assert f"basic-memory-{tenant_id}:{bucket_name}" in cmd
assert "--create-empty-src-dirs" in cmd
assert "--resilient" in cmd
assert f"--conflict-resolve={profile.conflict_resolve}" in cmd
assert f"--max-delete={profile.max_delete}" in cmd
assert "--progress" in cmd
def test_adds_dry_run_flag(self, tmp_path):
"""Test that dry-run flag is added when requested."""
tenant_id = "test-tenant"
bucket_name = "test-bucket"
local_path = tmp_path / "sync"
local_path.mkdir()
profile = BISYNC_PROFILES["safe"]
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bisync_filter_path"
) as mock_filter:
mock_filter.return_value = Path("/test/filter")
cmd = build_bisync_command(
tenant_id=tenant_id,
bucket_name=bucket_name,
local_path=local_path,
profile=profile,
dry_run=True,
)
assert "--dry-run" in cmd
def test_adds_resync_flag(self, tmp_path):
"""Test that resync flag is added when requested."""
tenant_id = "test-tenant"
bucket_name = "test-bucket"
local_path = tmp_path / "sync"
local_path.mkdir()
profile = BISYNC_PROFILES["balanced"]
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bisync_filter_path"
) as mock_filter:
mock_filter.return_value = Path("/test/filter")
cmd = build_bisync_command(
tenant_id=tenant_id,
bucket_name=bucket_name,
local_path=local_path,
profile=profile,
resync=True,
)
assert "--resync" in cmd
def test_adds_verbose_flag(self, tmp_path):
"""Test that verbose flag is added when requested."""
tenant_id = "test-tenant"
bucket_name = "test-bucket"
local_path = tmp_path / "sync"
local_path.mkdir()
profile = BISYNC_PROFILES["fast"]
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bisync_filter_path"
) as mock_filter:
mock_filter.return_value = Path("/test/filter")
cmd = build_bisync_command(
tenant_id=tenant_id,
bucket_name=bucket_name,
local_path=local_path,
profile=profile,
verbose=True,
)
assert "--verbose" in cmd
assert "--progress" not in cmd # Progress replaced by verbose
def test_creates_state_directory(self, tmp_path):
"""Test that state directory is created."""
tenant_id = "test-tenant"
bucket_name = "test-bucket"
local_path = tmp_path / "sync"
local_path.mkdir()
profile = BISYNC_PROFILES["balanced"]
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bisync_filter_path"
) as mock_filter:
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bisync_state_path"
) as mock_state:
state_path = tmp_path / "state"
mock_filter.return_value = Path("/test/filter")
mock_state.return_value = state_path
build_bisync_command(
tenant_id=tenant_id,
bucket_name=bucket_name,
local_path=local_path,
profile=profile,
)
# State directory should be created
assert state_path.exists()
assert state_path.is_dir()
class TestBisyncStateManagement:
"""Tests for bisync state functions."""
def test_get_bisync_state_path(self):
"""Test state path generation."""
tenant_id = "test-tenant-123"
result = get_bisync_state_path(tenant_id)
expected = Path.home() / ".basic-memory" / "bisync-state" / tenant_id
assert result == expected
def test_bisync_state_exists_true(self, tmp_path):
"""Test checking for existing state."""
state_dir = tmp_path / "state"
state_dir.mkdir()
(state_dir / "test.lst").write_text("test")
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bisync_state_path",
return_value=state_dir,
):
result = bisync_state_exists("test-tenant")
assert result is True
def test_bisync_state_exists_false_no_dir(self, tmp_path):
"""Test checking for nonexistent state directory."""
state_dir = tmp_path / "nonexistent"
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bisync_state_path",
return_value=state_dir,
):
result = bisync_state_exists("test-tenant")
assert result is False
def test_bisync_state_exists_false_empty_dir(self, tmp_path):
"""Test checking for empty state directory."""
state_dir = tmp_path / "state"
state_dir.mkdir()
with patch(
"basic_memory.cli.commands.cloud.bisync_commands.get_bisync_state_path",
return_value=state_dir,
):
result = bisync_state_exists("test-tenant")
assert result is False
class TestGetBisyncDirectory:
"""Tests for get_bisync_directory()."""
def test_returns_default_directory(self):
"""Test that default directory is returned when not configured."""
with patch("basic_memory.cli.commands.cloud.bisync_commands.ConfigManager") as mock_config:
mock_config.return_value.config.bisync_config = {}
result = get_bisync_directory()
expected = Path.home() / "basic-memory-cloud-sync"
assert result == expected
def test_returns_configured_directory(self, tmp_path):
"""Test that configured directory is returned."""
custom_dir = tmp_path / "custom-sync"
with patch("basic_memory.cli.commands.cloud.bisync_commands.ConfigManager") as mock_config:
mock_config.return_value.config.bisync_config = {"sync_dir": str(custom_dir)}
result = get_bisync_directory()
assert result == custom_dir
class TestCloudProjectAutoRegistration:
"""Tests for project auto-registration logic."""
@pytest.mark.asyncio
async def test_extracts_directory_names_from_cloud_paths(self):
"""Test extraction of directory names from cloud project paths."""
from basic_memory.cli.commands.cloud.cloud_utils import fetch_cloud_projects
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
mock_response = Mock()
mock_response.json.return_value = {
"projects": [
{"name": "Main Project", "path": "/app/data/basic-memory"},
{"name": "Work", "path": "/app/data/work-notes"},
{"name": "Personal", "path": "/app/data/personal"},
]
}
mock_request.return_value = mock_response
result = await fetch_cloud_projects()
# Extract directory names as the code does
cloud_dir_names = set()
for p in result.projects:
path = p.path
if path.startswith("/app/data/"):
path = path[len("/app/data/") :]
dir_name = Path(path).name
cloud_dir_names.add(dir_name)
assert cloud_dir_names == {"basic-memory", "work-notes", "personal"}
@pytest.mark.asyncio
async def test_create_cloud_project_generates_permalink(self):
"""Test that create_cloud_project generates correct permalink."""
from basic_memory.cli.commands.cloud.cloud_utils import create_cloud_project
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
with patch(
"basic_memory.cli.commands.cloud.cloud_utils.generate_permalink"
) as mock_permalink:
mock_permalink.return_value = "my-new-project"
mock_response = Mock()
mock_response.json.return_value = {
"message": "Project 'My New Project' added successfully",
"status": "success",
"default": False,
"old_project": None,
"new_project": {"name": "My New Project", "path": "my-new-project"},
}
mock_request.return_value = mock_response
await create_cloud_project("My New Project")
# Verify permalink was generated
mock_permalink.assert_called_once_with("My New Project")
# Verify request was made with correct data
call_args = mock_request.call_args
json_data = call_args.kwargs["json_data"]
assert json_data["name"] == "My New Project"
assert json_data["path"] == "my-new-project"
assert json_data["set_default"] is False
-5
View File
@@ -12,16 +12,12 @@ from textwrap import dedent
from typing import AsyncGenerator
from unittest.mock import patch
import nest_asyncio
import pytest_asyncio
from typer.testing import CliRunner
from basic_memory.cli.commands.tool import tool_app
from basic_memory.schemas.base import Entity as EntitySchema
# Allow nested asyncio.run() calls - needed for CLI tests with async fixtures
nest_asyncio.apply()
runner = CliRunner()
@@ -76,7 +72,6 @@ def test_write_note(cli_env, project_config, test_project):
test_project.name,
],
)
assert result.exit_code == 0
# Check for expected success message
+326
View File
@@ -0,0 +1,326 @@
"""Tests for cloud_utils module."""
from unittest.mock import AsyncMock, Mock, patch
import pytest
from basic_memory.cli.commands.cloud.cloud_utils import (
CloudUtilsError,
create_cloud_project,
fetch_cloud_projects,
project_exists,
sync_project,
)
class TestFetchCloudProjects:
"""Tests for fetch_cloud_projects()."""
@pytest.mark.asyncio
async def test_fetches_projects_successfully(self):
"""Test successful fetch of cloud projects."""
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
# Setup config
mock_config.return_value.config.cloud_host = "https://example.com"
# Mock API response
mock_response = Mock()
mock_response.json.return_value = {
"projects": [
{"name": "Project 1", "path": "/app/data/project-1"},
{"name": "Project 2", "path": "/app/data/project-2"},
]
}
mock_request.return_value = mock_response
result = await fetch_cloud_projects()
# Verify result
assert len(result.projects) == 2
assert result.projects[0].name == "Project 1"
assert result.projects[1].name == "Project 2"
# Verify API was called correctly
mock_request.assert_called_once_with(
method="GET", url="https://example.com/proxy/projects/projects"
)
@pytest.mark.asyncio
async def test_strips_trailing_slash_from_host(self):
"""Test that trailing slash is stripped from cloud_host."""
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
# Setup config with trailing slash
mock_config.return_value.config.cloud_host = "https://example.com/"
mock_response = Mock()
mock_response.json.return_value = {"projects": []}
mock_request.return_value = mock_response
await fetch_cloud_projects()
# Verify trailing slash was removed
call_args = mock_request.call_args
assert call_args[1]["url"] == "https://example.com/proxy/projects/projects"
@pytest.mark.asyncio
async def test_raises_error_on_api_failure(self):
"""Test that CloudUtilsError is raised on API failure."""
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
mock_config.return_value.config.cloud_host = "https://example.com"
mock_request.side_effect = Exception("API Error")
with pytest.raises(CloudUtilsError) as exc_info:
await fetch_cloud_projects()
assert "Failed to fetch cloud projects" in str(exc_info.value)
@pytest.mark.asyncio
async def test_handles_empty_project_list(self):
"""Test handling of empty project list."""
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
mock_config.return_value.config.cloud_host = "https://example.com"
mock_response = Mock()
mock_response.json.return_value = {"projects": []}
mock_request.return_value = mock_response
result = await fetch_cloud_projects()
assert len(result.projects) == 0
class TestCreateCloudProject:
"""Tests for create_cloud_project()."""
@pytest.mark.asyncio
async def test_creates_project_successfully(self):
"""Test successful project creation."""
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
with patch(
"basic_memory.cli.commands.cloud.cloud_utils.generate_permalink"
) as mock_permalink:
# Setup mocks
mock_config.return_value.config.cloud_host = "https://example.com"
mock_permalink.return_value = "my-project"
mock_response = Mock()
mock_response.json.return_value = {
"message": "Project 'My Project' added successfully",
"status": "success",
"default": False,
"old_project": None,
"new_project": {"name": "My Project", "path": "my-project"},
}
mock_request.return_value = mock_response
result = await create_cloud_project("My Project")
# Verify result
assert result.message == "Project 'My Project' added successfully"
assert result.status == "success"
assert result.default is False
# Verify permalink was generated
mock_permalink.assert_called_once_with("My Project")
# Verify API request
call_args = mock_request.call_args
assert call_args[1]["method"] == "POST"
assert call_args[1]["url"] == "https://example.com/proxy/projects/projects"
assert call_args[1]["headers"]["Content-Type"] == "application/json"
json_data = call_args[1]["json_data"]
assert json_data["name"] == "My Project"
assert json_data["path"] == "my-project"
assert json_data["set_default"] is False
@pytest.mark.asyncio
async def test_generates_permalink_from_name(self):
"""Test that permalink is generated from project name."""
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
with patch(
"basic_memory.cli.commands.cloud.cloud_utils.generate_permalink"
) as mock_permalink:
mock_config.return_value.config.cloud_host = "https://example.com"
mock_permalink.return_value = "test-project-123"
mock_response = Mock()
mock_response.json.return_value = {
"message": "Project 'Test Project 123' added successfully",
"status": "success",
"default": False,
"old_project": None,
"new_project": {"name": "Test Project 123", "path": "test-project-123"},
}
mock_request.return_value = mock_response
await create_cloud_project("Test Project 123")
# Verify generate_permalink was called with project name
mock_permalink.assert_called_once_with("Test Project 123")
@pytest.mark.asyncio
async def test_raises_error_on_api_failure(self):
"""Test that CloudUtilsError is raised on API failure."""
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
with patch(
"basic_memory.cli.commands.cloud.cloud_utils.generate_permalink"
) as mock_permalink:
mock_config.return_value.config.cloud_host = "https://example.com"
mock_permalink.return_value = "project"
mock_request.side_effect = Exception("API Error")
with pytest.raises(CloudUtilsError) as exc_info:
await create_cloud_project("Test Project")
assert "Failed to create cloud project 'Test Project'" in str(exc_info.value)
@pytest.mark.asyncio
async def test_strips_trailing_slash_from_host(self):
"""Test that trailing slash is stripped from cloud_host."""
with patch("basic_memory.cli.commands.cloud.cloud_utils.make_api_request") as mock_request:
with patch("basic_memory.cli.commands.cloud.cloud_utils.ConfigManager") as mock_config:
with patch(
"basic_memory.cli.commands.cloud.cloud_utils.generate_permalink"
) as mock_permalink:
mock_config.return_value.config.cloud_host = "https://example.com/"
mock_permalink.return_value = "project"
mock_response = Mock()
mock_response.json.return_value = {
"message": "Project 'Project' added successfully",
"status": "success",
"default": False,
"old_project": None,
"new_project": {"name": "Project", "path": "project"},
}
mock_request.return_value = mock_response
await create_cloud_project("Project")
# Verify trailing slash was removed
call_args = mock_request.call_args
assert call_args[1]["url"] == "https://example.com/proxy/projects/projects"
class TestSyncProject:
"""Tests for sync_project()."""
@pytest.mark.asyncio
async def test_syncs_project_successfully(self):
"""Test successful project sync."""
# Patch at the point where it's imported (inside the function)
with patch(
"basic_memory.cli.commands.command_utils.run_sync", new_callable=AsyncMock
) as mock_sync:
await sync_project("test-project")
# Verify run_sync was called with project name
mock_sync.assert_called_once_with(project="test-project")
@pytest.mark.asyncio
async def test_raises_error_on_sync_failure(self):
"""Test that CloudUtilsError is raised on sync failure."""
# Patch at the point where it's imported (inside the function)
with patch(
"basic_memory.cli.commands.command_utils.run_sync", new_callable=AsyncMock
) as mock_sync:
mock_sync.side_effect = Exception("Sync failed")
with pytest.raises(CloudUtilsError) as exc_info:
await sync_project("test-project")
assert "Failed to sync project 'test-project'" in str(exc_info.value)
class TestProjectExists:
"""Tests for project_exists()."""
@pytest.mark.asyncio
async def test_returns_true_when_project_exists(self):
"""Test that True is returned when project exists."""
from basic_memory.schemas.cloud import CloudProject, CloudProjectList
with patch(
"basic_memory.cli.commands.cloud.cloud_utils.fetch_cloud_projects"
) as mock_fetch:
# Create actual CloudProject objects
projects = CloudProjectList(
projects=[
CloudProject(name="project-1", path="/app/data/project-1"),
CloudProject(name="test-project", path="/app/data/test-project"),
CloudProject(name="project-2", path="/app/data/project-2"),
]
)
mock_fetch.return_value = projects
result = await project_exists("test-project")
assert result is True
@pytest.mark.asyncio
async def test_returns_false_when_project_not_found(self):
"""Test that False is returned when project doesn't exist."""
with patch(
"basic_memory.cli.commands.cloud.cloud_utils.fetch_cloud_projects"
) as mock_fetch:
# Mock project list without matching project
mock_projects = Mock()
mock_projects.projects = [
Mock(name="project-1"),
Mock(name="project-2"),
]
mock_fetch.return_value = mock_projects
result = await project_exists("nonexistent-project")
assert result is False
@pytest.mark.asyncio
async def test_returns_false_on_api_error(self):
"""Test that False is returned on API error."""
with patch(
"basic_memory.cli.commands.cloud.cloud_utils.fetch_cloud_projects"
) as mock_fetch:
mock_fetch.side_effect = Exception("API Error")
result = await project_exists("test-project")
# Should return False instead of raising exception
assert result is False
@pytest.mark.asyncio
async def test_handles_empty_project_list(self):
"""Test handling of empty project list."""
with patch(
"basic_memory.cli.commands.cloud.cloud_utils.fetch_cloud_projects"
) as mock_fetch:
mock_projects = Mock()
mock_projects.projects = []
mock_fetch.return_value = mock_projects
result = await project_exists("any-project")
assert result is False
@pytest.mark.asyncio
async def test_case_sensitive_matching(self):
"""Test that project name matching is case-sensitive."""
with patch(
"basic_memory.cli.commands.cloud.cloud_utils.fetch_cloud_projects"
) as mock_fetch:
mock_projects = Mock()
mock_projects.projects = [Mock(name="Test-Project")]
mock_fetch.return_value = mock_projects
# Different case should not match
result = await project_exists("test-project")
assert result is False
@@ -1,154 +0,0 @@
"""Tests for bm project add with --local-path flag."""
import json
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from typer.testing import CliRunner
from basic_memory.cli.app import app
@pytest.fixture
def runner():
return CliRunner()
@pytest.fixture
def mock_config(tmp_path, monkeypatch):
"""Create a mock config in cloud mode using environment variables."""
# Invalidate config cache to ensure clean state for each test
from basic_memory import config as config_module
config_module._CONFIG_CACHE = None
config_dir = tmp_path / ".basic-memory"
config_dir.mkdir(parents=True, exist_ok=True)
config_file = config_dir / "config.json"
config_data = {
"env": "dev",
"projects": {},
"default_project": "main",
"cloud_mode": True,
"cloud_projects": {},
}
config_file.write_text(json.dumps(config_data, indent=2))
# Set HOME to tmp_path so ConfigManager uses our test config
monkeypatch.setenv("HOME", str(tmp_path))
yield config_file
@pytest.fixture
def mock_api_client():
"""Mock the API client for project add."""
with patch("basic_memory.cli.commands.project.get_client"):
# Mock call_post to return a proper response
mock_response = AsyncMock()
mock_response.json = lambda: {
"message": "Project 'test-project' added successfully",
"status": "success",
"default": False,
"old_project": None,
"new_project": {
"name": "test-project",
"path": "/test-project",
"is_default": False,
},
}
with patch(
"basic_memory.cli.commands.project.call_post", return_value=mock_response
) as mock_post:
yield mock_post
def test_project_add_with_local_path_saves_to_config(
runner, mock_config, mock_api_client, tmp_path
):
"""Test that bm project add --local-path saves sync path to config."""
local_sync_dir = tmp_path / "sync" / "test-project"
result = runner.invoke(
app,
[
"project",
"add",
"test-project",
"--local-path",
str(local_sync_dir),
],
)
assert result.exit_code == 0, f"Exit code: {result.exit_code}, Stdout: {result.stdout}"
assert "Project 'test-project' added successfully" in result.stdout
assert "Local sync path configured" in result.stdout
# Check path is present (may be line-wrapped in output)
assert "test-project" in result.stdout
assert "sync" in result.stdout
# Verify config was updated
config_data = json.loads(mock_config.read_text())
assert "test-project" in config_data["cloud_projects"]
# Use as_posix() for cross-platform compatibility (Windows uses backslashes)
assert config_data["cloud_projects"]["test-project"]["local_path"] == local_sync_dir.as_posix()
assert config_data["cloud_projects"]["test-project"]["last_sync"] is None
assert config_data["cloud_projects"]["test-project"]["bisync_initialized"] is False
# Verify local directory was created
assert local_sync_dir.exists()
assert local_sync_dir.is_dir()
def test_project_add_without_local_path_no_config_entry(runner, mock_config, mock_api_client):
"""Test that bm project add without --local-path doesn't save to config."""
result = runner.invoke(
app,
["project", "add", "test-project"],
)
assert result.exit_code == 0
assert "Project 'test-project' added successfully" in result.stdout
assert "Local sync path configured" not in result.stdout
# Verify config was NOT updated with cloud_projects entry
config_data = json.loads(mock_config.read_text())
assert "test-project" not in config_data.get("cloud_projects", {})
def test_project_add_local_path_expands_tilde(runner, mock_config, mock_api_client):
"""Test that --local-path ~/path expands to absolute path."""
result = runner.invoke(
app,
["project", "add", "test-project", "--local-path", "~/test-sync"],
)
assert result.exit_code == 0
# Verify config has expanded path
config_data = json.loads(mock_config.read_text())
local_path = config_data["cloud_projects"]["test-project"]["local_path"]
# Path should be absolute (starts with / on Unix or drive letter on Windows)
assert Path(local_path).is_absolute()
assert "~" not in local_path
assert local_path.endswith("/test-sync")
def test_project_add_local_path_creates_nested_directories(
runner, mock_config, mock_api_client, tmp_path
):
"""Test that --local-path creates nested directories."""
nested_path = tmp_path / "a" / "b" / "c" / "test-project"
result = runner.invoke(
app,
["project", "add", "test-project", "--local-path", str(nested_path)],
)
assert result.exit_code == 0
assert nested_path.exists()
assert nested_path.is_dir()
-49
View File
@@ -327,55 +327,6 @@ class TestUploadPath:
call_args = mock_put.call_args
assert call_args[0][1] == "/webdav/my-project/subdir/file.txt"
@pytest.mark.asyncio
async def test_skips_archive_files(self, tmp_path, capsys):
"""Test that archive files are skipped during upload."""
# Create test files including archives
(tmp_path / "notes.md").write_text("content")
(tmp_path / "backup.zip").write_text("fake zip")
(tmp_path / "data.tar.gz").write_text("fake tar")
mock_client = AsyncMock()
mock_response = Mock()
mock_response.raise_for_status = Mock()
with patch("basic_memory.cli.commands.cloud.upload.get_client") as mock_get_client:
with patch("basic_memory.cli.commands.cloud.upload.call_put") as mock_put:
with patch(
"basic_memory.cli.commands.cloud.upload._get_files_to_upload"
) as mock_get_files:
with patch("aiofiles.open", create=True) as mock_aiofiles_open:
mock_get_client.return_value.__aenter__.return_value = mock_client
mock_get_client.return_value.__aexit__.return_value = None
mock_put.return_value = mock_response
# Mock file listing with all files
mock_get_files.return_value = [
(tmp_path / "notes.md", "notes.md"),
(tmp_path / "backup.zip", "backup.zip"),
(tmp_path / "data.tar.gz", "data.tar.gz"),
]
mock_file = AsyncMock()
mock_file.read.return_value = b"content"
mock_aiofiles_open.return_value.__aenter__.return_value = mock_file
result = await upload_path(tmp_path, "test-project")
# Should succeed
assert result is True
# Should only upload the .md file (not the archives)
assert mock_put.call_count == 1
call_args = mock_put.call_args
assert "notes.md" in call_args[0][1]
# Check output mentions skipping
captured = capsys.readouterr()
assert "Skipping archive file" in captured.out
assert "backup.zip" in captured.out
assert "Skipped 2 archive file(s)" in captured.out
def test_no_gitignore_skips_gitignore_patterns(self, tmp_path):
"""Test that --no-gitignore flag skips .gitignore patterns."""
# Create test files
+26 -128
View File
@@ -4,16 +4,15 @@ from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from textwrap import dedent
from typing import AsyncGenerator, Literal
from typing import AsyncGenerator
import os
import pytest
import pytest_asyncio
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from basic_memory import db
from basic_memory.config import ProjectConfig, BasicMemoryConfig, ConfigManager, DatabaseBackend
from basic_memory.config import ProjectConfig, BasicMemoryConfig, ConfigManager
from basic_memory.db import DatabaseType
from basic_memory.markdown import EntityParser
from basic_memory.markdown.markdown_processor import MarkdownProcessor
@@ -24,6 +23,7 @@ from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.observation_repository import ObservationRepository
from basic_memory.repository.project_repository import ProjectRepository
from basic_memory.repository.relation_repository import RelationRepository
from basic_memory.repository.search_repository import SearchRepository
from basic_memory.schemas.base import Entity as EntitySchema
from basic_memory.services import (
EntityService,
@@ -42,27 +42,6 @@ def anyio_backend():
return "asyncio"
@pytest.fixture(
params=[
pytest.param("sqlite", id="sqlite"),
pytest.param("postgres", id="postgres", marks=pytest.mark.postgres),
]
)
def db_backend(request) -> Literal["sqlite", "postgres"]:
"""Parametrize tests to run against both SQLite and Postgres.
Usage:
pytest # Runs tests against SQLite only (default)
pytest -m postgres # Runs tests against Postgres only
pytest -m "not postgres" # Runs tests against SQLite only
pytest --run-all-backends # Runs tests against both backends
Note: Only tests that use database fixtures (engine_factory, session_maker, etc.)
will be parametrized. Tests that don't use the database won't be affected.
"""
return request.param
@pytest.fixture
def project_root() -> Path:
return Path(__file__).parent.parent
@@ -80,41 +59,25 @@ def config_home(tmp_path, monkeypatch) -> Path:
return tmp_path
@pytest.fixture(scope="function")
def app_config(
config_home, db_backend: Literal["sqlite", "postgres"], monkeypatch
) -> BasicMemoryConfig:
@pytest.fixture(scope="function", autouse=True)
def app_config(config_home, tmp_path, monkeypatch) -> BasicMemoryConfig:
"""Create test app configuration."""
# Create a basic config without depending on test_project to avoid circular dependency
projects = {"test-project": str(config_home)}
# Configure database backend based on test parameter
if db_backend == "postgres":
database_backend = DatabaseBackend.POSTGRES
# Use env var if set, otherwise use default matching docker-compose-postgres.yml
# These are local test credentials only - NOT for production
database_url = os.getenv(
"POSTGRES_TEST_URL",
"postgresql+asyncpg://basic_memory_user:dev_password@localhost:5433/basic_memory_test",
)
else:
database_backend = DatabaseBackend.SQLITE
database_url = None
app_config = BasicMemoryConfig(
env="test",
projects=projects,
default_project="test-project",
update_permalinks_on_move=True,
database_backend=database_backend,
database_url=database_url,
)
return app_config
@pytest.fixture
def config_manager(app_config: BasicMemoryConfig, config_home: Path, monkeypatch) -> ConfigManager:
@pytest.fixture(autouse=True)
def config_manager(
app_config: BasicMemoryConfig, project_config: ProjectConfig, config_home: Path, monkeypatch
) -> ConfigManager:
# Invalidate config cache to ensure clean state for each test
from basic_memory import config as config_module
@@ -132,7 +95,7 @@ def config_manager(app_config: BasicMemoryConfig, config_home: Path, monkeypatch
return config_manager
@pytest.fixture(scope="function")
@pytest.fixture(scope="function", autouse=True)
def project_config(test_project):
"""Create test project configuration."""
@@ -161,80 +124,16 @@ def test_config(config_home, project_config, app_config, config_manager) -> Test
@pytest_asyncio.fixture(scope="function")
async def engine_factory(
app_config,
config_manager,
db_backend: Literal["sqlite", "postgres"],
) -> AsyncGenerator[tuple[AsyncEngine, async_sessionmaker[AsyncSession]], None]:
"""Create engine and session factory for the configured database backend."""
from basic_memory.models.search import CREATE_SEARCH_INDEX
"""Create an engine and session factory using an in-memory SQLite database."""
async with db.engine_session_factory(
db_path=app_config.database_path, db_type=DatabaseType.MEMORY
) as (engine, session_maker):
# Create all tables for the DB the engine is connected to
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
if db_backend == "postgres":
# Postgres: Create fresh engine for each test with full schema reset
config_manager._config = app_config
db_type = DatabaseType.FILESYSTEM
# Use context manager to handle engine disposal properly
async with db.engine_session_factory(db_path=app_config.database_path, db_type=db_type) as (
engine,
session_maker,
):
# Drop and recreate schema for complete isolation
async with engine.begin() as conn:
await conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE"))
await conn.execute(text("CREATE SCHEMA public"))
await conn.execute(text("GRANT ALL ON SCHEMA public TO basic_memory_user"))
await conn.execute(text("GRANT ALL ON SCHEMA public TO public"))
# Run migrations to create production tables (including search_index with correct schema)
# Alembic handles duplicate migration checks, so it's safe to call this for each test
from basic_memory.db import run_migrations
await run_migrations(app_config, db_type)
# For Postgres, migrations create all production tables with correct schemas
# We only need to create test-specific tables (like ModelTest) that aren't in migrations
# Don't create search_index via ORM - it's already created by migration with composite PK
async with engine.begin() as conn:
# List of tables created by migrations - don't recreate them via ORM
production_tables = {
"entity",
"observation",
"relation",
"project",
"search_index",
"alembic_version",
}
# Get test-specific tables that aren't created by migrations
test_tables = [
table
for table in Base.metadata.sorted_tables
if table.name not in production_tables
]
if test_tables:
await conn.run_sync(
lambda sync_conn: Base.metadata.create_all(sync_conn, tables=test_tables)
)
yield engine, session_maker
else:
# SQLite: Create fresh in-memory database for each test
db_type = DatabaseType.MEMORY
async with db.engine_session_factory(db_path=app_config.database_path, db_type=db_type) as (
engine,
session_maker,
):
# Create all tables via ORM
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Drop any SearchIndex ORM table, then create FTS5 virtual table
async with db.scoped_session(session_maker) as session:
await session.execute(text("DROP TABLE IF EXISTS search_index"))
await session.execute(CREATE_SEARCH_INDEX)
await session.commit()
# Yield after setup is complete
yield engine, session_maker
yield engine, session_maker
@pytest_asyncio.fixture
@@ -379,20 +278,19 @@ async def directory_service(entity_repository, project_config) -> DirectoryServi
@pytest_asyncio.fixture
async def search_repository(session_maker, test_project: Project, app_config: BasicMemoryConfig):
"""Create backend-appropriate SearchRepository instance with project context"""
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
async def search_repository(session_maker, test_project: Project):
"""Create SearchRepository instance with project context"""
return SearchRepository(session_maker, project_id=test_project.id)
if app_config.database_backend == DatabaseBackend.POSTGRES:
return PostgresSearchRepository(session_maker, project_id=test_project.id)
else:
return SQLiteSearchRepository(session_maker, project_id=test_project.id)
@pytest_asyncio.fixture(autouse=True)
async def init_search_index(search_service):
await search_service.init_search_index()
@pytest_asyncio.fixture
async def search_service(
search_repository,
search_repository: SearchRepository,
entity_repository: EntityRepository,
file_service: FileService,
) -> SearchService:
@@ -1,236 +0,0 @@
"""Test that YAML date parsing doesn't break frontmatter processing.
This test reproduces GitHub issue #236 from basic-memory-cloud where date fields
in YAML frontmatter are automatically parsed as datetime.date objects by PyYAML,
but later code expects strings and calls .strip() on them, causing AttributeError.
"""
import pytest
from basic_memory.markdown.entity_parser import EntityParser
@pytest.fixture
def test_file_with_date(tmp_path):
"""Create a test file with date fields in frontmatter."""
test_file = tmp_path / "test_note.md"
content = """---
title: Test Note
date: 2025-10-24
created: 2025-10-24
tags:
- python
- testing
---
# Test Content
This file has date fields in frontmatter that PyYAML will parse as datetime.date objects.
"""
test_file.write_text(content)
return test_file
@pytest.fixture
def test_file_with_date_in_tags(tmp_path):
"""Create a test file with a date value in tags (edge case)."""
test_file = tmp_path / "test_note_date_tags.md"
content = """---
title: Test Note with Date Tags
tags: 2025-10-24
---
# Test Content
This file has a date value as tags, which will be parsed as datetime.date.
"""
test_file.write_text(content)
return test_file
@pytest.fixture
def test_file_with_dates_in_tag_list(tmp_path):
"""Create a test file with dates in a tag list (edge case)."""
test_file = tmp_path / "test_note_dates_in_list.md"
content = """---
title: Test Note with Dates in Tags List
tags:
- valid-tag
- 2025-10-24
- another-tag
---
# Test Content
This file has date values mixed into tags list.
"""
test_file.write_text(content)
return test_file
@pytest.mark.asyncio
async def test_parse_file_with_date_fields(test_file_with_date, tmp_path):
"""Test that files with date fields in frontmatter can be parsed without errors."""
parser = EntityParser(tmp_path)
# This should not raise AttributeError about .strip()
entity_markdown = await parser.parse_file(test_file_with_date)
# Verify basic parsing worked
assert entity_markdown.frontmatter.title == "Test Note"
# Date fields should be converted to ISO format strings
date_field = entity_markdown.frontmatter.metadata.get("date")
assert date_field is not None
assert isinstance(date_field, str), "Date should be converted to string"
assert date_field == "2025-10-24", "Date should be in ISO format"
created_field = entity_markdown.frontmatter.metadata.get("created")
assert created_field is not None
assert isinstance(created_field, str), "Created date should be converted to string"
assert created_field == "2025-10-24", "Created date should be in ISO format"
@pytest.mark.asyncio
async def test_parse_file_with_date_as_tags(test_file_with_date_in_tags, tmp_path):
"""Test that date values in tags field don't cause errors."""
parser = EntityParser(tmp_path)
# This should not raise AttributeError - date should be converted to string
entity_markdown = await parser.parse_file(test_file_with_date_in_tags)
assert entity_markdown.frontmatter.title == "Test Note with Date Tags"
# The date should be converted to ISO format string before parse_tags processes it
tags = entity_markdown.frontmatter.tags
assert tags is not None
assert isinstance(tags, list)
# The date value should be converted to string
assert "2025-10-24" in tags
@pytest.mark.asyncio
async def test_parse_file_with_dates_in_tag_list(test_file_with_dates_in_tag_list, tmp_path):
"""Test that date values in a tags list don't cause errors."""
parser = EntityParser(tmp_path)
# This should not raise AttributeError - dates should be converted to strings
entity_markdown = await parser.parse_file(test_file_with_dates_in_tag_list)
assert entity_markdown.frontmatter.title == "Test Note with Dates in Tags List"
# Tags should be parsed
tags = entity_markdown.frontmatter.tags
assert tags is not None
assert isinstance(tags, list)
# Should have 3 tags (2 valid + 1 date converted to ISO string)
assert len(tags) == 3
assert "valid-tag" in tags
assert "another-tag" in tags
# Date should be converted to ISO format string
assert "2025-10-24" in tags
@pytest.mark.asyncio
async def test_parse_file_with_various_yaml_types(tmp_path):
"""Test that various YAML types in frontmatter don't cause errors.
This reproduces the broader issue from GitHub #236 where ANY non-string
YAML type (dates, lists, numbers, booleans) can cause AttributeError
when code expects strings and calls .strip().
"""
test_file = tmp_path / "test_yaml_types.md"
content = """---
title: Test YAML Types
date: 2025-10-24
priority: 1
completed: true
tags:
- python
- testing
metadata:
author: Test User
version: 1.0
---
# Test Content
This file has various YAML types that need to be normalized.
"""
test_file.write_text(content)
parser = EntityParser(tmp_path)
entity_markdown = await parser.parse_file(test_file)
# All values should be accessible without AttributeError
assert entity_markdown.frontmatter.title == "Test YAML Types"
# Date should be converted to ISO string
date_field = entity_markdown.frontmatter.metadata.get("date")
assert isinstance(date_field, str)
assert date_field == "2025-10-24"
# Number should be converted to string
priority = entity_markdown.frontmatter.metadata.get("priority")
assert isinstance(priority, str)
assert priority == "1"
# Boolean should be converted to string
completed = entity_markdown.frontmatter.metadata.get("completed")
assert isinstance(completed, str)
assert completed == "True" # Python's str(True) always returns "True"
# List should be preserved as list, but items should be strings
tags = entity_markdown.frontmatter.tags
assert isinstance(tags, list)
assert all(isinstance(tag, str) for tag in tags)
assert "python" in tags
assert "testing" in tags
# Dict should be preserved as dict, but nested values should be strings
metadata = entity_markdown.frontmatter.metadata.get("metadata")
assert isinstance(metadata, dict)
assert isinstance(metadata.get("author"), str)
assert metadata.get("author") == "Test User"
assert isinstance(metadata.get("version"), str)
assert metadata.get("version") in ("1.0", "1")
@pytest.mark.asyncio
async def test_parse_file_with_datetime_objects(tmp_path):
"""Test that datetime objects (not just date objects) are properly normalized.
This tests the edge case where frontmatter might contain datetime values
with time components (as parsed by PyYAML), ensuring they're converted to ISO format strings.
"""
test_file = tmp_path / "test_datetime.md"
# YAML datetime strings that PyYAML will parse as datetime objects
# Format: YYYY-MM-DD HH:MM:SS or YYYY-MM-DDTHH:MM:SS
content = """---
title: Test Datetime
created_at: 2025-10-24 14:30:00
updated_at: 2025-10-24T00:00:00
---
# Test Content
This file has datetime values in frontmatter that PyYAML will parse as datetime objects.
"""
test_file.write_text(content)
parser = EntityParser(tmp_path)
entity_markdown = await parser.parse_file(test_file)
# Verify datetime objects are converted to ISO format strings
created_at = entity_markdown.frontmatter.metadata.get("created_at")
assert isinstance(created_at, str), "Datetime should be converted to string"
# PyYAML parses "2025-10-24 14:30:00" as datetime, which we normalize to ISO
assert "2025-10-24" in created_at and "14:30:00" in created_at, (
f"Datetime with time should be normalized to ISO format, got: {created_at}"
)
updated_at = entity_markdown.frontmatter.metadata.get("updated_at")
assert isinstance(updated_at, str), "Datetime should be converted to string"
# PyYAML parses "2025-10-24T00:00:00" as datetime, which we normalize to ISO
assert "2025-10-24" in updated_at and "00:00:00" in updated_at, (
f"Datetime at midnight should be normalized to ISO format, got: {updated_at}"
)
+1 -1
View File
@@ -402,7 +402,7 @@ class TestReadNoteSecurityValidation:
Additional content with various formatting.
""").strip(),
tags=["security", "test", "full-feature"],
note_type="guide",
entity_type="guide",
)
# Test reading by permalink
+10 -10
View File
@@ -538,7 +538,7 @@ async def test_write_note_with_custom_entity_type(app, test_project):
folder="guides",
content="# Guide Content\nThis is a guide",
tags=["guide", "documentation"],
note_type="guide",
entity_type="guide",
)
assert result
@@ -574,14 +574,14 @@ async def test_write_note_with_custom_entity_type(app, test_project):
@pytest.mark.asyncio
async def test_write_note_with_report_entity_type(app, test_project):
"""Test creating a note with note_type="report"."""
"""Test creating a note with entity_type="report"."""
result = await write_note.fn(
project=test_project.name,
title="Monthly Report",
folder="reports",
content="# Monthly Report\nThis is a monthly report",
tags=["report", "monthly"],
note_type="report",
entity_type="report",
)
assert result
@@ -599,13 +599,13 @@ async def test_write_note_with_report_entity_type(app, test_project):
@pytest.mark.asyncio
async def test_write_note_with_config_entity_type(app, test_project):
"""Test creating a note with note_type="config"."""
"""Test creating a note with entity_type="config"."""
result = await write_note.fn(
project=test_project.name,
title="System Config",
folder="config",
content="# System Configuration\nThis is a config file",
note_type="config",
entity_type="config",
)
assert result
@@ -659,21 +659,21 @@ async def test_write_note_update_existing_with_different_entity_type(app, test_p
folder="test",
content="# Initial Content\nThis starts as a note",
tags=["test"],
note_type="note",
entity_type="note",
)
assert result1
assert "# Created note" in result1
assert f"project: {test_project.name}" in result1
# Update the same note with a different note_type
# Update the same note with a different entity_type
result2 = await write_note.fn(
project=test_project.name,
title="Changeable Type",
folder="test",
content="# Updated Content\nThis is now a guide",
tags=["guide"],
note_type="guide",
entity_type="guide",
)
assert result2
@@ -976,7 +976,7 @@ class TestWriteNoteSecurityValidation:
folder="../../../etc/malicious",
content="# Malicious Content\nThis should be blocked by security validation.",
tags=["malicious", "test"],
note_type="guide",
entity_type="guide",
)
assert isinstance(result, str)
@@ -1026,7 +1026,7 @@ class TestWriteNoteSecurityValidation:
Additional content with various formatting.
""").strip(),
tags=["security", "test", "full-feature"],
note_type="guide",
entity_type="guide",
)
# Should succeed normally
@@ -1,392 +0,0 @@
"""Comprehensive test suite for kebab_filenames configuration.
Tests the BASIC_MEMORY_KEBAB_FILENAMES configuration option which controls
whether note filenames are converted to kebab-case (lowercase with hyphens).
Feature added in PR #260 to handle forward slashes in filenames.
This test suite was expanded to comprehensively test all kebab-case transformations.
Key behaviors tested:
1. When kebab_filenames=true: All special characters, spaces, periods, underscores,
and mixed case are converted to lowercase kebab-case
2. When kebab_filenames=false: Original formatting is preserved (backward compatibility)
3. Folder paths are not affected by kebab_filenames setting
4. Permalinks are always kebab-case regardless of kebab_filenames setting
"""
import pytest
from basic_memory.mcp.tools import write_note
from basic_memory.config import ConfigManager
# =============================================================================
# Basic Transformations (kebab_filenames=true)
# =============================================================================
@pytest.mark.asyncio
async def test_write_note_spaces_to_hyphens(app, test_project, app_config):
"""Test that spaces are converted to hyphens when kebab_filenames=true."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
project=test_project.name,
title="My Awesome Note",
folder="test",
content="Testing space conversion",
)
assert "file_path: test/my-awesome-note.md" in result
assert "permalink: test/my-awesome-note" in result
@pytest.mark.asyncio
async def test_write_note_underscores_to_hyphens(app, test_project, app_config):
"""Test that underscores are converted to hyphens when kebab_filenames=true."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
project=test_project.name,
title="my_note_with_underscores",
folder="test",
content="Testing underscore conversion",
)
assert "file_path: test/my-note-with-underscores.md" in result
assert "permalink: test/my-note-with-underscores" in result
@pytest.mark.asyncio
async def test_write_note_camelcase_to_kebab(app, test_project, app_config):
"""Test that CamelCase is converted to kebab-case when kebab_filenames=true."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
project=test_project.name,
title="MyAwesomeFeature",
folder="test",
content="Testing CamelCase conversion",
)
assert "file_path: test/my-awesome-feature.md" in result
assert "permalink: test/my-awesome-feature" in result
@pytest.mark.asyncio
async def test_write_note_mixed_case_to_lowercase(app, test_project, app_config):
"""Test that mixed case is converted to lowercase when kebab_filenames=true."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
project=test_project.name,
title="MIXED_Case_Example",
folder="test",
content="Testing case conversion",
)
assert "file_path: test/mixed-case-example.md" in result
assert "permalink: test/mixed-case-example" in result
# =============================================================================
# Period Handling (kebab_filenames=true)
# =============================================================================
@pytest.mark.asyncio
async def test_write_note_single_period_preserved(app, test_project, app_config):
"""Test that periods in version numbers are preserved when kebab_filenames=true.
This preserves semantic meaning of version numbers like "3.0" while still
converting spaces to hyphens. Only actual file extensions are split off.
"""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
project=test_project.name,
title="Test 3.0 Version",
folder="test",
content="Testing period preservation",
)
assert "file_path: test/test-3.0-version.md" in result
assert "permalink: test/test-3.0-version" in result
@pytest.mark.asyncio
async def test_write_note_multiple_periods_preserved(app, test_project, app_config):
"""Test that multiple periods in version numbers are preserved when kebab_filenames=true."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
project=test_project.name,
title="Version 1.2.3 Release",
folder="test",
content="Testing multiple period preservation",
)
assert "file_path: test/version-1.2.3-release.md" in result
assert "permalink: test/version-1.2.3-release" in result
# =============================================================================
# Special Characters (kebab_filenames=true)
# =============================================================================
@pytest.mark.asyncio
async def test_write_note_special_chars_to_hyphens(app, test_project, app_config):
"""Test that special characters are converted while preserving periods in version numbers."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
project=test_project.name,
title="Test 2.0: New Feature",
folder="test",
content="Testing special character conversion",
)
assert "file_path: test/test-2.0-new-feature.md" in result
assert "permalink: test/test-2.0-new-feature" in result
@pytest.mark.asyncio
async def test_write_note_parentheses_removed(app, test_project, app_config):
"""Test that parentheses are handled while preserving periods in version numbers."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
project=test_project.name,
title="Feature (v2.0) Update",
folder="test",
content="Testing parentheses handling",
)
assert "file_path: test/feature-v2.0-update.md" in result
assert "permalink: test/feature-v2.0-update" in result
@pytest.mark.asyncio
async def test_write_note_apostrophes_removed(app, test_project, app_config):
"""Test that apostrophes are removed when kebab_filenames=true."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
project=test_project.name,
title="User's Guide",
folder="test",
content="Testing apostrophe handling",
)
assert "file_path: test/users-guide.md" in result
assert "permalink: test/users-guide" in result
# =============================================================================
# Combined Transformations (kebab_filenames=true)
# =============================================================================
@pytest.mark.asyncio
async def test_write_note_all_transformations_combined(app, test_project, app_config):
"""Test multiple transformation types combined while preserving periods in version numbers."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
project=test_project.name,
title="MyProject_v3.0: Feature Update (DRAFT)",
folder="test",
content="Testing combined transformations",
)
assert "file_path: test/my-project-v3.0-feature-update-draft.md" in result
assert "permalink: test/my-project-v3.0-feature-update-draft" in result
@pytest.mark.asyncio
async def test_write_note_consecutive_special_chars_collapsed(app, test_project, app_config):
"""Test that consecutive special characters collapse to single hyphen."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
project=test_project.name,
title="Test___Multiple---Separators",
folder="test",
content="Testing consecutive special character collapse",
)
# Multiple underscores/hyphens should collapse to single hyphen
assert "file_path: test/test-multiple-separators.md" in result
assert "permalink: test/test-multiple-separators" in result
# =============================================================================
# Edge Cases (kebab_filenames=true)
# =============================================================================
@pytest.mark.asyncio
async def test_write_note_leading_trailing_hyphens_trimmed(app, test_project, app_config):
"""Test that leading/trailing hyphens are trimmed when kebab_filenames=true."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
project=test_project.name,
title="---Test Note---",
folder="test",
content="Testing leading/trailing hyphen trimming",
)
assert "file_path: test/test-note.md" in result
assert "permalink: test/test-note" in result
@pytest.mark.asyncio
async def test_write_note_all_special_chars_becomes_valid_filename(app, test_project, app_config):
"""Test that a title with mostly special characters becomes valid."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
project=test_project.name,
title="!!!Test!!!",
folder="test",
content="Testing all special characters",
)
assert "file_path: test/test.md" in result
assert "permalink: test/test" in result
# =============================================================================
# Folder Path Handling (kebab_filenames=true)
# =============================================================================
@pytest.mark.asyncio
async def test_write_note_folder_path_unaffected(app, test_project, app_config):
"""Test that folder paths are NOT affected by kebab_filenames setting."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
project=test_project.name,
title="Test Note",
folder="My_Folder/Sub Folder", # Folder should remain as-is
content="Testing folder path preservation",
)
# Folder paths should be preserved (sanitized but not kebab-cased)
assert "file_path: My_Folder/Sub Folder/test-note.md" in result
assert "permalink: my-folder/sub-folder/test-note" in result
@pytest.mark.asyncio
async def test_write_note_root_folder_with_kebab(app, test_project, app_config):
"""Test kebab_filenames preserves periods in version numbers with root folder."""
ConfigManager().config.kebab_filenames = True
result = await write_note.fn(
project=test_project.name,
title="Test 3.0 Note",
folder="", # Root folder
content="Testing root folder",
)
assert "file_path: test-3.0-note.md" in result
assert "permalink: test-3.0-note" in result
# =============================================================================
# Backward Compatibility (kebab_filenames=false)
# =============================================================================
@pytest.mark.asyncio
async def test_write_note_kebab_disabled_preserves_original(app, test_project, app_config):
"""Test that original formatting is preserved when kebab_filenames=false."""
ConfigManager().config.kebab_filenames = False
result = await write_note.fn(
project=test_project.name,
title="Test 3.0 Version",
folder="test",
content="Testing backward compatibility",
)
# Periods and spaces should be preserved
assert "file_path: test/Test 3.0 Version.md" in result
# Permalinks are ALWAYS kebab-case regardless of setting, and preserve periods
assert "permalink: test/test-3.0-version" in result
@pytest.mark.asyncio
async def test_write_note_kebab_disabled_preserves_underscores(app, test_project, app_config):
"""Test that underscores are preserved when kebab_filenames=false."""
ConfigManager().config.kebab_filenames = False
result = await write_note.fn(
project=test_project.name,
title="my_note_example",
folder="test",
content="Testing underscore preservation",
)
assert "file_path: test/my_note_example.md" in result
assert "permalink: test/my-note-example" in result
@pytest.mark.asyncio
async def test_write_note_kebab_disabled_preserves_case(app, test_project, app_config):
"""Test that case is preserved when kebab_filenames=false."""
ConfigManager().config.kebab_filenames = False
result = await write_note.fn(
project=test_project.name,
title="MyAwesomeNote",
folder="test",
content="Testing case preservation",
)
assert "file_path: test/MyAwesomeNote.md" in result
assert "permalink: test/my-awesome-note" in result
# =============================================================================
# Permalink Consistency (both modes)
# =============================================================================
@pytest.mark.asyncio
async def test_permalinks_always_kebab_case(app, test_project, app_config):
"""Test that permalinks are ALWAYS kebab-case regardless of kebab_filenames setting.
This is important: even when kebab_filenames=false (preserving filename formatting),
permalinks should still be kebab-case for URL consistency. Both modes preserve periods
in version numbers.
"""
# Test with kebab disabled
ConfigManager().config.kebab_filenames = False
result1 = await write_note.fn(
project=test_project.name,
title="Test Note 1",
folder="test",
content="Testing permalink consistency",
)
# Filename preserves original, permalink is kebab-case
assert "file_path: test/Test Note 1.md" in result1
assert "permalink: test/test-note-1" in result1
# Test with kebab enabled
ConfigManager().config.kebab_filenames = True
result2 = await write_note.fn(
project=test_project.name,
title="Test Note 2",
folder="test",
content="Testing permalink consistency",
)
# Both filename and permalink are kebab-case
assert "file_path: test/test-note-2.md" in result2
assert "permalink: test/test-note-2" in result2
@@ -52,7 +52,7 @@ async def test_create_observation_entity_does_not_exist(
):
"""Test creating a new observation"""
observation_data = {
"entity_id": 99999, # Non-existent entity ID (integer for Postgres compatibility)
"entity_id": "does-not-exist",
"content": "Test content",
"context": "test-context",
}
+1 -1
View File
@@ -116,7 +116,7 @@ async def test_get_by_path(project_repository: ProjectRepository, sample_project
@pytest.mark.asyncio
async def test_get_default_project(project_repository: ProjectRepository, test_project: Project):
async def test_get_default_project(project_repository: ProjectRepository):
"""Test getting the default project."""
# We already have a default project from the test_project fixture
# So just create a non-default project
+1 -1
View File
@@ -160,7 +160,7 @@ async def test_create_relation_entity_does_not_exist(
):
"""Test creating a new relation"""
relation_data = {
"from_id": 99999, # Non-existent entity ID (integer for Postgres compatibility)
"from_id": "not_exist",
"to_id": related_entity.id,
"to_name": related_entity.title,
"relation_type": "test_relation",
+4 -8
View File
@@ -1,6 +1,6 @@
"""Test repository implementation."""
from datetime import datetime, UTC
from datetime import datetime
import pytest
from sqlalchemy import String, DateTime
from sqlalchemy.orm import Mapped, mapped_column
@@ -17,13 +17,9 @@ class ModelTest(Base):
id: Mapped[str] = mapped_column(String(255), primary_key=True)
name: Mapped[str] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(UTC).replace(tzinfo=None)
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
default=lambda: datetime.now(UTC).replace(tzinfo=None),
onupdate=lambda: datetime.now(UTC).replace(tzinfo=None),
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow
)
@@ -173,7 +169,7 @@ async def test_update_model_not_found(repository):
instance = ModelTest(id="test_add", name="Test Add")
await repository.add(instance)
modified = await repository.update("0", {}) # Use string ID for Postgres compatibility
modified = await repository.update(0, {})
assert modified is None
+25 -112
View File
@@ -9,16 +9,10 @@ from sqlalchemy import text
from basic_memory import db
from basic_memory.models import Entity
from basic_memory.models.project import Project
from basic_memory.repository.search_repository import SearchIndexRow
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
from basic_memory.repository.search_repository import SearchRepository, SearchIndexRow
from basic_memory.schemas.search import SearchItemType
def is_postgres_backend(search_repository):
"""Helper to check if search repository is Postgres-based."""
return isinstance(search_repository, PostgresSearchRepository)
@pytest_asyncio.fixture
async def search_entity(session_maker, test_project: Project):
"""Create a test entity for search testing."""
@@ -52,13 +46,9 @@ async def second_project(project_repository):
@pytest_asyncio.fixture
async def second_project_repository(session_maker, second_project, search_repository):
"""Create a backend-appropriate repository for the second project.
Uses the same type as search_repository to ensure backend consistency.
"""
# Use the same repository class as the main search_repository
return type(search_repository)(session_maker, project_id=second_project.id)
async def second_project_repository(session_maker, second_project):
"""Create a repository for the second project."""
return SearchRepository(session_maker, project_id=second_project.id)
@pytest_asyncio.fixture
@@ -81,30 +71,16 @@ async def second_entity(session_maker, second_project: Project):
@pytest.mark.asyncio
async def test_init_search_index(search_repository, app_config):
async def test_init_search_index(search_repository):
"""Test that search index can be initialized."""
from basic_memory.config import DatabaseBackend
await search_repository.init_search_index()
# Verify search_index table exists (backend-specific query)
# Verify search_index table exists
async with db.scoped_session(search_repository.session_maker) as session:
if app_config.database_backend == DatabaseBackend.POSTGRES:
# For Postgres, query information_schema
result = await session.execute(
text(
"SELECT table_name FROM information_schema.tables "
"WHERE table_schema = 'public' AND table_name = 'search_index';"
)
)
else:
# For SQLite, query sqlite_master
result = await session.execute(
text("SELECT name FROM sqlite_master WHERE type='table' AND name='search_index';")
)
table_name = result.scalar()
assert table_name == "search_index"
result = await session.execute(
text("SELECT name FROM sqlite_master WHERE type='table' AND name='search_index';")
)
assert result.scalar() == "search_index"
@pytest.mark.asyncio
@@ -328,69 +304,33 @@ def test_directory_property():
class TestSearchTermPreparation:
"""Test cases for search term preparation.
Note: Tests with `[sqlite]` marker test SQLite FTS5-specific implementation details.
Tests with `[asyncio-sqlite]` or `[asyncio-postgres]` test backend-agnostic functionality.
"""
"""Test cases for FTS5 search term preparation."""
def test_simple_terms_get_prefix_wildcard(self, search_repository):
"""Simple alphanumeric terms should get prefix matching."""
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
if isinstance(search_repository, PostgresSearchRepository):
# Postgres tsquery uses :* for prefix matching
assert search_repository._prepare_search_term("hello") == "hello:*"
assert search_repository._prepare_search_term("project") == "project:*"
assert search_repository._prepare_search_term("test123") == "test123:*"
else:
# SQLite FTS5 uses * for prefix matching
assert search_repository._prepare_search_term("hello") == "hello*"
assert search_repository._prepare_search_term("project") == "project*"
assert search_repository._prepare_search_term("test123") == "test123*"
assert search_repository._prepare_search_term("hello") == "hello*"
assert search_repository._prepare_search_term("project") == "project*"
assert search_repository._prepare_search_term("test123") == "test123*"
def test_terms_with_existing_wildcard_unchanged(self, search_repository):
"""Terms that already contain * should remain unchanged."""
if is_postgres_backend(search_repository):
# Postgres uses different syntax (:* instead of *)
assert search_repository._prepare_search_term("hello*") == "hello:*"
assert search_repository._prepare_search_term("test*world") == "test:*world"
else:
assert search_repository._prepare_search_term("hello*") == "hello*"
assert search_repository._prepare_search_term("test*world") == "test*world"
assert search_repository._prepare_search_term("hello*") == "hello*"
assert search_repository._prepare_search_term("test*world") == "test*world"
def test_boolean_operators_preserved(self, search_repository):
"""Boolean operators should be preserved without modification."""
if is_postgres_backend(search_repository):
# Postgres converts AND/OR/NOT to &/|/!
assert search_repository._prepare_search_term("hello AND world") == "hello & world"
assert search_repository._prepare_search_term("cat OR dog") == "cat | dog"
# NOT must be converted to "& !" for proper tsquery syntax
assert (
search_repository._prepare_search_term("project NOT meeting")
== "project & !meeting"
)
assert (
search_repository._prepare_search_term("(hello AND world) OR test")
== "(hello & world) | test"
)
else:
assert search_repository._prepare_search_term("hello AND world") == "hello AND world"
assert search_repository._prepare_search_term("cat OR dog") == "cat OR dog"
assert (
search_repository._prepare_search_term("project NOT meeting")
== "project NOT meeting"
)
assert (
search_repository._prepare_search_term("(hello AND world) OR test")
== "(hello AND world) OR test"
)
assert search_repository._prepare_search_term("hello AND world") == "hello AND world"
assert search_repository._prepare_search_term("cat OR dog") == "cat OR dog"
assert (
search_repository._prepare_search_term("project NOT meeting") == "project NOT meeting"
)
assert (
search_repository._prepare_search_term("(hello AND world) OR test")
== "(hello AND world) OR test"
)
def test_hyphenated_terms_with_boolean_operators(self, search_repository):
"""Hyphenated terms with Boolean operators should be properly quoted."""
if is_postgres_backend(search_repository):
pytest.skip("This test is for SQLite FTS5-specific quoting behavior")
# Test the specific case from the GitHub issue
result = search_repository._prepare_search_term("tier1-test AND unicode")
assert result == '"tier1-test" AND unicode'
@@ -421,9 +361,6 @@ class TestSearchTermPreparation:
def test_programming_terms_should_work(self, search_repository):
"""Programming-related terms with special chars should be searchable."""
if is_postgres_backend(search_repository):
pytest.skip("This test is for SQLite FTS5-specific behavior")
# These should be quoted to handle special characters safely
assert search_repository._prepare_search_term("C++") == '"C++"*'
assert search_repository._prepare_search_term("function()") == '"function()"*'
@@ -433,9 +370,6 @@ class TestSearchTermPreparation:
def test_malformed_fts5_syntax_quoted(self, search_repository):
"""Malformed FTS5 syntax should be quoted to prevent errors."""
if is_postgres_backend(search_repository):
pytest.skip("This test is for SQLite FTS5-specific behavior")
# Multiple operators without proper syntax
assert search_repository._prepare_search_term("+++invalid+++") == '"+++invalid+++"*'
assert search_repository._prepare_search_term("!!!error!!!") == '"!!!error!!!"*'
@@ -443,17 +377,11 @@ class TestSearchTermPreparation:
def test_quoted_strings_handled_properly(self, search_repository):
"""Strings with quotes should have quotes escaped."""
if is_postgres_backend(search_repository):
pytest.skip("This test is for SQLite FTS5-specific behavior")
assert search_repository._prepare_search_term('say "hello"') == '"say ""hello"""*'
assert search_repository._prepare_search_term("it's working") == '"it\'s working"*'
def test_file_paths_no_prefix_wildcard(self, search_repository):
"""File paths should not get prefix wildcards."""
if is_postgres_backend(search_repository):
pytest.skip("This test is for SQLite FTS5-specific behavior")
assert (
search_repository._prepare_search_term("config.json", is_prefix=False)
== '"config.json"'
@@ -465,9 +393,6 @@ class TestSearchTermPreparation:
def test_spaces_handled_correctly(self, search_repository):
"""Terms with spaces should use boolean AND for word order independence."""
if is_postgres_backend(search_repository):
pytest.skip("This test is for SQLite FTS5-specific behavior")
assert search_repository._prepare_search_term("hello world") == "hello* AND world*"
assert (
search_repository._prepare_search_term("project planning") == "project* AND planning*"
@@ -475,9 +400,6 @@ class TestSearchTermPreparation:
def test_version_strings_with_dots_handled_correctly(self, search_repository):
"""Version strings with dots should be quoted to prevent FTS5 syntax errors."""
if is_postgres_backend(search_repository):
pytest.skip("This test is for SQLite FTS5-specific behavior")
# This reproduces the bug where "Basic Memory v0.13.0b2" becomes "Basic* AND Memory* AND v0.13.0b2*"
# which causes FTS5 syntax errors because v0.13.0b2* is not valid FTS5 syntax
result = search_repository._prepare_search_term("Basic Memory v0.13.0b2")
@@ -486,9 +408,6 @@ class TestSearchTermPreparation:
def test_mixed_special_characters_in_multi_word_queries(self, search_repository):
"""Multi-word queries with special characters in any word should be fully quoted."""
if is_postgres_backend(search_repository):
pytest.skip("This test is for SQLite FTS5-specific behavior")
# Any word containing special characters should cause the entire phrase to be quoted
assert search_repository._prepare_search_term("config.json file") == '"config.json file"*'
assert (
@@ -645,9 +564,6 @@ class TestSearchTermPreparation:
def test_parenthetical_term_quote_escaping(self, search_repository):
"""Test quote escaping in parenthetical terms (lines 190-191 coverage)."""
if is_postgres_backend(search_repository):
pytest.skip("This test is for SQLite FTS5-specific behavior")
# Test term with quotes that needs escaping
result = search_repository._prepare_parenthetical_term('(say "hello" world)')
# Should escape quotes by doubling them
@@ -659,9 +575,6 @@ class TestSearchTermPreparation:
def test_needs_quoting_empty_input(self, search_repository):
"""Test _needs_quoting with empty inputs (line 207 coverage)."""
if is_postgres_backend(search_repository):
pytest.skip("This test is for SQLite FTS5-specific behavior")
# Test empty string
assert not search_repository._needs_quoting("")
@@ -10,8 +10,7 @@ import pytest
import pytest_asyncio
from basic_memory.models.project import Project
from basic_memory.repository.search_index_row import SearchIndexRow
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
from basic_memory.repository.search_repository import SearchRepository, SearchIndexRow
from basic_memory.schemas.search import SearchItemType
@@ -31,7 +30,7 @@ async def second_test_project(project_repository):
@pytest_asyncio.fixture
async def second_search_repo(session_maker, second_test_project):
"""Create a search repository for the second project."""
return SQLiteSearchRepository(session_maker, project_id=second_test_project.id)
return SearchRepository(session_maker, project_id=second_test_project.id)
@pytest.mark.asyncio
@@ -44,7 +43,7 @@ async def test_index_item_respects_project_isolation_during_edit():
"""
from basic_memory import db
from basic_memory.models.base import Base
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
from basic_memory.repository.search_repository import SearchRepository
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
# Create a separate in-memory database for this test
@@ -80,8 +79,8 @@ async def test_index_item_respects_project_isolation_during_edit():
await session.commit()
# Create search repositories for both projects
repo1 = SQLiteSearchRepository(session_maker, project_id=project1_id)
repo2 = SQLiteSearchRepository(session_maker, project_id=project2_id)
repo1 = SearchRepository(session_maker, project_id=project1_id)
repo2 = SearchRepository(session_maker, project_id=project2_id)
# Initialize search index
await repo1.init_search_index()
@@ -181,7 +180,7 @@ async def test_index_item_updates_existing_record_same_project():
"""Test that index_item() correctly updates existing records within the same project."""
from basic_memory import db
from basic_memory.models.base import Base
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
from basic_memory.repository.search_repository import SearchRepository
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
# Create a separate in-memory database for this test
@@ -207,7 +206,7 @@ async def test_index_item_updates_existing_record_same_project():
await session.commit()
# Create search repository
repo = SQLiteSearchRepository(session_maker, project_id=project_id)
repo = SearchRepository(session_maker, project_id=project_id)
await repo.init_search_index()
permalink = "test/my-note"
+1 -1
View File
@@ -348,7 +348,7 @@ class TestTimeframeParsing:
result_1d = parse_timeframe("1d")
expected_1d = now - timedelta(days=1)
diff = abs((result_1d - expected_1d).total_seconds())
assert diff < 3600 # Within 1 hour tolerance (accounts for DST transitions)
assert diff < 60 # Within 1 minute tolerance
assert result_1d.tzinfo is not None
# Test yesterday - should be yesterday at same time
+11 -25
View File
@@ -45,7 +45,7 @@ async def test_find_connected_depth_limit(context_service, test_graph):
@pytest.mark.asyncio
async def test_find_connected_timeframe(
context_service, test_graph, search_repository, entity_repository, app_config
context_service, test_graph, search_repository, entity_repository
):
"""Test timeframe filtering.
This tests how traversal is affected by the item dates.
@@ -53,12 +53,6 @@ async def test_find_connected_timeframe(
1. They match the timeframe
2. There is a valid path to them through other items in the timeframe
"""
# Skip for Postgres - needs investigation of duplicate key violations
from basic_memory.config import DatabaseBackend
if app_config.database_backend == DatabaseBackend.POSTGRES:
pytest.skip("Not yet supported for Postgres - duplicate key violation issue")
now = datetime.now(UTC)
old_date = now - timedelta(days=10)
recent_date = now - timedelta(days=1)
@@ -85,8 +79,8 @@ async def test_find_connected_timeframe(
file_path=test_graph["root"].file_path,
type=SearchItemType.ENTITY,
metadata={"created_at": old_date.isoformat()},
created_at=old_date,
updated_at=old_date,
created_at=old_date.isoformat(),
updated_at=old_date.isoformat(),
)
)
await search_repository.index_item(
@@ -102,8 +96,8 @@ async def test_find_connected_timeframe(
to_id=test_graph["connected1"].id,
relation_type="connects_to",
metadata={"created_at": old_date.isoformat()},
created_at=old_date,
updated_at=old_date,
created_at=old_date.isoformat(),
updated_at=old_date.isoformat(),
)
)
await search_repository.index_item(
@@ -116,8 +110,8 @@ async def test_find_connected_timeframe(
file_path=test_graph["connected1"].file_path,
type=SearchItemType.ENTITY,
metadata={"created_at": recent_date.isoformat()},
created_at=recent_date,
updated_at=recent_date,
created_at=recent_date.isoformat(),
updated_at=recent_date.isoformat(),
)
)
@@ -229,13 +223,11 @@ async def test_context_metadata(context_service, test_graph):
@pytest.mark.asyncio
async def test_project_isolation_in_find_related(session_maker, app_config):
async def test_project_isolation_in_find_related(session_maker):
"""Test that find_related respects project boundaries and doesn't leak data."""
from basic_memory.repository.entity_repository import EntityRepository
from basic_memory.repository.observation_repository import ObservationRepository
from basic_memory.repository.sqlite_search_repository import SQLiteSearchRepository
from basic_memory.repository.postgres_search_repository import PostgresSearchRepository
from basic_memory.config import DatabaseBackend
from basic_memory.repository.search_repository import SearchRepository
from basic_memory import db
# Create database session
@@ -294,20 +286,14 @@ async def test_project_isolation_in_find_related(session_maker, app_config):
db_session.add(relation_p1)
await db_session.commit()
# Create database-specific search repositories based on backend
if app_config.database_backend == DatabaseBackend.POSTGRES:
search_repo_p1 = PostgresSearchRepository(session_maker, project1.id)
search_repo_p2 = PostgresSearchRepository(session_maker, project2.id)
else:
search_repo_p1 = SQLiteSearchRepository(session_maker, project1.id)
search_repo_p2 = SQLiteSearchRepository(session_maker, project2.id)
# Create repositories for project1
search_repo_p1 = SearchRepository(session_maker, project1.id)
entity_repo_p1 = EntityRepository(session_maker, project1.id)
obs_repo_p1 = ObservationRepository(session_maker, project1.id)
context_service_p1 = ContextService(search_repo_p1, entity_repo_p1, obs_repo_p1)
# Create repositories for project2
search_repo_p2 = SearchRepository(session_maker, project2.id)
entity_repo_p2 = EntityRepository(session_maker, project2.id)
obs_repo_p2 = ObservationRepository(session_maker, project2.id)
context_service_p2 = ContextService(search_repo_p2, entity_repo_p2, obs_repo_p2)
-49
View File
@@ -1275,55 +1275,6 @@ async def test_edit_entity_replace_section_with_subsections(
assert "Other content" in file_content
@pytest.mark.asyncio
async def test_edit_entity_replace_section_strips_duplicate_header(
entity_service: EntityService, file_service: FileService
):
"""Test that replace_section strips duplicate header from content (issue #390)."""
# Create test entity with a section
content = dedent("""
# Main Title
## Testing
Original content
## Another Section
Other content
""").strip()
entity = await entity_service.create_entity(
EntitySchema(
title="Sample Note",
folder="docs",
entity_type="note",
content=content,
)
)
# Replace section with content that includes the duplicate header
# (This is what LLMs sometimes do)
updated = await entity_service.edit_entity(
identifier=entity.permalink,
operation="replace_section",
content="## Testing\nNew content for testing section",
section="## Testing",
)
# Verify that we don't have duplicate headers
file_path = file_service.get_entity_path(updated)
file_content, _ = await file_service.read_file(file_path)
# Count occurrences of "## Testing" - should only be 1
testing_header_count = file_content.count("## Testing")
assert testing_header_count == 1, (
f"Expected 1 '## Testing' header, found {testing_header_count}"
)
assert "New content for testing section" in file_content
assert "Original content" not in file_content
assert "## Another Section" in file_content # Other sections preserved
# Move entity tests
@pytest.mark.asyncio
async def test_move_entity_success(
-1
View File
@@ -81,7 +81,6 @@ async def test_entities(entity_service, file_service):
entity_type="file",
content_type="image/png",
file_path="Image.png",
permalink="image", # Required for Postgres NOT NULL constraint
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
project_id=entity_service.repository.project_id,
+31 -37
View File
@@ -77,18 +77,18 @@ async def test_project_operations_sync_methods(
test_project_name = f"test-project-{os.urandom(4).hex()}"
with tempfile.TemporaryDirectory() as temp_dir:
test_root = Path(temp_dir)
test_project_path = test_root / "test-project"
test_project_path = (test_root / "test-project").as_posix()
# Make sure the test directory exists
test_project_path.mkdir(parents=True, exist_ok=True)
os.makedirs(test_project_path, exist_ok=True)
try:
# Test adding a project (using ConfigManager directly)
config_manager.add_project(test_project_name, str(test_project_path))
config_manager.add_project(test_project_name, test_project_path)
# Verify it was added
assert test_project_name in project_service.projects
assert Path(project_service.projects[test_project_name]) == test_project_path
assert project_service.projects[test_project_name] == test_project_path
# Test setting as default
original_default = project_service.default_project
@@ -173,24 +173,24 @@ async def test_add_project_async(project_service: ProjectService):
test_project_name = f"test-async-project-{os.urandom(4).hex()}"
with tempfile.TemporaryDirectory() as temp_dir:
test_root = Path(temp_dir)
test_project_path = test_root / "test-async-project"
test_project_path = (test_root / "test-async-project").as_posix()
# Make sure the test directory exists
test_project_path.mkdir(parents=True, exist_ok=True)
os.makedirs(test_project_path, exist_ok=True)
try:
# Test adding a project
await project_service.add_project(test_project_name, str(test_project_path))
await project_service.add_project(test_project_name, test_project_path)
# Verify it was added to config
assert test_project_name in project_service.projects
assert Path(project_service.projects[test_project_name]) == test_project_path
assert project_service.projects[test_project_name] == test_project_path
# Verify it was added to the database
project = await project_service.repository.get_by_name(test_project_name)
assert project is not None
assert project.name == test_project_name
assert Path(project.path) == test_project_path
assert project.path == test_project_path
finally:
# Clean up
@@ -204,7 +204,7 @@ async def test_add_project_async(project_service: ProjectService):
@pytest.mark.asyncio
async def test_set_default_project_async(project_service: ProjectService, test_project):
async def test_set_default_project_async(project_service: ProjectService):
"""Test setting a project as default with the updated async method."""
# First add a test project
test_project_name = f"test-default-project-{os.urandom(4).hex()}"
@@ -238,11 +238,9 @@ async def test_set_default_project_async(project_service: ProjectService, test_p
assert old_default_project.is_default is not True
finally:
# Restore original default (only if it exists in database)
# Restore original default
if original_default:
original_project = await project_service.repository.get_by_name(original_default)
if original_project:
await project_service.set_default_project(original_default)
await project_service.set_default_project(original_default)
# Clean up test project
if test_project_name in project_service.projects:
@@ -321,7 +319,7 @@ async def test_set_default_project_config_db_mismatch(
@pytest.mark.asyncio
async def test_add_project_with_set_default_true(project_service: ProjectService, test_project):
async def test_add_project_with_set_default_true(project_service: ProjectService):
"""Test adding a project with set_default=True enforces single default."""
test_project_name = f"test-default-true-{os.urandom(4).hex()}"
with tempfile.TemporaryDirectory() as temp_dir:
@@ -363,11 +361,9 @@ async def test_add_project_with_set_default_true(project_service: ProjectService
assert default_projects[0].name == test_project_name
finally:
# Restore original default (only if it exists in database)
# Restore original default
if original_default:
original_project = await project_service.repository.get_by_name(original_default)
if original_project:
await project_service.set_default_project(original_default)
await project_service.set_default_project(original_default)
# Clean up test project
if test_project_name in project_service.projects:
@@ -446,9 +442,7 @@ async def test_add_project_default_parameter_omitted(project_service: ProjectSer
@pytest.mark.asyncio
async def test_ensure_single_default_project_enforcement_logic(
project_service: ProjectService, test_project
):
async def test_ensure_single_default_project_enforcement_logic(project_service: ProjectService):
"""Test that _ensure_single_default_project logic works correctly."""
# Test that the method exists and is callable
assert hasattr(project_service, "_ensure_single_default_project")
@@ -575,34 +569,34 @@ async def test_move_project(project_service: ProjectService):
test_project_name = f"test-move-project-{os.urandom(4).hex()}"
with tempfile.TemporaryDirectory() as temp_dir:
test_root = Path(temp_dir)
old_path = test_root / "old-location"
new_path = test_root / "new-location"
old_path = (test_root / "old-location").as_posix()
new_path = (test_root / "new-location").as_posix()
# Create old directory
old_path.mkdir(parents=True, exist_ok=True)
os.makedirs(old_path, exist_ok=True)
try:
# Add project with initial path
await project_service.add_project(test_project_name, str(old_path))
await project_service.add_project(test_project_name, old_path)
# Verify initial state
assert test_project_name in project_service.projects
assert Path(project_service.projects[test_project_name]) == old_path
assert project_service.projects[test_project_name] == old_path
project = await project_service.repository.get_by_name(test_project_name)
assert project is not None
assert Path(project.path) == old_path
assert project.path == old_path
# Move project to new location
await project_service.move_project(test_project_name, str(new_path))
await project_service.move_project(test_project_name, new_path)
# Verify config was updated
assert Path(project_service.projects[test_project_name]) == new_path
assert project_service.projects[test_project_name] == new_path
# Verify database was updated
updated_project = await project_service.repository.get_by_name(test_project_name)
assert updated_project is not None
assert Path(updated_project.path) == new_path
assert updated_project.path == new_path
# Verify new directory was created
assert os.path.exists(new_path)
@@ -630,17 +624,17 @@ async def test_move_project_db_mismatch(project_service: ProjectService):
test_project_name = f"test-move-mismatch-{os.urandom(4).hex()}"
with tempfile.TemporaryDirectory() as temp_dir:
test_root = Path(temp_dir)
old_path = test_root / "old-location"
new_path = test_root / "new-location"
old_path = (test_root / "old-location").as_posix()
new_path = (test_root / "new-location").as_posix()
# Create directories
old_path.mkdir(parents=True, exist_ok=True)
os.makedirs(old_path, exist_ok=True)
config_manager = project_service.config_manager
try:
# Add project to config only (not to database)
config_manager.add_project(test_project_name, str(old_path))
config_manager.add_project(test_project_name, old_path)
# Verify it's in config but not in database
assert test_project_name in project_service.projects
@@ -649,10 +643,10 @@ async def test_move_project_db_mismatch(project_service: ProjectService):
# Try to move project - should fail and restore config
with pytest.raises(ValueError, match="not found in database"):
await project_service.move_project(test_project_name, str(new_path))
await project_service.move_project(test_project_name, new_path)
# Verify config was restored to original path
assert Path(project_service.projects[test_project_name]) == old_path
assert project_service.projects[test_project_name] == old_path
finally:
# Clean up
@@ -53,18 +53,18 @@ async def test_add_project_to_config(project_service: ProjectService, config_man
test_project_name = f"config-project-{os.urandom(4).hex()}"
with tempfile.TemporaryDirectory() as temp_dir:
test_root = Path(temp_dir)
test_path = test_root / "config-project"
test_path = (test_root / "config-project").as_posix()
# Make sure directory exists
test_path.mkdir(parents=True, exist_ok=True)
os.makedirs(test_path, exist_ok=True)
try:
# Add a project to config only (using ConfigManager directly)
config_manager.add_project(test_project_name, str(test_path))
config_manager.add_project(test_project_name, test_path)
# Verify it's in the config
assert test_project_name in project_service.projects
assert Path(project_service.projects[test_project_name]) == test_path
assert project_service.projects[test_project_name] == test_path
finally:
# Clean up
@@ -79,23 +79,23 @@ async def test_update_project_path(project_service: ProjectService, config_manag
test_project = f"path-update-test-project-{os.urandom(4).hex()}"
with tempfile.TemporaryDirectory() as temp_dir:
test_root = Path(temp_dir)
original_path = test_root / "original-path"
new_path = test_root / "new-path"
original_path = (test_root / "original-path").as_posix()
new_path = (test_root / "new-path").as_posix()
# Make sure directories exist
original_path.mkdir(parents=True, exist_ok=True)
new_path.mkdir(parents=True, exist_ok=True)
os.makedirs(original_path, exist_ok=True)
os.makedirs(new_path, exist_ok=True)
try:
# Add the project
await project_service.add_project(test_project, str(original_path))
await project_service.add_project(test_project, original_path)
# Mock the update_project method to avoid issues with complex DB updates
with patch.object(project_service, "update_project"):
# Just check if the project exists
project = await project_service.repository.get_by_name(test_project)
assert project is not None
assert Path(project.path) == original_path
assert project.path == original_path
# Since we mock the update_project method, we skip verifying path updates
+5 -19
View File
@@ -163,13 +163,7 @@ async def test_after_date(search_service, test_graph):
)
)
for r in results:
# Handle both string (SQLite) and datetime (Postgres) formats
created_at = (
r.created_at
if isinstance(r.created_at, datetime)
else datetime.fromisoformat(r.created_at)
)
assert created_at > past_date
assert datetime.fromisoformat(r.created_at) > past_date
# Should not find with future date
future_date = datetime(2030, 1, 1).astimezone()
@@ -256,20 +250,12 @@ async def test_no_criteria(search_service, test_graph):
@pytest.mark.asyncio
async def test_init_search_index(search_service, session_maker, app_config):
async def test_init_search_index(search_service, session_maker):
"""Test search index initialization."""
from basic_memory.config import DatabaseBackend
async with db.scoped_session(session_maker) as session:
# Use database-specific query to check table existence
if app_config.database_backend == DatabaseBackend.POSTGRES:
result = await session.execute(
text("SELECT tablename FROM pg_catalog.pg_tables WHERE tablename='search_index';")
)
else:
result = await session.execute(
text("SELECT name FROM sqlite_master WHERE type='table' AND name='search_index';")
)
result = await session.execute(
text("SELECT name FROM sqlite_master WHERE type='table' AND name='search_index';")
)
assert result.scalar() == "search_index"
+86 -25
View File
@@ -618,9 +618,7 @@ async def test_handle_entity_deletion(
obs_results = await search_service.search(SearchQuery(text="Root note 1"))
assert len(obs_results) == 0
# Verify relations from root entity are gone
# (Postgres stemming would match "connects_to" with "connected_to", so use permalink)
rel_results = await search_service.search(SearchQuery(permalink=root_entity.permalink))
rel_results = await search_service.search(SearchQuery(text="connects_to"))
assert len(rel_results) == 0
@@ -629,11 +627,8 @@ async def test_sync_preserves_timestamps(
sync_service: SyncService,
project_config: ProjectConfig,
entity_service: EntityService,
db_backend,
):
"""Test that sync preserves file timestamps and frontmatter dates."""
if db_backend == "postgres":
pytest.skip("Postgres timestamp handling differs from SQLite")
project_dir = project_config.home
# Create a file with explicit frontmatter dates
@@ -685,7 +680,6 @@ async def test_sync_updates_timestamps_on_file_modification(
sync_service: SyncService,
project_config: ProjectConfig,
entity_service: EntityService,
db_backend,
):
"""Test that sync updates entity timestamps when files are modified.
@@ -694,8 +688,6 @@ async def test_sync_updates_timestamps_on_file_modification(
not the database operation time. This is critical for accurate temporal ordering in
search and recent_activity queries.
"""
if db_backend == "postgres":
pytest.skip("Postgres timestamp handling differs from SQLite")
project_dir = project_config.home
@@ -1486,12 +1478,11 @@ async def test_circuit_breaker_skips_after_three_failures(
# Create a file with malformed content that will fail to parse
await create_test_file(test_file, "invalid markdown content")
# Mock sync_markdown_batch to always fail for all files in batch
async def mock_sync_markdown_batch(paths, new=True):
# Simulate batch failure - return (None, "") for each path
# Mock sync_markdown_file to always fail
async def mock_sync_markdown_file(*args, **kwargs):
raise ValueError("Simulated sync failure")
with patch.object(sync_service, "sync_markdown_batch", side_effect=mock_sync_markdown_batch):
with patch.object(sync_service, "sync_markdown_file", side_effect=mock_sync_markdown_file):
# First sync - should fail and record (1/3)
report1 = await sync_service.sync(project_dir)
assert len(report1.skipped_files) == 0 # Not skipped yet
@@ -1546,15 +1537,15 @@ async def test_circuit_breaker_resets_on_file_change(
# Create initial failing content
await create_test_file(test_file, "initial bad content")
# Mock sync_markdown_batch to fail
# Mock sync_markdown_file to fail
call_count = 0
async def mock_sync_markdown_batch(paths, new=True):
async def mock_sync_markdown_file(*args, **kwargs):
nonlocal call_count
call_count += len(paths)
call_count += 1
raise ValueError("Simulated sync failure")
with patch.object(sync_service, "sync_markdown_batch", side_effect=mock_sync_markdown_batch):
with patch.object(sync_service, "sync_markdown_file", side_effect=mock_sync_markdown_file):
# Fail 3 times to hit circuit breaker threshold
await sync_service.sync(project_dir) # Fail 1
await touch_file(test_file) # Touch to trigger incremental scan
@@ -1660,6 +1651,76 @@ async def test_circuit_breaker_clears_on_success(
assert entity is not None
@pytest.mark.asyncio
async def test_circuit_breaker_tracks_multiple_files(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that circuit breaker tracks multiple failing files independently."""
from unittest.mock import patch
project_dir = project_config.home
# Create multiple files with valid markdown
await create_test_file(
project_dir / "file1.md",
"""
---
type: knowledge
---
# File 1
Content 1
""",
)
await create_test_file(
project_dir / "file2.md",
"""
---
type: knowledge
---
# File 2
Content 2
""",
)
await create_test_file(
project_dir / "file3.md",
"""
---
type: knowledge
---
# File 3
Content 3
""",
)
# Mock to make file1 and file2 fail, but file3 succeed
original_sync_markdown_file = sync_service.sync_markdown_file
async def mock_sync_markdown_file(path, new):
if "file1.md" in path or "file2.md" in path:
raise ValueError(f"Failure for {path}")
# file3 succeeds - use real implementation
return await original_sync_markdown_file(path, new)
with patch.object(sync_service, "sync_markdown_file", side_effect=mock_sync_markdown_file):
# Fail 3 times for file1 and file2 (file3 succeeds each time)
await sync_service.sync(project_dir) # Fail count: file1=1, file2=1
await touch_file(project_dir / "file1.md") # Touch to trigger incremental scan
await touch_file(project_dir / "file2.md") # Touch to trigger incremental scan
await sync_service.sync(project_dir) # Fail count: file1=2, file2=2
await touch_file(project_dir / "file1.md") # Touch to trigger incremental scan
await touch_file(project_dir / "file2.md") # Touch to trigger incremental scan
report3 = await sync_service.sync(project_dir) # Fail count: file1=3, file2=3, now skipped
# Both files should be skipped on third sync
assert len(report3.skipped_files) == 2
skipped_paths = {f.path for f in report3.skipped_files}
assert "file1.md" in skipped_paths
assert "file2.md" in skipped_paths
# Verify file3 is not in failures dict
assert "file3.md" not in sync_service._file_failures
@pytest.mark.asyncio
async def test_circuit_breaker_handles_checksum_computation_failure(
sync_service: SyncService, project_config: ProjectConfig
@@ -1671,8 +1732,8 @@ async def test_circuit_breaker_handles_checksum_computation_failure(
test_file = project_dir / "checksum_fail.md"
await create_test_file(test_file, "content")
# Mock sync_markdown_batch to fail
async def mock_sync_markdown_batch(paths, new=True):
# Mock sync_markdown_file to fail
async def mock_sync_markdown_file(*args, **kwargs):
raise ValueError("Sync failure")
# Mock checksum computation to fail only during _record_failure (not during scan)
@@ -1689,7 +1750,7 @@ async def test_circuit_breaker_handles_checksum_computation_failure(
raise IOError("Cannot read file")
with (
patch.object(sync_service, "sync_markdown_batch", side_effect=mock_sync_markdown_batch),
patch.object(sync_service, "sync_markdown_file", side_effect=mock_sync_markdown_file),
patch.object(
sync_service.file_service,
"compute_checksum",
@@ -1758,16 +1819,16 @@ async def test_sync_fatal_error_terminates_sync_immediately(
),
)
# Mock entity_repository.upsert_entities to raise SyncFatalError
# This simulates project being deleted during batch sync
async def mock_upsert_entities(entities):
# Mock entity_service.create_entity_from_markdown to raise SyncFatalError on first file
# This simulates project being deleted during sync
async def mock_create_entity_from_markdown(*args, **kwargs):
raise SyncFatalError(
"Cannot sync entities: project_id=99999 does not exist in database. "
"Cannot sync file 'file1.md': project_id=99999 does not exist in database. "
"The project may have been deleted. This sync will be terminated."
)
with patch.object(
sync_service.entity_repository, "upsert_entities", side_effect=mock_upsert_entities
entity_service, "create_entity_from_markdown", side_effect=mock_create_entity_from_markdown
):
# Sync should raise SyncFatalError and terminate immediately
with pytest.raises(SyncFatalError, match="project_id=99999 does not exist"):
@@ -147,67 +147,6 @@ async def test_file_count_increased_uses_incremental_scan(
assert "file3.md" in report.new
@pytest.mark.asyncio
async def test_force_full_bypasses_watermark_optimization(
sync_service: SyncService, project_config: ProjectConfig
):
"""Test that force_full=True bypasses watermark optimization and scans all files.
This is critical for detecting changes made by external tools like rclone bisync
that don't update mtimes detectably. See issue #407.
"""
project_dir = project_config.home
# Create initial files
await create_test_file(project_dir / "file1.md", "# File 1\nOriginal")
await create_test_file(project_dir / "file2.md", "# File 2\nOriginal")
# First sync - establishes watermark
report = await sync_service.sync(project_dir)
assert len(report.new) == 2
# Verify watermark was set
project = await sync_service.project_repository.find_by_id(
sync_service.entity_repository.project_id
)
assert project.last_scan_timestamp is not None
initial_timestamp = project.last_scan_timestamp
# Sleep to ensure time passes
await sleep_past_watermark()
# Modify a file WITHOUT updating mtime (simulates external tool like rclone)
# We set mtime to be BEFORE the watermark to ensure incremental scan won't detect it
file_path = project_dir / "file1.md"
file_path.stat()
await create_test_file(file_path, "# File 1\nModified by external tool")
# Set mtime to be before the watermark (use time from before first sync)
# This simulates rclone bisync which may preserve original timestamps
import os
old_time = initial_timestamp - 10 # 10 seconds before watermark
os.utime(file_path, (old_time, old_time))
# Normal incremental sync should NOT detect the change (mtime before watermark)
report = await sync_service.sync(project_dir)
assert len(report.modified) == 0, (
"Incremental scan should not detect changes with mtime older than watermark"
)
# Force full scan should detect the change via checksum comparison
report = await sync_service.sync(project_dir, force_full=True)
assert len(report.modified) == 1, "Force full scan should detect changes via checksum"
assert "file1.md" in report.modified
# Verify watermark was still updated after force_full
project = await sync_service.project_repository.find_by_id(
sync_service.entity_repository.project_id
)
assert project.last_scan_timestamp is not None
assert project.last_scan_timestamp > initial_timestamp
# ==============================================================================
# Incremental Scan Base Cases
# ==============================================================================

Some files were not shown because too many files have changed in this diff Show More