15 KiB
Phase 2.1: Notes Management - Quick Reference
Overview
Quick reference guide for implementing Phase 2.1: Notes Management (CRUD operations) in StarPunk.
File: starpunk/notes.py
Estimated Time: 6-8 hours
Dependencies: utils.py, models.py, database.py
Function Checklist
Required Functions
-
create_note(content, published=False, created_at=None) -> Note
- Generate unique slug
- Write file atomically
- Insert database record
- Return Note object
-
get_note(slug=None, id=None, load_content=True) -> Optional[Note]
- Query database by slug or id
- Load content from file if requested
- Return Note or None
-
list_notes(published_only=False, limit=50, offset=0, order_by='created_at', order_dir='DESC') -> list[Note]
- Query database with filters
- Support pagination
- No file I/O (metadata only)
- Return list of Notes
-
update_note(slug=None, id=None, content=None, published=None) -> Note
- Update file if content changed
- Update database record
- Return updated Note
-
delete_note(slug=None, id=None, soft=True) -> None
- Soft delete: mark deleted_at in database
- Hard delete: remove file and database record
- Return None
Custom Exceptions
-
NoteNotFoundError(Exception)
- Raised when note doesn't exist
-
InvalidNoteDataError(Exception)
- Raised for invalid content/parameters
-
NoteSyncError(Exception)
- Raised when file/database sync fails
Implementation Order
Step 1: Module Setup (15 minutes)
# starpunk/notes.py
"""Notes management for StarPunk"""
# Imports
from datetime import datetime
from pathlib import Path
from typing import Optional
from flask import current_app
from starpunk.database import get_db
from starpunk.models import Note
from starpunk.utils import (
generate_slug, make_slug_unique, generate_note_path,
ensure_note_directory, write_note_file, read_note_file,
delete_note_file, calculate_content_hash,
validate_note_path, validate_slug
)
# Exception classes (define all 3)
Time: 15 minutes
Step 2: create_note() (90 minutes)
Algorithm:
- Validate content not empty
- Set created_at to now if not provided
- Query existing slugs from database
- Generate unique slug
- Generate file path
- Validate path (security)
- Calculate content hash
- Write file (ensure_note_directory + write_note_file)
- Insert database record
- If DB fails: delete file, raise NoteSyncError
- If success: commit, return Note object
Testing:
- Create basic note
- Create with empty content (should fail)
- Create with duplicate slug (should add suffix)
- Create with specific timestamp
- File write fails (should not create DB record)
Time: 90 minutes (45 min implementation + 45 min testing)
Step 3: get_note() (45 minutes)
Algorithm:
- Validate parameters (exactly one of slug or id)
- Query database
- Return None if not found
- Create Note.from_row()
- Optionally verify integrity (log warning if mismatch)
- Return Note
Testing:
- Get by slug
- Get by id
- Get nonexistent (returns None)
- Get with both parameters (should fail)
- Get without loading content
Time: 45 minutes (25 min implementation + 20 min testing)
Step 4: list_notes() (60 minutes)
Algorithm:
- Validate order_by (whitelist check)
- Validate order_dir (ASC/DESC)
- Validate limit (max 1000)
- Build SQL query with filters
- Add ORDER BY and LIMIT/OFFSET
- Execute query
- Create Note objects (don't load content)
- Return list
Testing:
- List all notes
- List published only
- List with pagination
- List with different ordering
- Invalid order_by (should fail - SQL injection test)
Time: 60 minutes (35 min implementation + 25 min testing)
Step 5: update_note() (90 minutes)
Algorithm:
- Validate parameters
- Get existing note (raises NoteNotFoundError if missing)
- Validate content if provided
- Setup paths and timestamps
- If content changed: write new file, calculate new hash
- Build UPDATE query for changed fields
- Execute database update
- If DB fails: log error, raise NoteSyncError
- If success: commit, return updated Note
Testing:
- Update content only
- Update published only
- Update both
- Update nonexistent (should fail)
- Update with empty content (should fail)
- Update with no changes (should fail)
Time: 90 minutes (50 min implementation + 40 min testing)
Step 6: delete_note() (60 minutes)
Algorithm:
- Validate parameters
- Get existing note (return if None - idempotent)
- Validate path
- If soft delete:
- UPDATE notes SET deleted_at = now WHERE id = ?
- Optionally move file to trash (best effort)
- If hard delete:
- DELETE FROM notes WHERE id = ?
- Delete file (best effort)
- Return None
Testing:
- Soft delete
- Hard delete
- Delete nonexistent (should succeed)
- Delete already deleted (should succeed)
Time: 60 minutes (35 min implementation + 25 min testing)
Step 7: Integration Tests (60 minutes)
Full CRUD cycle test:
- Create note
- Retrieve note
- Update content
- Update published status
- List notes (verify appears)
- Delete note
- Verify gone
Sync tests:
- Verify file exists after create
- Verify DB record exists after create
- Verify file updated after update
- Verify file deleted after hard delete
- Verify DB record deleted after hard delete
Time: 60 minutes
Step 8: Documentation and Cleanup (30 minutes)
- Review all docstrings
- Format with Black
- Run flake8
- Check type hints
- Review error messages
Time: 30 minutes
Common Pitfalls
1. Forgetting to Commit Transactions
# BAD - no commit
db.execute("INSERT INTO notes ...")
# GOOD - explicit commit
db.execute("INSERT INTO notes ...")
db.commit()
2. Not Cleaning Up on Failure
# BAD - orphaned file if DB fails
write_note_file(path, content)
db.execute("INSERT ...") # What if this fails?
# GOOD - cleanup on failure
write_note_file(path, content)
try:
db.execute("INSERT ...")
db.commit()
except Exception as e:
path.unlink() # Delete file we created
raise NoteSyncError(...)
3. SQL Injection in ORDER BY
# BAD - SQL injection risk
order_by = request.args.get('order')
query = f"SELECT * FROM notes ORDER BY {order_by}"
# GOOD - whitelist validation
ALLOWED = ['id', 'slug', 'created_at', 'updated_at']
if order_by not in ALLOWED:
raise ValueError(f"Invalid order_by: {order_by}")
query = f"SELECT * FROM notes ORDER BY {order_by}"
4. Not Using Parameterized Queries
# BAD - SQL injection risk
slug = request.args.get('slug')
query = f"SELECT * FROM notes WHERE slug = '{slug}'"
# GOOD - parameterized query
query = "SELECT * FROM notes WHERE slug = ?"
db.execute(query, (slug,))
5. Forgetting Path Validation
# BAD - directory traversal risk
note_path = data_dir / file_path
write_note_file(note_path, content)
# GOOD - validate path
note_path = data_dir / file_path
if not validate_note_path(note_path, data_dir):
raise NoteSyncError(...)
write_note_file(note_path, content)
6. Not Handling None in Optional Parameters
# BAD - will crash on None
if slug and id:
raise ValueError(...)
# GOOD - explicit None checks
if slug is None and id is None:
raise ValueError("Must provide slug or id")
if slug is not None and id is not None:
raise ValueError("Cannot provide both")
Testing Checklist
Unit Tests
- create_note with valid content
- create_note with empty content (fail)
- create_note with duplicate slug (unique suffix)
- create_note with specific timestamp
- create_note with unicode content
- create_note file write failure (no DB record)
- get_note by slug
- get_note by id
- get_note nonexistent (returns None)
- get_note with invalid parameters
- get_note without loading content
- list_notes all
- list_notes published only
- list_notes with pagination
- list_notes with ordering
- list_notes with invalid order_by (fail)
- update_note content
- update_note published
- update_note both
- update_note nonexistent (fail)
- update_note empty content (fail)
- delete_note soft
- delete_note hard
- delete_note nonexistent (succeed)
Integration Tests
- Full CRUD cycle (create → read → update → delete)
- File-database sync maintained throughout lifecycle
- Orphaned files cleaned up on DB failure
- Soft-deleted notes excluded from queries
- Hard-deleted notes removed from DB and filesystem
Performance Tests
- list_notes with 1000 notes (< 10ms)
- get_note (< 10ms)
- create_note (< 20ms)
Time Estimates
| Task | Time |
|---|---|
| Module setup | 15 min |
| create_note() | 90 min |
| get_note() | 45 min |
| list_notes() | 60 min |
| update_note() | 90 min |
| delete_note() | 60 min |
| Integration tests | 60 min |
| Documentation/cleanup | 30 min |
| Total | 7.5 hours |
Add 30-60 minutes for unexpected issues and debugging.
Key Design Decisions
1. File Operations Before Database
Rationale: Fail fast on disk issues before database changes.
Pattern:
# Write file first
write_note_file(path, content)
# Then update database
db.execute("INSERT ...")
db.commit()
# If DB fails, cleanup file
2. Best-Effort File Cleanup
Rationale: Database is source of truth. Missing files can be recreated or cleaned up later.
Pattern:
try:
path.unlink()
except OSError:
logger.warning("Cleanup failed")
# Don't fail - log and continue
3. Idempotent Delete
Rationale: DELETE operations should succeed even if already deleted.
Pattern:
note = get_note(slug=slug)
if note is None:
return # Already deleted, that's fine
# ... proceed with delete
4. Lazy Content Loading
Rationale: list_notes() should not trigger file I/O for every note.
Pattern:
# list_notes creates Notes without loading content
notes = [Note.from_row(row, data_dir) for row in rows]
# Content loaded on access
for note in notes:
print(note.slug) # Fast (metadata)
print(note.content) # Triggers file I/O
5. Parameterized Queries Only
Rationale: Prevent SQL injection.
Pattern:
# Always use parameter binding
db.execute("SELECT * FROM notes WHERE slug = ?", (slug,))
# Never use string interpolation
db.execute(f"SELECT * FROM notes WHERE slug = '{slug}'") # NO!
Dependencies Reference
From utils.py
generate_slug(content, created_at) -> str
make_slug_unique(base_slug, existing_slugs) -> str
validate_slug(slug) -> bool
generate_note_path(slug, created_at, data_dir) -> Path
ensure_note_directory(note_path) -> Path
write_note_file(file_path, content) -> None
read_note_file(file_path) -> str
delete_note_file(file_path, soft=False, data_dir=None) -> None
calculate_content_hash(content) -> str
validate_note_path(file_path, data_dir) -> bool
From models.py
Note.from_row(row, data_dir) -> Note
Note.content -> str (property, lazy-loaded)
Note.to_dict(include_content=False) -> dict
Note.verify_integrity() -> bool
From database.py
get_db() -> sqlite3.Connection
db.execute(query, params) -> Cursor
db.commit() -> None
db.rollback() -> None
Error Handling Quick Reference
When to Raise vs Return None
| Scenario | Action |
|---|---|
| Note not found in get_note() | Return None |
| Note not found in update_note() | Raise NoteNotFoundError |
| Note not found in delete_note() | Return None (idempotent) |
| Empty content | Raise InvalidNoteDataError |
| File write fails | Raise NoteSyncError |
| Database fails | Raise NoteSyncError |
| Invalid parameters | Raise ValueError |
Error Message Examples
# NoteNotFoundError
raise NoteNotFoundError(
slug,
f"Note '{slug}' does not exist or has been deleted"
)
# InvalidNoteDataError
raise InvalidNoteDataError(
'content',
content,
"Note content cannot be empty. Please provide markdown content."
)
# NoteSyncError
raise NoteSyncError(
'create',
f"Database insert failed: {str(e)}",
f"Failed to create note. File written but database update failed."
)
Security Checklist
- All SQL queries use parameterized binding (no string interpolation)
- order_by field validated against whitelist
- All file paths validated with validate_note_path()
- No symlinks followed in path operations
- Content validated (not empty)
- Slug validated before use in file paths
- No code execution from user content
Final Checks
Before submitting Phase 2.1 as complete:
- All 5 functions implemented
- All 3 exceptions implemented
- Full type hints on all functions
- Comprehensive docstrings with examples
- Test coverage > 90%
- All tests passing
- Black formatting applied
- flake8 linting passes (no errors)
- Integration test passes (full CRUD cycle)
- No orphaned files in test runs
- No orphaned database records in test runs
- Error messages are clear and actionable
- Performance targets met:
- create_note < 20ms
- get_note < 10ms
- list_notes < 10ms
- update_note < 20ms
- delete_note < 10ms
Quick Command Reference
# Run tests
pytest tests/test_notes.py -v
# Check coverage
pytest tests/test_notes.py --cov=starpunk.notes --cov-report=term-missing
# Format code
black starpunk/notes.py tests/test_notes.py
# Lint code
flake8 starpunk/notes.py --max-line-length=100
# Type check (optional)
mypy starpunk/notes.py
What's Next?
After completing Phase 2.1:
Phase 3: Authentication
- IndieLogin OAuth flow
- Session management
- Admin access control
Phase 4: Web Routes
- Admin interface (create/edit/delete notes)
- Public note views
- Template rendering
Phase 5: Micropub
- Micropub endpoint
- Token validation
- IndieWeb API compliance
Phase 6: RSS Feed
- Feed generation
- RFC-822 date formatting
- Published notes only
Help & Resources
- Full Design Doc: phase-2.1-notes-management.md
- Utilities Design: phase-1.1-core-utilities.md
- Models Design: phase-1.2-data-models.md
- Coding Standards: python-coding-standards.md
- Implementation Plan: implementation-plan.md
Remember: "Every line of code must justify its existence. When in doubt, leave it out."