refactor (v2): better arch
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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
48
.env.example
Normal 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=
|
||||
@@ -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
93
ARCHITECTURE.mmd
Normal 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
779
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
46
Cargo.toml
46
Cargo.toml
@@ -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"
|
||||
|
||||
95
Dockerfile
95
Dockerfile
@@ -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"]
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -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
|
||||
|
||||
86
Makefile
86
Makefile
@@ -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
240
README.md
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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
66
compose.prod.yml
Normal 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
|
||||
56
compose.yml
56
compose.yml
@@ -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
|
||||
|
||||
27
crates/adapters/auth/Cargo.toml
Normal file
27
crates/adapters/auth/Cargo.toml
Normal 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 }
|
||||
30
crates/adapters/auth/src/config.rs
Normal file
30
crates/adapters/auth/src/config.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
100
crates/adapters/auth/src/jwt.rs
Normal file
100
crates/adapters/auth/src/jwt.rs
Normal 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;
|
||||
8
crates/adapters/auth/src/lib.rs
Normal file
8
crates/adapters/auth/src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub mod config;
|
||||
pub mod password;
|
||||
|
||||
#[cfg(feature = "jwt")]
|
||||
pub mod jwt;
|
||||
|
||||
#[cfg(feature = "oidc")]
|
||||
pub mod oidc;
|
||||
178
crates/adapters/auth/src/oidc.rs
Normal file
178
crates/adapters/auth/src/oidc.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
51
crates/adapters/auth/src/password.rs
Normal file
51
crates/adapters/auth/src/password.rs
Normal 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;
|
||||
68
crates/adapters/auth/src/tests/jwt.rs
Normal file
68
crates/adapters/auth/src/tests/jwt.rs
Normal 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)));
|
||||
}
|
||||
36
crates/adapters/auth/src/tests/password.rs
Normal file
36
crates/adapters/auth/src/tests/password.rs
Normal 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());
|
||||
}
|
||||
11
crates/adapters/event-payload/Cargo.toml
Normal file
11
crates/adapters/event-payload/Cargo.toml
Normal 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 }
|
||||
86
crates/adapters/event-payload/src/lib.rs
Normal file
86
crates/adapters/event-payload/src/lib.rs
Normal 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(¬e_id)?),
|
||||
user_id: UserId::from_uuid(parse(&user_id)?),
|
||||
}),
|
||||
EventPayload::NoteUpdated { note_id, user_id } => Ok(DomainEvent::NoteUpdated {
|
||||
note_id: NoteId::from_uuid(parse(¬e_id)?),
|
||||
user_id: UserId::from_uuid(parse(&user_id)?),
|
||||
}),
|
||||
EventPayload::NoteDeleted { note_id, user_id } => Ok(DomainEvent::NoteDeleted {
|
||||
note_id: NoteId::from_uuid(parse(¬e_id)?),
|
||||
user_id: UserId::from_uuid(parse(&user_id)?),
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/lib.rs"]
|
||||
mod tests;
|
||||
88
crates/adapters/event-payload/src/tests/lib.rs
Normal file
88
crates/adapters/event-payload/src/tests/lib.rs
Normal 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());
|
||||
}
|
||||
11
crates/adapters/event-publisher-memory/Cargo.toml
Normal file
11
crates/adapters/event-publisher-memory/Cargo.toml
Normal 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 }
|
||||
85
crates/adapters/event-publisher-memory/src/lib.rs
Normal file
85
crates/adapters/event-publisher-memory/src/lib.rs
Normal 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;
|
||||
75
crates/adapters/event-publisher-memory/src/tests/lib.rs
Normal file
75
crates/adapters/event-publisher-memory/src/tests/lib.rs
Normal 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(¬e_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(¬e_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(¬e_updated()).await.unwrap();
|
||||
}
|
||||
14
crates/adapters/fastembed/Cargo.toml
Normal file
14
crates/adapters/fastembed/Cargo.toml
Normal 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 }
|
||||
88
crates/adapters/fastembed/src/lib.rs
Normal file
88
crates/adapters/fastembed/src/lib.rs
Normal 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;
|
||||
33
crates/adapters/fastembed/src/tests/lib.rs
Normal file
33
crates/adapters/fastembed/src/tests/lib.rs
Normal 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);
|
||||
}
|
||||
17
crates/adapters/nats/Cargo.toml
Normal file
17
crates/adapters/nats/Cargo.toml
Normal 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 }
|
||||
104
crates/adapters/nats/src/consumer.rs
Normal file
104
crates/adapters/nats/src/consumer.rs
Normal 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))
|
||||
}
|
||||
92
crates/adapters/nats/src/lib.rs
Normal file
92
crates/adapters/nats/src/lib.rs
Normal 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),
|
||||
))
|
||||
}
|
||||
34
crates/adapters/nats/src/publisher.rs
Normal file
34
crates/adapters/nats/src/publisher.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
14
crates/adapters/qdrant/Cargo.toml
Normal file
14
crates/adapters/qdrant/Cargo.toml
Normal 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 }
|
||||
139
crates/adapters/qdrant/src/lib.rs
Normal file
139
crates/adapters/qdrant/src/lib.rs
Normal 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;
|
||||
45
crates/adapters/qdrant/src/tests/lib.rs
Normal file
45
crates/adapters/qdrant/src/tests/lib.rs
Normal 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));
|
||||
}
|
||||
15
crates/adapters/sqlite/Cargo.toml
Normal file
15
crates/adapters/sqlite/Cargo.toml
Normal 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 }
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add password_hash column to users table
|
||||
ALTER TABLE users ADD COLUMN password_hash TEXT;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE notes ADD COLUMN color TEXT NOT NULL DEFAULT 'DEFAULT';
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
43
crates/adapters/sqlite/src/db.rs
Normal file
43
crates/adapters/sqlite/src/db.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
5
crates/adapters/sqlite/src/lib.rs
Normal file
5
crates/adapters/sqlite/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod db;
|
||||
pub mod link;
|
||||
pub mod note;
|
||||
pub mod tag;
|
||||
pub mod user;
|
||||
103
crates/adapters/sqlite/src/link.rs
Normal file
103
crates/adapters/sqlite/src/link.rs
Normal 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()
|
||||
}
|
||||
}
|
||||
281
crates/adapters/sqlite/src/note.rs
Normal file
281
crates/adapters/sqlite/src/note.rs
Normal 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(¬e.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;
|
||||
157
crates/adapters/sqlite/src/tag.rs
Normal file
157
crates/adapters/sqlite/src/tag.rs
Normal 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;
|
||||
119
crates/adapters/sqlite/src/tests/note.rs
Normal file
119
crates/adapters/sqlite/src/tests/note.rs
Normal 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(¬e).await.unwrap();
|
||||
|
||||
let found = repo.find_by_id(¬e.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(¬e).await.unwrap();
|
||||
|
||||
let found = repo.find_by_id(¬e.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(¬e).await.unwrap();
|
||||
repo.delete(¬e.id).await.unwrap();
|
||||
|
||||
assert!(repo.find_by_id(¬e.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(¬e).await.unwrap();
|
||||
|
||||
let version = domain::note::entity::NoteVersion::snapshot(¬e);
|
||||
repo.save_version(&version).await.unwrap();
|
||||
|
||||
let versions = repo.find_versions(¬e.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(¬e).await.unwrap();
|
||||
|
||||
note.set_content("updated");
|
||||
repo.save(¬e).await.unwrap();
|
||||
|
||||
let found = repo.find_by_id(¬e.id).await.unwrap().unwrap();
|
||||
assert_eq!(found.content, "updated");
|
||||
}
|
||||
82
crates/adapters/sqlite/src/tests/tag.rs
Normal file
82
crates/adapters/sqlite/src/tests/tag.rs
Normal 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());
|
||||
}
|
||||
84
crates/adapters/sqlite/src/tests/user.rs
Normal file
84
crates/adapters/sqlite/src/tests/user.rs
Normal 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");
|
||||
}
|
||||
129
crates/adapters/sqlite/src/user.rs
Normal file
129
crates/adapters/sqlite/src/user.rs
Normal 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;
|
||||
10
crates/api-types/Cargo.toml
Normal file
10
crates/api-types/Cargo.toml
Normal 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"] }
|
||||
29
crates/api-types/src/auth.rs
Normal file
29
crates/api-types/src/auth.rs
Normal 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,
|
||||
}
|
||||
18
crates/api-types/src/backup.rs
Normal file
18
crates/api-types/src/backup.rs
Normal 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>,
|
||||
}
|
||||
6
crates/api-types/src/config.rs
Normal file
6
crates/api-types/src/config.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize, utoipa::ToSchema)]
|
||||
pub struct ConfigResponse {
|
||||
pub allow_registration: bool,
|
||||
}
|
||||
36
crates/api-types/src/errors.rs
Normal file
36
crates/api-types/src/errors.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
6
crates/api-types/src/lib.rs
Normal file
6
crates/api-types/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod config;
|
||||
pub mod errors;
|
||||
pub mod notes;
|
||||
pub mod tags;
|
||||
82
crates/api-types/src/notes.rs
Normal file
82
crates/api-types/src/notes.rs
Normal 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>,
|
||||
}
|
||||
18
crates/api-types/src/tags.rs
Normal file
18
crates/api-types/src/tags.rs
Normal 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,
|
||||
}
|
||||
13
crates/application/Cargo.toml
Normal file
13
crates/application/Cargo.toml
Normal 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 }
|
||||
9
crates/application/src/auth/commands.rs
Normal file
9
crates/application/src/auth/commands.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub struct RegisterCommand {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
pub struct LoginCommand {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
37
crates/application/src/auth/login.rs
Normal file
37
crates/application/src/auth/login.rs
Normal 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;
|
||||
3
crates/application/src/auth/mod.rs
Normal file
3
crates/application/src/auth/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod commands;
|
||||
pub mod login;
|
||||
pub mod register;
|
||||
31
crates/application/src/auth/register.rs
Normal file
31
crates/application/src/auth/register.rs
Normal 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;
|
||||
66
crates/application/src/auth/tests/login.rs
Normal file
66
crates/application/src/auth/tests/login.rs
Normal 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(_))
|
||||
));
|
||||
}
|
||||
56
crates/application/src/auth/tests/register.rs
Normal file
56
crates/application/src/auth/tests/register.rs
Normal 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(_))
|
||||
));
|
||||
}
|
||||
33
crates/application/src/config.rs
Normal file
33
crates/application/src/config.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
37
crates/application/src/context.rs
Normal file
37
crates/application/src/context.rs
Normal 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,
|
||||
}
|
||||
10
crates/application/src/lib.rs
Normal file
10
crates/application/src/lib.rs
Normal 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;
|
||||
51
crates/application/src/notes/add_tag.rs
Normal file
51
crates/application/src/notes/add_tag.rs
Normal 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(¬e_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, ¬e_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/add_tag.rs"]
|
||||
mod tests;
|
||||
30
crates/application/src/notes/archive_note.rs
Normal file
30
crates/application/src/notes/archive_note.rs
Normal 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(¬e_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(¬e).await?;
|
||||
Ok(note)
|
||||
}
|
||||
46
crates/application/src/notes/commands.rs
Normal file
46
crates/application/src/notes/commands.rs
Normal 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,
|
||||
}
|
||||
46
crates/application/src/notes/create_note.rs
Normal file
46
crates/application/src/notes/create_note.rs
Normal 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(¬e).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;
|
||||
44
crates/application/src/notes/delete_note.rs
Normal file
44
crates/application/src/notes/delete_note.rs
Normal 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(¬e_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(¬e_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;
|
||||
33
crates/application/src/notes/export_notes.rs
Normal file
33
crates/application/src/notes/export_notes.rs
Normal 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())
|
||||
}
|
||||
28
crates/application/src/notes/get_note.rs
Normal file
28
crates/application/src/notes/get_note.rs
Normal 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(¬e_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)
|
||||
}
|
||||
28
crates/application/src/notes/get_related.rs
Normal file
28
crates/application/src/notes/get_related.rs
Normal 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(¬e_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(¬e_id).await
|
||||
}
|
||||
28
crates/application/src/notes/get_versions.rs
Normal file
28
crates/application/src/notes/get_versions.rs
Normal 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(¬e_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(¬e_id).await
|
||||
}
|
||||
73
crates/application/src/notes/import_notes.rs
Normal file
73
crates/application/src/notes/import_notes.rs
Normal 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(())
|
||||
}
|
||||
22
crates/application/src/notes/list_notes.rs
Normal file
22
crates/application/src/notes/list_notes.rs
Normal 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
|
||||
}
|
||||
16
crates/application/src/notes/mod.rs
Normal file
16
crates/application/src/notes/mod.rs
Normal 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;
|
||||
30
crates/application/src/notes/pin_note.rs
Normal file
30
crates/application/src/notes/pin_note.rs
Normal 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(¬e_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(¬e).await?;
|
||||
Ok(note)
|
||||
}
|
||||
33
crates/application/src/notes/queries.rs
Normal file
33
crates/application/src/notes/queries.rs
Normal 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,
|
||||
}
|
||||
30
crates/application/src/notes/remove_tag.rs
Normal file
30
crates/application/src/notes/remove_tag.rs
Normal 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(¬e_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, ¬e_id).await
|
||||
}
|
||||
9
crates/application/src/notes/search_notes.rs
Normal file
9
crates/application/src/notes/search_notes.rs
Normal 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
|
||||
}
|
||||
98
crates/application/src/notes/tests/add_tag.rs
Normal file
98
crates/application/src/notes/tests/add_tag.rs
Normal 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(¬e).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(_))
|
||||
));
|
||||
}
|
||||
50
crates/application/src/notes/tests/create_note.rs
Normal file
50
crates/application/src/notes/tests/create_note.rs
Normal 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());
|
||||
}
|
||||
73
crates/application/src/notes/tests/delete_note.rs
Normal file
73
crates/application/src/notes/tests/delete_note.rs
Normal 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(¬e.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(_))
|
||||
));
|
||||
}
|
||||
115
crates/application/src/notes/tests/update_note.rs
Normal file
115
crates/application/src/notes/tests/update_note.rs
Normal 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(¬e.id).await.unwrap();
|
||||
assert_eq!(versions.len(), 1);
|
||||
assert_eq!(versions[0].content, "v1");
|
||||
}
|
||||
63
crates/application/src/notes/update_note.rs
Normal file
63
crates/application/src/notes/update_note.rs
Normal 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(¬e_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(¬e);
|
||||
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(¬e).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;
|
||||
12
crates/application/src/smart/delete_vectors.rs
Normal file
12
crates/application/src/smart/delete_vectors.rs
Normal 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(¬e_id).await
|
||||
{
|
||||
tracing::warn!("failed to delete vector for note {note_id}: {e}");
|
||||
}
|
||||
ctx.repos.link.delete_for_source(¬e_id).await
|
||||
}
|
||||
2
crates/application/src/smart/mod.rs
Normal file
2
crates/application/src/smart/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod delete_vectors;
|
||||
pub mod process_note;
|
||||
48
crates/application/src/smart/process_note.rs
Normal file
48
crates/application/src/smart/process_note.rs
Normal 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(¬e_id).await?;
|
||||
let Some(note) = note else { return Ok(()) };
|
||||
|
||||
let text = match ¬e.title {
|
||||
Some(t) => format!("{} {}", t.as_ref(), note.content),
|
||||
None => note.content.clone(),
|
||||
};
|
||||
|
||||
let embedding = embedder.generate(&text).await?;
|
||||
vector_store.upsert(¬e_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(¬e_id).await?;
|
||||
if !links.is_empty() {
|
||||
ctx.repos.link.save_links(&links).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/process_note.rs"]
|
||||
mod tests;
|
||||
113
crates/application/src/smart/tests/process_note.rs
Normal file
113
crates/application/src/smart/tests/process_note.rs
Normal 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());
|
||||
}
|
||||
17
crates/application/src/tags/commands.rs
Normal file
17
crates/application/src/tags/commands.rs
Normal 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,
|
||||
}
|
||||
26
crates/application/src/tags/create_tag.rs
Normal file
26
crates/application/src/tags/create_tag.rs
Normal 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;
|
||||
28
crates/application/src/tags/delete_tag.rs
Normal file
28
crates/application/src/tags/delete_tag.rs
Normal 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
|
||||
}
|
||||
9
crates/application/src/tags/list_tags.rs
Normal file
9
crates/application/src/tags/list_tags.rs
Normal 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
Reference in New Issue
Block a user