refactor (v2): better arch

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-06-07 21:19:54 +02:00
parent 0753f3d256
commit 839308ec19
166 changed files with 8553 additions and 884 deletions

View File

@@ -1,10 +1,10 @@
target/
node_modules/
.git/
.fastembed_cache/
*.db
*.db-shm
*.db-wal
k-notes-frontend/
dist/
.env
data/
# Frontend build artefacts are excluded individually; the source must reach the builder stage
k-notes-frontend/node_modules/
k-notes-frontend/dist/

48
.env.example Normal file
View File

@@ -0,0 +1,48 @@
# ── Required ──────────────────────────────────────────────────────────────────
# SQLite database path
DATABASE_URL=sqlite:data.db?mode=rwc
# JWT signing secret — change this before deploying
# Generate a strong one: openssl rand -hex 32
JWT_SECRET=change-me-before-deploying-use-openssl-rand-hex-32
# ── Server ────────────────────────────────────────────────────────────────────
HOST=0.0.0.0
PORT=3000
# Comma-separated allowed CORS origins (empty = block all cross-origin requests)
CORS_ORIGINS=http://localhost:5173
# Set to false to disable public registration
ALLOW_REGISTRATION=true
# ── Events ────────────────────────────────────────────────────────────────────
# If unset, an in-memory bus is used (fine for single-process dev).
# For the worker to process note events, set this.
# NATS_URL=nats://localhost:4222
# ── Smart features (optional) ─────────────────────────────────────────────────
# Set both to enable semantic search and related notes.
# QDRANT_URL=http://localhost:6334
# QDRANT_COLLECTION=notes
# Set to true ONLY in the worker process — loads the fastembed model (~150 MB).
# Leave unset (or false) in the API server.
# ENABLE_EMBEDDINGS=true
# ── Frontend ──────────────────────────────────────────────────────────────────
# Path to the built SPA dist directory.
# Defaults to k-notes-frontend/dist (run `make build-frontend` first).
# Set to empty string to run API-only (no SPA served).
# SPA_DIR=k-notes-frontend/dist
# ── JWT options (optional) ────────────────────────────────────────────────────
# JWT_EXPIRY_HOURS=24
# JWT_ISSUER=
# JWT_AUDIENCE=

View File

@@ -1,90 +0,0 @@
# K-Note
## Executive Summary
A high-performance, self-hosted note-taking engine designed to replicate the Google Keep experience. Built with **Rust**, it prioritizes speed, memory safety, and long-term maintainability through **Hexagonal Architecture** (Ports and Adapters). The system is designed to be "storage-agnostic" and "format-blind," allowing users to own their data in simple formats like Markdown.
## Architecture Pattern: Hexagonal (Ports & Adapters)
We are decoupling the **Domain Logic** (the "What") from the **Infrastructure** (the "How").
- **The Core:** Contains `Note` and `User` entities and the business rules (e.g., "A note cannot have more than 10 tags").
- **Ports:** Traits that define how the core communicates with the outside world (e.g., `NoteRepository` or `AuthService`).
- **Adapters:** Concrete implementations (e.g., `SqliteNoteRepository`, `Argon2Auth`).
- **Why:** This allows us to start with REST/SQLite today and pivot to WebSockets/PostgreSQL tomorrow by simply writing a new adapter.
## Tech Stack
| | | |
| --- | --- | --- |
| **Component** | **Primary Recommendation** | **Alternative** |
| **Language** | **Rust** (1.75+) | Go |
| **API Framework** | **Axum** | Actix-Web |
| **Database** | **SQLite (via SQLx)** | PostgreSQL |
| **Search** | **SQLite FTS5** | Meilisearch |
| **Vector Search** | **Qdrant** | Pgvector |
| **Authentication** | **Axum Login** | OIDC (Keycloak/Authelia) |
| **Frontend** | **React + Tailwind + Shadcn/ui** | Vue + Radix |
## Data Model (Entity Relationship)
Code snippet
```
erDiagram
USER ||--o{ NOTE : owns
USER {
uuid id PK
string email
string password_hash
}
NOTE ||--o{ TAG : contains
NOTE {
uuid id PK
uuid user_id FK
string title
text content
datetime created_at
datetime updated_at
boolean is_pinned
boolean is_archived
}
TAG {
uuid id PK
string name
uuid user_id FK
}
```
## Folder Structure (Workspace Layout)
We will use a Cargo Workspace to enforce strict boundaries.
Plaintext
```
.
├── Cargo.toml
├── Makefile # Task runner for DB migrations, builds, and tests
├── crates
│ ├── domain # Pure logic, Traits (Ports), and Entities (No SQLx here)
│ ├── infra # Adapters: SQLx implementations, Email, Auth logic
│ └── api # Axum routes, Request/Response DTOs, Middleware
├── migrations # SQLx migration files
└── docker-compose.yaml # For easy self-hosting deployment
```
## API Design (MVP)
- `GET /api/v1/notes` - List notes (with filter for archived/pinned).
- `POST /api/v1/notes` - Create a new note (Accepts Markdown).
- `PATCH /api/v1/notes/:id` - Partial updates.
- `GET /api/v1/search?q=query` - Full-text search via FTS5.
- `GET /api/v1/notes/:id/related` - Get semantically related notes.
## Guidelines & Principles
- **Error Handling:** Use `thiserror` for internal library errors and `anyhow` for high-level application flow. Map domain errors to specific HTTP status codes in the API layer.
- **Dependency Injection:** We will use **Atomic References (**`Arc<dyn Repository>`**)** to inject adapters into Axum state.
- **Validation:** All incoming data must be validated at the API boundary using `validator` crate before reaching the Domain.
- **DX (Developer Experience):** A `Makefile` is mandatory for one-command setups: `make setup`, `make dev`, `make test`.

93
ARCHITECTURE.mmd Normal file
View File

@@ -0,0 +1,93 @@
---
title: K-Notes — Hexagonal Architecture + DDD
---
graph TB
subgraph Binaries["Binaries (Composition Root)"]
BOOTSTRAP["bootstrap<br/><i>Axum HTTP server</i><br/>Routes, Handlers, SPA serving,<br/>OpenAPI docs (/docs, /scalar)"]
WORKER["worker<br/><i>Event consumer</i><br/>NoteEventHandler,<br/>Semaphore(8), graceful shutdown"]
end
subgraph Wiring["wiring (Assembly)"]
CTX_BUILD["build_context()<br/><i>Reads env vars, connects DB,<br/>wires adapters into AppContext</i>"]
end
subgraph Application["Application Layer"]
direction TB
CTX["AppContext<br/><i>Repositories + Services + AppConfig</i>"]
subgraph UseCases["Use Cases (CQRS)"]
UC_AUTH["auth<br/>register, login"]
UC_NOTES["notes<br/>create, update, delete, get,<br/>list, search, pin, archive,<br/>add_tag, remove_tag,<br/>get_versions, get_related,<br/>export, import"]
UC_TAGS["tags<br/>create (get-or-create),<br/>delete, rename, list"]
UC_SMART["smart<br/>process_note, delete_vectors"]
end
WORKER_SVC["WorkerService<br/><i>EventConsumer + EventHandler[]<br/>Semaphore(8), JoinSet,<br/>shutdown watch channel</i>"]
end
subgraph Domain["Domain Layer (0 dependencies)"]
direction TB
subgraph Contexts["Business Contexts"]
D_NOTE["note/<br/>Note, NoteId, NoteVersion,<br/>NoteLink, NoteFilter,<br/>NoteTitle, NoteColor"]
D_TAG["tag/<br/>Tag, TagId, TagName"]
D_USER["user/<br/>User, UserId, Email,<br/>Password, PasswordHash"]
D_SMART["smart/<br/>EmbeddingGenerator port<br/>VectorStore port"]
end
subgraph Ports["Port Traits"]
P_REPOS["NoteRepository<br/>TagRepository<br/>UserRepository<br/>LinkRepository"]
P_AUTH["PasswordHasher"]
P_EVENTS["EventPublisher<br/>EventConsumer<br/>EventHandler"]
end
EVENTS["DomainEvent<br/><i>NoteCreated, NoteUpdated,<br/>NoteDeleted</i><br/>EventEnvelope (ack / nack)"]
end
subgraph ApiTypes["api-types (0 domain deps)"]
DTO["DTOs<br/><i>NoteResponse, TagResponse,<br/>UserResponse, AuthResponse,<br/>BackupData, ConfigResponse,<br/>ErrorResponse, ...</i>"]
end
subgraph Adapters["Adapters (implement Port Traits)"]
direction TB
subgraph Storage["Storage"]
A_SQLITE["sqlite<br/><i>SQLiteNoteRepository<br/>SqliteTagRepository<br/>SqliteUserRepository<br/>SqliteLinkRepository<br/>Migrations (FTS5)</i>"]
end
subgraph Auth["Auth"]
A_AUTH["auth<br/><i>Argon2PasswordHasher<br/>JwtValidator (HS256)<br/>OidcService (optional)</i>"]
end
subgraph Messaging["Messaging"]
A_NATS["nats<br/><i>JetStream publisher + consumer<br/>Explicit ack/nack, backoff,<br/>DLQ via max_deliver advisory</i>"]
A_MEM["event-publisher-memory<br/><i>Broadcast bus for dev/test</i>"]
A_PAYLOAD["event-payload<br/><i>DomainEvent ↔ JSON wire format</i>"]
end
subgraph Smart["Smart Features"]
A_FASTEMBED["fastembed<br/><i>FastEmbedGenerator<br/>AllMiniLML6V2 (384-dim)</i>"]
A_QDRANT["qdrant<br/><i>QdrantVectorStore<br/>upsert, find_similar, delete</i>"]
end
end
%% Dependency arrows
BOOTSTRAP -->|"uses"| Wiring
BOOTSTRAP -->|"maps to"| ApiTypes
WORKER -->|"uses"| Wiring
Wiring -->|"assembles"| Application
Wiring -->|"constructs"| Adapters
Application -->|"depends on"| Domain
Adapters -.->|"implements"| Ports
%% Key flows
BOOTSTRAP ===|"JWT Bearer"| DTO
WORKER ===|"DomainEvent"| EVENTS
classDef domain fill:#1a1a2e,stroke:#e94560,color:#fff
classDef app fill:#16213e,stroke:#0f3460,color:#fff
classDef adapter fill:#0f3460,stroke:#533483,color:#fff
classDef binary fill:#533483,stroke:#e94560,color:#fff
classDef api fill:#2a2a4a,stroke:#e94560,color:#fff
classDef wiring fill:#0d2137,stroke:#22c55e,color:#fff
class Domain domain
class Application app
class Adapters adapter
class Binaries binary
class ApiTypes api
class Wiring wiring

779
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,47 @@
[workspace]
resolver = "3"
members = [ "notes-api", "notes-domain", "notes-infra", "notes-worker"]
members = [
"crates/adapters/auth",
"crates/adapters/event-payload",
"crates/adapters/event-publisher-memory",
"crates/adapters/fastembed",
"crates/adapters/nats",
"crates/adapters/qdrant",
"crates/adapters/sqlite",
"crates/api-types",
"crates/application",
"crates/bootstrap",
"crates/domain",
"crates/presentation",
"crates/wiring",
"crates/worker",
]
[workspace.dependencies]
api-types = { path = "crates/api-types" }
auth = { path = "crates/adapters/auth" }
event-payload = { path = "crates/adapters/event-payload" }
event-publisher-memory = { path = "crates/adapters/event-publisher-memory" }
fastembed-adapter = { path = "crates/adapters/fastembed" }
nats = { path = "crates/adapters/nats" }
qdrant-adapter = { path = "crates/adapters/qdrant" }
sqlite = { path = "crates/adapters/sqlite" }
application = { path = "crates/application" }
bootstrap = { path = "crates/bootstrap" }
domain = { path = "crates/domain" }
presentation = { path = "crates/presentation" }
wiring = { path = "crates/wiring" }
worker = { path = "crates/worker" }
tokio = { version = "1.0", features = ["full"] }
anyhow = "1.0"
thiserror = "2.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tracing = "0.1"
tracing-subscriber = { features = ["fmt", "env-filter"], version = "0.3" }
async-trait = "0.1"
rand = "0.10.1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
futures = "0.3"

View File

@@ -1,28 +1,91 @@
FROM rust:1.92 AS builder
# ----- spa -----
FROM oven/bun:1-slim AS spa-builder
WORKDIR /spa
COPY k-notes-frontend/package.json k-notes-frontend/bun.lock ./
RUN bun install --frozen-lockfile
COPY k-notes-frontend/ .
RUN bun run build
WORKDIR /app
COPY . .
# ----- build -----
FROM rust:slim-bookworm AS builder
# Build the release binary
RUN cargo build --release -p notes-api -p notes-worker
WORKDIR /build
# NOTE: before building, remove the legacy crates (notes-api, notes-domain,
# notes-infra, notes-worker) from the [workspace] members in Cargo.toml —
# they depend on k-core (a private git repo) not accessible in CI/CD.
# Copy workspace manifests first so Docker can cache the dep-fetch layer.
COPY Cargo.toml Cargo.lock ./
COPY crates/adapters/auth/Cargo.toml crates/adapters/auth/Cargo.toml
COPY crates/adapters/event-payload/Cargo.toml crates/adapters/event-payload/Cargo.toml
COPY crates/adapters/event-publisher-memory/Cargo.toml crates/adapters/event-publisher-memory/Cargo.toml
COPY crates/adapters/fastembed/Cargo.toml crates/adapters/fastembed/Cargo.toml
COPY crates/adapters/nats/Cargo.toml crates/adapters/nats/Cargo.toml
COPY crates/adapters/qdrant/Cargo.toml crates/adapters/qdrant/Cargo.toml
COPY crates/adapters/sqlite/Cargo.toml crates/adapters/sqlite/Cargo.toml
COPY crates/api-types/Cargo.toml crates/api-types/Cargo.toml
COPY crates/application/Cargo.toml crates/application/Cargo.toml
COPY crates/bootstrap/Cargo.toml crates/bootstrap/Cargo.toml
COPY crates/domain/Cargo.toml crates/domain/Cargo.toml
COPY crates/presentation/Cargo.toml crates/presentation/Cargo.toml
COPY crates/wiring/Cargo.toml crates/wiring/Cargo.toml
COPY crates/worker/Cargo.toml crates/worker/Cargo.toml
# Stub every crate so Cargo can resolve and fetch all dependencies.
RUN find crates -name "Cargo.toml" | sed 's|/Cargo.toml||' | \
xargs -I{} sh -c 'mkdir -p {}/src && printf "fn main(){}\n" > {}/src/main.rs && touch {}/src/lib.rs'
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
libssl-dev \
libstdc++-12-dev \
&& rm -rf /var/lib/apt/lists/*
# ORT_STRATEGY=download fetches a prebuilt ONNX Runtime binary used by
# fastembed (smart features). Only initialised at runtime when QDRANT_URL is set.
ENV ORT_STRATEGY=download
RUN cargo fetch
# Copy real source and adapter migrations.
COPY crates/ crates/
COPY crates/adapters/sqlite/migrations/ crates/adapters/sqlite/migrations/
RUN cargo build --release -p bootstrap -p worker
# ----- runtime -----
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
libssl3 \
libgomp1 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN mkdir -p /app/data/model-cache
# Install OpenSSL (required for many Rust networking crates) and CA certificates
RUN apt-get update && apt-get install -y libssl3 ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/release/bootstrap ./bootstrap
COPY --from=builder /build/target/release/worker ./worker
COPY --from=builder /app/target/release/notes-api .
COPY --from=builder /app/target/release/notes-worker .
# Copy ONNX Runtime shared library (required when smart features are enabled).
RUN find /build 2>/dev/null || true
COPY --from=builder /build/target/release/build/ /tmp/build/
RUN find /tmp/build -name "libonnxruntime*" -exec cp {} /app/ \; 2>/dev/null || true \
&& rm -rf /tmp/build
# Create data directory for SQLite
RUN mkdir -p /app/data
ENV DATABASE_URL=sqlite:///app/data/notes.db
ENV SESSION_SECRET=supersecretchangeinproduction
# Frontend dist — served at / by bootstrap when SPA_DIR is set.
COPY --from=spa-builder /spa/dist ./frontend/dist
EXPOSE 3000
CMD ["./notes-api"]
ENV RUST_LOG=bootstrap=info,tower_http=info,worker=info
ENV LD_LIBRARY_PATH=/app
ENV SPA_DIR=/app/frontend/dist
# FastEmbed downloads the embedding model here on first use.
# Mount a persistent volume to avoid re-downloading across container restarts.
ENV FASTEMBED_CACHE_DIR=/app/data/model-cache
CMD ["./bootstrap"]

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 Gabriel Kaszewski
Copyright (c) 2025-2026 Gabriel Kaszewski
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,48 +1,66 @@
.PHONY: setup dev test check clean migrate
.DEFAULT_GOAL := check
# Database URL for development
DATABASE_URL ?= sqlite:data.db?mode=rwc
IMAGE ?= registry.gabrielkaszewski.dev/k-notes:latest
# Setup development environment
setup:
@echo "🔧 Setting up K-Notes development environment..."
cargo build --workspace
@echo "✅ Setup complete!"
# ── Local dev ─────────────────────────────────────────────────────────────────
# Run the development server
# Start the API server. JWT_SECRET is required; this default is dev-only.
dev:
@echo "🚀 Starting K-Notes API server..."
DATABASE_URL=$(DATABASE_URL) cargo run --package notes-api
DATABASE_URL=$(DATABASE_URL) JWT_SECRET=dev-secret-not-for-production-use \
cargo run -p bootstrap
# Start the background worker (uses in-memory bus when NATS_URL is unset).
dev-worker:
DATABASE_URL=$(DATABASE_URL) cargo run -p worker
# Start the Vite dev server for the frontend (expects the API on :3000).
dev-frontend:
cd k-notes-frontend && bun run dev
# Build the frontend SPA into k-notes-frontend/dist.
build-frontend:
cd k-notes-frontend && bun install && bun run build
# ── Quality checks ────────────────────────────────────────────────────────────
# Run the full check suite (same order as CI).
check: fmt-check clippy test
@echo "✅ All checks passed"
fmt:
cargo fmt
fmt-check:
cargo fmt --check
clippy:
cargo clippy --workspace -- -D warnings
# Run all tests
test:
@echo "🧪 Running tests..."
cargo test --workspace
# Check code compiles
check:
cargo check --workspace
# Apply fmt + clippy fixes in one shot.
fix:
cargo fmt
cargo clippy --fix --allow-dirty --allow-staged
# ── Docker ────────────────────────────────────────────────────────────────────
docker-build:
docker buildx build --platform linux/amd64 -t $(IMAGE) .
docker-push:
docker push $(IMAGE)
deploy:
IMAGE=$(IMAGE) ./deploy.sh
# ── Housekeeping ──────────────────────────────────────────────────────────────
# Clean build artifacts
clean:
cargo clean
rm -f data.db data.db-wal data.db-shm
# Run migrations (done automatically on server start)
migrate:
@echo "📦 Running database migrations..."
DATABASE_URL=$(DATABASE_URL) cargo run --package notes-api -- --migrate-only 2>/dev/null || \
(cargo run --package notes-api & sleep 2 && kill $$!)
@echo "✅ Migrations complete!"
# Run clippy lints
lint:
cargo clippy --workspace -- -D warnings
# Format code
fmt:
cargo fmt --all
# Quick development cycle
quick: check test
@echo "✅ All checks passed!"
.PHONY: dev dev-worker dev-frontend build-frontend check fmt fmt-check clippy test fix \
docker-build docker-push deploy clean

240
README.md
View File

@@ -1,148 +1,188 @@
# K-Notes
A modern, self-hosted note-taking application built with performance, security, and clean architecture in mind.
A self-hosted note-taking engine built in Rust with a strict hexagonal + DDD architecture.
![K-Notes Logo](k-notes-frontend/public/logo.png)
## Features
- **Authentication**: Secure user registration and login.
- **Note Management**: Create, edit, pin, archive, and delete notes.
- **Rich Text**: Markdown support for note content.
- **Version History**: Track changes, view history, note diffs, download versions, and restore previous states.
- **Organization**: Tagging system for easy filtering.
- **Smart Features**: Semantic search and automatically generated related notes using local embeddings.
- **Theme**: Dark and Light mode support.
- **Responsive**: Mobile-friendly UI built with Tailwind CSS.
- **Architecture**:
- **Backend**: Hexagonal Architecture (Domain, Infra, API layers) in Rust.
- **Infrastructure**: Configurable database backends (SQLite, Postgres).
- **Frontend**: Modern React with TypeScript and Vite.
- **Deployment**: Full Docker support with `compose.yml`.
- **Authentication** — JWT-based login and registration (disable registration via `ALLOW_REGISTRATION=false`)
- **Note Management** — create, update, pin, archive, delete, version history
- **Markdown** — content stored and served as Markdown
- **Tagging** — user-scoped tags with get-or-create semantics
- **Search** — full-text search via SQLite FTS5
- **Smart Features** — semantic similarity links between notes using local embeddings (fastembed) and Qdrant; enabled when `QDRANT_URL` is set
- **Export / Import** — portable JSON backup and restore
- **API Docs** — Swagger UI at `/docs`, Scalar at `/scalar`
- **SPA** — React frontend served at `/` by the same process
## Tech Stack
### Backend
- **Language**: Rust
- **Framework**: Axum
- **Database**: SQLite (Default) or Postgres (Supported via feature flag)
- **Vector Database**: Qdrant (for Smart Features)
- **Dependency Injection**: Manual wiring for clear boundaries
| Layer | Technology |
|-------|-----------|
| Language | Rust |
| HTTP | Axum 0.8 |
| Database | SQLite (sqlx + FTS5) |
| Events | NATS JetStream (prod) · in-memory bus (dev) |
| Embeddings | fastembed (AllMiniLML6V2) |
| Vector store | Qdrant |
| Auth | JWT (jsonwebtoken + argon2) |
| API docs | utoipa + Scalar + Swagger UI |
### Frontend
- **Framework**: React + Vite
- **Language**: TypeScript
- **Styling**: Tailwind CSS + Shadcn UI
- **State Management**: TanStack Query (React Query)
| Layer | Technology |
|-------|-----------|
| Framework | React + Vite |
| Language | TypeScript |
| Styling | Tailwind CSS + shadcn/ui |
| State | TanStack Query |
| Package manager | Bun |
## Architecture
The backend follows **Hexagonal Architecture + CQRS**:
```
crates/
domain/ # Entities, value objects, ports (traits)
application/ # Use cases (commands + queries), WorkerService
adapters/
sqlite/ # NoteRepository, TagRepository, UserRepository, LinkRepository
auth/ # Argon2PasswordHasher, JwtValidator, OidcService
nats/ # NatsEventPublisher, NatsEventConsumer (JetStream)
event-publisher-memory/ # In-memory bus for dev/test
event-payload/ # DomainEvent ↔ wire format (JSON)
fastembed/ # EmbeddingGenerator implementation
qdrant/ # VectorStore implementation
wiring/ # Assembles AppContext from env vars
presentation/ # Axum routes, OpenAPI, SPA serving
api-types/ # Request/response DTOs (no domain dependency)
bootstrap/ # HTTP server binary
worker/ # Background event processor binary
```
Dependency direction: `domain ← application ← {presentation, worker}`. Adapters depend on domain only. `wiring` assembles everything.
## Getting Started
### Docker (Recommended)
Run the entire stack with a single command:
### Docker (recommended)
```bash
docker compose up -d --build
docker compose up -d
```
- **Frontend**: http://localhost:8080
- **Backend**: http://localhost:3000
- **App + API**: http://localhost:3000
- **API docs**: http://localhost:3000/docs
The frontend is automatically configured to talk to the backend.
### Local development
### Local Development
#### Prerequisites
#### Backend
- Rust stable (`rustup update stable`)
- Bun (`curl -fsSL https://bun.sh/install | bash`)
1. Navigate to the `notes-api` directory (or root).
2. Set up the environment variables (see `.env.example`).
3. Run the server:
#### Quickstart
```bash
cargo run -p notes-api
cp .env.example .env # edit JWT_SECRET at minimum
make dev # API server on :3000
make dev-frontend # Vite dev server on :5173 (separate terminal)
make dev-worker # background worker (separate terminal, optional)
```
The API server also serves the SPA if you run `make build-frontend` first and `SPA_DIR` points at the dist directory. For active frontend development, use the Vite dev server instead — it hot-reloads and proxies API calls to `:3000`.
By default, this uses the **SQLite** backend.
#### Environment variables
#### Configuration
| Variable | Process | Required | Default | Description |
|----------|---------|----------|---------|-------------|
| `DATABASE_URL` | both | yes | — | SQLite path, e.g. `sqlite:data.db?mode=rwc` |
| `JWT_SECRET` | backend | yes | — | HS256 signing secret — generate with `openssl rand -hex 32` |
| `NATS_URL` | both | no | — | NATS JetStream URL; in-memory bus used if unset |
| `QDRANT_URL` | both | no | — | Enables smart features (semantic links) |
| `ENABLE_EMBEDDINGS` | **worker only** | no | `false` | Set `true` in the worker to load the fastembed model (~150 MB). Leave unset in the backend to save memory. |
| `QDRANT_COLLECTION` | both | no | `notes` | Qdrant collection name |
| `QDRANT_VECTOR_SIZE` | both | no | `384` | Must match the embedding model output dimension |
| `ALLOW_REGISTRATION` | backend | no | `true` | Set `false` to close public registration |
| `SPA_DIR` | backend | no | `k-notes-frontend/dist` | Path to built frontend; set empty for API-only mode |
| `CORS_ORIGINS` | backend | no | — | Comma-separated allowed origins |
| `PORT` | backend | no | `3000` | HTTP listen port |
| `HOST` | backend | no | `0.0.0.0` | HTTP listen address |
| `NATS_MAX_DELIVER` | worker | no | `5` | JetStream dead-letter threshold |
| `SMART_NEIGHBOUR_LIMIT` | worker | no | `10` | Max semantic links per note |
| `SMART_MIN_SIMILARITY` | worker | no | `0.7` | Cosine similarity threshold |
The application is configured via environment variables (or `.env` file):
See `.env.example` for a commented template.
- `ALLOW_REGISTRATION`: Set to `false` to disable new user registration (default: `true`).
- `DATABASE_URL`: Connection string for the database.
- `SESSION_SECRET`: Secret key for session encryption.
- `CORS_ALLOWED_ORIGINS`: Comma-separated list of allowed origins.
## API
**Running with Postgres:**
All endpoints are under `/api/v1`. Full interactive docs at `/docs` (Swagger) or `/scalar` after starting the server.
To use PostgreSQL, build with the `postgres` feature:
```bash
cargo run -p notes-api --no-default-features --features notes-infra/postgres
```
*Note: Ensure your `DATABASE_URL` is set to a valid Postgres connection string.*
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/auth/login` | — | Login, returns JWT |
| POST | `/auth/register` | — | Register (if enabled) |
| GET | `/auth/me` | ✓ | Current user |
| GET | `/config` | — | Server capabilities |
| GET | `/notes` | ✓ | List notes (filter: pinned, archived, tag) |
| POST | `/notes` | ✓ | Create note |
| GET | `/notes/:id` | ✓ | Get note |
| PATCH | `/notes/:id` | ✓ | Update note |
| DELETE | `/notes/:id` | ✓ | Delete note |
| PATCH | `/notes/:id/pin` | ✓ | Pin / unpin |
| PATCH | `/notes/:id/archive` | ✓ | Archive / unarchive |
| GET | `/notes/:id/versions` | ✓ | Version history |
| GET | `/notes/:id/related` | ✓ | Semantically related notes |
| POST | `/notes/:id/tags` | ✓ | Add tag by name |
| DELETE | `/notes/:id/tags/:tag_id` | ✓ | Remove tag |
| GET | `/search?q=` | ✓ | Full-text search |
| GET | `/tags` | ✓ | List tags |
| POST | `/tags` | ✓ | Create tag |
| DELETE | `/tags/:id` | ✓ | Delete tag |
| PATCH | `/tags/:id` | ✓ | Rename tag |
| GET | `/export` | ✓ | Export all data as JSON |
| POST | `/import` | ✓ | Import from backup JSON |
**Feature Flags (Smart Features):**
The application includes "Smart Features" (semantic search, related notes) enabled by default. These require `fastembed`, `qdrant-client`, and `async-nats`.
To build/run **without** smart features (for faster compilation or lighter deployment):
## Deployment
```bash
cargo run -p notes-api --no-default-features --features sqlite
# Build and push image
make deploy
# Or manually
IMAGE=your-registry/k-notes:latest make docker-build docker-push
```
#### Frontend
The `CMD` in the Dockerfile starts `bootstrap` (HTTP server). Run `./worker` as a separate container or process for background event processing.
1. Navigate to `k-notes-frontend`.
2. Install dependencies:
```bash
bun install
```
3. Run the dev server:
```bash
bun dev
```
## Database Architecture
The backend follows a Hexagonal Architecture (Ports and Adapters). The `notes-domain` crate defines the repository capabilities (Ports), and `notes-infra` implements them (Adapters).
### Supported Databases
- **SQLite**: Fully implemented (default). Ideal for single-instance, self-hosted deployments.
- **Postgres**: Structure is in place (via feature flag), ready for implementation.
### Extending Database Support
To add a new database (e.g., MySQL), follow these steps:
1. **Dependencies**: Add the driver to `notes-infra/Cargo.toml` (e.g., `sqlx` with `mysql` feature) and create a feature flag.
2. **Configuration**: Update `DatabaseConfig` in `notes-infra/src/db.rs` to handle the new connection URL scheme and connection logic in `create_pool`.
3. **Repository Implementation**:
- Implement `NoteRepository`, `TagRepository`, and `UserRepository` traits for the new database in `notes-infra`.
4. **Factory Integration**:
- Update `notes-infra/src/factory.rs` to include a builder for the new repositories.
- Update `build_database_pool` and repository `build_*` functions to support the new database type match arm.
5. **Migrations**:
- Add migration files in `migrations/<db_type>`.
- Update `run_migrations` in `db.rs` to execute them.
This design ensures the `notes-api` layer remains completely agnostic to the underlying database technology.
**Docker Compose** volumes to mount:
- `/app/data` — SQLite database file
- `/app/data/model-cache` — fastembed model cache (avoids re-download on restart)
## Project Structure
```
├── notes-api # API Interface (Axum, HTTP routes)
├── notes-domain # Core Business Logic (Entities, Services, Ports)
├── notes-infra # Infrastructure (Database adapters, Repositories)
├── k-notes-frontend # React Frontend Application
├── migrations # SQLx Database Migrations
└── compose.yml # Docker Composition
crates/ # New architecture (active)
adapters/ # Infrastructure adapters
api-types/ # HTTP DTOs
application/ # Use cases + WorkerService
bootstrap/ # API server binary
domain/ # Core domain
presentation/ # Axum + OpenAPI + SPA
wiring/ # Dependency assembly
worker/ # Event worker binary
k-notes-frontend/ # React SPA
migrations/ # Legacy migration files (see crates/adapters/sqlite/migrations/)
notes-api/ ⚠ DEPRECATED — will be removed in a future release
notes-domain/ ⚠ DEPRECATED — will be removed in a future release
notes-infra/ ⚠ DEPRECATED — will be removed in a future release
notes-worker/ ⚠ DEPRECATED — will be removed in a future release
```
> **Note**: The `notes-*` directories contain the original monolithic implementation and are kept for reference only. They are excluded from the workspace and are not built. All active development happens in `crates/`.
## License
MIT
MIT — Copyright (c) 2025-2026 Gabriel Kaszewski

66
compose.prod.yml Normal file
View File

@@ -0,0 +1,66 @@
services:
backend:
image: registry.gabrielkaszewski.dev/k-notes:latest
environment:
- DATABASE_URL=sqlite:///app/data/notes.db
- JWT_SECRET=change-me
- JWT_ISSUER=https://idm.gabrielkaszewski.dev/oauth/v2/keys
- CORS_ORIGINS=https://knotes.gabrielkaszewski.dev
- HOST=0.0.0.0
- PORT=3000
- ALLOW_REGISTRATION=false
- NATS_URL=nats://k_nats:4222
- QDRANT_URL=http://qdrant:6334
- QDRANT_COLLECTION=notes
# SPA is bundled in the image at /app/frontend/dist (set in Dockerfile)
# Override here only if you move the dist elsewhere:
# - SPA_DIR=/app/frontend/dist
volumes:
- ./data:/app/data
networks:
- traefik
- shared-services
- internal
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
# Both the app domain and the API subdomain point to the same service.
# The backend serves /api/v1/* and falls back to the SPA for everything else.
- "traefik.http.routers.knotes.rule=Host(`knotes.gabrielkaszewski.dev`) || Host(`api.knotes.gabrielkaszewski.dev`)"
- "traefik.http.routers.knotes.entrypoints=websecure"
- "traefik.http.routers.knotes.tls.certresolver=letsencrypt"
- "traefik.http.services.knotes.loadbalancer.server.port=3000"
worker:
image: registry.gabrielkaszewski.dev/k-notes:latest
command: ["./worker"]
environment:
- DATABASE_URL=sqlite:///app/data/notes.db
- NATS_URL=nats://k_nats:4222
- QDRANT_URL=http://qdrant:6334
- ENABLE_EMBEDDINGS=true
depends_on:
- backend
- qdrant
volumes:
- ./data:/app/data
networks:
- internal
- shared-services
qdrant:
image: qdrant/qdrant:latest
container_name: k_notes_qdrant
volumes:
- ./data/qdrant_storage:/qdrant/storage:z
restart: unless-stopped
networks:
- internal
networks:
traefik:
external: true
shared-services:
external: true
internal:
driver: bridge

View File

@@ -4,60 +4,48 @@ services:
ports:
- "3000:3000"
environment:
# In production, use a secure secret
- SESSION_SECRET=dev_secret_key_12345
- DATABASE_URL=sqlite:///app/data/notes.db
- CORS_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5173
- JWT_SECRET=dev-secret-not-for-production-use
- CORS_ORIGINS=http://localhost:5173
- HOST=0.0.0.0
- PORT=3000
- ALLOW_REGISTRATION=true
- NATS_URL=nats://nats:4222
# Uncomment to enable smart features:
# - QDRANT_URL=http://qdrant:6334
volumes:
- ./data:/app/data
depends_on:
- nats
worker:
build: .
command: ["./notes-worker"]
command: ["./worker"]
environment:
- DATABASE_URL=sqlite:///app/data/notes.db
- BROKER_URL=nats://nats:4222
- QDRANT_URL=http://qdrant:6334
- EMBEDDING_PROVIDER=fastembed
- NATS_URL=nats://nats:4222
- ENABLE_EMBEDDINGS=true
# Uncomment to enable smart features:
# - QDRANT_URL=http://qdrant:6334
depends_on:
- backend
- nats
- qdrant
volumes:
- ./data:/app/data
frontend:
build: ./k-notes-frontend
ports:
- "8080:80"
environment:
# This sets the default backend URL for the frontend
- API_URL=http://localhost:3000
depends_on:
- backend
nats:
image: nats:alpine
container_name: k_notes_nats
# --jetstream enables persistent messaging required by the worker
command: ["--jetstream"]
ports:
- "4222:4222"
- "6222:6222"
- "8222:8222"
restart: unless-stopped
qdrant:
image: qdrant/qdrant:latest
container_name: k_notes_qdrant
ports:
- "6333:6333"
- "6334:6334"
volumes:
- ./data/qdrant_storage:/qdrant/storage:z
restart: unless-stopped
# Optional: Define volumes explicitly if needed
# volumes:
# backend_data:
# Uncomment to enable smart features (semantic search + related notes)
# qdrant:
# image: qdrant/qdrant:latest
# ports:
# - "6334:6334"
# volumes:
# - ./data/qdrant_storage:/qdrant/storage:z
# restart: unless-stopped

View File

@@ -0,0 +1,27 @@
[package]
name = "auth"
version = "0.1.0"
edition = "2024"
[features]
default = []
jwt = ["dep:jsonwebtoken"]
oidc = ["dep:openidconnect", "dep:reqwest"]
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
serde = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
argon2 = "0.5"
url = "2"
jsonwebtoken = { version = "10", features = ["rust_crypto"], optional = true }
openidconnect = { version = "4", optional = true }
reqwest = { version = "0.12", features = ["json"], optional = true }
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -0,0 +1,30 @@
/// Config for OIDC. Validated when constructing OidcService.
#[derive(Debug, Clone)]
pub struct OidcConfig {
pub issuer_url: String,
pub client_id: String,
pub client_secret: Option<String>,
pub redirect_url: String,
/// Optional audience / resource ID for token validation.
pub resource_id: Option<String>,
}
/// Config for JWT. Validated when constructing JwtValidator.
#[derive(Debug, Clone)]
pub struct JwtConfig {
pub secret: String,
pub issuer: Option<String>,
pub audience: Option<String>,
pub expiry_hours: u64,
}
impl JwtConfig {
pub fn new(secret: impl Into<String>) -> Self {
Self {
secret: secret.into(),
issuer: None,
audience: None,
expiry_hours: 24,
}
}
}

View File

@@ -0,0 +1,100 @@
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
use serde::{Deserialize, Serialize};
use std::time::{SystemTime, UNIX_EPOCH};
use thiserror::Error;
use domain::user::entity::User;
use crate::config::JwtConfig;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct JwtClaims {
pub sub: String,
pub email: String,
pub exp: usize,
pub iat: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub iss: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aud: Option<String>,
}
#[derive(Debug, Error)]
pub enum JwtError {
#[error("token creation failed: {0}")]
Creation(#[from] jsonwebtoken::errors::Error),
#[error("token expired")]
Expired,
#[error("invalid token: {0}")]
Invalid(String),
}
pub struct JwtValidator {
config: JwtConfig,
encoding_key: EncodingKey,
decoding_key: DecodingKey,
validation: Validation,
}
impl JwtValidator {
pub fn new(config: JwtConfig) -> Self {
let encoding_key = EncodingKey::from_secret(config.secret.as_bytes());
let decoding_key = DecodingKey::from_secret(config.secret.as_bytes());
let mut validation = Validation::new(Algorithm::HS256);
if let Some(ref iss) = config.issuer {
validation.set_issuer(&[iss]);
}
if let Some(ref aud) = config.audience {
validation.set_audience(&[aud]);
}
Self {
config,
encoding_key,
decoding_key,
validation,
}
}
pub fn create_token(&self, user: &User) -> Result<String, JwtError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before epoch")
.as_secs() as usize;
let claims = JwtClaims {
sub: user.id.as_uuid().to_string(),
email: user.email.as_ref().to_string(),
exp: now + self.config.expiry_hours as usize * 3600,
iat: now,
iss: self.config.issuer.clone(),
aud: self.config.audience.clone(),
};
encode(&Header::new(Algorithm::HS256), &claims, &self.encoding_key)
.map_err(JwtError::Creation)
}
pub fn validate_token(&self, token: &str) -> Result<JwtClaims, JwtError> {
decode::<JwtClaims>(token, &self.decoding_key, &self.validation)
.map(|td| td.claims)
.map_err(|e| match e.kind() {
jsonwebtoken::errors::ErrorKind::ExpiredSignature => JwtError::Expired,
_ => JwtError::Invalid(e.to_string()),
})
}
}
impl std::fmt::Debug for JwtValidator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JwtValidator")
.field("issuer", &self.config.issuer)
.field("expiry_hours", &self.config.expiry_hours)
.finish_non_exhaustive()
}
}
#[cfg(test)]
#[path = "tests/jwt.rs"]
mod tests;

View File

@@ -0,0 +1,8 @@
pub mod config;
pub mod password;
#[cfg(feature = "jwt")]
pub mod jwt;
#[cfg(feature = "oidc")]
pub mod oidc;

View File

@@ -0,0 +1,178 @@
use anyhow::{Result, anyhow};
use openidconnect::{
AccessTokenHash, Client, EmptyAdditionalClaims, EndpointMaybeSet, EndpointNotSet, EndpointSet,
OAuth2TokenResponse, PkceCodeChallenge, Scope, StandardErrorResponse, TokenResponse,
UserInfoClaims,
core::{
CoreAuthDisplay, CoreAuthPrompt, CoreAuthenticationFlow, CoreClient, CoreErrorResponseType,
CoreGenderClaim, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreProviderMetadata,
CoreRevocableToken, CoreRevocationErrorResponse, CoreTokenIntrospectionResponse,
CoreTokenResponse,
},
reqwest,
};
use serde::{Deserialize, Serialize};
use crate::config::OidcConfig;
pub type OidcClient = Client<
EmptyAdditionalClaims,
CoreAuthDisplay,
CoreGenderClaim,
CoreJweContentEncryptionAlgorithm,
CoreJsonWebKey,
CoreAuthPrompt,
StandardErrorResponse<CoreErrorResponseType>,
CoreTokenResponse,
CoreTokenIntrospectionResponse,
CoreRevocableToken,
CoreRevocationErrorResponse,
EndpointSet,
EndpointNotSet,
EndpointNotSet,
EndpointNotSet,
EndpointMaybeSet,
EndpointMaybeSet,
>;
/// Data returned when starting the OIDC authorization flow.
#[derive(Debug, Clone)]
pub struct AuthorizationUrlData {
pub url: url::Url,
pub csrf_token: String,
pub nonce: String,
pub pkce_verifier: String,
}
/// Verified identity returned after a successful callback.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcUser {
pub subject: String,
pub email: String,
}
#[derive(Clone)]
pub struct OidcService {
client: OidcClient,
resource_id: Option<String>,
}
impl OidcService {
pub async fn new(config: OidcConfig) -> Result<Self> {
let http_client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let provider_metadata = CoreProviderMetadata::discover_async(
openidconnect::IssuerUrl::new(config.issuer_url)?,
&http_client,
)
.await?;
let client_secret = config
.client_secret
.filter(|s| !s.trim().is_empty())
.map(openidconnect::ClientSecret::new);
let client = CoreClient::from_provider_metadata(
provider_metadata,
openidconnect::ClientId::new(config.client_id),
client_secret,
)
.set_redirect_uri(openidconnect::RedirectUrl::new(config.redirect_url)?);
Ok(Self {
client,
resource_id: config.resource_id,
})
}
pub fn authorization_url(&self) -> AuthorizationUrlData {
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (url, csrf_token, nonce) = self
.client
.authorize_url(
CoreAuthenticationFlow::AuthorizationCode,
openidconnect::CsrfToken::new_random,
openidconnect::Nonce::new_random,
)
.add_scope(Scope::new("profile".into()))
.add_scope(Scope::new("email".into()))
.set_pkce_challenge(pkce_challenge)
.url();
AuthorizationUrlData {
url: url.into(),
csrf_token: csrf_token.secret().clone(),
nonce: nonce.secret().clone(),
pkce_verifier: pkce_verifier.secret().clone(),
}
}
pub async fn exchange_code(
&self,
code: &str,
nonce: &str,
pkce_verifier: &str,
) -> Result<OidcUser> {
let http_client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let token_response = self
.client
.exchange_code(openidconnect::AuthorizationCode::new(code.to_owned()))?
.set_pkce_verifier(openidconnect::PkceCodeVerifier::new(
pkce_verifier.to_owned(),
))
.request_async(&http_client)
.await?;
let id_token = token_response
.id_token()
.ok_or_else(|| anyhow!("server did not return an ID token"))?;
let mut verifier = self.client.id_token_verifier().clone();
if let Some(ref rid) = self.resource_id {
let rid = rid.clone();
verifier =
verifier.set_other_audience_verifier_fn(move |aud| aud.as_str() == rid.as_str());
}
let oidc_nonce = openidconnect::Nonce::new(nonce.to_owned());
let claims = id_token.claims(&verifier, &oidc_nonce)?;
if let Some(expected_hash) = claims.access_token_hash() {
let actual_hash = AccessTokenHash::from_token(
token_response.access_token(),
id_token.signing_alg()?,
id_token.signing_key(&verifier)?,
)?;
if actual_hash != *expected_hash {
return Err(anyhow!("access token hash mismatch"));
}
}
let email = match claims.email() {
Some(e) => e.as_str().to_owned(),
None => {
tracing::debug!("email absent in ID token, fetching userinfo");
let userinfo: UserInfoClaims<EmptyAdditionalClaims, CoreGenderClaim> = self
.client
.user_info(token_response.access_token().clone(), None)?
.request_async(&http_client)
.await?;
userinfo
.email()
.map(|e| e.as_str().to_owned())
.ok_or_else(|| anyhow!("no verified email in identity provider response"))?
}
};
Ok(OidcUser {
subject: claims.subject().to_string(),
email,
})
}
}

View File

@@ -0,0 +1,51 @@
use argon2::{
Argon2,
password_hash::{
PasswordHash, PasswordHasher as _, PasswordVerifier, SaltString, rand_core::OsRng,
},
};
use async_trait::async_trait;
use domain::{
errors::{DomainError, DomainResult},
user::{
ports::PasswordHasher,
value_objects::{Password, PasswordHash as DomainPasswordHash},
},
};
pub struct Argon2PasswordHasher;
#[async_trait]
impl PasswordHasher for Argon2PasswordHasher {
async fn hash(&self, password: &Password) -> DomainResult<DomainPasswordHash> {
let password_str = password.as_ref().to_owned();
tokio::task::spawn_blocking(move || {
let salt = SaltString::generate(&mut OsRng);
let hash = Argon2::default()
.hash_password(password_str.as_bytes(), &salt)
.map_err(|e| DomainError::Infrastructure(format!("hash failed: {e}")))?;
Ok(DomainPasswordHash::new(hash.to_string()))
})
.await
.map_err(|e| DomainError::Infrastructure(format!("task panicked: {e}")))?
}
async fn verify(&self, password: &Password, hash: &DomainPasswordHash) -> DomainResult<bool> {
let password_str = password.as_ref().to_owned();
let hash_str = hash.as_str().to_owned();
tokio::task::spawn_blocking(move || {
let parsed = PasswordHash::new(&hash_str)
.map_err(|e| DomainError::Infrastructure(format!("invalid hash: {e}")))?;
Ok(Argon2::default()
.verify_password(password_str.as_bytes(), &parsed)
.is_ok())
})
.await
.map_err(|e| DomainError::Infrastructure(format!("task panicked: {e}")))?
}
}
#[cfg(test)]
#[path = "tests/password.rs"]
mod tests;

View File

@@ -0,0 +1,68 @@
use domain::user::{entity::User, value_objects::Email};
use crate::{config::JwtConfig, jwt::JwtValidator};
fn validator() -> JwtValidator {
JwtValidator::new(JwtConfig::new(
"a-test-secret-that-is-long-enough-for-hs256",
))
}
fn user() -> User {
User::new_oidc("sub|123", Email::new("test@example.com").unwrap())
}
#[test]
fn create_and_validate_round_trip() {
let v = validator();
let u = user();
let token = v.create_token(&u).unwrap();
let claims = v.validate_token(&token).unwrap();
assert_eq!(claims.email, "test@example.com");
assert_eq!(claims.sub, u.id.as_uuid().to_string());
}
#[test]
fn wrong_secret_rejects_token() {
let v1 = JwtValidator::new(JwtConfig::new(
"secret-one-long-enough-for-hs256-validation",
));
let v2 = JwtValidator::new(JwtConfig::new(
"secret-two-long-enough-for-hs256-validation",
));
let token = v1.create_token(&user()).unwrap();
assert!(v2.validate_token(&token).is_err());
}
#[test]
fn invalid_token_is_rejected() {
let v = validator();
assert!(v.validate_token("not.a.valid.jwt").is_err());
}
#[test]
fn expired_token_returns_expired_error() {
use crate::jwt::JwtError;
use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
let secret = "a-test-secret-that-is-long-enough-for-hs256";
let claims = crate::jwt::JwtClaims {
sub: "user-id".into(),
email: "x@example.com".into(),
exp: 1, // epoch + 1 second — already expired
iat: 0,
iss: None,
aud: None,
};
let token = encode(
&Header::new(Algorithm::HS256),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.unwrap();
let v = JwtValidator::new(JwtConfig::new(secret));
assert!(matches!(v.validate_token(&token), Err(JwtError::Expired)));
}

View File

@@ -0,0 +1,36 @@
use domain::user::{
ports::PasswordHasher,
value_objects::{Password, PasswordHash},
};
use crate::password::Argon2PasswordHasher;
#[tokio::test]
async fn hash_produces_verifiable_hash() {
let hasher = Argon2PasswordHasher;
let password = Password::new("correcthorsebattery").unwrap();
let hash = hasher.hash(&password).await.unwrap();
assert!(hasher.verify(&password, &hash).await.unwrap());
}
#[tokio::test]
async fn wrong_password_does_not_verify() {
let hasher = Argon2PasswordHasher;
let password = Password::new("correcthorsebattery").unwrap();
let wrong = Password::new("wrongpassword12345").unwrap();
let hash = hasher.hash(&password).await.unwrap();
assert!(!hasher.verify(&wrong, &hash).await.unwrap());
}
#[tokio::test]
async fn same_password_produces_different_hashes() {
let hasher = Argon2PasswordHasher;
let password = Password::new("samepassword123").unwrap();
let hash1 = hasher.hash(&password).await.unwrap();
let hash2 = hasher.hash(&password).await.unwrap();
assert_ne!(hash1.as_str(), hash2.as_str());
}

View File

@@ -0,0 +1,11 @@
[package]
name = "event-payload"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true }
thiserror = { workspace = true }

View File

@@ -0,0 +1,86 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use domain::{
errors::DomainError, events::DomainEvent, note::entity::NoteId, user::entity::UserId,
};
/// Wire-format representation of a DomainEvent.
/// Uses primitive types only — no domain newtypes — so it is stable across
/// schema versions and safe to serialize to any transport (NATS, HTTP, file).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", content = "data")]
pub enum EventPayload {
NoteCreated { note_id: String, user_id: String },
NoteUpdated { note_id: String, user_id: String },
NoteDeleted { note_id: String, user_id: String },
}
impl EventPayload {
pub fn event_type(&self) -> &'static str {
match self {
Self::NoteCreated { .. } => "NoteCreated",
Self::NoteUpdated { .. } => "NoteUpdated",
Self::NoteDeleted { .. } => "NoteDeleted",
}
}
pub fn to_json(&self) -> Result<Vec<u8>, DomainError> {
serde_json::to_vec(self)
.map_err(|e| DomainError::Infrastructure(format!("serialize failed: {e}")))
}
pub fn from_json(bytes: &[u8]) -> Result<Self, DomainError> {
serde_json::from_slice(bytes)
.map_err(|e| DomainError::Infrastructure(format!("deserialize failed: {e}")))
}
}
impl From<&DomainEvent> for EventPayload {
fn from(event: &DomainEvent) -> Self {
match event {
DomainEvent::NoteCreated { note_id, user_id } => Self::NoteCreated {
note_id: note_id.as_uuid().to_string(),
user_id: user_id.as_uuid().to_string(),
},
DomainEvent::NoteUpdated { note_id, user_id } => Self::NoteUpdated {
note_id: note_id.as_uuid().to_string(),
user_id: user_id.as_uuid().to_string(),
},
DomainEvent::NoteDeleted { note_id, user_id } => Self::NoteDeleted {
note_id: note_id.as_uuid().to_string(),
user_id: user_id.as_uuid().to_string(),
},
}
}
}
impl TryFrom<EventPayload> for DomainEvent {
type Error = DomainError;
fn try_from(payload: EventPayload) -> Result<Self, Self::Error> {
fn parse(s: &str) -> Result<Uuid, DomainError> {
Uuid::parse_str(s)
.map_err(|e| DomainError::Infrastructure(format!("invalid uuid '{s}': {e}")))
}
match payload {
EventPayload::NoteCreated { note_id, user_id } => Ok(DomainEvent::NoteCreated {
note_id: NoteId::from_uuid(parse(&note_id)?),
user_id: UserId::from_uuid(parse(&user_id)?),
}),
EventPayload::NoteUpdated { note_id, user_id } => Ok(DomainEvent::NoteUpdated {
note_id: NoteId::from_uuid(parse(&note_id)?),
user_id: UserId::from_uuid(parse(&user_id)?),
}),
EventPayload::NoteDeleted { note_id, user_id } => Ok(DomainEvent::NoteDeleted {
note_id: NoteId::from_uuid(parse(&note_id)?),
user_id: UserId::from_uuid(parse(&user_id)?),
}),
}
}
}
#[cfg(test)]
#[path = "tests/lib.rs"]
mod tests;

View File

@@ -0,0 +1,88 @@
use domain::{events::DomainEvent, note::entity::NoteId, user::entity::UserId};
use crate::EventPayload;
fn note_created() -> DomainEvent {
DomainEvent::NoteCreated {
note_id: NoteId::new(),
user_id: UserId::new(),
}
}
#[test]
fn domain_event_round_trips_through_payload() {
let event = note_created();
let payload = EventPayload::from(&event);
let recovered = DomainEvent::try_from(payload).unwrap();
// Compare by serialising both — DomainEvent doesn't implement PartialEq.
let EventPayload::NoteCreated {
note_id: orig_nid,
user_id: orig_uid,
} = EventPayload::from(&event)
else {
panic!("wrong variant");
};
let EventPayload::NoteCreated {
note_id: rec_nid,
user_id: rec_uid,
} = EventPayload::from(&recovered)
else {
panic!("wrong variant");
};
assert_eq!(orig_nid, rec_nid);
assert_eq!(orig_uid, rec_uid);
}
#[test]
fn payload_serialises_to_json_and_back() {
let event = note_created();
let payload = EventPayload::from(&event);
let bytes = payload.to_json().unwrap();
let recovered = EventPayload::from_json(&bytes).unwrap();
assert_eq!(payload, recovered);
}
#[test]
fn event_type_label_is_correct() {
let uid = UserId::new();
let nid = NoteId::new();
assert_eq!(
EventPayload::NoteCreated {
note_id: nid.to_string(),
user_id: uid.to_string()
}
.event_type(),
"NoteCreated"
);
assert_eq!(
EventPayload::NoteUpdated {
note_id: nid.to_string(),
user_id: uid.to_string()
}
.event_type(),
"NoteUpdated"
);
assert_eq!(
EventPayload::NoteDeleted {
note_id: nid.to_string(),
user_id: uid.to_string()
}
.event_type(),
"NoteDeleted"
);
}
#[test]
fn invalid_json_returns_error() {
assert!(EventPayload::from_json(b"not json at all").is_err());
}
#[test]
fn invalid_uuid_in_payload_returns_error() {
let payload = EventPayload::NoteCreated {
note_id: "not-a-uuid".into(),
user_id: "also-not-a-uuid".into(),
};
assert!(DomainEvent::try_from(payload).is_err());
}

View File

@@ -0,0 +1,11 @@
[package]
name = "event-publisher-memory"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
futures = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }

View File

@@ -0,0 +1,85 @@
use std::sync::Arc;
use async_trait::async_trait;
use futures::stream::BoxStream;
use tokio::sync::broadcast;
use domain::{
errors::DomainError,
events::{DomainEvent, EventConsumer, EventEnvelope, EventPublisher},
};
const CHANNEL_CAPACITY: usize = 256;
/// Shared in-memory event bus backed by a tokio broadcast channel.
/// Create one bus, then hand out publisher and consumer handles from it.
pub struct MemoryEventBus {
sender: broadcast::Sender<DomainEvent>,
}
impl MemoryEventBus {
pub fn new() -> Self {
let (sender, _) = broadcast::channel(CHANNEL_CAPACITY);
Self { sender }
}
pub fn publisher(&self) -> Arc<MemoryEventPublisher> {
Arc::new(MemoryEventPublisher {
sender: self.sender.clone(),
})
}
pub fn consumer(&self) -> Arc<MemoryEventConsumer> {
Arc::new(MemoryEventConsumer {
sender: self.sender.clone(),
})
}
}
impl Default for MemoryEventBus {
fn default() -> Self {
Self::new()
}
}
pub struct MemoryEventPublisher {
sender: broadcast::Sender<DomainEvent>,
}
#[async_trait]
impl EventPublisher for MemoryEventPublisher {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
// send() only fails when there are no receivers; that is fine in dev/test.
let _ = self.sender.send(event.clone());
Ok(())
}
}
pub struct MemoryEventConsumer {
sender: broadcast::Sender<DomainEvent>,
}
impl EventConsumer for MemoryEventConsumer {
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
let rx = self.sender.subscribe();
Box::pin(futures::stream::unfold(rx, |mut rx| async move {
loop {
match rx.recv().await {
Ok(event) => {
let envelope = EventEnvelope::noop(event);
return Some((Ok(envelope), rx));
}
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!("memory event bus: consumer lagged, skipped {n} messages");
}
Err(broadcast::error::RecvError::Closed) => return None,
}
}
}))
}
}
#[cfg(test)]
#[path = "tests/lib.rs"]
mod tests;

View File

@@ -0,0 +1,75 @@
use futures::StreamExt;
use domain::{
events::{DomainEvent, EventConsumer, EventPublisher},
note::entity::NoteId,
user::entity::UserId,
};
use crate::MemoryEventBus;
fn note_updated() -> DomainEvent {
DomainEvent::NoteUpdated {
note_id: NoteId::new(),
user_id: UserId::new(),
}
}
#[tokio::test]
async fn published_event_is_received_by_consumer() {
let bus = MemoryEventBus::new();
let publisher = bus.publisher();
let consumer = bus.consumer();
let event = note_updated();
let mut stream = consumer.consume();
publisher.publish(&event).await.unwrap();
let envelope = stream.next().await.unwrap().unwrap();
assert!(matches!(envelope.event, DomainEvent::NoteUpdated { .. }));
}
#[tokio::test]
async fn ack_on_memory_envelope_is_noop() {
let bus = MemoryEventBus::new();
let publisher = bus.publisher();
let consumer = bus.consumer();
// Subscribe before publishing — broadcast drops messages sent before subscribe.
let mut stream = consumer.consume();
publisher.publish(&note_updated()).await.unwrap();
let envelope = stream.next().await.unwrap().unwrap();
envelope.ack().await.unwrap();
}
#[tokio::test]
async fn multiple_consumers_each_receive_the_event() {
let bus = MemoryEventBus::new();
let publisher = bus.publisher();
let c1 = bus.consumer();
let c2 = bus.consumer();
let mut s1 = c1.consume();
let mut s2 = c2.consume();
publisher.publish(&note_updated()).await.unwrap();
assert!(matches!(
s1.next().await.unwrap().unwrap().event,
DomainEvent::NoteUpdated { .. }
));
assert!(matches!(
s2.next().await.unwrap().unwrap().event,
DomainEvent::NoteUpdated { .. }
));
}
#[tokio::test]
async fn publish_with_no_consumer_does_not_error() {
let bus = MemoryEventBus::new();
let publisher = bus.publisher();
// No consumer — publish should silently succeed.
publisher.publish(&note_updated()).await.unwrap();
}

View File

@@ -0,0 +1,14 @@
[package]
name = "fastembed-adapter"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
fastembed = "5"
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -0,0 +1,88 @@
use std::{
path::PathBuf,
sync::{Arc, Mutex},
};
use async_trait::async_trait;
use fastembed::{EmbeddingModel, TextEmbedding, TextInitOptions};
use domain::{
errors::{DomainError, DomainResult},
smart::ports::EmbeddingGenerator,
};
pub struct FastEmbedConfig {
pub model: EmbeddingModel,
/// Directory used to cache downloaded model files.
/// Defaults to the system cache directory when `None`.
pub cache_dir: Option<PathBuf>,
pub show_download_progress: bool,
}
impl Default for FastEmbedConfig {
fn default() -> Self {
Self {
model: EmbeddingModel::AllMiniLML6V2,
cache_dir: None,
show_download_progress: false,
}
}
}
impl FastEmbedConfig {
pub fn with_model(model: EmbeddingModel) -> Self {
Self {
model,
..Default::default()
}
}
}
pub struct FastEmbedGenerator {
model: Arc<Mutex<TextEmbedding>>,
}
impl FastEmbedGenerator {
/// Initialise the model. Downloads and caches model files on first call.
pub fn new(config: FastEmbedConfig) -> Result<Self, DomainError> {
let mut opts = TextInitOptions::new(config.model)
.with_show_download_progress(config.show_download_progress);
if let Some(dir) = config.cache_dir {
opts = opts.with_cache_dir(dir);
}
let model = TextEmbedding::try_new(opts)
.map_err(|e| DomainError::Infrastructure(format!("fastembed init failed: {e}")))?;
Ok(Self {
model: Arc::new(Mutex::new(model)),
})
}
}
#[async_trait]
impl EmbeddingGenerator for FastEmbedGenerator {
async fn generate(&self, text: &str) -> DomainResult<Vec<f32>> {
let model = Arc::clone(&self.model);
let text = text.to_owned();
tokio::task::spawn_blocking(move || {
let mut guard = model
.lock()
.map_err(|_| DomainError::Infrastructure("model mutex poisoned".into()))?;
guard
.embed(vec![text.as_str()], None)
.map_err(|e| DomainError::Infrastructure(format!("embedding failed: {e}")))?
.into_iter()
.next()
.ok_or_else(|| DomainError::Infrastructure("no embedding returned".into()))
})
.await
.map_err(|e| DomainError::Infrastructure(format!("spawn_blocking panicked: {e}")))?
}
}
#[cfg(test)]
#[path = "tests/lib.rs"]
mod tests;

View File

@@ -0,0 +1,33 @@
use crate::{FastEmbedConfig, FastEmbedGenerator};
use domain::smart::ports::EmbeddingGenerator;
use fastembed::EmbeddingModel;
/// Downloads the model on first run (~90 MB). Run with:
/// cargo test -p fastembed-adapter -- --ignored
#[tokio::test]
#[ignore]
async fn generates_embedding_with_correct_dimension() {
let generator =
FastEmbedGenerator::new(FastEmbedConfig::with_model(EmbeddingModel::AllMiniLML6V2))
.expect("model init failed");
let embedding = generator.generate("hello world").await.unwrap();
// AllMiniLML6V2 produces 384-dimensional vectors.
assert_eq!(embedding.len(), 384);
// Sanity: values are in a reasonable range.
assert!(embedding.iter().all(|v| v.is_finite()));
}
#[tokio::test]
#[ignore]
async fn different_texts_produce_different_embeddings() {
let generator =
FastEmbedGenerator::new(FastEmbedConfig::with_model(EmbeddingModel::AllMiniLML6V2))
.expect("model init failed");
let a = generator.generate("cats").await.unwrap();
let b = generator.generate("quantum mechanics").await.unwrap();
assert_ne!(a, b);
}

View File

@@ -0,0 +1,17 @@
[package]
name = "nats"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
event-payload = { workspace = true }
async-trait = { workspace = true }
futures = { workspace = true }
tracing = { workspace = true }
serde_json = { workspace = true }
async-nats = "0.37"
async-stream = "0.3"
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -0,0 +1,104 @@
use std::{sync::Arc, time::Duration};
use async_nats::jetstream::{
AckKind,
consumer::{self, pull},
};
use futures::{StreamExt, future::BoxFuture, stream::BoxStream};
use domain::{
errors::DomainError,
events::{DomainEvent, EventConsumer, EventEnvelope},
};
use event_payload::EventPayload;
pub struct NatsEventConsumer {
consumer: Arc<consumer::Consumer<pull::Config>>,
}
impl NatsEventConsumer {
pub(crate) fn new(consumer: consumer::Consumer<pull::Config>) -> Self {
Self {
consumer: Arc::new(consumer),
}
}
}
impl EventConsumer for NatsEventConsumer {
fn consume(&self) -> BoxStream<'_, Result<EventEnvelope, DomainError>> {
let consumer = Arc::clone(&self.consumer);
Box::pin(async_stream::stream! {
let mut messages = match consumer.messages().await {
Ok(m) => m,
Err(e) => {
yield Err(DomainError::Infrastructure(
format!("failed to open jetstream message stream: {e}")
));
return;
}
};
while let Some(result) = messages.next().await {
let msg = match result {
Ok(m) => m,
Err(e) => {
yield Err(DomainError::Infrastructure(e.to_string()));
continue;
}
};
// Malformed messages are acked immediately to prevent infinite
// redelivery of poison payloads that can never be processed.
let payload = match EventPayload::from_json(&msg.payload) {
Ok(p) => p,
Err(e) => {
tracing::error!("unprocessable message payload, acking to discard: {e}");
let _ = msg.ack().await;
continue;
}
};
let event = match DomainEvent::try_from(payload) {
Ok(e) => e,
Err(e) => {
tracing::error!("invalid event payload, acking to discard: {e}");
let _ = msg.ack().await;
continue;
}
};
let delivered = msg.info().map(|i| i.delivered).unwrap_or(1);
let nack_delay = backoff(delivered);
let msg = Arc::new(msg);
let ack_msg = Arc::clone(&msg);
let nack_msg = Arc::clone(&msg);
yield Ok(EventEnvelope::new(
event,
move || -> BoxFuture<'static, _> {
Box::pin(async move {
ack_msg.ack().await.map_err(|e| {
DomainError::Infrastructure(format!("nats ack failed: {e}"))
})
})
},
move || -> BoxFuture<'static, _> {
Box::pin(async move {
nack_msg.ack_with(AckKind::Nak(Some(nack_delay))).await.map_err(|e| {
DomainError::Infrastructure(format!("nats nack failed: {e}"))
})
})
},
));
}
})
}
}
/// Exponential backoff capped at 5 minutes: 1s → 5s → 25s → 125s → 300s.
fn backoff(delivered: i64) -> Duration {
let exp = delivered.saturating_sub(1) as u32;
Duration::from_secs(5u64.saturating_pow(exp).min(300))
}

View File

@@ -0,0 +1,92 @@
pub mod consumer;
pub mod publisher;
use std::time::Duration;
use async_nats::jetstream::{self, consumer as nats_consumer, consumer::pull};
use crate::{consumer::NatsEventConsumer, publisher::NatsEventPublisher};
// ── Subject routing ───────────────────────────────────────────────────────────
pub(crate) fn subject_for(event: &domain::events::DomainEvent) -> &'static str {
use domain::events::DomainEvent;
match event {
DomainEvent::NoteCreated { .. } => "knotes.note.created",
DomainEvent::NoteUpdated { .. } => "knotes.note.updated",
DomainEvent::NoteDeleted { .. } => "knotes.note.deleted",
}
}
pub(crate) const SUBSCRIBE_SUBJECT: &str = "knotes.note.>";
// ── Config ────────────────────────────────────────────────────────────────────
/// Configuration for the JetStream stream and durable pull consumer.
///
/// **Dead-letter queue**: after `max_deliver` failed attempts NATS stops
/// redelivering and publishes an advisory to
/// `$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.{stream}.{consumer}`.
/// Subscribe to those with a monitoring consumer or NATS dashboard to
/// observe dead messages.
#[derive(Debug, Clone)]
pub struct JetStreamConfig {
/// Name of the JetStream stream (created on first use if absent).
pub stream_name: String,
/// Durable consumer name — survives worker restarts.
pub consumer_name: String,
/// Maximum delivery attempts before the message is considered dead.
pub max_deliver: i64,
/// How long JetStream waits for an ack before redelivering.
pub ack_wait: Duration,
}
impl Default for JetStreamConfig {
fn default() -> Self {
Self {
stream_name: "KNOTES".into(),
consumer_name: "knotes-worker".into(),
max_deliver: 5,
ack_wait: Duration::from_secs(30),
}
}
}
// ── Setup ─────────────────────────────────────────────────────────────────────
/// Connect to NATS and initialise both the publisher and consumer.
/// Creates the JetStream stream and durable pull consumer if they do not exist.
pub async fn setup(
url: &str,
config: JetStreamConfig,
) -> Result<(NatsEventPublisher, NatsEventConsumer), Box<dyn std::error::Error + Send + Sync>> {
let client = async_nats::connect(url).await?;
let js = jetstream::new(client);
let stream = js
.get_or_create_stream(jetstream::stream::Config {
name: config.stream_name.clone(),
subjects: vec![SUBSCRIBE_SUBJECT.into()],
..Default::default()
})
.await?;
let nats_consumer: nats_consumer::Consumer<pull::Config> = stream
.get_or_create_consumer(
&config.consumer_name,
pull::Config {
durable_name: Some(config.consumer_name.clone()),
ack_policy: jetstream::consumer::AckPolicy::Explicit,
max_deliver: config.max_deliver,
ack_wait: config.ack_wait,
filter_subject: SUBSCRIBE_SUBJECT.into(),
..Default::default()
},
)
.await?;
Ok((
NatsEventPublisher::new(js),
NatsEventConsumer::new(nats_consumer),
))
}

View File

@@ -0,0 +1,34 @@
use async_nats::jetstream;
use async_trait::async_trait;
use domain::{
errors::DomainError,
events::{DomainEvent, EventPublisher},
};
use event_payload::EventPayload;
use crate::subject_for;
pub struct NatsEventPublisher {
js: jetstream::Context,
}
impl NatsEventPublisher {
pub(crate) fn new(js: jetstream::Context) -> Self {
Self { js }
}
}
#[async_trait]
impl EventPublisher for NatsEventPublisher {
async fn publish(&self, event: &DomainEvent) -> Result<(), DomainError> {
let bytes = EventPayload::from(event).to_json()?;
self.js
.publish(subject_for(event), bytes.into())
.await
.map_err(|e| DomainError::Infrastructure(format!("nats publish failed: {e}")))?
.await
.map_err(|e| DomainError::Infrastructure(format!("nats publish ack failed: {e}")))?;
Ok(())
}
}

View File

@@ -0,0 +1,14 @@
[package]
name = "qdrant-adapter"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true }
qdrant-client = "1"
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -0,0 +1,139 @@
use async_trait::async_trait;
use qdrant_client::{
Qdrant, QdrantError,
qdrant::{
CreateCollectionBuilder, DeletePointsBuilder, Distance, PointId, PointStruct,
PointsIdsList, SearchPointsBuilder, UpsertPointsBuilder, VectorParamsBuilder,
point_id::PointIdOptions,
},
};
use uuid::Uuid;
use domain::{
errors::{DomainError, DomainResult},
note::entity::NoteId,
smart::ports::VectorStore,
};
pub struct QdrantConfig {
pub url: String,
pub collection: String,
/// Dimensionality of the vectors stored in this collection.
/// Must match the output size of the embedding model (e.g. 384 for AllMiniLML6V2).
pub vector_size: u64,
}
impl Default for QdrantConfig {
fn default() -> Self {
Self {
url: "http://localhost:6334".into(),
collection: "notes".into(),
vector_size: 384,
}
}
}
pub struct QdrantVectorStore {
client: Qdrant,
collection: String,
}
impl QdrantVectorStore {
pub fn new(config: QdrantConfig) -> Result<Self, Box<QdrantError>> {
let client = Qdrant::from_url(&config.url).build().map_err(Box::new)?;
Ok(Self {
client,
collection: config.collection,
})
}
/// Ensure the collection exists. Call once during startup before accepting requests.
pub async fn init(&self, vector_size: u64) -> DomainResult<()> {
if self
.client
.collection_exists(&self.collection)
.await
.map_err(qdrant_err)?
{
return Ok(());
}
self.client
.create_collection(
CreateCollectionBuilder::new(&self.collection)
.vectors_config(VectorParamsBuilder::new(vector_size, Distance::Cosine)),
)
.await
.map_err(qdrant_err)?;
tracing::info!(collection = %self.collection, "qdrant collection created");
Ok(())
}
}
#[async_trait]
impl VectorStore for QdrantVectorStore {
async fn upsert(&self, id: &NoteId, vector: &[f32]) -> DomainResult<()> {
let point = PointStruct::new(
uuid_to_point_id(id.as_uuid()),
vector.to_vec(),
qdrant_client::Payload::default(),
);
self.client
.upsert_points(UpsertPointsBuilder::new(&self.collection, vec![point]))
.await
.map_err(qdrant_err)
.map(|_| ())
}
async fn find_similar(&self, vector: &[f32], limit: usize) -> DomainResult<Vec<(NoteId, f32)>> {
let response = self
.client
.search_points(
SearchPointsBuilder::new(&self.collection, vector.to_vec(), limit as u64)
.with_payload(false),
)
.await
.map_err(qdrant_err)?;
response
.result
.into_iter()
.filter_map(|scored| {
let uuid_str = match scored.id?.point_id_options? {
PointIdOptions::Uuid(s) => s,
_ => return None,
};
let uuid = Uuid::parse_str(&uuid_str).ok()?;
Some(Ok((NoteId::from_uuid(uuid), scored.score)))
})
.collect()
}
async fn delete(&self, id: &NoteId) -> DomainResult<()> {
self.client
.delete_points(
DeletePointsBuilder::new(&self.collection).points(PointsIdsList {
ids: vec![uuid_to_point_id(id.as_uuid())],
}),
)
.await
.map_err(qdrant_err)
.map(|_| ())
}
}
fn uuid_to_point_id(uuid: Uuid) -> PointId {
PointId {
point_id_options: Some(PointIdOptions::Uuid(uuid.to_string())),
}
}
fn qdrant_err(e: QdrantError) -> DomainError {
DomainError::Infrastructure(format!("qdrant: {e}"))
}
#[cfg(test)]
#[path = "tests/lib.rs"]
mod tests;

View File

@@ -0,0 +1,45 @@
use domain::{note::entity::NoteId, smart::ports::VectorStore};
use crate::{QdrantConfig, QdrantVectorStore};
const VECTOR_SIZE: u64 = 4; // small for tests
fn test_config() -> QdrantConfig {
QdrantConfig {
url: "http://localhost:6334".into(),
collection: "test-notes".into(),
vector_size: VECTOR_SIZE,
}
}
/// Requires a running Qdrant instance. Run with:
/// cargo test -p qdrant-adapter -- --ignored
#[tokio::test]
#[ignore]
async fn upsert_and_find_similar() {
let store = QdrantVectorStore::new(test_config()).unwrap();
store.init(VECTOR_SIZE).await.unwrap();
let id = NoteId::new();
let vector = vec![1.0f32, 0.0, 0.0, 0.0];
store.upsert(&id, &vector).await.unwrap();
let results = store.find_similar(&vector, 1).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].0, id);
assert!(results[0].1 > 0.99);
}
#[tokio::test]
#[ignore]
async fn delete_removes_vector() {
let store = QdrantVectorStore::new(test_config()).unwrap();
store.init(VECTOR_SIZE).await.unwrap();
let id = NoteId::new();
store.upsert(&id, &[1.0, 0.0, 0.0, 0.0]).await.unwrap();
store.delete(&id).await.unwrap();
let results = store.find_similar(&[1.0, 0.0, 0.0, 0.0], 10).await.unwrap();
assert!(!results.iter().any(|(rid, _)| rid == &id));
}

View File

@@ -0,0 +1,15 @@
[package]
name = "sqlite"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
chrono = { workspace = true }
uuid = { workspace = true }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid", "migrate", "macros"] }
serde_json = { workspace = true }
[dev-dependencies]
tokio = { workspace = true }

View File

@@ -0,0 +1,71 @@
-- Initial schema for K-Notes
-- SQLite with FTS5 for full-text search
-- Users table (OIDC-ready)
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
subject TEXT UNIQUE NOT NULL, -- OIDC subject identifier
email TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_users_subject ON users(subject);
CREATE INDEX idx_users_email ON users(email);
-- Notes table
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
is_pinned INTEGER NOT NULL DEFAULT 0,
is_archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_notes_user_id ON notes(user_id);
CREATE INDEX idx_notes_is_pinned ON notes(is_pinned);
CREATE INDEX idx_notes_is_archived ON notes(is_archived);
CREATE INDEX idx_notes_updated_at ON notes(updated_at);
-- Tags table (user-scoped)
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(name, user_id)
);
CREATE INDEX idx_tags_user_id ON tags(user_id);
-- Junction table for note-tag relationship
CREATE TABLE IF NOT EXISTS note_tags (
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (note_id, tag_id)
);
CREATE INDEX idx_note_tags_tag_id ON note_tags(tag_id);
-- Full-text search virtual table
CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
title,
content,
content='notes',
content_rowid='rowid'
);
-- Triggers to keep FTS index in sync
CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN
INSERT INTO notes_fts(rowid, title, content) VALUES (NEW.rowid, NEW.title, NEW.content);
END;
CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES('delete', OLD.rowid, OLD.title, OLD.content);
END;
CREATE TRIGGER notes_au AFTER UPDATE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES('delete', OLD.rowid, OLD.title, OLD.content);
INSERT INTO notes_fts(rowid, title, content) VALUES (NEW.rowid, NEW.title, NEW.content);
END;

View File

@@ -0,0 +1,2 @@
-- Add password_hash column to users table
ALTER TABLE users ADD COLUMN password_hash TEXT;

View File

@@ -0,0 +1 @@
ALTER TABLE notes ADD COLUMN color TEXT NOT NULL DEFAULT 'DEFAULT';

View File

@@ -0,0 +1,11 @@
-- Add note_versions table
CREATE TABLE note_versions (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE
);
CREATE INDEX idx_note_versions_note_id ON note_versions(note_id);

View File

@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS note_links (
source_note_id TEXT NOT NULL,
target_note_id TEXT NOT NULL,
score REAL NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (source_note_id, target_note_id),
FOREIGN KEY (source_note_id) REFERENCES notes(id) ON DELETE CASCADE,
FOREIGN KEY (target_note_id) REFERENCES notes(id) ON DELETE CASCADE
);
CREATE INDEX idx_note_links_source ON note_links(source_note_id);
CREATE INDEX idx_note_links_target ON note_links(target_note_id);

View File

@@ -0,0 +1,45 @@
-- Allow NULL titles in notes table
-- SQLite doesn't support ALTER COLUMN, so we need to recreate the table
-- Step 1: Create new table with nullable title
CREATE TABLE notes_new (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT, -- Now nullable
content TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'DEFAULT',
is_pinned INTEGER NOT NULL DEFAULT 0,
is_archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Step 2: Copy data from old table
INSERT INTO notes_new (id, user_id, title, content, color, is_pinned, is_archived, created_at, updated_at)
SELECT id, user_id, title, content, color, is_pinned, is_archived, created_at, updated_at FROM notes;
-- Step 3: Drop old table
DROP TABLE notes;
-- Step 4: Rename new table
ALTER TABLE notes_new RENAME TO notes;
-- Step 5: Recreate indexes
CREATE INDEX idx_notes_user_id ON notes(user_id);
CREATE INDEX idx_notes_is_pinned ON notes(is_pinned);
CREATE INDEX idx_notes_is_archived ON notes(is_archived);
CREATE INDEX idx_notes_updated_at ON notes(updated_at);
-- Step 6: Recreate FTS triggers
CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN
INSERT INTO notes_fts(rowid, title, content) VALUES (NEW.rowid, COALESCE(NEW.title, ''), NEW.content);
END;
CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES('delete', OLD.rowid, COALESCE(OLD.title, ''), OLD.content);
END;
CREATE TRIGGER notes_au AFTER UPDATE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, content) VALUES('delete', OLD.rowid, COALESCE(OLD.title, ''), OLD.content);
INSERT INTO notes_fts(rowid, title, content) VALUES (NEW.rowid, COALESCE(NEW.title, ''), NEW.content);
END;

View File

@@ -0,0 +1,16 @@
-- note_versions.title should be nullable to match notes where title is optional
CREATE TABLE note_versions_new (
id TEXT PRIMARY KEY,
note_id TEXT NOT NULL,
title TEXT,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(note_id) REFERENCES notes(id) ON DELETE CASCADE
);
INSERT INTO note_versions_new SELECT id, note_id, NULLIF(title, ''), content, created_at FROM note_versions;
DROP TABLE note_versions;
ALTER TABLE note_versions_new RENAME TO note_versions;
CREATE INDEX idx_note_versions_note_id ON note_versions(note_id);

View File

@@ -0,0 +1,43 @@
use chrono::{DateTime, Utc};
pub use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use std::str::FromStr;
use domain::errors::DomainError;
pub async fn connect(database_url: &str) -> Result<SqlitePool, sqlx::Error> {
let options = SqliteConnectOptions::from_str(database_url)?
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.foreign_keys(true);
SqlitePoolOptions::new()
.max_connections(5)
.connect_with(options)
.await
}
pub async fn run_migrations(pool: &SqlitePool) -> Result<(), sqlx::migrate::MigrateError> {
sqlx::migrate!("./migrations").run(pool).await
}
/// Parse a datetime string from SQLite (RFC3339 or naive format).
pub(crate) fn parse_dt(s: &str) -> Result<DateTime<Utc>, DomainError> {
DateTime::parse_from_rfc3339(s)
.map(|dt| dt.with_timezone(&Utc))
.or_else(|_| {
chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").map(|dt| dt.and_utc())
})
.map_err(|e| DomainError::Repository(format!("invalid datetime '{s}': {e}")))
}
/// Map a sqlx error to DomainError::Repository.
pub(crate) trait RepoExt<T> {
fn repo(self) -> Result<T, DomainError>;
}
impl<T> RepoExt<T> for Result<T, sqlx::Error> {
fn repo(self) -> Result<T, DomainError> {
self.map_err(|e| DomainError::Repository(e.to_string()))
}
}

View File

@@ -0,0 +1,5 @@
pub mod db;
pub mod link;
pub mod note;
pub mod tag;
pub mod user;

View File

@@ -0,0 +1,103 @@
use async_trait::async_trait;
use sqlx::{FromRow, SqlitePool};
use domain::{
errors::{DomainError, DomainResult},
note::{
entity::{NoteId, NoteLink},
ports::LinkRepository,
},
};
use crate::db::RepoExt;
pub struct SqliteLinkRepository {
pool: SqlitePool,
}
impl SqliteLinkRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[derive(FromRow)]
struct LinkRow {
source_note_id: String,
target_note_id: String,
score: f32,
created_at: String,
}
impl TryFrom<LinkRow> for NoteLink {
type Error = DomainError;
fn try_from(row: LinkRow) -> Result<Self, Self::Error> {
let source_id = NoteId::from_uuid(
uuid::Uuid::parse_str(&row.source_note_id)
.map_err(|e| DomainError::Repository(format!("invalid source uuid: {e}")))?,
);
let target_id = NoteId::from_uuid(
uuid::Uuid::parse_str(&row.target_note_id)
.map_err(|e| DomainError::Repository(format!("invalid target uuid: {e}")))?,
);
let created_at = crate::db::parse_dt(&row.created_at)?;
Ok(NoteLink {
source_id,
target_id,
score: row.score,
created_at,
})
}
}
#[async_trait]
impl LinkRepository for SqliteLinkRepository {
async fn save_links(&self, links: &[NoteLink]) -> DomainResult<()> {
let mut tx = self.pool.begin().await.repo()?;
for link in links {
sqlx::query(
r#"
INSERT INTO note_links (source_note_id, target_note_id, score, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(source_note_id, target_note_id) DO UPDATE SET
score = excluded.score,
created_at = excluded.created_at
"#,
)
.bind(link.source_id.as_uuid().to_string())
.bind(link.target_id.as_uuid().to_string())
.bind(link.score)
.bind(link.created_at.to_rfc3339())
.execute(&mut *tx)
.await
.repo()?;
}
tx.commit().await.repo()
}
async fn delete_for_source(&self, source_id: &NoteId) -> DomainResult<()> {
sqlx::query("DELETE FROM note_links WHERE source_note_id = ?")
.bind(source_id.as_uuid().to_string())
.execute(&self.pool)
.await
.repo()
.map(|_| ())
}
async fn find_for_note(&self, note_id: &NoteId) -> DomainResult<Vec<NoteLink>> {
sqlx::query_as::<_, LinkRow>(
"SELECT source_note_id, target_note_id, score, created_at \
FROM note_links WHERE source_note_id = ? ORDER BY score DESC",
)
.bind(note_id.as_uuid().to_string())
.fetch_all(&self.pool)
.await
.repo()?
.into_iter()
.map(NoteLink::try_from)
.collect()
}
}

View File

@@ -0,0 +1,281 @@
use async_trait::async_trait;
use sqlx::{FromRow, QueryBuilder, Sqlite, SqlitePool};
use domain::{
errors::{DomainError, DomainResult},
note::{
entity::{Note, NoteFilter, NoteId, NoteVersion},
ports::NoteRepository,
value_objects::{NoteColor, NoteTitle},
},
tag::entity::{Tag, TagId},
user::entity::UserId,
};
use crate::db::{RepoExt, parse_dt};
pub struct SqliteNoteRepository {
pool: SqlitePool,
}
impl SqliteNoteRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
// ── Row types ────────────────────────────────────────────────────────────────
#[derive(FromRow)]
struct NoteRow {
id: String,
user_id: String,
title: Option<String>,
content: String,
color: String,
is_pinned: i32,
is_archived: i32,
created_at: String,
updated_at: String,
tags_json: String,
}
impl TryFrom<NoteRow> for Note {
type Error = DomainError;
fn try_from(row: NoteRow) -> Result<Self, Self::Error> {
let id = NoteId::from_uuid(
uuid::Uuid::parse_str(&row.id)
.map_err(|e| DomainError::Repository(format!("invalid note uuid: {e}")))?,
);
let user_id = UserId::from_uuid(
uuid::Uuid::parse_str(&row.user_id)
.map_err(|e| DomainError::Repository(format!("invalid user uuid: {e}")))?,
);
let title = NoteTitle::from_optional(row.title)?;
let tags = parse_tags_json(&row.tags_json)?;
Ok(Note {
id,
user_id,
title,
content: row.content,
color: NoteColor::new(row.color),
is_pinned: row.is_pinned != 0,
is_archived: row.is_archived != 0,
created_at: parse_dt(&row.created_at)?,
updated_at: parse_dt(&row.updated_at)?,
tags,
})
}
}
fn parse_tags_json(json: &str) -> Result<Vec<Tag>, DomainError> {
let values: Vec<serde_json::Value> = serde_json::from_str(json)
.map_err(|e| DomainError::Repository(format!("invalid tags json: {e}")))?;
values
.into_iter()
.filter(|v| !v.is_null())
.map(|v| {
let parse_str = |key: &str| {
v[key]
.as_str()
.ok_or_else(|| DomainError::Repository(format!("missing tag field '{key}'")))
};
let id = TagId::from_uuid(
uuid::Uuid::parse_str(parse_str("id")?)
.map_err(|e| DomainError::Repository(format!("invalid tag uuid: {e}")))?,
);
let user_id = UserId::from_uuid(
uuid::Uuid::parse_str(parse_str("user_id")?)
.map_err(|e| DomainError::Repository(format!("invalid tag user_id: {e}")))?,
);
let name = domain::tag::value_objects::TagName::new(parse_str("name")?)?;
Ok(Tag::from_row(id, name, user_id))
})
.collect()
}
#[derive(FromRow)]
struct VersionRow {
id: String,
note_id: String,
title: Option<String>,
content: String,
created_at: String,
}
impl TryFrom<VersionRow> for NoteVersion {
type Error = DomainError;
fn try_from(row: VersionRow) -> Result<Self, Self::Error> {
Ok(NoteVersion {
id: uuid::Uuid::parse_str(&row.id)
.map_err(|e| DomainError::Repository(format!("invalid version uuid: {e}")))?,
note_id: NoteId::from_uuid(
uuid::Uuid::parse_str(&row.note_id)
.map_err(|e| DomainError::Repository(format!("invalid note uuid: {e}")))?,
),
title: row.title,
content: row.content,
created_at: parse_dt(&row.created_at)?,
})
}
}
// ── Shared SELECT fragment ────────────────────────────────────────────────────
const NOTE_SELECT: &str = r#"
SELECT n.id, n.user_id, n.title, n.content, n.color, n.is_pinned, n.is_archived,
n.created_at, n.updated_at,
json_group_array(
CASE WHEN t.id IS NOT NULL
THEN json_object('id', t.id, 'name', t.name, 'user_id', t.user_id)
ELSE NULL END
) AS tags_json
FROM notes n
LEFT JOIN note_tags nt ON n.id = nt.note_id
LEFT JOIN tags t ON nt.tag_id = t.id
"#;
// ── NoteRepository ───────────────────────────────────────────────────────────
#[async_trait]
impl NoteRepository for SqliteNoteRepository {
async fn find_by_id(&self, id: &NoteId) -> DomainResult<Option<Note>> {
let sql = format!("{NOTE_SELECT} WHERE n.id = ? GROUP BY n.id");
sqlx::query_as::<_, NoteRow>(&sql)
.bind(id.as_uuid().to_string())
.fetch_optional(&self.pool)
.await
.repo()?
.map(Note::try_from)
.transpose()
}
async fn find_by_user(&self, user_id: &UserId, filter: NoteFilter) -> DomainResult<Vec<Note>> {
let base = format!("{NOTE_SELECT} WHERE n.user_id = ");
let mut qb: QueryBuilder<Sqlite> = QueryBuilder::new(base);
qb.push_bind(user_id.as_uuid().to_string());
if let Some(pinned) = filter.is_pinned {
qb.push(" AND n.is_pinned = ").push_bind(pinned as i32);
}
if let Some(archived) = filter.is_archived {
qb.push(" AND n.is_archived = ").push_bind(archived as i32);
}
if let Some(tag_id) = filter.tag_id {
qb.push(" AND n.id IN (SELECT note_id FROM note_tags WHERE tag_id = ")
.push_bind(tag_id.as_uuid().to_string())
.push(")");
}
qb.push(" GROUP BY n.id ORDER BY n.is_pinned DESC, n.updated_at DESC");
qb.build_query_as::<NoteRow>()
.fetch_all(&self.pool)
.await
.repo()?
.into_iter()
.map(Note::try_from)
.collect()
}
async fn search(&self, user_id: &UserId, query: &str) -> DomainResult<Vec<Note>> {
let sql = format!(
r#"{NOTE_SELECT}
WHERE n.user_id = ?
AND (
n.rowid IN (SELECT rowid FROM notes_fts WHERE notes_fts MATCH ?)
OR EXISTS (
SELECT 1 FROM note_tags nt2
JOIN tags t2 ON nt2.tag_id = t2.id
WHERE nt2.note_id = n.id AND t2.name LIKE ?
)
)
GROUP BY n.id ORDER BY n.updated_at DESC"#
);
sqlx::query_as::<_, NoteRow>(&sql)
.bind(user_id.as_uuid().to_string())
.bind(query)
.bind(format!("%{query}%"))
.fetch_all(&self.pool)
.await
.repo()?
.into_iter()
.map(Note::try_from)
.collect()
}
async fn save(&self, note: &Note) -> DomainResult<()> {
sqlx::query(
r#"
INSERT INTO notes (id, user_id, title, content, color, is_pinned, is_archived, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
content = excluded.content,
color = excluded.color,
is_pinned = excluded.is_pinned,
is_archived = excluded.is_archived,
updated_at = excluded.updated_at
"#,
)
.bind(note.id.as_uuid().to_string())
.bind(note.user_id.as_uuid().to_string())
.bind(note.title.as_ref().map(|t| t.as_ref()))
.bind(&note.content)
.bind(note.color.as_str())
.bind(note.is_pinned as i32)
.bind(note.is_archived as i32)
.bind(note.created_at.to_rfc3339())
.bind(note.updated_at.to_rfc3339())
.execute(&self.pool)
.await
.repo()
.map(|_| ())
}
async fn delete(&self, id: &NoteId) -> DomainResult<()> {
sqlx::query("DELETE FROM notes WHERE id = ?")
.bind(id.as_uuid().to_string())
.execute(&self.pool)
.await
.repo()
.map(|_| ())
}
async fn save_version(&self, version: &NoteVersion) -> DomainResult<()> {
sqlx::query(
"INSERT INTO note_versions (id, note_id, title, content, created_at) VALUES (?, ?, ?, ?, ?)",
)
.bind(version.id.to_string())
.bind(version.note_id.as_uuid().to_string())
.bind(version.title.as_deref())
.bind(&version.content)
.bind(version.created_at.to_rfc3339())
.execute(&self.pool)
.await
.repo()
.map(|_| ())
}
async fn find_versions(&self, note_id: &NoteId) -> DomainResult<Vec<NoteVersion>> {
sqlx::query_as::<_, VersionRow>(
"SELECT id, note_id, title, content, created_at FROM note_versions WHERE note_id = ? ORDER BY created_at DESC",
)
.bind(note_id.as_uuid().to_string())
.fetch_all(&self.pool)
.await
.repo()?
.into_iter()
.map(NoteVersion::try_from)
.collect()
}
}
#[cfg(test)]
#[path = "tests/note.rs"]
mod tests;

View File

@@ -0,0 +1,157 @@
use async_trait::async_trait;
use sqlx::{FromRow, SqlitePool};
use domain::{
errors::{DomainError, DomainResult},
note::entity::NoteId,
tag::{
entity::{Tag, TagId},
ports::TagRepository,
value_objects::TagName,
},
user::entity::UserId,
};
use crate::db::RepoExt;
pub struct SqliteTagRepository {
pool: SqlitePool,
}
impl SqliteTagRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[derive(FromRow)]
struct TagRow {
id: String,
name: String,
user_id: String,
}
impl TryFrom<TagRow> for Tag {
type Error = DomainError;
fn try_from(row: TagRow) -> Result<Self, Self::Error> {
let id = TagId::from_uuid(
uuid::Uuid::parse_str(&row.id)
.map_err(|e| DomainError::Repository(format!("invalid tag uuid: {e}")))?,
);
let user_id = UserId::from_uuid(
uuid::Uuid::parse_str(&row.user_id)
.map_err(|e| DomainError::Repository(format!("invalid user uuid: {e}")))?,
);
let name = TagName::new(row.name)?;
Ok(Tag::from_row(id, name, user_id))
}
}
#[async_trait]
impl TagRepository for SqliteTagRepository {
async fn find_by_id(&self, id: &TagId) -> DomainResult<Option<Tag>> {
sqlx::query_as::<_, TagRow>("SELECT id, name, user_id FROM tags WHERE id = ?")
.bind(id.as_uuid().to_string())
.fetch_optional(&self.pool)
.await
.repo()?
.map(Tag::try_from)
.transpose()
}
async fn find_by_user(&self, user_id: &UserId) -> DomainResult<Vec<Tag>> {
sqlx::query_as::<_, TagRow>(
"SELECT id, name, user_id FROM tags WHERE user_id = ? ORDER BY name",
)
.bind(user_id.as_uuid().to_string())
.fetch_all(&self.pool)
.await
.repo()?
.into_iter()
.map(Tag::try_from)
.collect()
}
async fn find_by_name(&self, user_id: &UserId, name: &TagName) -> DomainResult<Option<Tag>> {
sqlx::query_as::<_, TagRow>(
"SELECT id, name, user_id FROM tags WHERE user_id = ? AND name = ?",
)
.bind(user_id.as_uuid().to_string())
.bind(name.as_ref())
.fetch_optional(&self.pool)
.await
.repo()?
.map(Tag::try_from)
.transpose()
}
async fn find_by_note(&self, note_id: &NoteId) -> DomainResult<Vec<Tag>> {
sqlx::query_as::<_, TagRow>(
r#"
SELECT t.id, t.name, t.user_id
FROM tags t
INNER JOIN note_tags nt ON t.id = nt.tag_id
WHERE nt.note_id = ?
ORDER BY t.name
"#,
)
.bind(note_id.as_uuid().to_string())
.fetch_all(&self.pool)
.await
.repo()?
.into_iter()
.map(Tag::try_from)
.collect()
}
async fn save(&self, tag: &Tag) -> DomainResult<()> {
sqlx::query(
r#"
INSERT INTO tags (id, name, user_id)
VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET name = excluded.name
"#,
)
.bind(tag.id.as_uuid().to_string())
.bind(tag.name.as_ref())
.bind(tag.user_id.as_uuid().to_string())
.execute(&self.pool)
.await
.repo()
.map(|_| ())
}
async fn delete(&self, id: &TagId) -> DomainResult<()> {
sqlx::query("DELETE FROM tags WHERE id = ?")
.bind(id.as_uuid().to_string())
.execute(&self.pool)
.await
.repo()
.map(|_| ())
}
async fn add_to_note(&self, tag_id: &TagId, note_id: &NoteId) -> DomainResult<()> {
sqlx::query("INSERT OR IGNORE INTO note_tags (note_id, tag_id) VALUES (?, ?)")
.bind(note_id.as_uuid().to_string())
.bind(tag_id.as_uuid().to_string())
.execute(&self.pool)
.await
.repo()
.map(|_| ())
}
async fn remove_from_note(&self, tag_id: &TagId, note_id: &NoteId) -> DomainResult<()> {
sqlx::query("DELETE FROM note_tags WHERE note_id = ? AND tag_id = ?")
.bind(note_id.as_uuid().to_string())
.bind(tag_id.as_uuid().to_string())
.execute(&self.pool)
.await
.repo()
.map(|_| ())
}
}
#[cfg(test)]
#[path = "tests/tag.rs"]
mod tests;

View File

@@ -0,0 +1,119 @@
use sqlx::SqlitePool;
use domain::{
note::{
entity::{Note, NoteFilter},
ports::NoteRepository,
value_objects::NoteTitle,
},
user::{entity::User, ports::UserRepository, value_objects::Email},
};
use crate::{db::run_migrations, note::SqliteNoteRepository, user::SqliteUserRepository};
async fn pool() -> SqlitePool {
let p = SqlitePool::connect("sqlite::memory:").await.unwrap();
run_migrations(&p).await.unwrap();
p
}
async fn seed_user(pool: &SqlitePool) -> User {
let repo = SqliteUserRepository::new(pool.clone());
let user = User::new_oidc("sub", Email::new("u@example.com").unwrap());
repo.save(&user).await.unwrap();
user
}
#[tokio::test]
async fn save_and_find_by_id() {
let p = pool().await;
let user = seed_user(&p).await;
let repo = SqliteNoteRepository::new(p);
let note = Note::new(user.id, NoteTitle::new("Hello").ok(), "world".to_string());
repo.save(&note).await.unwrap();
let found = repo.find_by_id(&note.id).await.unwrap().unwrap();
assert_eq!(found.content, "world");
assert_eq!(found.title.as_ref().unwrap().as_ref(), "Hello");
}
#[tokio::test]
async fn save_note_without_title() {
let p = pool().await;
let user = seed_user(&p).await;
let repo = SqliteNoteRepository::new(p);
let note = Note::new(user.id, None, "no title".to_string());
repo.save(&note).await.unwrap();
let found = repo.find_by_id(&note.id).await.unwrap().unwrap();
assert!(found.title.is_none());
}
#[tokio::test]
async fn find_by_user_with_pinned_filter() {
let p = pool().await;
let user = seed_user(&p).await;
let repo = SqliteNoteRepository::new(p);
let mut pinned = Note::new(user.id, None, "pinned".to_string());
pinned.set_pinned(true);
repo.save(&pinned).await.unwrap();
repo.save(&Note::new(user.id, None, "normal".to_string()))
.await
.unwrap();
let results = repo
.find_by_user(&user.id, NoteFilter::default().pinned())
.await
.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].content, "pinned");
}
#[tokio::test]
async fn delete_removes_note() {
let p = pool().await;
let user = seed_user(&p).await;
let repo = SqliteNoteRepository::new(p);
let note = Note::new(user.id, None, "bye".to_string());
repo.save(&note).await.unwrap();
repo.delete(&note.id).await.unwrap();
assert!(repo.find_by_id(&note.id).await.unwrap().is_none());
}
#[tokio::test]
async fn save_and_find_versions() {
let p = pool().await;
let user = seed_user(&p).await;
let repo = SqliteNoteRepository::new(p);
let note = Note::new(user.id, None, "v1".to_string());
repo.save(&note).await.unwrap();
let version = domain::note::entity::NoteVersion::snapshot(&note);
repo.save_version(&version).await.unwrap();
let versions = repo.find_versions(&note.id).await.unwrap();
assert_eq!(versions.len(), 1);
assert_eq!(versions[0].content, "v1");
}
#[tokio::test]
async fn upsert_updates_note() {
let p = pool().await;
let user = seed_user(&p).await;
let repo = SqliteNoteRepository::new(p);
let mut note = Note::new(user.id, None, "original".to_string());
repo.save(&note).await.unwrap();
note.set_content("updated");
repo.save(&note).await.unwrap();
let found = repo.find_by_id(&note.id).await.unwrap().unwrap();
assert_eq!(found.content, "updated");
}

View File

@@ -0,0 +1,82 @@
use sqlx::SqlitePool;
use domain::{
tag::{entity::Tag, ports::TagRepository, value_objects::TagName},
user::entity::{User, UserId},
};
use crate::{db::run_migrations, tag::SqliteTagRepository, user::SqliteUserRepository};
use domain::user::{ports::UserRepository, value_objects::Email};
async fn pool() -> SqlitePool {
let p = SqlitePool::connect("sqlite::memory:").await.unwrap();
run_migrations(&p).await.unwrap();
p
}
async fn seed_user(pool: &SqlitePool) -> User {
let repo = SqliteUserRepository::new(pool.clone());
let user = User::new_oidc("sub", Email::new("u@example.com").unwrap());
repo.save(&user).await.unwrap();
user
}
#[tokio::test]
async fn save_and_find_by_id() {
let p = pool().await;
let user = seed_user(&p).await;
let repo = SqliteTagRepository::new(p);
let tag = Tag::new(TagName::new("work").unwrap(), user.id);
repo.save(&tag).await.unwrap();
let found = repo.find_by_id(&tag.id).await.unwrap().unwrap();
assert_eq!(found.name.as_ref(), "work");
}
#[tokio::test]
async fn find_by_name() {
let p = pool().await;
let user = seed_user(&p).await;
let repo = SqliteTagRepository::new(p);
let tag = Tag::new(TagName::new("rust").unwrap(), user.id);
repo.save(&tag).await.unwrap();
let found = repo
.find_by_name(&user.id, &TagName::new("rust").unwrap())
.await
.unwrap();
assert_eq!(found.unwrap().id, tag.id);
}
#[tokio::test]
async fn find_by_user_returns_sorted() {
let p = pool().await;
let user = seed_user(&p).await;
let repo = SqliteTagRepository::new(p);
repo.save(&Tag::new(TagName::new("zebra").unwrap(), user.id))
.await
.unwrap();
repo.save(&Tag::new(TagName::new("alpha").unwrap(), user.id))
.await
.unwrap();
let tags = repo.find_by_user(&user.id).await.unwrap();
assert_eq!(tags[0].name.as_ref(), "alpha");
assert_eq!(tags[1].name.as_ref(), "zebra");
}
#[tokio::test]
async fn delete_removes_tag() {
let p = pool().await;
let user = seed_user(&p).await;
let repo = SqliteTagRepository::new(p);
let tag = Tag::new(TagName::new("gone").unwrap(), user.id);
repo.save(&tag).await.unwrap();
repo.delete(&tag.id).await.unwrap();
assert!(repo.find_by_id(&tag.id).await.unwrap().is_none());
}

View File

@@ -0,0 +1,84 @@
use sqlx::SqlitePool;
use domain::user::{
entity::{User, UserId},
ports::UserRepository,
value_objects::{Email, PasswordHash},
};
use crate::{db::run_migrations, user::SqliteUserRepository};
async fn pool() -> SqlitePool {
let p = SqlitePool::connect("sqlite::memory:").await.unwrap();
run_migrations(&p).await.unwrap();
p
}
#[tokio::test]
async fn save_and_find_by_id() {
let repo = SqliteUserRepository::new(pool().await);
let user = User::new_oidc("oidc|123", Email::new("a@example.com").unwrap());
repo.save(&user).await.unwrap();
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(found.subject, "oidc|123");
assert_eq!(found.email.as_ref(), "a@example.com");
assert!(found.password_hash.is_none());
}
#[tokio::test]
async fn save_local_user_with_password_hash() {
let repo = SqliteUserRepository::new(pool().await);
let user = User::new_local(
Email::new("local@example.com").unwrap(),
PasswordHash::new("argon2hash"),
);
repo.save(&user).await.unwrap();
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(found.password_hash.unwrap().as_str(), "argon2hash");
}
#[tokio::test]
async fn find_by_subject() {
let repo = SqliteUserRepository::new(pool().await);
let user = User::new_oidc("google|456", Email::new("g@example.com").unwrap());
repo.save(&user).await.unwrap();
let found = repo.find_by_subject("google|456").await.unwrap().unwrap();
assert_eq!(found.id, user.id);
}
#[tokio::test]
async fn find_by_email() {
let repo = SqliteUserRepository::new(pool().await);
let email = Email::new("find@example.com").unwrap();
let user = User::new_oidc("sub", email.clone());
repo.save(&user).await.unwrap();
let found = repo.find_by_email(&email).await.unwrap().unwrap();
assert_eq!(found.id, user.id);
}
#[tokio::test]
async fn delete_removes_user() {
let repo = SqliteUserRepository::new(pool().await);
let user = User::new_oidc("del|1", Email::new("del@example.com").unwrap());
repo.save(&user).await.unwrap();
repo.delete(&user.id).await.unwrap();
assert!(repo.find_by_id(&user.id).await.unwrap().is_none());
}
#[tokio::test]
async fn upsert_updates_existing_user() {
let repo = SqliteUserRepository::new(pool().await);
let mut user = User::new_oidc("sub", Email::new("u@example.com").unwrap());
repo.save(&user).await.unwrap();
user.subject = "sub-updated".into();
repo.save(&user).await.unwrap();
let found = repo.find_by_id(&user.id).await.unwrap().unwrap();
assert_eq!(found.subject, "sub-updated");
}

View File

@@ -0,0 +1,129 @@
use async_trait::async_trait;
use sqlx::{FromRow, SqlitePool};
use domain::{
errors::DomainResult,
user::{
entity::{User, UserId},
ports::UserRepository,
value_objects::{Email, PasswordHash},
},
};
use crate::db::{RepoExt, parse_dt};
pub struct SqliteUserRepository {
pool: SqlitePool,
}
impl SqliteUserRepository {
pub fn new(pool: SqlitePool) -> Self {
Self { pool }
}
}
#[derive(FromRow)]
struct UserRow {
id: String,
subject: String,
email: String,
password_hash: Option<String>,
created_at: String,
}
impl TryFrom<UserRow> for User {
type Error = domain::errors::DomainError;
fn try_from(row: UserRow) -> Result<Self, Self::Error> {
use domain::errors::DomainError;
let id = UserId::from_uuid(
uuid::Uuid::parse_str(&row.id)
.map_err(|e| DomainError::Repository(format!("invalid user uuid: {e}")))?,
);
let email = Email::new(&row.email)?;
let password_hash = row.password_hash.map(PasswordHash::new);
let created_at = parse_dt(&row.created_at)?;
Ok(User::from_row(
id,
row.subject,
email,
password_hash,
created_at,
))
}
}
#[async_trait]
impl UserRepository for SqliteUserRepository {
async fn find_by_id(&self, id: &UserId) -> DomainResult<Option<User>> {
let id_str = id.as_uuid().to_string();
sqlx::query_as::<_, UserRow>(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE id = ?",
)
.bind(&id_str)
.fetch_optional(&self.pool)
.await
.repo()?
.map(User::try_from)
.transpose()
}
async fn find_by_subject(&self, subject: &str) -> DomainResult<Option<User>> {
sqlx::query_as::<_, UserRow>(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE subject = ?",
)
.bind(subject)
.fetch_optional(&self.pool)
.await
.repo()?
.map(User::try_from)
.transpose()
}
async fn find_by_email(&self, email: &Email) -> DomainResult<Option<User>> {
sqlx::query_as::<_, UserRow>(
"SELECT id, subject, email, password_hash, created_at FROM users WHERE email = ?",
)
.bind(email.as_ref())
.fetch_optional(&self.pool)
.await
.repo()?
.map(User::try_from)
.transpose()
}
async fn save(&self, user: &User) -> DomainResult<()> {
sqlx::query(
r#"
INSERT INTO users (id, subject, email, password_hash, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
subject = excluded.subject,
email = excluded.email,
password_hash = excluded.password_hash
"#,
)
.bind(user.id.as_uuid().to_string())
.bind(&user.subject)
.bind(user.email.as_ref())
.bind(user.password_hash.as_ref().map(PasswordHash::as_str))
.bind(user.created_at.to_rfc3339())
.execute(&self.pool)
.await
.repo()
.map(|_| ())
}
async fn delete(&self, id: &UserId) -> DomainResult<()> {
sqlx::query("DELETE FROM users WHERE id = ?")
.bind(id.as_uuid().to_string())
.execute(&self.pool)
.await
.repo()
.map(|_| ())
}
}
#[cfg(test)]
#[path = "tests/user.rs"]
mod tests;

View File

@@ -0,0 +1,10 @@
[package]
name = "api-types"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }

View File

@@ -0,0 +1,29 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct LoginRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct RegisterRequest {
pub email: String,
pub password: String,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct UserResponse {
pub id: Uuid,
pub email: String,
pub created_at: DateTime<Utc>,
}
/// Returned after a successful login or register.
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct AuthResponse {
pub user: UserResponse,
pub access_token: String,
}

View File

@@ -0,0 +1,18 @@
use serde::{Deserialize, Serialize};
/// A note in portable backup format (no IDs — uses names and content only).
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct BackupNote {
pub title: Option<String>,
pub content: String,
pub color: String,
pub is_pinned: bool,
pub is_archived: bool,
/// Tag names associated with this note.
pub tags: Vec<String>,
}
#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)]
pub struct BackupData {
pub notes: Vec<BackupNote>,
}

View File

@@ -0,0 +1,6 @@
use serde::Serialize;
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ConfigResponse {
pub allow_registration: bool,
}

View File

@@ -0,0 +1,36 @@
use serde::Serialize;
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct ErrorResponse {
pub code: String,
pub message: String,
}
impl ErrorResponse {
pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
Self {
code: code.into(),
message: message.into(),
}
}
pub fn not_found(message: impl Into<String>) -> Self {
Self::new("NOT_FOUND", message)
}
pub fn forbidden(message: impl Into<String>) -> Self {
Self::new("FORBIDDEN", message)
}
pub fn conflict(message: impl Into<String>) -> Self {
Self::new("CONFLICT", message)
}
pub fn validation(message: impl Into<String>) -> Self {
Self::new("VALIDATION_ERROR", message)
}
pub fn internal(message: impl Into<String>) -> Self {
Self::new("INTERNAL_ERROR", message)
}
}

View File

@@ -0,0 +1,6 @@
pub mod auth;
pub mod backup;
pub mod config;
pub mod errors;
pub mod notes;
pub mod tags;

View File

@@ -0,0 +1,82 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::tags::TagResponse;
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateNoteRequest {
pub title: Option<String>,
#[serde(default)]
pub content: String,
pub color: Option<String>,
#[serde(default)]
pub is_pinned: bool,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct UpdateNoteRequest {
pub title: Option<String>,
pub content: Option<String>,
pub color: Option<String>,
}
#[derive(Debug, Deserialize, Default, utoipa::IntoParams)]
pub struct ListNotesParams {
pub pinned: Option<bool>,
pub archived: Option<bool>,
/// Filter by tag name.
pub tag: Option<String>,
}
#[derive(Debug, Deserialize, utoipa::IntoParams)]
pub struct SearchParams {
pub q: String,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct AddTagRequest {
pub tag_name: String,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct PinRequest {
pub pinned: bool,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct ArchiveRequest {
pub archived: bool,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct NoteResponse {
pub id: Uuid,
pub user_id: Uuid,
pub title: Option<String>,
pub content: String,
pub color: String,
pub is_pinned: bool,
pub is_archived: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub tags: Vec<TagResponse>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct NoteVersionResponse {
pub id: Uuid,
pub note_id: Uuid,
pub title: Option<String>,
pub content: String,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct NoteLinkResponse {
pub source_id: Uuid,
pub target_id: Uuid,
/// Cosine similarity score in [0.0, 1.0].
pub score: f32,
pub created_at: DateTime<Utc>,
}

View File

@@ -0,0 +1,18 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateTagRequest {
pub name: String,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct RenameTagRequest {
pub name: String,
}
#[derive(Debug, Serialize, utoipa::ToSchema)]
pub struct TagResponse {
pub id: Uuid,
pub name: String,
}

View File

@@ -0,0 +1,13 @@
[package]
name = "application"
version = "0.1.0"
edition = "2024"
[dependencies]
domain = { workspace = true }
async-trait = { workspace = true }
chrono = { workspace = true }
futures = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true }

View File

@@ -0,0 +1,9 @@
pub struct RegisterCommand {
pub email: String,
pub password: String,
}
pub struct LoginCommand {
pub email: String,
pub password: String,
}

View File

@@ -0,0 +1,37 @@
use domain::{
errors::{DomainError, DomainResult},
user::{
entity::User,
value_objects::{Email, Password},
},
};
use super::commands::LoginCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: LoginCommand) -> DomainResult<User> {
let email = Email::new(&cmd.email)?;
let password = Password::new(cmd.password)?;
let user = ctx
.repos
.user
.find_by_email(&email)
.await?
.ok_or_else(|| DomainError::NotFound(format!("user {email}")))?;
let hash = user
.password_hash
.as_ref()
.ok_or_else(|| DomainError::Forbidden("account uses external authentication".into()))?;
if !ctx.services.password_hasher.verify(&password, hash).await? {
return Err(DomainError::Forbidden("invalid credentials".into()));
}
Ok(user)
}
#[cfg(test)]
#[path = "tests/login.rs"]
mod tests;

View File

@@ -0,0 +1,3 @@
pub mod commands;
pub mod login;
pub mod register;

View File

@@ -0,0 +1,31 @@
use domain::{
errors::DomainResult,
user::{
entity::User,
value_objects::{Email, Password},
},
};
use super::commands::RegisterCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: RegisterCommand) -> DomainResult<User> {
let email = Email::new(&cmd.email)?;
let password = Password::new(cmd.password)?;
if ctx.repos.user.find_by_email(&email).await?.is_some() {
return Err(domain::errors::DomainError::Conflict(format!(
"user with email {} already exists",
email
)));
}
let hash = ctx.services.password_hasher.hash(&password).await?;
let user = User::new_local(email, hash);
ctx.repos.user.save(&user).await?;
Ok(user)
}
#[cfg(test)]
#[path = "tests/register.rs"]
mod tests;

View File

@@ -0,0 +1,66 @@
use crate::{
auth::{
commands::{LoginCommand, RegisterCommand},
login, register,
},
test_helpers::TestContext,
};
async fn registered_ctx() -> (TestContext, String, String) {
let t = TestContext::new();
let email = "user@example.com".to_string();
let password = "password123".to_string();
register::execute(
&t.ctx,
RegisterCommand {
email: email.clone(),
password: password.clone(),
},
)
.await
.unwrap();
(t, email, password)
}
#[tokio::test]
async fn valid_credentials_return_user() {
let (t, email, password) = registered_ctx().await;
let user = login::execute(&t.ctx, LoginCommand { email, password })
.await
.unwrap();
assert_eq!(user.email.as_ref(), "user@example.com");
}
#[tokio::test]
async fn wrong_password_is_rejected() {
let (t, email, _) = registered_ctx().await;
let result = login::execute(
&t.ctx,
LoginCommand {
email,
password: "wrongpass".into(),
},
)
.await;
assert!(matches!(
result,
Err(domain::errors::DomainError::Forbidden(_))
));
}
#[tokio::test]
async fn unknown_email_is_not_found() {
let t = TestContext::new();
let result = login::execute(
&t.ctx,
LoginCommand {
email: "ghost@example.com".into(),
password: "password123".into(),
},
)
.await;
assert!(matches!(
result,
Err(domain::errors::DomainError::NotFound(_))
));
}

View File

@@ -0,0 +1,56 @@
use crate::{
auth::{commands::RegisterCommand, register},
test_helpers::TestContext,
};
#[tokio::test]
async fn registers_new_user() {
let t = TestContext::new();
let user = register::execute(
&t.ctx,
RegisterCommand {
email: "user@example.com".into(),
password: "password123".into(),
},
)
.await
.unwrap();
assert_eq!(user.email.as_ref(), "user@example.com");
assert!(user.password_hash.is_some());
}
#[tokio::test]
async fn rejects_duplicate_email() {
let t = TestContext::new();
let cmd = || RegisterCommand {
email: "dup@example.com".into(),
password: "password123".into(),
};
register::execute(&t.ctx, cmd()).await.unwrap();
let result = register::execute(&t.ctx, cmd()).await;
assert!(matches!(
result,
Err(domain::errors::DomainError::Conflict(_))
));
}
#[tokio::test]
async fn rejects_invalid_email() {
let t = TestContext::new();
let result = register::execute(
&t.ctx,
RegisterCommand {
email: "not-an-email".into(),
password: "password123".into(),
},
)
.await;
assert!(matches!(
result,
Err(domain::errors::DomainError::Validation(_))
));
}

View File

@@ -0,0 +1,33 @@
/// Application-level configuration. Auth and infra adapter config lives in their own crates.
#[derive(Debug, Clone)]
pub struct AppConfig {
pub base_url: String,
pub smart: SmartConfig,
/// When false the `/auth/register` endpoint returns 403.
pub allow_registration: bool,
}
#[derive(Debug, Clone)]
pub struct SmartConfig {
pub neighbour_limit: usize,
pub min_similarity: f32,
}
impl Default for SmartConfig {
fn default() -> Self {
Self {
neighbour_limit: 10,
min_similarity: 0.7,
}
}
}
impl AppConfig {
pub fn from_env() -> Self {
Self {
base_url: std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".into()),
smart: SmartConfig::default(),
allow_registration: true,
}
}
}

View File

@@ -0,0 +1,37 @@
use std::sync::Arc;
use domain::{
events::{EventConsumer, EventPublisher},
note::ports::{LinkRepository, NoteRepository},
smart::ports::{EmbeddingGenerator, VectorStore},
tag::ports::TagRepository,
user::ports::{PasswordHasher, UserRepository},
};
use crate::config::AppConfig;
#[derive(Clone)]
pub struct Repositories {
pub note: Arc<dyn NoteRepository>,
pub tag: Arc<dyn TagRepository>,
pub user: Arc<dyn UserRepository>,
pub link: Arc<dyn LinkRepository>,
}
#[derive(Clone)]
pub struct Services {
pub password_hasher: Arc<dyn PasswordHasher>,
pub event_publisher: Arc<dyn EventPublisher>,
/// None when smart features are not configured.
pub embedding: Option<Arc<dyn EmbeddingGenerator>>,
/// None when smart features are not configured.
pub vector_store: Option<Arc<dyn VectorStore>>,
pub event_consumer: Arc<dyn EventConsumer>,
}
#[derive(Clone)]
pub struct AppContext {
pub repos: Repositories,
pub services: Services,
pub config: AppConfig,
}

View File

@@ -0,0 +1,10 @@
pub mod auth;
pub mod config;
pub mod context;
pub mod notes;
pub mod smart;
pub mod tags;
pub mod worker;
#[cfg(test)]
pub(crate) mod test_helpers;

View File

@@ -0,0 +1,51 @@
use domain::{
errors::{DomainError, DomainResult},
note::entity::{MAX_TAGS_PER_NOTE, NoteId},
tag::entity::TagId,
user::entity::UserId,
};
use super::commands::AddTagCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: AddTagCommand) -> DomainResult<()> {
let note_id = NoteId::from_uuid(cmd.note_id);
let tag_id = TagId::from_uuid(cmd.tag_id);
let user_id = UserId::from_uuid(cmd.user_id);
let note = ctx
.repos
.note
.find_by_id(&note_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("note {}", cmd.note_id)))?;
if note.user_id != user_id {
return Err(DomainError::Forbidden(
"cannot modify another user's note".into(),
));
}
if !note.can_add_tag() {
return Err(DomainError::Conflict(format!(
"note already has the maximum of {MAX_TAGS_PER_NOTE} tags"
)));
}
let tag = ctx
.repos
.tag
.find_by_id(&tag_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("tag {}", cmd.tag_id)))?;
if tag.user_id != user_id {
return Err(DomainError::Forbidden("tag belongs to another user".into()));
}
ctx.repos.tag.add_to_note(&tag_id, &note_id).await
}
#[cfg(test)]
#[path = "tests/add_tag.rs"]
mod tests;

View File

@@ -0,0 +1,30 @@
use domain::{
errors::{DomainError, DomainResult},
note::entity::Note,
user::entity::UserId,
};
use super::commands::ArchiveNoteCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: ArchiveNoteCommand) -> DomainResult<Note> {
let note_id = domain::note::entity::NoteId::from_uuid(cmd.note_id);
let user_id = UserId::from_uuid(cmd.user_id);
let mut note = ctx
.repos
.note
.find_by_id(&note_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("note {}", cmd.note_id)))?;
if note.user_id != user_id {
return Err(DomainError::Forbidden(
"cannot modify another user's note".into(),
));
}
note.set_archived(cmd.archived);
ctx.repos.note.save(&note).await?;
Ok(note)
}

View File

@@ -0,0 +1,46 @@
use uuid::Uuid;
pub struct CreateNoteCommand {
pub user_id: Uuid,
pub title: Option<String>,
pub content: String,
pub color: Option<String>,
pub is_pinned: bool,
}
pub struct UpdateNoteCommand {
pub note_id: Uuid,
pub user_id: Uuid,
pub title: Option<String>,
pub content: Option<String>,
pub color: Option<String>,
}
pub struct DeleteNoteCommand {
pub note_id: Uuid,
pub user_id: Uuid,
}
pub struct PinNoteCommand {
pub note_id: Uuid,
pub user_id: Uuid,
pub pinned: bool,
}
pub struct ArchiveNoteCommand {
pub note_id: Uuid,
pub user_id: Uuid,
pub archived: bool,
}
pub struct AddTagCommand {
pub note_id: Uuid,
pub tag_id: Uuid,
pub user_id: Uuid,
}
pub struct RemoveTagCommand {
pub note_id: Uuid,
pub tag_id: Uuid,
pub user_id: Uuid,
}

View File

@@ -0,0 +1,46 @@
use domain::{
errors::DomainResult,
events::DomainEvent,
note::{
entity::Note,
value_objects::{NoteColor, NoteTitle},
},
user::entity::UserId,
};
use super::commands::CreateNoteCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: CreateNoteCommand) -> DomainResult<Note> {
let user_id = UserId::from_uuid(cmd.user_id);
let title = NoteTitle::from_optional(cmd.title)?;
let mut note = Note::new(user_id, title, cmd.content);
if let Some(color) = cmd.color {
note.set_color(NoteColor::new(color));
}
if cmd.is_pinned {
note.set_pinned(true);
}
ctx.repos.note.save(&note).await?;
if let Err(e) = ctx
.services
.event_publisher
.publish(&DomainEvent::NoteCreated {
note_id: note.id,
user_id,
})
.await
{
tracing::warn!("failed to publish NoteCreated: {e}");
}
Ok(note)
}
#[cfg(test)]
#[path = "tests/create_note.rs"]
mod tests;

View File

@@ -0,0 +1,44 @@
use domain::{
errors::{DomainError, DomainResult},
events::DomainEvent,
note::entity::NoteId,
user::entity::UserId,
};
use super::commands::DeleteNoteCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: DeleteNoteCommand) -> DomainResult<()> {
let note_id = NoteId::from_uuid(cmd.note_id);
let user_id = UserId::from_uuid(cmd.user_id);
let note = ctx
.repos
.note
.find_by_id(&note_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("note {}", cmd.note_id)))?;
if note.user_id != user_id {
return Err(DomainError::Forbidden(
"cannot delete another user's note".into(),
));
}
ctx.repos.note.delete(&note_id).await?;
if let Err(e) = ctx
.services
.event_publisher
.publish(&DomainEvent::NoteDeleted { note_id, user_id })
.await
{
tracing::warn!("failed to publish NoteDeleted: {e}");
}
Ok(())
}
#[cfg(test)]
#[path = "tests/delete_note.rs"]
mod tests;

View File

@@ -0,0 +1,33 @@
use domain::{errors::DomainResult, note::entity::NoteFilter, user::entity::UserId};
use crate::context::AppContext;
pub struct ExportedNote {
pub title: Option<String>,
pub content: String,
pub color: String,
pub is_pinned: bool,
pub is_archived: bool,
pub tags: Vec<String>,
}
pub async fn execute(ctx: &AppContext, user_id: uuid::Uuid) -> DomainResult<Vec<ExportedNote>> {
let uid = UserId::from_uuid(user_id);
let notes = ctx
.repos
.note
.find_by_user(&uid, NoteFilter::default())
.await?;
Ok(notes
.into_iter()
.map(|n| ExportedNote {
title: n.title.map(|t| t.into_inner()),
content: n.content,
color: n.color.into_inner(),
is_pinned: n.is_pinned,
is_archived: n.is_archived,
tags: n.tags.into_iter().map(|t| t.name.into_inner()).collect(),
})
.collect())
}

View File

@@ -0,0 +1,28 @@
use domain::{
errors::{DomainError, DomainResult},
note::entity::Note,
user::entity::UserId,
};
use super::queries::GetNoteQuery;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, q: GetNoteQuery) -> DomainResult<Note> {
let note_id = domain::note::entity::NoteId::from_uuid(q.note_id);
let user_id = UserId::from_uuid(q.user_id);
let note = ctx
.repos
.note
.find_by_id(&note_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("note {}", q.note_id)))?;
if note.user_id != user_id {
return Err(DomainError::Forbidden(
"note belongs to another user".into(),
));
}
Ok(note)
}

View File

@@ -0,0 +1,28 @@
use domain::{
errors::{DomainError, DomainResult},
note::entity::{NoteId, NoteLink},
user::entity::UserId,
};
use super::queries::GetRelatedQuery;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, q: GetRelatedQuery) -> DomainResult<Vec<NoteLink>> {
let note_id = NoteId::from_uuid(q.note_id);
let user_id = UserId::from_uuid(q.user_id);
let note = ctx
.repos
.note
.find_by_id(&note_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("note {}", q.note_id)))?;
if note.user_id != user_id {
return Err(DomainError::Forbidden(
"note belongs to another user".into(),
));
}
ctx.repos.link.find_for_note(&note_id).await
}

View File

@@ -0,0 +1,28 @@
use domain::{
errors::{DomainError, DomainResult},
note::entity::{NoteId, NoteVersion},
user::entity::UserId,
};
use super::queries::GetVersionsQuery;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, q: GetVersionsQuery) -> DomainResult<Vec<NoteVersion>> {
let note_id = NoteId::from_uuid(q.note_id);
let user_id = UserId::from_uuid(q.user_id);
let note = ctx
.repos
.note
.find_by_id(&note_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("note {}", q.note_id)))?;
if note.user_id != user_id {
return Err(DomainError::Forbidden(
"note belongs to another user".into(),
));
}
ctx.repos.note.find_versions(&note_id).await
}

View File

@@ -0,0 +1,73 @@
use domain::errors::DomainResult;
use super::{
add_tag, archive_note,
commands::{AddTagCommand, ArchiveNoteCommand, CreateNoteCommand},
create_note,
};
use crate::context::AppContext;
use crate::tags::{commands::CreateTagCommand, create_tag};
pub struct ImportNote {
pub title: Option<String>,
pub content: String,
pub color: Option<String>,
pub is_pinned: bool,
pub is_archived: bool,
pub tags: Vec<String>,
}
pub async fn execute(
ctx: &AppContext,
user_id: uuid::Uuid,
notes: Vec<ImportNote>,
) -> DomainResult<()> {
for item in notes {
let note = create_note::execute(
ctx,
CreateNoteCommand {
user_id,
title: item.title,
content: item.content,
color: item.color,
is_pinned: item.is_pinned,
},
)
.await?;
if item.is_archived {
archive_note::execute(
ctx,
ArchiveNoteCommand {
note_id: note.id.as_uuid(),
user_id,
archived: true,
},
)
.await?;
}
for tag_name in item.tags {
let tag = create_tag::execute(
ctx,
CreateTagCommand {
user_id,
name: tag_name,
},
)
.await?;
add_tag::execute(
ctx,
AddTagCommand {
note_id: note.id.as_uuid(),
tag_id: tag.id.as_uuid(),
user_id,
},
)
.await?;
}
}
Ok(())
}

View File

@@ -0,0 +1,22 @@
use domain::{
errors::DomainResult, note::entity::Note, tag::value_objects::TagName, user::entity::UserId,
};
use super::queries::ListNotesQuery;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, q: ListNotesQuery) -> DomainResult<Vec<Note>> {
let user_id = UserId::from_uuid(q.user_id);
let mut filter = q.filter;
if let Some(name_str) = q.tag_name {
let name = TagName::new(name_str)?;
match ctx.repos.tag.find_by_name(&user_id, &name).await? {
Some(tag) => filter.tag_id = Some(tag.id),
// Tag doesn't exist for this user — no notes can match.
None => return Ok(vec![]),
}
}
ctx.repos.note.find_by_user(&user_id, filter).await
}

View File

@@ -0,0 +1,16 @@
pub mod add_tag;
pub mod archive_note;
pub mod commands;
pub mod create_note;
pub mod delete_note;
pub mod export_notes;
pub mod get_note;
pub mod get_related;
pub mod get_versions;
pub mod import_notes;
pub mod list_notes;
pub mod pin_note;
pub mod queries;
pub mod remove_tag;
pub mod search_notes;
pub mod update_note;

View File

@@ -0,0 +1,30 @@
use domain::{
errors::{DomainError, DomainResult},
note::entity::Note,
user::entity::UserId,
};
use super::commands::PinNoteCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: PinNoteCommand) -> DomainResult<Note> {
let note_id = domain::note::entity::NoteId::from_uuid(cmd.note_id);
let user_id = UserId::from_uuid(cmd.user_id);
let mut note = ctx
.repos
.note
.find_by_id(&note_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("note {}", cmd.note_id)))?;
if note.user_id != user_id {
return Err(DomainError::Forbidden(
"cannot modify another user's note".into(),
));
}
note.set_pinned(cmd.pinned);
ctx.repos.note.save(&note).await?;
Ok(note)
}

View File

@@ -0,0 +1,33 @@
use uuid::Uuid;
use domain::note::entity::NoteFilter;
/// Query to list a user's notes.
/// Provide either `filter.tag_id` (already resolved) **or** `tag_name`
/// (the use case will resolve it). `tag_name` takes precedence.
pub struct ListNotesQuery {
pub user_id: Uuid,
pub filter: NoteFilter,
/// If set, resolves the tag by name before applying the filter.
pub tag_name: Option<String>,
}
pub struct GetNoteQuery {
pub note_id: Uuid,
pub user_id: Uuid,
}
pub struct SearchNotesQuery {
pub user_id: Uuid,
pub query: String,
}
pub struct GetVersionsQuery {
pub note_id: Uuid,
pub user_id: Uuid,
}
pub struct GetRelatedQuery {
pub note_id: Uuid,
pub user_id: Uuid,
}

View File

@@ -0,0 +1,30 @@
use domain::{
errors::{DomainError, DomainResult},
note::entity::NoteId,
tag::entity::TagId,
user::entity::UserId,
};
use super::commands::RemoveTagCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: RemoveTagCommand) -> DomainResult<()> {
let note_id = NoteId::from_uuid(cmd.note_id);
let tag_id = TagId::from_uuid(cmd.tag_id);
let user_id = UserId::from_uuid(cmd.user_id);
let note = ctx
.repos
.note
.find_by_id(&note_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("note {}", cmd.note_id)))?;
if note.user_id != user_id {
return Err(DomainError::Forbidden(
"cannot modify another user's note".into(),
));
}
ctx.repos.tag.remove_from_note(&tag_id, &note_id).await
}

View File

@@ -0,0 +1,9 @@
use domain::{errors::DomainResult, note::entity::Note, user::entity::UserId};
use super::queries::SearchNotesQuery;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, q: SearchNotesQuery) -> DomainResult<Vec<Note>> {
let user_id = UserId::from_uuid(q.user_id);
ctx.repos.note.search(&user_id, &q.query).await
}

View File

@@ -0,0 +1,98 @@
use crate::{
notes::{
add_tag,
commands::{AddTagCommand, CreateNoteCommand},
create_note,
},
tags::{commands::CreateTagCommand, create_tag},
test_helpers::TestContext,
};
use domain::note::entity::MAX_TAGS_PER_NOTE;
use uuid::Uuid;
#[tokio::test]
async fn adds_tag_to_note() {
let t = TestContext::new();
let user_id = Uuid::new_v4();
let note = create_note::execute(
&t.ctx,
CreateNoteCommand {
user_id,
title: None,
content: "tagged".into(),
color: None,
is_pinned: false,
},
)
.await
.unwrap();
let tag = create_tag::execute(
&t.ctx,
CreateTagCommand {
user_id,
name: "rust".into(),
},
)
.await
.unwrap();
add_tag::execute(
&t.ctx,
AddTagCommand {
note_id: note.id.as_uuid(),
tag_id: tag.id.as_uuid(),
user_id,
},
)
.await
.unwrap();
}
#[tokio::test]
async fn rejects_when_tag_limit_reached() {
let t = TestContext::new();
let user_id = Uuid::new_v4();
let mut note = domain::note::entity::Note::new(
domain::user::entity::UserId::from_uuid(user_id),
None,
"content",
);
// fill tags to the limit
for i in 0..MAX_TAGS_PER_NOTE {
let tag = domain::tag::entity::Tag::new(
domain::tag::value_objects::TagName::new(format!("tag-{i}")).unwrap(),
domain::user::entity::UserId::from_uuid(user_id),
);
t.ctx.repos.tag.save(&tag).await.unwrap();
note.tags.push(tag);
}
t.ctx.repos.note.save(&note).await.unwrap();
let extra_tag = create_tag::execute(
&t.ctx,
CreateTagCommand {
user_id,
name: "extra".into(),
},
)
.await
.unwrap();
let result = add_tag::execute(
&t.ctx,
AddTagCommand {
note_id: note.id.as_uuid(),
tag_id: extra_tag.id.as_uuid(),
user_id,
},
)
.await;
assert!(matches!(
result,
Err(domain::errors::DomainError::Conflict(_))
));
}

View File

@@ -0,0 +1,50 @@
use crate::{
notes::{commands::CreateNoteCommand, create_note},
test_helpers::TestContext,
};
use domain::events::DomainEvent;
use uuid::Uuid;
#[tokio::test]
async fn creates_note_and_publishes_event() {
let t = TestContext::new();
let user_id = Uuid::new_v4();
let note = create_note::execute(
&t.ctx,
CreateNoteCommand {
user_id,
title: Some("Hello".into()),
content: "world".into(),
color: None,
is_pinned: false,
},
)
.await
.unwrap();
assert_eq!(note.content, "world");
assert_eq!(note.title.as_ref().unwrap().as_ref(), "Hello");
let events = t.publisher.events.lock().unwrap();
assert!(matches!(events[0], DomainEvent::NoteCreated { .. }));
}
#[tokio::test]
async fn creates_note_without_title() {
let t = TestContext::new();
let note = create_note::execute(
&t.ctx,
CreateNoteCommand {
user_id: Uuid::new_v4(),
title: None,
content: "untitled".into(),
color: None,
is_pinned: false,
},
)
.await
.unwrap();
assert!(note.title.is_none());
}

View File

@@ -0,0 +1,73 @@
use crate::{
notes::{
commands::{CreateNoteCommand, DeleteNoteCommand},
create_note, delete_note,
},
test_helpers::TestContext,
};
use uuid::Uuid;
#[tokio::test]
async fn owner_can_delete_note() {
let t = TestContext::new();
let user_id = Uuid::new_v4();
let note = create_note::execute(
&t.ctx,
CreateNoteCommand {
user_id,
title: None,
content: "bye".into(),
color: None,
is_pinned: false,
},
)
.await
.unwrap();
delete_note::execute(
&t.ctx,
DeleteNoteCommand {
note_id: note.id.as_uuid(),
user_id,
},
)
.await
.unwrap();
let found = t.ctx.repos.note.find_by_id(&note.id).await.unwrap();
assert!(found.is_none());
}
#[tokio::test]
async fn other_user_cannot_delete_note() {
let t = TestContext::new();
let owner = Uuid::new_v4();
let note = create_note::execute(
&t.ctx,
CreateNoteCommand {
user_id: owner,
title: None,
content: "mine".into(),
color: None,
is_pinned: false,
},
)
.await
.unwrap();
let result = delete_note::execute(
&t.ctx,
DeleteNoteCommand {
note_id: note.id.as_uuid(),
user_id: Uuid::new_v4(),
},
)
.await;
assert!(matches!(
result,
Err(domain::errors::DomainError::Forbidden(_))
));
}

View File

@@ -0,0 +1,115 @@
use crate::{
notes::{
commands::{CreateNoteCommand, UpdateNoteCommand},
create_note, update_note,
},
test_helpers::TestContext,
};
use uuid::Uuid;
#[tokio::test]
async fn owner_can_update_note() {
let t = TestContext::new();
let user_id = Uuid::new_v4();
let note = create_note::execute(
&t.ctx,
CreateNoteCommand {
user_id,
title: None,
content: "original".into(),
color: None,
is_pinned: false,
},
)
.await
.unwrap();
let updated = update_note::execute(
&t.ctx,
UpdateNoteCommand {
note_id: note.id.as_uuid(),
user_id,
title: None,
content: Some("updated".into()),
color: None,
},
)
.await
.unwrap();
assert_eq!(updated.content, "updated");
}
#[tokio::test]
async fn other_user_cannot_update_note() {
let t = TestContext::new();
let owner = Uuid::new_v4();
let other = Uuid::new_v4();
let note = create_note::execute(
&t.ctx,
CreateNoteCommand {
user_id: owner,
title: None,
content: "secret".into(),
color: None,
is_pinned: false,
},
)
.await
.unwrap();
let result = update_note::execute(
&t.ctx,
UpdateNoteCommand {
note_id: note.id.as_uuid(),
user_id: other,
title: None,
content: Some("hacked".into()),
color: None,
},
)
.await;
assert!(matches!(
result,
Err(domain::errors::DomainError::Forbidden(_))
));
}
#[tokio::test]
async fn update_creates_version_snapshot() {
let t = TestContext::new();
let user_id = Uuid::new_v4();
let note = create_note::execute(
&t.ctx,
CreateNoteCommand {
user_id,
title: None,
content: "v1".into(),
color: None,
is_pinned: false,
},
)
.await
.unwrap();
update_note::execute(
&t.ctx,
UpdateNoteCommand {
note_id: note.id.as_uuid(),
user_id,
title: None,
content: Some("v2".into()),
color: None,
},
)
.await
.unwrap();
let versions = t.ctx.repos.note.find_versions(&note.id).await.unwrap();
assert_eq!(versions.len(), 1);
assert_eq!(versions[0].content, "v1");
}

View File

@@ -0,0 +1,63 @@
use domain::{
errors::{DomainError, DomainResult},
events::DomainEvent,
note::{
entity::{Note, NoteVersion},
value_objects::{NoteColor, NoteTitle},
},
user::entity::UserId,
};
use super::commands::UpdateNoteCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: UpdateNoteCommand) -> DomainResult<Note> {
let note_id = domain::note::entity::NoteId::from_uuid(cmd.note_id);
let user_id = UserId::from_uuid(cmd.user_id);
let mut note = ctx
.repos
.note
.find_by_id(&note_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("note {}", cmd.note_id)))?;
if note.user_id != user_id {
return Err(DomainError::Forbidden(
"cannot modify another user's note".into(),
));
}
let version = NoteVersion::snapshot(&note);
ctx.repos.note.save_version(&version).await?;
if let Some(title) = cmd.title {
note.set_title(NoteTitle::from_optional(Some(title))?);
}
if let Some(content) = cmd.content {
note.set_content(content);
}
if let Some(color) = cmd.color {
note.set_color(NoteColor::new(color));
}
ctx.repos.note.save(&note).await?;
if let Err(e) = ctx
.services
.event_publisher
.publish(&DomainEvent::NoteUpdated {
note_id: note.id,
user_id,
})
.await
{
tracing::warn!("failed to publish NoteUpdated: {e}");
}
Ok(note)
}
#[cfg(test)]
#[path = "tests/update_note.rs"]
mod tests;

View File

@@ -0,0 +1,12 @@
use domain::{errors::DomainResult, note::entity::NoteId};
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, note_id: NoteId) -> DomainResult<()> {
if let Some(vector_store) = ctx.services.vector_store.as_ref()
&& let Err(e) = vector_store.delete(&note_id).await
{
tracing::warn!("failed to delete vector for note {note_id}: {e}");
}
ctx.repos.link.delete_for_source(&note_id).await
}

View File

@@ -0,0 +1,2 @@
pub mod delete_vectors;
pub mod process_note;

View File

@@ -0,0 +1,48 @@
use domain::{
errors::DomainResult,
note::entity::{NoteId, NoteLink},
user::entity::UserId,
};
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, note_id: NoteId, _user_id: UserId) -> DomainResult<()> {
let (Some(embedder), Some(vector_store)) = (
ctx.services.embedding.as_ref(),
ctx.services.vector_store.as_ref(),
) else {
return Ok(());
};
let note = ctx.repos.note.find_by_id(&note_id).await?;
let Some(note) = note else { return Ok(()) };
let text = match &note.title {
Some(t) => format!("{} {}", t.as_ref(), note.content),
None => note.content.clone(),
};
let embedding = embedder.generate(&text).await?;
vector_store.upsert(&note_id, &embedding).await?;
let limit = ctx.config.smart.neighbour_limit;
let similar = vector_store.find_similar(&embedding, limit + 1).await?;
let links: Vec<NoteLink> = similar
.into_iter()
.filter(|(id, score)| *id != note_id && *score >= ctx.config.smart.min_similarity)
.take(limit)
.map(|(target_id, score)| NoteLink::new(note_id, target_id, score))
.collect();
ctx.repos.link.delete_for_source(&note_id).await?;
if !links.is_empty() {
ctx.repos.link.save_links(&links).await?;
}
Ok(())
}
#[cfg(test)]
#[path = "tests/process_note.rs"]
mod tests;

View File

@@ -0,0 +1,113 @@
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::{
errors::DomainResult,
note::entity::{Note, NoteId},
smart::ports::{EmbeddingGenerator, VectorStore},
user::entity::UserId,
};
use uuid::Uuid;
use crate::{
notes::{commands::CreateNoteCommand, create_note},
smart::process_note,
test_helpers::TestContext,
};
struct FakeEmbedder;
#[async_trait]
impl EmbeddingGenerator for FakeEmbedder {
async fn generate(&self, _text: &str) -> DomainResult<Vec<f32>> {
Ok(vec![1.0, 0.0, 0.0])
}
}
#[derive(Default)]
struct FakeVectorStore {
upserted: Mutex<Vec<NoteId>>,
}
#[async_trait]
impl VectorStore for FakeVectorStore {
async fn upsert(&self, id: &NoteId, _vector: &[f32]) -> DomainResult<()> {
self.upserted.lock().unwrap().push(*id);
Ok(())
}
async fn find_similar(
&self,
_vector: &[f32],
_limit: usize,
) -> DomainResult<Vec<(NoteId, f32)>> {
Ok(vec![])
}
async fn delete(&self, _id: &NoteId) -> DomainResult<()> {
Ok(())
}
}
fn ctx_with_smart() -> TestContext {
let mut t = TestContext::new();
let store = Arc::new(FakeVectorStore::default());
t.ctx.services.embedding = Some(Arc::new(FakeEmbedder));
t.ctx.services.vector_store = Some(Arc::clone(&store) as Arc<dyn VectorStore>);
t
}
#[tokio::test]
async fn processes_note_when_smart_enabled() {
let t = ctx_with_smart();
let user_id = Uuid::new_v4();
let note = create_note::execute(
&t.ctx,
CreateNoteCommand {
user_id,
title: None,
content: "interesting content".into(),
color: None,
is_pinned: false,
},
)
.await
.unwrap();
process_note::execute(
&t.ctx,
note.id,
domain::user::entity::UserId::from_uuid(user_id),
)
.await
.unwrap();
}
#[tokio::test]
async fn skips_when_smart_disabled() {
let t = TestContext::new(); // no embedding/vector_store
let user_id = Uuid::new_v4();
let note = create_note::execute(
&t.ctx,
CreateNoteCommand {
user_id,
title: None,
content: "content".into(),
color: None,
is_pinned: false,
},
)
.await
.unwrap();
let result = process_note::execute(
&t.ctx,
note.id,
domain::user::entity::UserId::from_uuid(user_id),
)
.await;
assert!(result.is_ok());
}

View File

@@ -0,0 +1,17 @@
use uuid::Uuid;
pub struct CreateTagCommand {
pub user_id: Uuid,
pub name: String,
}
pub struct DeleteTagCommand {
pub tag_id: Uuid,
pub user_id: Uuid,
}
pub struct RenameTagCommand {
pub tag_id: Uuid,
pub user_id: Uuid,
pub new_name: String,
}

View File

@@ -0,0 +1,26 @@
use domain::{
errors::DomainResult,
tag::{entity::Tag, value_objects::TagName},
user::entity::UserId,
};
use super::commands::CreateTagCommand;
use crate::context::AppContext;
/// Returns an existing tag with the same name if one exists, otherwise creates a new one.
pub async fn execute(ctx: &AppContext, cmd: CreateTagCommand) -> DomainResult<Tag> {
let user_id = UserId::from_uuid(cmd.user_id);
let name = TagName::new(cmd.name)?;
if let Some(existing) = ctx.repos.tag.find_by_name(&user_id, &name).await? {
return Ok(existing);
}
let tag = Tag::new(name, user_id);
ctx.repos.tag.save(&tag).await?;
Ok(tag)
}
#[cfg(test)]
#[path = "tests/create_tag.rs"]
mod tests;

View File

@@ -0,0 +1,28 @@
use domain::{
errors::{DomainError, DomainResult},
tag::entity::TagId,
user::entity::UserId,
};
use super::commands::DeleteTagCommand;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, cmd: DeleteTagCommand) -> DomainResult<()> {
let tag_id = TagId::from_uuid(cmd.tag_id);
let user_id = UserId::from_uuid(cmd.user_id);
let tag = ctx
.repos
.tag
.find_by_id(&tag_id)
.await?
.ok_or_else(|| DomainError::NotFound(format!("tag {}", cmd.tag_id)))?;
if tag.user_id != user_id {
return Err(DomainError::Forbidden(
"cannot delete another user's tag".into(),
));
}
ctx.repos.tag.delete(&tag_id).await
}

View File

@@ -0,0 +1,9 @@
use domain::{errors::DomainResult, tag::entity::Tag, user::entity::UserId};
use super::queries::ListTagsQuery;
use crate::context::AppContext;
pub async fn execute(ctx: &AppContext, q: ListTagsQuery) -> DomainResult<Vec<Tag>> {
let user_id = UserId::from_uuid(q.user_id);
ctx.repos.tag.find_by_user(&user_id).await
}

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